Rust Programlama Dili
Steve Klabnik, Carol Nichols ve Chris Krycho tarafından, Rust Topluluğunun katkılarıyla yazılmıştır.
Kitabın bu sürümü, Rust 1.99.1 (yayın tarihi: 2026-10-15) veya üzerini kullandığınızı ve tüm projeleri Rust 2024 Sürümü (Edition) kurallarını kullanacak şekilde yapılandırmak üzere Cargo.toml dosyalarında edition = "2024" ayarının bulunduğunu varsayar. Rust’ı kurma veya güncelleme yönergeleri için Bölüm 1’deki “Kurulum” başlığına, sürümler hakkında bilgi için ise Ek E’ye bakabilirsiniz.
Kitabın orijinal HTML sürümü çevrim içi olarak https://doc.rust-lang.org/stable/book/ adresinde, rustup ile yapılan Rust kurulumlarında ise çevrim dışı olarak yer alır; yerel kopyayı açmak için terminalde rustup doc --book komutunu çalıştırabilirsiniz.
Topluluk tarafından hazırlanan çeşitli çeviriler de mevcuttur.
Bu metin, İngilizce olarak No Starch Press’ten karton kapaklı baskı ve e-kitap formatında edinilebilir.
Not: Bu Türkçe çeviride kavramları ana dilinizde rahatça özümseyebilmeniz için standart Türkçe teknik terimler kullanılmış; derleyicinin (rustc) ürettiği İngilizce hata mesajları ve resmî belgelerle kolayca bağ kurabilmeniz adına temel terimlerin İngilizce karşılıkları bölümlerde ilk geçtikleri yerde belirtilmiştir. Ayrıca yukarıda adı geçen derleyici sürümü (1.99.1, altı haftada bir yayınlanan araç zinciri güncellemesi) ile Rust Sürümü (edition = "2024", yaklaşık üç yılda bir sunulan dil kuralları paketi) birbirinden farklı iki kavramdır.
🚨 Daha etkileşimli bir öğrenme deneyimi mi arıyorsunuz? Kısa sınavlar, kod vurgulamaları, görselleştirmeler ve daha fazlasını içeren alternatif Rust Kitabı sürümünü (İngilizce) deneyin: https://rust-book.cs.brown.edu
Önsöz
Rust programlama dili, meraklılardan oluşan küçük ve yeni filizlenen bir topluluğun elinde doğup olgunlaşmasından dünyanın en sevilen ve en çok talep gören programlama dillerinden biri hâline gelmesine dek birkaç kısa yıl içinde uzun bir yol katetti. Geriye dönüp bakıldığında Rust’ın gücünün ve vadettiklerinin dikkatleri üzerine çekmesi ve sistem programlamada kendine sağlam bir yer edinmesi kaçınılmazdı. Kaçınılmaz olmayan ise açık kaynak topluluklarına nüfuz eden ve pek çok sektörde geniş ölçekli benimsenmeyi tetikleyen küresel çaptaki ilgi ve yenilik artışıydı.
Bugün gelinen noktada ilgi ve benimsenmedeki bu patlamayı açıklamak için Rust’ın sunduğu harika özelliklere işaret etmek kolaydır. Diğer pek çok güçlü özelliğin yanı sıra hem bellek güvenliğini hem yüksek performansı hem dost canlısı bir derleyiciyi hem de mükemmel geliştirme araçlarını kim istemez ki? Bugün gördüğünüz Rust dili, sistem programlama alanında yıllar süren araştırmaları canlı ve tutkulu bir topluluğun pratik bilgeliğiyle bir araya getiriyor. Bilinçli bir amaçla tasarlanıp büyük bir özenle işlenen bu dil, geliştiricilere güvenli, hızlı ve güvenilir kod yazmayı kolaylaştıran bir araç sunuyor.
Ancak Rust’ı gerçekten özel kılan, temelinde bir kullanıcı olarak hedeflerinize ulaşabilmeniz için sizi güçlendirme anlayışının yatmasıdır. Rust sizin başarılı olmanızı isteyen bir dildir ve kullanıcıyı güçlendirme ilkesi, bu dili inşa eden, sürdüren ve savunan topluluğun özüne işlemiştir. Bu başvuru niteliğindeki eserin önceki baskısından bu yana Rust, daha da gelişerek gerçek anlamda küresel ve güvenilir bir dil hâline geldi. Rust Projesi artık Rust Vakfı tarafından güçlü bir biçimde destekleniyor; vakıf aynı zamanda Rust’ın güvenli, kararlı ve sürdürülebilir kalmasını güvence altına alacak kritik girişimlere yatırım yapıyor.
Rust Programlama Dili kitabının bu baskısı, dilin yıllar içindeki evrimini yansıtan ve değerli yeni bilgiler sunan kapsamlı bir güncellemedir. Ancak elinizdeki kitap yalnızca sözdizimi ve kütüphaneler için bir rehber değil; kaliteye, performansa ve iyi düşünülmüş tasarıma değer veren bir topluluğa katılmanız için bir davettir. İster Rust’ı ilk kez keşfetmeye hazırlanan tecrübeli bir geliştirici ister becerilerini keskinleştirmek isteyen deneyimli bir Rustacean olun, bu baskı herkes için bir şeyler sunuyor.
Rust’ın yolculuğu; iş birliği, öğrenme ve adım adım iyileştirmeyle şekillendi. Dilin ve ekosisteminin büyümesi, arkasındaki canlı ve zengin çeşitliliğe sahip topluluğun doğrudan bir yansımasıdır. Çekirdek dil tasarımcılarından fırsat buldukça katkı sunanlara kadar binlerce geliştiricinin emeği, Rust’ı böylesine benzersiz ve güçlü bir araç hâline getiriyor. Bu kitabı elinize alarak yalnızca yeni bir programlama dili öğrenmekle kalmıyor; yazılımı daha iyi, daha güvenli ve üzerinde çalışması daha keyifli hâle getiren bir harekete katılıyorsunuz.
Rust topluluğuna hoş geldiniz!
- Bec Rumbul, Rust Vakfı İcra Direktörü
Not: Metinde geçen Rustacean ifadesi, Rust ile İngilizce kabuklu deniz canlısı anlamındaki crustacean sözcüğünün birleşiminden türetilmiş bir kelime oyunudur; topluluğun yengeç maskotu Ferris’e de gönderme yapan bu adlandırma, Rust geliştiricilerinin kendilerini tanımlamak için kullandığı sevimli bir lakaptır.
Giriş
Not: Kitabın bu baskısı, No Starch Press tarafından basılı ve e-kitap formatında sunulan The Rust Programming Language kitabıyla aynıdır.
Rust’a giriş niteliğindeki Rust Programlama Dili (The Rust Programming Language) kitabına hoş geldiniz. Rust programlama dili, daha hızlı ve daha güvenilir yazılımlar geliştirmenize yardımcı olur. Programlama dili tasarımında üst düzey ergonomi ile alt düzey kontrol genellikle birbiriyle çelişir; Rust ise bu ikileme meydan okur. Güçlü teknik kapasite ile mükemmel bir geliştirici deneyimini dengeleyen Rust, geleneksel olarak bu düzeydeki kontrolün beraberinde getirdiği zahmetlere katlanmak zorunda kalmadan alt düzey ayrıntıları (bellek kullanımı gibi) yönetme olanağı sunar.
Rust Kimler İçin?
Rust, çeşitli nedenlerle pek çok kişi için ideal bir dildir. Gelin en önemli gruplardan birkaçına birlikte bakalım.
Geliştirici Ekipleri
Rust, farklı düzeylerde sistem programlama bilgisine sahip geliştiricilerden oluşan büyük ekiplerin iş birliği içinde çalışması için üretken bir araç olduğunu kanıtlamıştır. Alt düzey kodlar, diğer pek çok dilde ancak kapsamlı testler ve deneyimli geliştiricilerin titiz kod incelemeleriyle yakalanabilen çeşitli sinsi hatalara açıktır. Rust’ta ise derleyici, bellek güvenliği ihlalleri ve veri yarışları gibi yakalanması güç pek çok hatayı derleme aşamasında engeller. Bu güvence, bütün eşzamanlılık (concurrency) hatalarının önlendiği anlamına gelmez; kilitlenmeler ve işlemlerin yanlış sıralanmasından kaynaklanan mantık hataları yine mümkündür. Ekip, derleyiciyle omuz omuza çalışarak vaktini hataların peşinden koşmak yerine programın mantığına odaklanmaya ayırabilir.
Rust ayrıca sistem programlama dünyasına çağdaş geliştirici araçları da kazandırır:
- Rust ile birlikte gelen bağımlılık yöneticisi ve derleme aracı olan Cargo; bağımlılıkları eklemeyi, derlemeyi ve yönetmeyi tüm Rust ekosistemi genelinde zahmetsiz ve tutarlı hâle getirir.
rustfmtbiçimlendirme aracı, geliştiriciler arasında tutarlı bir kodlama stili sağlar.- Rust’ın dil sunucusu
rust-analyzer, kod tamamlama ve satır içi hata mesajları gibi özellikler için tümleşik geliştirme ortamı (IDE) entegrasyonunu sağlar.
Geliştiriciler, Rust ekosistemindeki bu ve benzeri araçları kullanarak sistem düzeyinde kod yazarken yüksek üretkenliklerini koruyabilirler.
Öğrenciler
Rust, sistem kavramlarını öğrenmek isteyen öğrenciler ve meraklılar içindir. Pek çok kişi işletim sistemi geliştirme gibi konuları Rust kullanarak öğrenmiştir. Topluluk son derece sıcakkanlıdır ve öğrencilerin sorularını yanıtlamaktan mutluluk duyar. Rust ekipleri, bu kitap gibi çalışmalar aracılığıyla sistem kavramlarını başta programlamaya yeni başlayanlar olmak üzere daha fazla insan için erişilebilir kılmayı amaçlar.
Şirketler
İrili ufaklı yüzlerce şirket; komut satırı araçları, web servisleri, DevOps araçları, gömülü cihazlar, ses ve video analizi ile format dönüştürme, kripto paralar, biyoinformatik, arama motorları, Nesnelerin İnterneti uygulamaları, makine öğrenimi ve hatta Firefox web tarayıcısının önemli bölümleri dahil olmak üzere çok çeşitli görevler için üretim ortamında Rust kullanır.
Açık Kaynak Geliştiricileri
Rust; Rust programlama dilini, topluluğunu, geliştirici araçlarını ve kütüphanelerini inşa etmek isteyenler içindir. Rust diline katkıda bulunmanızdan büyük mutluluk duyarız.
Hıza ve Kararlılığa Önem Verenler
Rust, bir dilde hız ve kararlılık arayanlar içindir. Hız derken hem Rust kodunun çalışma hızını hem de Rust’ın size program geliştirme sürecinde kazandırdığı hızı kastediyoruz. Rust derleyicisinin yaptığı denetimler, yeni özellik eklemeleri ve kodda yapılan yeniden düzenlemeler boyunca kararlılığı güvence altına alır. Bu durum, bu tür denetimlerden yoksun dillerde geliştiricilerin dokunmaktan çekindiği kırılgan eski kod tabanlarıyla tam bir tezat oluşturur. Rust, sıfır maliyetli soyutlamalar (zero-cost abstractions) —elle yazılmış alt düzey kod kadar hızlı çalışan koda derlenen üst düzey özellikler— hedefleyerek güvenli kodun aynı zamanda hızlı kod olmasını sağlamaya çalışır.
Rust dili, daha pek çok farklı kullanıcı kitlesini de desteklemeyi hedefler; burada değinilenler yalnızca en büyük paydaşlardan bazılarıdır. Özetle Rust’ın en büyük tutkusu; güvenliği ve üretkenliği, hızı ve ergonomiyi bir arada sunarak programcıların on yıllardır kabullendiği ödünleşimleri ortadan kaldırmaktır. Rust’a bir şans verin ve tasarım tercihlerinin sizin için işe yarayıp yaramadığını kendiniz görün.
Bu Kitap Kimler İçin?
Bu kitap, daha önce başka bir programlama dilinde kod yazdığınızı varsayar; ancak bunun hangi dil olduğuna dair herhangi bir ön kabulde bulunmaz. İçeriği, çok çeşitli programlama geçmişlerinden gelen okuyucular için geniş ölçüde erişilebilir kılmaya özen gösterdik. Programlamanın ne olduğu veya programlama mantığıyla nasıl düşünüleceği üzerine uzun uzadıya vakit harcamayacağız. Programlamaya tamamen yeniyseniz öncelikle doğrudan programlamaya giriş niteliği taşıyan bir kitap okumanız sizin için çok daha faydalı olacaktır.
Bu Kitap Nasıl Kullanılır?
Genel olarak bu kitap, bölümleri baştan sona sırasıyla okuduğunuzu varsayar. İlerleyen bölümler önceki bölümlerde tanıtılan kavramların üzerine inşa edilir; önceki bölümler ise belirli bir konunun tüm ayrıntılarına hemen inmeyip konuyu daha sonraki bir bölümde yeniden ele alabilir.
Bu kitapta iki tür bölüm bulacaksınız: kavram bölümleri ve proje bölümleri. Kavram bölümlerinde Rust’ın belirli bir yönünü öğreneceksiniz. Proje bölümlerinde ise o ana dek öğrendiklerinizi uygulayarak birlikte küçük programlar geliştireceğiz. Bölüm 2, Bölüm 12 ve Bölüm 21 proje bölümleridir; geri kalanlar ise kavram bölümleridir.
Bölüm 1, Rust’ın nasıl kurulacağını, “Merhaba, dünya!” programının nasıl yazılacağını ve Rust’ın paket yöneticisi ve derleme aracı olan Cargo’nun nasıl kullanılacağını açıklar. Bölüm 2, bir sayı tahmin oyunu geliştirmenizi sağlayarak Rust’ta program yazmaya uygulamalı bir giriş sunar. Burada kavramları genel hatlarıyla ele alırız; ek ayrıntıları ise ilerleyen bölümler sağlar. Hemen kolları sıvayıp pratik yapmak istiyorsanız Bölüm 2 tam size göredir. Bir sonraki konuya geçmeden önce her ayrıntıyı öğrenmeyi tercih eden titiz bir okuyucuysanız Bölüm 2’yi atlayıp doğrudan Bölüm 3’e geçmek isteyebilirsiniz; bu bölüm, diğer programlama dillerindekilere benzeyen Rust özelliklerini ele alır. Öğrendiğiniz ayrıntıları bir proje üzerinde uygulamak istediğinizde Bölüm 2’ye geri dönebilirsiniz.
Bölüm 4’te Rust’ın sahiplik (ownership) sistemini öğreneceksiniz. Bölüm 5, struct’ları ve metotları ele alır. Bölüm 6 ise enum’ları, match ifadelerini ve if let ile let...else kontrol akışı yapılarını kapsar. Kendi özel türlerinizi oluşturmak için struct’ları ve enum’ları kullanacaksınız.
Bölüm 7’de Rust’ın modül sistemini ve kodunuz ile herkese açık uygulama programlama arayüzünüzü (API) düzenlemeye yönelik gizlilik kurallarını öğreneceksiniz. Bölüm 8, standart kütüphanenin sunduğu yaygın koleksiyon veri yapılarını ele alır: vektörler, string’ler ve hash map’ler. Bölüm 9, Rust’ın hata yönetimi felsefesini ve tekniklerini inceler.
Bölüm 10, birden fazla türe uygulanabilen kodlar tanımlama gücü veren jenerikleri, trait’leri ve ömürleri (lifetimes) derinlemesine inceler. Bölüm 11 tamamen test yazmaya ayrılmıştır; Rust’ın sunduğu güvenlik garantilerine rağmen programınızın mantığının doğru çalıştığından emin olmak için testler gereklidir. Bölüm 12’de, dosyalar içinde metin arayan grep komut satırı aracının işlevlerinin bir alt kümesini barındıran kendi uygulamamızı geliştireceğiz. Bunun için önceki bölümlerde ele aldığımız pek çok kavramı kullanacağız.
Bölüm 13, Rust’ın fonksiyonel programlama dillerinden gelen özellikleri olan kapanışları (closures) ve iteratörleri inceler. Bölüm 14’te Cargo’yu daha derinlemesine inceleyecek ve kütüphanelerinizi başkalarıyla paylaşmaya yönelik en iyi uygulamaları konuşacağız. Bölüm 15, standart kütüphanenin sunduğu akıllı işaretçileri (smart pointers) ve bunların işlevselliğini mümkün kılan trait’leri ele alır.
Bölüm 16’da farklı eşzamanlı programlama modellerini adım adım inceleyecek ve Rust’ın birden fazla thread (iş parçacığı) ile korkusuzca programlama yapmanıza nasıl yardımcı olduğunu konuşacağız. Bölüm 17’de ise bu temelin üzerine inşa ederek Rust’ın async ve await sözdizimini; görevler (tasks), future’lar ve akışlar (streams) ile bunların mümkün kıldığı hafif eşzamanlılık modelini keşfedeceğiz.
Bölüm 18, Rust’a özgü kullanım kalıplarının (idioms) aşina olabileceğiniz nesne yönelimli programlama ilkeleriyle nerelerde örtüşüp nerelerde ayrıştığını inceler. Bölüm 19, Rust programlarının genelinde fikirleri ifade etmenin güçlü yolları olan desenler (patterns) ve desen eşleştirme (pattern matching) üzerine bir başvuru kaynağıdır. Bölüm 20 ise güvenli olmayan (unsafe) Rust, makrolar ve ömürler, trait’ler, türler, fonksiyonlar ile kapanışlar hakkında daha fazla ayrıntının yer aldığı, ilgi çekici gelişmiş konulardan oluşan zengin bir seçki sunar.
Bölüm 21’de alt düzey, çok iş parçacıklı (multithreaded) bir web sunucusu geliştireceğimiz bir projeyi tamamlayacağız!
Son olarak ekler bölümü, dil hakkında başvuru kaynağı niteliğinde faydalı bilgiler barındırır. Ek A Rust’ın anahtar kelimelerini, Ek B Rust’ın operatörlerini ve sembollerini, Ek C standart kütüphane tarafından sağlanan türetilebilir trait’leri, Ek D faydalı geliştirme araçlarını ve Ek E Rust sürümlerini (editions) kapsar. Ek F’de kitabın çevirilerini bulabilir, Ek G’de ise Rust’ın nasıl geliştirildiğini ve “Nightly Rust” (gecelik derleme) sürümünün ne olduğunu öğrenebilirsiniz.
Bu kitabı okumanın yanlış bir yolu yoktur: İlerideki bölümlere atlamak isterseniz hiç çekinmeyin! Herhangi bir kafa karışıklığı yaşarsanız önceki bölümlere geri dönmeniz gerekebilir; ancak sizin için hangi yöntem en verimliyse onu izleyin.
Rust öğrenme sürecinin önemli bir parçası, derleyicinin gösterdiği hata mesajlarını nasıl okuyacağınızı öğrenmektir: Bu mesajlar sizi çalışan koda doğru yönlendirecektir. Bu doğrultuda kitap boyunca derlenmeyen pek çok örneği, derleyicinin her durumda size göstereceği hata mesajıyla birlikte paylaşacağız. Rastgele bir kod örneğini yazıp çalıştırmayı denediğinizde derlenmeyebileceğini unutmayın! Çalıştırmak istediğiniz örneğin bilerek hata üretmek üzere yazılıp yazılmadığını görmek için çevresindeki metni mutlaka okuyun. Ferris de çalışması amaçlanmayan kodları ayırt etmenize yardımcı olacaktır:
| Ferris | Anlamı |
|---|---|
| Bu kod derlenmez! | |
| Bu kod panik oluşturur! | |
| Bu kod istenen davranışı üretmez. |
Çoğu durumda, derlenmeyen bir örneği sonraki adımlarda düzelteceğiz. Bu yüzden örnekleri yalnızca son hâlleriyle değil, anlatıldıkları aşamadaki amaçlarıyla birlikte değerlendirin.
Bir kod bloğu her zaman tek başına çalıştırılabilecek tam bir program değildir. // --snip-- işareti, konuyla doğrudan ilgili olmayan satırların gösterilmediğini belirtir; bir örnek de önceki adımda tanımladığınız tür veya fonksiyonları kullanabilir. Dosya adlarını ve çevredeki yönergeleri izleyerek kodu mevcut projenizin ilgili bölümüne yerleştirin.
Derleme hatası, panik ve istenmeyen sonuç farklı durumlardır: Derleme hatasında çalıştırılabilir program üretilmez; panikte kod derlenmiş olsa da yürütme sırasında normal akış kesilir; mantık hatasında ise program çalışabilir fakat beklenen sonucu üretmez. Bu ayrımı öğrenmek de örneklerin amacının bir parçasıdır.
Not: Rust derleyicisi (rustc), hata ve uyarı mesajlarını İngilizce olarak
üretir; ancak hata kodları (örneğin E0382), satır işaretçileri ve help:
önerileri son derece açık ve yol göstericidir. Terminaldeki hatalarla
karşılaştırabilmeniz için kitap boyunca derleyici mesajlarını İngilizce olarak
koruyup Türkçe açıklamalarla ele alacağız. Mesajların sözcükleri, önerileri ve
satır numaraları Rust sürümüne ve kodunuzun dosyadaki konumuna göre
değişebilir; önemli olan, hatanın nedeni ile varsa E0382 gibi hata kodunu
eşleştirmektir. Ayrıca karşılaştığınız herhangi bir hata kodunun kapsamlı
açıklamasına terminalde rustc --explain E0382 gibi bir komutla dilediğiniz
an ulaşabilirsiniz.
Kaynak Kod
Bu kitabın oluşturulduğu orijinal İngilizce kaynak dosyalarına GitHub üzerindeki resmî rust-lang/book deposundan, elinizdeki Türkçe çevirinin kaynak dosyalarına ise hakantr/kitap-rs deposundan ulaşabilirsiniz.
Başlangıç
Rust yolculuğunuza başlayalım! Öğrenecek çok şey var, ancak her yolculuk bir yerden başlar. Bu bölümde şunları ele alacağız:
- Linux, macOS ve Windows üzerinde Rust’ı kurmak
- Ekrana
Merhaba, dünya!yazdıran bir program yazmak - Rust’ın paket yöneticisi ve derleme sistemi olan
cargo’yu kullanmak
Not: Bu bölüm boyunca Rust araç zincirinin (toolchain) üç temel bileşeniyle tanışacaksınız: Rust sürümlerini ve araçlarını yöneten rustup, kaynak kodunuzu çalıştırılabilir ikili dosyalara dönüştüren rustc derleyicisi ve günlük geliştirmede derleme, test ve bağımlılık yönetimini üstlenen cargo. Bilgisayarınıza hemen kurulum yapamıyorsanız veya küçük kod parçacıklarını anında denemek isterseniz tarayıcı üzerinden çalışan resmî Rust Playground ortamını ya da bu kitabın çevrim içi sürümündeki kod bloklarının sağ üst köşesinde yer alan çalıştırma düğmesini de kullanabilirsiniz.
Kurulum
Kurulum
İlk adım Rust’ı kurmaktır. Rust sürümlerini ve ilişkili araçları yönetmek için kullanılan bir komut satırı aracı olan rustup aracılığıyla Rust’ı indireceğiz. İndirme işlemi için internet bağlantısına ihtiyacınız olacak.
Not: Herhangi bir nedenden ötürü rustup kullanmayı tercih etmezseniz diğer seçenekler için lütfen Diğer Rust Kurulum Yöntemleri sayfasına göz atın.
Aşağıdaki adımlar, Rust derleyicisinin en son kararlı (stable) sürümünü kurar. Rust’ın kararlılık güvenceleri sayesinde, bu kitapta yer alan ve derlenen tüm örnekler daha yeni Rust sürümleriyle de sorunsuzca derlenmeye devam edecektir. Rust ekibi hata mesajlarını ve uyarıları düzenli olarak iyileştirdiği için çıktılar sürümler arasında küçük farklılıklar gösterebilir. Başka bir deyişle, bu adımları izleyerek kuracağınız daha yeni herhangi bir kararlı Rust sürümü, kitabın içeriğiyle beklendiği gibi çalışacaktır.
Komut Satırı Gösterimi
Bu bölümde ve kitabın geri kalanında terminalde kullanılan bazı komutları göstereceğiz. Terminale girmeniz gereken satırların tümü $ karakteriyle başlar. Komutu yazarken $ karakterini girmenize gerek yoktur; bu sembol her komutun başlangıcını göstermek için kullanılan komut istemini (prompt) temsil eder. $ ile başlamayan satırlar ise genellikle bir önceki komutun çıktısını gösterir. Ek olarak PowerShell’e özgü örneklerde $ yerine > karakteri kullanılacaktır.
Linux veya macOS Üzerinde rustup Kurulumu
Linux veya macOS kullanıyorsanız bir terminal açın ve aşağıdaki komutu girin:
$ curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh
Bu komut bir betik indirir ve en son kararlı Rust sürümünü kuran rustup aracının kurulumunu başlatır. İşlem sırasında parolanız istenebilir. Kurulum başarılı olursa ekranda şu satır görünür:
Rust is installed now. Great!
Ayrıca Rust’ın derlenmiş çıktıları tek bir dosyada birleştirmek için kullandığı bir bağlayıcıya (linker) da ihtiyacınız olacaktır. Büyük olasılıkla sisteminizde zaten bir bağlayıcı kuruludur. Bağlayıcı hataları alırsanız genellikle bir bağlayıcı da içeren bir C derleyicisi kurmalısınız. Bazı yaygın Rust paketleri C koduna bağımlı olduğundan ve bir C derleyicisine ihtiyaç duyacağından, sisteminizde C derleyicisi bulunması ileride de faydalı olacaktır.
macOS üzerinde şu komutu çalıştırarak bir C derleyicisi edinebilirsiniz:
$ xcode-select --install
Linux kullanıcıları ise genel olarak kendi dağıtımlarının belgelerine göre GCC veya Clang kurmalıdır. Örneğin Ubuntu kullanıyorsanız build-essential paketini kurabilirsiniz.
Not: Linux üzerinde ilk programınızı derlerken error: linker `cc` not found hatasıyla karşılaşırsanız sisteminizde C derleyicisi ve bağlayıcı kurulu değil demektir. Bunu gidermek için dağıtımınıza göre Ubuntu/Debian tabanlı sistemlerde sudo apt update && sudo apt install build-essential, Fedora’da sudo dnf install gcc veya Arch Linux’ta sudo pacman -S base-devel komutunu çalıştırabilirsiniz.
Windows Üzerinde rustup Kurulumu
Windows üzerinde https://www.rust-lang.org/tools/install adresine gidin ve Rust kurulum yönergelerini izleyin. Kurulumun bir aşamasında sizden Visual Studio kurmanız istenecektir. Bu adım, programları derlemek için gereken bağlayıcıyı ve yerel kütüphaneleri sağlar. Bu adımda daha fazla yardıma ihtiyacınız olursa https://rust-lang.github.io/rustup/installation/windows-msvc.html adresine başvurabilirsiniz.
Bu kitabın geri kalanında hem cmd.exe hem de PowerShell üzerinde çalışan komutlar kullanılır. Belirli farklılıklar olduğunda hangisini kullanmanız gerektiğini açıklayacağız.
Not: Windows’ta Visual Studio’nun tam IDE sürümünü kurmanıza gerek yoktur; kurulum sihirbazının yönlendirdiği Visual Studio Build Tools yükleyicisinde “C++ ile masaüstü geliştirme” (Desktop development with C++) iş yükünü seçmeniz, MSVC bağlayıcısının (link.exe) ve Windows SDK bileşenlerinin kurulması için yeterlidir.
Sorun Giderme
Rust’ı doğru bir şekilde kurup kurmadığınızı kontrol etmek için bir terminal açın ve şu satırı girin:
$ rustc --version
Yayınlanmış en son kararlı sürüme ait sürüm numarasını, commit hash’ini ve commit tarihini şu biçimde görmelisiniz:
rustc x.y.z (abcabcabc yyyy-mm-dd)
Bu bilgiyi görüyorsanız Rust’ı başarıyla kurmuşsunuz demektir! Bu bilgiyi göremiyorsanız Rust’ın sisteminizin %PATH% ortam değişkeninde yer alıp almadığını aşağıdaki şekilde kontrol edin.
Windows CMD’de şunu kullanın:
> echo %PATH%
PowerShell’de şunu kullanın:
> echo $env:Path
Linux ve macOS’te şunu kullanın:
$ echo $PATH
Not: Varsayılan kurulumda rustup, tüm Rust araçlarını Linux ve macOS’te ~/.cargo/bin, Windows’ta ise %USERPROFILE%\.cargo\bin dizinine kurar. Kurulumu az önce tamamladıysanız yeni PATH ayarlarının etkinleşmesi için mevcut terminal penceresini kapatıp yeniden açmanız (veya Linux/macOS’te . "$HOME/.cargo/env" komutunu çalıştırmanız) gerekebilir.
Tüm bunlar doğru görünüyorsa ve Rust hâlâ çalışmıyorsa yardım alabileceğiniz pek çok yer vardır. Diğer Rustacean’larla (kendimize taktığımız esprili bir lakap) nasıl iletişime geçebileceğinizi topluluk sayfasında öğrenebilirsiniz.
Güncelleme ve Kaldırma
Rust’ı rustup aracılığıyla kurduktan sonra, yeni yayınlanan bir sürüme geçmek oldukça kolaydır. Terminalinizden şu güncelleme komutunu çalıştırın:
$ rustup update
Rust’ı ve rustup’ı sisteminizden kaldırmak içinse terminalinizden şu kaldırma komutunu çalıştırın:
$ rustup self uninstall
Yerel Belgeleri Okumak
Rust kurulumu, çevrim dışı okuyabilmeniz için belgelerin yerel bir kopyasını da içerir. Yerel belgeleri tarayıcınızda açmak için rustup doc komutunu çalıştırın.
Standart kütüphanenin sunduğu bir türün ya da fonksiyonun ne işe yaradığından veya nasıl kullanılacağından emin olmadığınız her an, bunu öğrenmek için uygulama programlama arayüzü (API) belgelerine başvurabilirsiniz!
Metin Düzenleyiciler ve IDE’leri Kullanmak
Bu kitap, Rust kodu yazmak için hangi araçları kullandığınız konusunda hiçbir varsayımda bulunmaz. Hemen hemen her metin düzenleyici işinizi görecektir! Bununla birlikte, pek çok metin düzenleyicinin ve tümleşik geliştirme ortamının (IDE) yerleşik Rust desteği vardır. Rust web sitesindeki araçlar sayfasında her zaman pek çok düzenleyici ve IDE’nin oldukça güncel bir listesini bulabilirsiniz.
Bu Kitapla Çevrim Dışı Çalışmak
Çeşitli örneklerde, standart kütüphanenin ötesindeki Rust paketlerini kullanacağız. Bu örnekler üzerinde çalışabilmek için ya internet bağlantınızın olması ya da söz konusu bağımlılıkları önceden indirmiş olmanız gerekir. Bağımlılıkları önceden indirmek için aşağıdaki komutları çalıştırabilirsiniz. (cargo’nun ne olduğunu ve bu komutların her birinin ne işe yaradığını ilerleyen bölümlerde ayrıntılı olarak açıklayacağız.)
$ cargo new get-dependencies
$ cd get-dependencies
$ cargo add rand@0.10.1 trpl@0.2.0
Bu işlem söz konusu paketleri indirip önbelleğe alır; böylece daha sonra tekrar indirmeniz gerekmez. İşlem tamamlandıktan sonra get-dependencies klasörünü saklamanıza gerek yoktur. Bağımlılıkları bu şekilde önceden indirdiyseniz ağa bağlanmaya çalışmak yerine önbelleğe alınmış sürümleri kullanmak için kitabın geri kalanındaki tüm cargo komutlarıyla birlikte --offline bayrağını kullanabilirsiniz.
Merhaba, Dünya!
Merhaba, Dünya!
Artık Rust’ı kurduğunuza göre ilk Rust programınızı yazmanın zamanı geldi. Yeni bir dil öğrenirken ekrana Merhaba, dünya! metnini yazdıran küçük bir program yazmak gelenektir; biz de burada aynısını yapacağız!
Not: Bu kitap, komut satırına temel düzeyde aşina olduğunuzu varsayar. Rust; kullandığınız metin düzenleyici, araçlar veya kodunuzun nerede bulunduğu konusunda özel bir koşul dayatmaz. Bu nedenle komut satırı yerine tümleşik bir geliştirme ortamı (IDE) kullanmayı tercih ederseniz dilediğiniz IDE’yi rahatlıkla kullanabilirsiniz. Günümüzde pek çok IDE belirli düzeyde Rust desteği sunar; ayrıntılar için IDE’nin kendi belgelerini inceleyebilirsiniz. Rust ekibi, rust-analyzer aracılığıyla güçlü bir IDE desteği sağlamaya odaklanmıştır. Daha fazla ayrıntı için Ek D bölümüne bakabilirsiniz.
Proje Dizinini Ayarlamak
İşe Rust kodunuzu saklayacağınız bir dizin oluşturarak başlayacaksınız. Kodunuzun nerede durduğu Rust açısından fark etmez; ancak bu kitaptaki alıştırmalar ve projeler için ev dizininizde bir projects dizini oluşturmanızı ve tüm projelerinizi orada tutmanızı öneririz.
Bir terminal açın; projects dizinini ve onun içinde “Merhaba, dünya!” projesinin dizinini oluşturmak için aşağıdaki komutları girin.
Linux, macOS ve Windows’taki PowerShell için şunu girin:
$ mkdir ~/projects
$ cd ~/projects
$ mkdir merhaba_dunya
$ cd merhaba_dunya
Windows CMD için şunu girin:
> mkdir "%USERPROFILE%\projects"
> cd /d "%USERPROFILE%\projects"
> mkdir merhaba_dunya
> cd merhaba_dunya
Rust Programlamanın Temelleri
Ardından yeni bir kaynak dosya oluşturun ve adını main.rs koyun. Rust kaynak dosyaları her zaman .rs uzantısıyla biter. Dosya adınızda birden fazla kelime kullanıyorsanız kelimeleri ayırmak için alt çizgi (_) kullanmak yerleşik bir gelenektir. Örneğin merhabadunya.rs yerine merhaba_dunya.rs kullanın.
Şimdi yeni oluşturduğunuz main.rs dosyasını açın ve Liste 1-1’deki kodu girin.
fn main() {
println!("Merhaba, dünya!");
}
Merhaba, dünya! yazdıran bir programDosyayı kaydedin ve ~/projects/merhaba_dunya dizinindeki terminal pencerenize geri dönün. Linux veya macOS üzerinde dosyayı derlemek ve çalıştırmak için aşağıdaki komutları girin:
$ rustc main.rs
$ ./main
Merhaba, dünya!
Windows’ta ./main yerine .\main komutunu girin:
> rustc main.rs
> .\main
Merhaba, dünya!
İşletim sisteminiz ne olursa olsun terminale Merhaba, dünya! metni yazdırılmalıdır. Bu çıktıyı göremiyorsanız yardım alma yolları için Kurulum bölümündeki “Sorun Giderme” kısmına tekrar göz atın.
Eğer Merhaba, dünya! yazdırıldıysa tebrikler! Resmen bir Rust programı yazdınız. Bu sizi bir Rust programcısı yapar; aramıza hoş geldiniz!
Bir Rust Programının Anatomisi
Şimdi bu “Merhaba, dünya!” programını ayrıntılı olarak inceleyelim. İşte yapbozun ilk parçası:
fn main() {
}
Bu satırlar main adında bir fonksiyon tanımlar. main fonksiyonu özeldir: Çalıştırılabilir her Rust programında çalışan ilk kod her zaman budur. Buradaki ilk satır, hiçbir parametre almayan ve değer döndürmeyen main adında bir fonksiyon bildirir. Parametreleri olsaydı bunlar parantezlerin (()) içine yazılırdı.
Fonksiyon gövdesi {} içine alınmıştır. Rust, tüm fonksiyon gövdelerinin süslü parantez içine alınmasını zorunlu kılar. Açılış süslü parantezini fonksiyon bildirimiyle aynı satıra koymak ve araya bir boşluk bırakmak iyi bir kodlama stili kabul edilir.
Not: Rust projeleri genelinde standart bir kod stiline bağlı kalmak isterseniz kodunuzu belirli kurallara göre otomatik biçimlendiren rustfmt aracını kullanabilirsiniz (rustfmt hakkında daha fazla bilgi için Ek D bölümüne bakın). Rust ekibi bu aracı tıpkı rustc gibi standart Rust dağıtımına dahil etmiştir; dolayısıyla bilgisayarınızda hâlihazırda kurulu olmalıdır!
main fonksiyonunun gövdesi şu kodu barındırır:
#![allow(unused)]
fn main() {
println!("Merhaba, dünya!");
}
Bu küçük programdaki tüm işi bu satır yapar: Ekrana metin yazdırır. Burada dikkat edilmesi gereken üç önemli ayrıntı vardır.
Birincisi, println! bir Rust makrosunu çağırır. Bunun yerine bir fonksiyon çağırsaydı println şeklinde (! olmadan) yazılırdı. Rust makroları, Rust sözdizimini genişletmek amacıyla kod üreten kodlar yazmanın bir yoludur; makroları Bölüm 20’de daha ayrıntılı ele alacağız. Şimdilik ! kullanımının normal bir fonksiyon yerine bir makro çağırdığınız anlamına geldiğini ve makroların her zaman fonksiyonlarla aynı kurallara uymadığını bilmeniz yeterlidir.
İkincisi, "Merhaba, dünya!" metnini (string) görüyorsunuz. Bu metni println! makrosuna argüman olarak iletiyoruz ve metin ekrana yazdırılıyor.
Üçüncüsü, bu ifadenin sona erdiğini ve bir sonrakinin başlamaya hazır olduğunu belirten noktalı virgül (;) ile satırı sonlandırıyoruz. Rust kod satırlarının çoğu noktalı virgülle biter.
Not: Rust kaynak kod dosyaları her zaman UTF-8 kodlamasıyla yazılır ve String gibi metin türleri de içeriklerini UTF-8 olarak tutar; bu sayede "Merhaba, dünya!" örneğindeki gibi Türkçe karakterleri (ç, ğ, ı, İ, ö, ş, ü) ek bir ayar yapmadan metinlerin içinde doğrudan kullanabilirsiniz. Rust 1.53’ten bu yana değişken ve fonksiyon adlarında da Unicode harflere izin verilir; ancak ekosistemdeki kodla uyumlu olması için dosya, değişken ve fonksiyon adlarında Türkçe karakter içermeyen küçük harfli snake_case biçimi (merhaba_dunya gibi) tercih edilir. Ayrıca ilk kodunuzu yazarken ! veya ; gibi bir işareti unutursanız endişelenmeyin; rustc derleyicisi hatanın tam satır ve sütununu göstermenin yanı sıra doğrudan düzeltme ipucu da sunar (örneğin println! yerine println yazdığınızda help: use `!` to invoke the macro önerisiyle sizi yönlendirir).
Derleme ve Çalıştırma
Az önce yeni oluşturduğunuz bir programı çalıştırdınız; şimdi bu süreçteki her bir adımı inceleyelim.
Bir Rust programını çalıştırmadan önce, rustc komutuna kaynak dosyanızın adını vererek onu Rust derleyicisiyle derlemeniz gerekir:
$ rustc main.rs
C veya C++ geçmişiniz varsa bunun gcc ya da clang kullanımına benzediğini fark edeceksiniz. Derleme başarıyla tamamlandığında Rust, çalıştırılabilir bir ikili dosya (binary executable) üretir.
Linux, macOS ve Windows’taki PowerShell’de, komut kabuğunuza ls komutunu girerek çalıştırılabilir dosyayı görebilirsiniz:
$ ls
main main.rs
Linux ve macOS’te iki dosya görürsünüz. Windows’ta PowerShell kullandığınızda ise CMD’dekiyle aynı üç dosyayı görürsünüz. Windows CMD’de şu komutu girebilirsiniz:
> dir /B %= /B seçeneği yalnızca dosya adlarını gösterir =%
main.exe
main.pdb
main.rs
Bu çıktı; .rs uzantılı kaynak kod dosyasını, çalıştırılabilir dosyayı (Windows’ta main.exe, diğer tüm platformlarda main) ve Windows kullanıyorsanız hata ayıklama bilgilerini içeren .pdb uzantılı dosyayı gösterir. Buradan main veya main.exe dosyasını şu şekilde çalıştırabilirsiniz:
$ ./main # veya Windows'ta .\main
Eğer main.rs dosyanız “Merhaba, dünya!” programınızsa bu satır terminalinize Merhaba, dünya! yazdırır.
Not: Linux, macOS ve PowerShell güvenlik nedeniyle bulunduğunuz dizindeki çalıştırılabilir dosyaları doğrudan adıyla (main yazarak) aramaz; yalnızca sistemin PATH ortam değişkenindeki dizinlere bakar. Baştaki ./ (veya Windows’ta .\) öneki, kabuğa programı içinde bulunduğunuz dizinden çalıştırmak istediğinizi açıkça belirtir.
Ruby, Python veya JavaScript gibi dinamik bir dile daha aşinaysanız bir programı derleme ve çalıştırma işlemlerini ayrı adımlar hâlinde yapmaya alışkın olmayabilirsiniz. Rust, önceden derlenen (ahead-of-time compiled / AOT) bir dildir; yani bir programı derleyip ürettiğiniz çalıştırılabilir dosyayı başka birine verebilirsiniz ve o kişi bilgisayarında Rust kurulu olmasa bile programı çalıştırabilir. Birine bir .rb, .py veya .js dosyası verirseniz karşı tarafın bilgisayarında sırasıyla bir Ruby, Python veya JavaScript dil uygulamasının (yorumlayıcısının veya çalışma ortamının) kurulu olması gerekir. Buna karşılık o dillerde programınızı derleyip çalıştırmak için tek bir komut yeterlidir. Dil tasarımında her karar bir ödünleşimdir.
Basit programlar için doğrudan rustc ile derleme yapmak yeterlidir; ancak projeniz büyüdükçe tüm derleme seçeneklerini yönetmek ve kodunuzu paylaşmayı kolaylaştırmak isteyeceksiniz. Bir sonraki bölümde, gerçek dünya Rust programları yazmanıza yardımcı olacak Cargo aracını tanıtacağız.
Merhaba, Cargo!
Merhaba, Cargo!
Cargo, Rust’ın derleme sistemi ve paket yöneticisidir. Rust geliştiricilerinin (Rustacean’ların) büyük çoğunluğu projelerini yönetmek için bu aracı kullanır; çünkü Cargo kodunuzu derlemek, kodunuzun ihtiyaç duyduğu kütüphaneleri indirmek ve bu kütüphaneleri derlemek gibi pek çok işi sizin yerinize üstlenir. (Kodunuzun ihtiyaç duyduğu bu kütüphanelere bağımlılıklar (dependencies) adını veriyoruz.)
Şimdiye kadar yazdığımız türden en basit Rust programlarının herhangi bir bağımlılığı yoktur. “Merhaba, dünya!” projesini Cargo ile oluşturmuş olsaydık, Cargo’nun yalnızca kod derleme özelliğinden yararlanırdık. Daha karmaşık Rust programları yazdıkça projelerinize bağımlılıklar ekleyeceksiniz; projeye baştan Cargo ile başlarsanız sonradan bağımlılık eklemek çok daha kolay olacaktır.
Rust projelerinin büyük çoğunluğu Cargo kullandığından, kitabın geri kalanında sizin de Cargo kullandığınız varsayılmaktadır. “Kurulum” bölümünde anlatılan resmî yükleyicileri kullandıysanız Cargo, Rust ile birlikte kurulu gelir. Rust’ı başka bir yöntemle kurduysanız terminalinize aşağıdaki komutu girerek Cargo’nun kurulu olup olmadığını kontrol edin:
$ cargo --version
Bir sürüm numarası görüyorsanız Cargo kuruludur! command not found (komut bulunamadı) gibi bir hata alırsanız Cargo’yu ayrıca nasıl kuracağınızı öğrenmek için kullandığınız kurulum yönteminin belgelerine bakın.
Cargo ile Proje Oluşturmak
Şimdi Cargo kullanarak yeni bir proje oluşturalım ve orijinal “Merhaba, dünya!” projemizden ne gibi farkları olduğuna bakalım. projects dizininize (veya kodlarınızı saklamaya karar verdiğiniz dizine) geri dönün. Ardından, hangi işletim sistemini kullanırsanız kullanın şu komutları çalıştırın:
$ cargo new merhaba_cargo
$ cd merhaba_cargo
İlk komut, merhaba_cargo adında yeni bir dizin ve proje oluşturur. Projemize merhaba_cargo adını verdiğimiz için Cargo, proje dosyalarını aynı adlı bir dizinin içinde oluşturur.
merhaba_cargo dizinine girip dosyaları listeleyin. Cargo’nun bizim için iki dosya ve bir dizin oluşturduğunu göreceksiniz: bir Cargo.toml dosyası ve içinde main.rs dosyası bulunan bir src dizini.
Cargo ayrıca bir .gitignore dosyasıyla birlikte yeni bir Git deposu da başlatmıştır. cargo new komutunu hâlihazırda var olan bir Git deposunun içinde çalıştırırsanız Git dosyaları oluşturulmaz; bu davranışı cargo new --vcs=git komutuyla geçersiz kılabilirsiniz.
Not: Git yaygın bir sürüm kontrol sistemidir (version control system). --vcs seçeneğini kullanarak cargo new komutunun farklı bir sürüm kontrol sistemi kullanmasını veya hiçbir sürüm kontrol sistemi başlatmamasını sağlayabilirsiniz. Kullanılabilir seçenekleri görmek için cargo new --help komutunu çalıştırın.
Cargo.toml dosyasını tercih ettiğiniz metin düzenleyicide açın. İçeriği Liste 1-2’deki koda benzer görünmelidir.
[package]
name = "merhaba_cargo"
version = "0.1.0"
edition = "2024"
[dependencies]
cargo new tarafından oluşturulan Cargo.toml dosyasının içeriğiBu dosya, Cargo’nun yapılandırma biçimi olan TOML (Tom’s Obvious, Minimal Language) biçimindedir.
İlk satırdaki [package], izleyen deyimlerin bir paketi yapılandırdığını belirten bir bölüm başlığıdır. Bu dosyaya daha fazla bilgi ekledikçe başka bölümler de ekleyeceğiz.
Sonraki üç satır, Cargo’nun programınızı derlemek için ihtiyaç duyduğu yapılandırma bilgilerini belirler: paket adı, paket sürümü ve kullanılacak Rust sürümü (edition). edition anahtarını Ek E bölümünde ayrıntılı olarak ele alacağız.
Son satırdaki [dependencies], projenizin bağımlılıklarını listeleyeceğiniz bölümün başlangıcıdır. Rust’ta kod paketlerine crate adı verilir. Bu projede başka bir crate’e ihtiyacımız olmayacak; ancak Bölüm 2’deki ilk projemizde harici bir crate kullanacağımız için bu bağımlılıklar bölümünden yararlanacağız.
Şimdi src/main.rs dosyasını açın ve bir göz atın:
Dosya adı: src/main.rs
fn main() {
println!("Hello, world!");
}
Cargo, tıpkı Liste 1-1’de yazdığımız programa karşılık gelen bir “Hello, world!” başlangıç programı oluşturdu! Şu ana kadar önceki projemiz ile Cargo’nun oluşturduğu proje arasındaki farklar; Cargo’nun kaynak kodu src dizinine yerleştirmesi ve en üst dizinde bir Cargo.toml yapılandırma dosyasının bulunmasıdır.
Not: Cargo’nun yerleşik proje şablonu İngilizce olduğundan cargo new komutu src/main.rs içinde varsayılan olarak "Hello, world!" metnini üretir. Dilerseniz bu metni Liste 1-1’deki gibi "Merhaba, dünya!" olarak değiştirebilir, dilerseniz aşağıdaki adımlarda olduğu gibi bırakabilirsiniz.
Cargo, kaynak dosyalarınızın src dizini içinde bulunmasını bekler. En üst düzey proje dizini yalnızca README dosyaları, lisans bilgileri, yapılandırma dosyaları ve doğrudan kaynak kodunuzla ilgili olmayan diğer dosyalar içindir. Cargo kullanmak projelerinizi düzenli tutmanıza yardımcı olur: Her şeyin bir yeri vardır ve her şey yerli yerindedir.
“Merhaba, dünya!” projesinde yaptığımız gibi Cargo kullanmadan başladığınız bir projeyi sonradan Cargo projesine dönüştürebilirsiniz. Bunun için proje kodunu src dizinine taşıyıp uygun bir Cargo.toml dosyası oluşturmanız yeterlidir. Cargo.toml dosyasını oluşturmanın kolay bir yolu da mevcut dizinde cargo init komutunu çalıştırmaktır; bu komut dosyayı sizin için otomatik olarak üretir.
Bir Cargo Projesini Derlemek ve Çalıştırmak
Şimdi “Hello, world!” programını Cargo ile derleyip çalıştırdığımızda nelerin değiştiğine bakalım! merhaba_cargo dizininizdeyken aşağıdaki komutu girerek projenizi derleyin:
$ cargo build
Compiling merhaba_cargo v0.1.0 (file:///projects/merhaba_cargo)
Finished dev [unoptimized + debuginfo] target(s) in 2.85 secs
Bu komut, çalıştırılabilir dosyayı bulunduğunuz dizin yerine target/debug/merhaba_cargo (veya Windows’ta target\debug\merhaba_cargo.exe) konumunda oluşturur. Varsayılan derleme bir hata ayıklama (debug) derlemesi olduğundan Cargo, ikili dosyayı debug adlı bir dizinin içine koyar. Çalıştırılabilir dosyayı şu komutla çalıştırabilirsiniz:
$ ./target/debug/merhaba_cargo # veya Windows'ta .\target\debug\merhaba_cargo.exe
Hello, world!
Her şey yolunda giderse terminale Hello, world! yazdırılmalıdır. cargo build komutunu ilk kez çalıştırmak, Cargo’nun en üst dizinde yeni bir dosya daha oluşturmasını sağlar: Cargo.lock. Bu dosya, projenizdeki bağımlılıkların tam sürümlerini kayıt altında tutar. Bu projenin henüz bağımlılığı olmadığından dosyanın içeriği oldukça kısadır. Bu dosyayı hiçbir zaman elle değiştirmenize gerek kalmaz; Cargo içeriğini sizin yerinize otomatik olarak yönetir.
Not: Cargo.toml ile Cargo.lock arasındaki farkı bilmek faydalıdır: Cargo.toml sizin düzenlediğiniz ve istediğiniz bağımlılık sürümlerini genel olarak belirttiğiniz yapılandırma dosyasıdır; Cargo.lock ise Cargo’nun derleme sırasında çözdüğü kesin sürüm numaralarını kilitlediği dosyadır. Bu sayede projeniz başka bir bilgisayarda veya aylar sonra derlendiğinde bile birebir aynı paket sürümleri kullanılır. Cargo.lock dosyasını Git deposuna dahil etmek standart pratiktir; Cargo’nun güncel önerisi, bunu hem çalıştırılabilir projeler hem de kütüphaneler için yapmaktır. Derleme çıktılarının bulunduğu target dizini ise Cargo’nun oluşturduğu .gitignore dosyası sayesinde Git dışında tutulur.
Az önce projemizi cargo build ile derledik ve ./target/debug/merhaba_cargo ile çalıştırdık; ancak kodu derleyip üretilen çalıştırılabilir dosyayı tek bir komutla çalıştırmak için cargo run komutunu da kullanabiliriz:
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running `target/debug/merhaba_cargo`
Hello, world!
cargo run kullanmak, önce cargo build komutunu çalıştırmayı ve ardından ikili dosyanın tam yolunu yazmayı hatırlamaktan çok daha pratiktir; bu nedenle geliştiricilerin çoğu cargo run komutunu tercih eder.
Dikkat ederseniz bu kez Cargo’nun merhaba_cargo paketini derlediğini belirten bir çıktı görmedik. Cargo kaynak dosyaların değişmediğini fark etti ve projeyi yeniden derlemek yerine doğrudan mevcut ikili dosyayı çalıştırdı. Kaynak kodunuzda değişiklik yapmış olsaydınız Cargo çalıştırmadan önce projeyi yeniden derlerdi ve şu çıktıyı görürdünüz:
$ cargo run
Compiling merhaba_cargo v0.1.0 (file:///projects/merhaba_cargo)
Finished dev [unoptimized + debuginfo] target(s) in 0.33 secs
Running `target/debug/merhaba_cargo`
Hello, world!
Cargo ayrıca cargo check adında bir komut da sunar. Bu komut, kodunuzun sorunsuz derlenip derlenmediğini hızla denetler ancak çalıştırılabilir bir dosya üretmez:
$ cargo check
Checking merhaba_cargo v0.1.0 (file:///projects/merhaba_cargo)
Finished dev [unoptimized + debuginfo] target(s) in 0.32 secs
Neden çalıştırılabilir bir dosya üretilmemesini isteyesiniz? Çoğu zaman cargo check, çalıştırılabilir ikili dosya üretme adımını atladığı için cargo build komutundan çok daha hızlıdır. Kod yazarken çalışmanızı sık sık kontrol ediyorsanız cargo check kullanmak, projenizin hâlâ derlenip derlenmediğini çok daha kısa sürede öğrenmenizi sağlar! Bu nedenle pek çok Rust geliştiricisi program yazarken kodun derlendiğinden emin olmak için düzenli aralıklarla cargo check çalıştırır; çalıştırılabilir dosyayı gerçekten kullanmaya hazır olduklarında ise cargo build (veya cargo run) komutuna başvurur.
Cargo hakkında şimdiye kadar öğrendiklerimizi özetleyelim:
cargo newkomutuyla yeni bir proje oluşturabiliriz.cargo buildkomutuyla bir projeyi derleyebiliriz.cargo runkomutuyla bir projeyi tek adımda derleyip çalıştırabiliriz.cargo checkkomutuyla ikili dosya üretmeden projedeki hataları denetleyebiliriz.- Cargo, derleme çıktısını kaynak kodumuzla aynı dizine kaydetmek yerine target/debug dizininde saklar.
Cargo kullanmanın bir diğer avantajı da hangi işletim sisteminde çalışırsanız çalışın komutların tamamen aynı olmasıdır. Dolayısıyla bu noktadan itibaren Linux ve macOS ile Windows için ayrı komut talimatları vermeyeceğiz.
Yayın İçin Derlemek
Projeniz nihayet yayınlanmaya hazır olduğunda, optimizasyonlarla derlemek için cargo build --release komutunu kullanabilirsiniz. Bu komut, çalıştırılabilir dosyayı target/debug yerine target/release dizininde oluşturur. Optimizasyonlar Rust kodunuzun çok daha hızlı çalışmasını sağlar; ancak bu optimizasyonları uygulamak programınızın derlenme süresini uzatır. İki farklı derleme profilinin bulunmasının nedeni tam olarak budur: Biri hızlı ve sık yeniden derleme yapmak istediğiniz geliştirme süreci içindir; diğeri ise kullanıcıya teslim edeceğiniz, tekrar tekrar derlenmeyecek ve mümkün olduğunca hızlı çalışması gereken nihai programı üretmek içindir. Kodunuzun çalışma süresini ölçüyorsanız (benchmarking), derlemeyi mutlaka cargo build --release komutuyla yaptığınızdan ve ölçümleri target/release dizinindeki çalıştırılabilir dosyayla gerçekleştirdiğinizden emin olun.
Not: Cargo’nun varsayılan dev profili, terminal çıktısındaki [unoptimized + debuginfo] ifadesinden de görebileceğiniz üzere kod optimizasyonlarını kapalı tutar ve hata ayıklama (debug) sembollerini ikili dosyaya ekler; bu sayede derleme çok hızlı tamamlanır. release profili (cargo build --release veya doğrudan çalıştırmak için cargo run --release) ise kapsamlı derleyici optimizasyonlarını devreye sokar. Derleme profillerini Cargo.toml üzerinden nasıl özelleştirebileceğinizi Bölüm 14’te inceleyeceğiz.
Cargo Geleneklerinden Yararlanmak
Çok basit projelerde Cargo, doğrudan rustc kullanmaya kıyasla büyük bir fark yaratmıyor gibi görünebilir; ancak programlarınız karmaşıklaştıkça değerini kanıtlayacaktır. Programlar birden fazla dosyaya yayıldığında veya bir bağımlılığa ihtiyaç duyduğunda, derleme sürecini Cargo’nun koordine etmesine izin vermek işinizi çok kolaylaştırır.
merhaba_cargo projesi her ne kadar basit olsa da Rust kariyerinizin geri kalanında başvuracağınız gerçek araç takımının büyük bir kısmını şimdiden kullanıyor. Nitekim mevcut herhangi bir Rust projesi üzerinde çalışmak için Git ile kodu indirip proje dizinine geçmek ve derlemek üzere şu komutları kullanmanız yeterlidir:
$ git clone example.org/bir_proje
$ cd bir_proje
$ cargo build
Cargo hakkında daha fazla bilgi edinmek için Cargo belgelerine göz atabilirsiniz.
Özet
Rust yolculuğunuza şimdiden harika bir başlangıç yaptınız! Bu bölümde şunları öğrendiniz:
rustupkullanarak Rust’ın en son kararlı sürümünü kurmayı,- Daha yeni bir Rust sürümüne güncellemeyi,
- Yerel olarak kurulmuş belgeleri açmayı,
- Doğrudan
rustckullanarak bir “Merhaba, dünya!” programı yazıp çalıştırmayı, - Cargo’nun standart düzenini kullanarak yeni bir proje oluşturup çalıştırmayı.
Artık Rust kodu okumaya ve yazmaya alışmak için daha kapsamlı bir program geliştirmenin tam zamanı! Bu doğrultuda Bölüm 2’de bir sayı tahmin oyunu geliştireceğiz. Öncelikle yaygın programlama kavramlarının Rust’ta nasıl çalıştığını öğrenerek başlamayı tercih ederseniz doğrudan Bölüm 3’e geçebilir ve ardından Bölüm 2’ye dönebilirsiniz.
Bir Tahmin Oyunu Programlama
Birlikte uygulamalı bir proje üzerinde çalışarak Rust’a hızlı bir giriş yapalım! Bu bölüm, yaygın kullanılan bazı Rust kavramlarını gerçek bir program içinde nasıl kullanacağınızı göstererek tanıtır. let, match, metotlar, ilişkili fonksiyonlar (associated functions), harici crate’ler ve daha pek çok kavramla tanışacaksınız! İlerleyen bölümlerde bu konuları tüm ayrıntılarıyla inceleyeceğiz; bu bölümde ise yalnızca temellerin pratiğini yapacağız.
Klasik bir başlangıç programlama problemini uygulayacağız: bir sayı tahmin oyunu. Oyun şöyle çalışacak: Program 1 ile 100 arasında rastgele bir tam sayı üretecek ve ardından oyuncudan bir tahmin girmesini isteyecek. Oyuncu tahminini girdikten sonra program, bu tahminin çok düşük mü yoksa çok yüksek mi olduğunu belirtecek. Tahmin doğruysa oyun bir tebrik mesajı yazdırıp sonlanacak.
Yeni Bir Proje Kurulumu
Yeni bir proje kurmak için Bölüm 1’de oluşturduğunuz projects dizinine gidin ve Cargo kullanarak aşağıdaki gibi yeni bir proje oluşturun:
$ cargo new tahmin_oyunu
$ cd tahmin_oyunu
İlk komut olan cargo new, projenin adını (tahmin_oyunu) ilk argüman olarak alır. İkinci komut ise çalışma dizinini yeni oluşturulan projenin dizinine geçirir.
Oluşturulan Cargo.toml dosyasına göz atalım:
Dosya adı: Cargo.toml
[package]
name = "tahmin_oyunu"
version = "0.1.0"
edition = "2024"
[dependencies]
Bölüm 1’de gördüğünüz gibi cargo new sizin için bir “Merhaba, dünya!” başlangıç programı üretir (varsayılan şablon "Hello, world!" yazdırır; burada Türkçe karşılığıyla gösterilmiştir). src/main.rs dosyasına göz atın:
Dosya adı: src/main.rs
fn main() {
println!("Merhaba, dünya!");
}
Şimdi bu “Merhaba, dünya!” programını cargo run komutuyla tek adımda derleyip çalıştıralım:
$ cargo run
Compiling tahmin_oyunu v0.1.0 (file:///projects/tahmin_oyunu)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.10s
Running `target/debug/tahmin_oyunu`
Merhaba, dünya!
run komutu, bu oyunda da yapacağımız gibi bir proje üzerinde hızlı adımlarla geliştirme yapmanız ve bir sonraki aşamaya geçmeden önce her adımı çabucak test etmeniz gerektiğinde çok işinize yarar.
src/main.rs dosyasını yeniden açın. Tüm kodu bu dosyaya yazacaksınız.
Bir Tahmini İşlemek
Tahmin oyunu programının ilk kısmı kullanıcıdan girdi isteyecek, bu girdiyi işleyecek ve girdinin beklenen biçimde olup olmadığını kontrol edecektir. Başlangıç olarak oyuncunun bir tahmin girmesini sağlayalım. Liste 2-1’deki kodu src/main.rs dosyasına yazın.
use std::io;
fn main() {
println!("Sayıyı tahmin et!");
println!("Lütfen tahmininizi girin.");
let mut tahmin = String::new();
io::stdin()
.read_line(&mut tahmin)
.expect("Satır okunamadı");
println!("Tahmininiz: {tahmin}");
}
Bu kod pek çok bilgi içeriyor; gelin satır satır üzerinden geçelim. Kullanıcıdan girdi almak ve ardından sonucu çıktı olarak yazdırmak için girdi/çıktı kütüphanesi olan io modülünü kapsama dahil etmemiz gerekir. io kütüphanesi, std olarak bilinen standart kütüphaneden gelir:
use std::io;
fn main() {
println!("Sayıyı tahmin et!");
println!("Lütfen tahmininizi girin.");
let mut tahmin = String::new();
io::stdin()
.read_line(&mut tahmin)
.expect("Satır okunamadı");
println!("Tahmininiz: {tahmin}");
}
Rust, varsayılan olarak standart kütüphanede tanımlanmış belirli bir öğe kümesini her programın kapsamına otomatik olarak dahil eder. Bu kümeye prelude (ön tanımlı kapsam) adı verilir ve içindeki her şeyi standart kütüphane belgelerinde görebilirsiniz.
Kullanmak istediğiniz bir tür prelude içinde yer almıyorsa o türü bir use deyimiyle açıkça kapsama dahil etmeniz gerekir. std::io kütüphanesini kullanmak, kullanıcı girdisini kabul etme olanağı da dahil olmak üzere pek çok yararlı özellik sunar.
Bölüm 1’de gördüğünüz gibi main fonksiyonu programın giriş noktasıdır:
use std::io;
fn main() {
println!("Sayıyı tahmin et!");
println!("Lütfen tahmininizi girin.");
let mut tahmin = String::new();
io::stdin()
.read_line(&mut tahmin)
.expect("Satır okunamadı");
println!("Tahmininiz: {tahmin}");
}
fn sözdizimi yeni bir fonksiyon bildirir; () parantezleri parametre bulunmadığını gösterir; { süslü parantezi ise fonksiyonun gövdesini başlatır.
Yine Bölüm 1’de öğrendiğiniz gibi println!, ekrana bir metin yazdıran bir makrodur:
use std::io;
fn main() {
println!("Sayıyı tahmin et!");
println!("Lütfen tahmininizi girin.");
let mut tahmin = String::new();
io::stdin()
.read_line(&mut tahmin)
.expect("Satır okunamadı");
println!("Tahmininiz: {tahmin}");
}
Bu kod, oyunun ne olduğunu belirten bir karşılama metni yazdırır ve kullanıcıdan girdi ister.
Değerleri Değişkenlerle Saklamak
Sırada, kullanıcı girdisini saklamak üzere aşağıdaki gibi bir değişken oluşturmak var:
use std::io;
fn main() {
println!("Sayıyı tahmin et!");
println!("Lütfen tahmininizi girin.");
let mut tahmin = String::new();
io::stdin()
.read_line(&mut tahmin)
.expect("Satır okunamadı");
println!("Tahmininiz: {tahmin}");
}
Şimdi program ilginçleşmeye başlıyor! Bu kısacık satırda pek çok şey gerçekleşiyor. Bir değişken oluşturmak için let deyimini kullanırız. İşte başka bir örnek:
let elmalar = 5;
Bu satır elmalar adında yeni bir değişken oluşturur ve onu 5 değerine bağlar (bind). Rust’ta değişkenler varsayılan olarak değiştirilemezdir (immutable); yani değişkene bir kez değer verdiğimizde bu değer bir daha değişmez. Bu kavramı Bölüm 3’teki “Değişkenler ve Değiştirilebilirlik” kısmında ayrıntılı olarak inceleyeceğiz. Bir değişkeni değiştirilebilir (mutable) yapmak için değişken adından önce mut anahtar kelimesini ekleriz:
let elmalar = 5; // değiştirilemez
let mut muzlar = 5; // değiştirilebilir
Not: // sözdizimi, satır sonuna kadar devam eden bir yorum başlatır. Rust yorumların içindeki her şeyi yok sayar. Yorumları Bölüm 3’te daha ayrıntılı olarak ele alacağız.
Tahmin oyunu programına dönecek olursak artık let mut tahmin kısmının tahmin adında değiştirilebilir bir değişken tanımlayacağını biliyorsunuz. Eşittir işareti (=), Rust’a değişkene şimdi bir değer bağlamak istediğimizi söyler. Eşittir işaretinin sağında tahmin değişkeninin bağlandığı değer yer alır; bu değer, yeni bir String örneği döndüren String::new fonksiyonunun çağrılmasıyla elde edilir. String, standart kütüphane tarafından sağlanan, büyüyebilen ve UTF-8 kodlamalı bir metin türüdür.
::new yazımındaki :: sözdizimi, new fonksiyonunun String türüne ait bir ilişkili fonksiyon olduğunu gösterir. Bir ilişkili fonksiyon, belirli bir tür (bu örnekte String) üzerinde tanımlanmış fonksiyondur. Bu new fonksiyonu yeni ve boş bir string oluşturur. Pek çok tür üzerinde bir new fonksiyonuyla karşılaşırsınız; çünkü new, herhangi bir türden yeni bir değer üreten fonksiyonlar için yaygın bir isimdir.
Özetle let mut tahmin = String::new(); satırı, şu an yeni ve boş bir String örneğine bağlı olan değiştirilebilir bir değişken oluşturmuştur. Vay canına!
Kullanıcı Girdisini Almak
Programın ilk satırında standart kütüphanedeki girdi/çıktı işlevselliğini use std::io; ile dahil ettiğimizi hatırlayın. Şimdi kullanıcı girdisini işleyebilmek için io modülündeki stdin fonksiyonunu çağıracağız:
use std::io;
fn main() {
println!("Sayıyı tahmin et!");
println!("Lütfen tahmininizi girin.");
let mut tahmin = String::new();
io::stdin()
.read_line(&mut tahmin)
.expect("Satır okunamadı");
println!("Tahmininiz: {tahmin}");
}
Programın başında use std::io; ile io modülünü kapsama dahil etmemiş olsaydık bile, bu fonksiyon çağrısını std::io::stdin şeklinde açıkça yazarak yine kullanabilirdik. stdin fonksiyonu, terminalinizin standart girdisine ait bir tutamacı (handle) temsil eden std::io::Stdin türünden bir örnek döndürür.
Ardından gelen .read_line(&mut tahmin) satırı, kullanıcıdan girdi almak üzere standart girdi tutamacı üzerinde read_line metodunu çağırır. Kullanıcı girdisini hangi string içine kaydedeceğini belirtmek için read_line metoduna argüman olarak &mut tahmin değerini geçiriyoruz. read_line metodunun görevi, kullanıcının standart girdiye yazdığı metni alıp mevcut içeriğin üzerine yazmadan verilen string’in sonuna eklemektir; bu nedenle hedef string’i argüman olarak iletiriz. Metodun string içeriğini değiştirebilmesi için bu argümanın değiştirilebilir olması gerekir.
& işareti bu argümanın bir referans (reference) olduğunu belirtir. Referanslar, veriyi bellekte defalarca kopyalamaya gerek kalmadan kodunuzun farklı bölümlerinin aynı veriye erişmesine olanak tanır. Referanslar karmaşık bir özelliktir ve Rust’ın en büyük güçlü yönlerinden biri referansları hem son derece güvenli hem de kullanımı kolay biçimde sunmasıdır. Bu programı tamamlamak için tüm bu ayrıntıları şimdiden bilmenize gerek yok. Şimdilik bilmeniz gereken tek şey, tıpkı değişkenler gibi referansların da varsayılan olarak değiştirilemez olduğudur. Dolayısıyla referansı değiştirilebilir kılmak için &tahmin yerine &mut tahmin yazmanız gerekir. (Bölüm 4 referansları çok daha kapsamlı biçimde açıklayacaktır.)
Potansiyel Bir Hatayı Result ile Ele Almak
Hâlâ aynı kod satırı üzerinde çalışıyoruz. Metin olarak üçüncü satırı inceliyor olsak da bunun hâlâ tek bir mantıksal kod satırının parçası olduğuna dikkat edin. Sıradaki kısım şu metot çağrısıdır:
use std::io;
fn main() {
println!("Sayıyı tahmin et!");
println!("Lütfen tahmininizi girin.");
let mut tahmin = String::new();
io::stdin()
.read_line(&mut tahmin)
.expect("Satır okunamadı");
println!("Tahmininiz: {tahmin}");
}
Bu kodu tek satırda şu şekilde de yazabilirdik:
io::stdin().read_line(&mut tahmin).expect("Satır okunamadı");
Ancak tek bir uzun satırı okumak zordur; bu yüzden onu bölmek en doğrusudur. .metot_adi() sözdizimiyle zincirleme metot çağrıları yaparken uzun satırları yeni satır ve girintilerle ayırmak okunabilirliği büyük ölçüde artırır. Şimdi bu satırın ne işe yaradığına bakalım.
Daha önce belirttiğimiz gibi read_line, kullanıcının yazdığı metni kendisine geçirdiğimiz string içine ekler; ancak bunun yanı sıra bir Result değeri de döndürür. Result, genellikle kısaca enum olarak adlandırılan bir numaralandırma (enumeration) türüdür; yani birden fazla olası durumdan birinde bulunabilen bir türdür. Bu olası durumların her birine varyant (variant) adını veririz.
Bölüm 6 enum’ları çok daha ayrıntılı biçimde ele alacaktır. Result türlerinin amacı, hata yönetimine ilişkin bilgiyi taşımaktır.
Result türünün varyantları Ok ve Err’dir. Ok varyantı işlemin başarılı olduğunu belirtir ve başarıyla üretilen değeri içinde taşır. Err varyantı ise işlemin başarısız olduğu anlamına gelir ve işlemin nasıl ya da neden başarısız olduğuna dair bilgiyi barındırır.
Diğer türlerdeki değerler gibi Result türündeki değerlerin de üzerlerinde tanımlanmış metotları vardır. Bir Result örneği, çağırabileceğiniz bir expect metoduna sahiptir. Eğer bu Result örneği bir Err değeriyse expect programın çökmesine (panic) neden olur ve expect’e argüman olarak verdiğiniz mesajı ekrana yazdırır. read_line metodunun bir Err döndürmesi büyük olasılıkla altta yatan işletim sisteminden kaynaklanan bir girdi/çıktı hatasının sonucu olacaktır. Öte yandan bu Result örneği bir Ok değeriyse expect metodu Ok içinde tutulan dönüş değerini çıkarır ve doğrudan kullanabilmeniz için size döndürür. Bu örnekte söz konusu değer, kullanıcının girdisindeki bayt sayısıdır (UTF-8 kodlamasında her bir ASCII karakteri 1 bayt, 'ç', 'ğ', 'ı', 'ö', 'ş', 'ü' gibi Türkçe harfler ise ikişer bayt yer kaplar).
Eğer expect metodunu çağırmazsanız program yine derlenir, ancak derleyiciden bir uyarı alırsınız:
$ cargo build
Compiling tahmin_oyunu v0.1.0 (file:///projects/tahmin_oyunu)
warning: unused `Result` that must be used
--> src/main.rs:10:5
|
10 | io::stdin().read_line(&mut tahmin);
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: this `Result` may be an `Err` variant, which should be handled
= note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by default
help: use `let _ = ...` to ignore the resulting value
|
10 | let _ = io::stdin().read_line(&mut tahmin);
| +++++++
warning: `tahmin_oyunu` (bin "tahmin_oyunu") generated 1 warning
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.18s
Rust derleyicisi, read_line çağrısından dönen Result değerini kullanmadığınız konusunda (unused Result that must be used) sizi uyarır; bu uyarı programın olası bir hatayı henüz ele almadığına işaret eder.
Bu uyarıyı gidermenin doğru yolu gerçek bir hata yönetimi kodu yazmaktır; ancak şu anki aşamada bir sorun oluştuğunda programın doğrudan durmasını istediğimizden expect kullanabiliriz. Hatalardan kurtularak çalışmaya devam etmeyi Bölüm 9’da öğreneceksiniz.
println! Yer Tutucuları ile Değerleri Yazdırmak
Kapanış süslü parantezini saymazsak şu ana kadarki kodda inceleyeceğimiz yalnızca bir satır kaldı:
use std::io;
fn main() {
println!("Sayıyı tahmin et!");
println!("Lütfen tahmininizi girin.");
let mut tahmin = String::new();
io::stdin()
.read_line(&mut tahmin)
.expect("Satır okunamadı");
println!("Tahmininiz: {tahmin}");
}
Bu satır, artık kullanıcının girdisini barındıran string’i ekrana yazdırır. {} süslü parantez çifti bir yer tutucudur (placeholder): {} işaretini, bir değeri yerinde tutan küçük yengeç kıskaçları gibi düşünebilirsiniz. Bir değişkenin değerini yazdırırken değişken adını doğrudan süslü parantezlerin içine yazabilirsiniz. Bir ifadenin (expression) hesaplanan sonucunu yazdırırken ise biçimlendirme metnine boş {} süslü parantezler yerleştirir, ardından biçimlendirme metninden sonra virgülle ayrılmış ifadeleri sırasıyla verirsiniz; Rust bu ifadelerin sonuçlarını boş yer tutuculara aynı sırayla yerleştirir. Tek bir println! çağrısında hem bir değişkeni hem de bir ifadenin sonucunu yazdırmak şöyle görünür:
#![allow(unused)]
fn main() {
let x = 5;
let y = 10;
println!("x = {x} ve y + 2 = {}", y + 2);
}
Bu kod ekrana x = 5 ve y + 2 = 12 yazdıracaktır.
İlk Kısmı Test Etmek
Tahmin oyununun ilk bölümünü test edelim. cargo run komutuyla programı çalıştırın:
$ cargo run
Compiling tahmin_oyunu v0.1.0 (file:///projects/tahmin_oyunu)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 6.44s
Running `target/debug/tahmin_oyunu`
Sayıyı tahmin et!
Lütfen tahmininizi girin.
6
Tahmininiz: 6
Bu noktada oyunun ilk kısmı tamamlandı: Klavyeden girdi alıyor ve bu girdiyi ekrana yazdırabiliyoruz.
Gizli Bir Sayı Üretmek
Sırada, kullanıcının tahmin etmeye çalışacağı gizli bir sayı üretmek var. Oyunun tekrar tekrar oynanmasının keyifli olması için gizli sayı her seferinde farklı olmalıdır. Oyunun çok zor olmaması adına 1 ile 100 arasında rastgele bir sayı kullanacağız. Rust, standart kütüphanesinde henüz rastgele sayı üretme işlevselliği barındırmaz. Bununla birlikte Rust ekibi, bu işlevselliği sunan rand crate’ini sağlamaktadır.
Bir Crate ile İşlevselliği Artırmak
Bir crate’in, Rust kaynak kodu dosyalarından oluşan bir bütün olduğunu hatırlayın. Şimdiye kadar geliştirdiğimiz proje, çalıştırılabilir bir dosya üreten bir ikili crate’tir (binary crate). rand crate’i ise başka programlarda kullanılmak üzere tasarlanmış kodlar içeren ve tek başına çalıştırılamayan bir kütüphane crate’idir (library crate).
Harici crate’lerin yönetimi, Cargo’nun gerçekten parladığı alandır. rand kullanan kodu yazmadan önce Cargo.toml dosyasını düzenleyip rand crate’ini bir bağımlılık (dependency) olarak eklememiz gerekir. Şimdi Cargo.toml dosyasını açın ve Cargo’nun sizin için oluşturduğu [dependencies] bölüm başlığının altına aşağıdaki satırı ekleyin. rand paketini burada verdiğimiz sürüm numarasıyla birebir aynı şekilde belirttiğinizden emin olun; aksi takdirde bu bölümdeki kod örnekleri çalışmayabilir:
Dosya adı: Cargo.toml
[dependencies]
rand = "0.10.1"
Cargo.toml dosyasında bir başlıktan sonra gelen her satır, yeni bir bölüm başlayana kadar o bölümün parçası kabul edilir. [dependencies] bölümünde Cargo’ya, projenizin hangi harici crate’lere bağımlı olduğunu ve bu crate’lerin hangi sürümlerine ihtiyaç duyduğunuzu bildirirsiniz. Bu örnekte rand crate’ini semantik sürüm belirteci 0.10.1 ile belirtiyoruz. Cargo, sürüm numaralarını yazmaya yönelik bir standart olan Semantik Sürümlemeyi (kısaca SemVer) anlar. 0.10.1 belirteci aslında ^0.10.1 ifadesinin kısaltmasıdır; bu da en az 0.10.1 olan ancak 0.11.0’ın altında kalan her sürümün kullanılabileceği anlamına gelir.
Cargo bu aralıktaki sürümlerin 0.10.1 sürümüyle uyumlu genel API’lere sahip olduğunu kabul eder; bu tanım sayesinde bu bölümdeki kodla sorunsuz derlenmeye devam edecek en güncel yama sürümünü (patch release) almanız sağlanır. 0.11.0 veya üzerindeki sürümlerin ise aşağıdaki örneklerin kullandığı API ile aynı kalacağı garanti edilmez.
Not: SemVer’de sürüm numarası ANA.ALT.YAMA (major.minor.patch)
biçimindedir. Ana sürümü 1 veya daha büyük olan kararlı API’lerde, geriye
dönük uyumsuz değişiklikler ana sürüm numarası artırılarak yayımlanır. Ana
sürümü 0 olan crate’lerde ise API henüz kararlı kabul edilmez. Cargo,
0.10.1 gibi bir belirteçte uyumluluk sınırı olarak ilk sıfır olmayan
bileşeni (10) kullanır ve 0.11.0’ı uyumsuz kabul eder. Ana sürümü 1 veya
daha büyük olan bir crate için "1.2.3" yazmak, 1.2.3 dahil bu sürümden
başlayıp 2.0.0 hariç bu sınıra kadar olan uyumlu sürümlere izin verir;
1.2.2 gibi daha eski bir sürüme izin vermez.
Şimdi kodda hiçbir değişiklik yapmadan Liste 2-2’de gösterildiği gibi projeyi derleyelim.
$ cargo build
Updating crates.io index
Locking 8 packages to latest Rust 1.96.0 compatible versions
Downloaded rand_core v0.10.1
Downloaded chacha20 v0.10.1
Downloaded rand v0.10.1
Downloaded 3 crates (162.9KiB) in 0.59s
Compiling libc v0.2.186
Compiling rand_core v0.10.1
Compiling getrandom v0.4.3
Compiling cfg-if v1.0.4
Compiling chacha20 v0.10.1
Compiling rand v0.10.1
Compiling tahmin_oyunu v0.1.0 (file:///projects/tahmin_oyunu)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.03s
rand crate’ini bağımlılık olarak ekledikten sonra cargo build komutunun çıktısıKendi ekranınızda farklı sürüm numaraları (ancak SemVer sayesinde hepsi bu kodla uyumlu olacaktır!) ve işletim sisteminize bağlı olarak farklı satırlar görebilirsiniz; ayrıca bu satırların sırası da değişebilir.
Harici bir bağımlılık eklediğimizde Cargo, o bağımlılığın ihtiyaç duyduğu tüm paketlerin uyumlu en son sürümlerini Crates.io verilerinin bir kopyası olan kayıt dizininden (registry) çeker. Crates.io, Rust ekosistemindeki geliştiricilerin açık kaynaklı Rust projelerini başkalarının da kullanabilmesi için yayınladıkları merkezdir.
Cargo, kayıt dizinini güncelledikten sonra [dependencies] bölümünü kontrol eder ve listelenen ancak henüz bilgisayarınızda önbelleğe alınmamış tüm crate’leri indirir. Bu örnekte bağımlılık olarak yalnızca rand’i yazmış olsak da Cargo, rand’in çalışmak için ihtiyaç duyduğu alt crate’leri de (rand_core, chacha20, getrandom vb.) otomatik olarak temin eder. Crate’leri indirdikten sonra Rust önce bu bağımlılıkları, ardından da bağımlılıkları hazır hâle gelen projemizi derler.
Hiçbir değişiklik yapmadan hemen ardından cargo build komutunu tekrar çalıştırırsanız Finished satırı dışında hiçbir çıktı almazsınız. Cargo tüm bağımlılıkları zaten indirip derlediğini ve Cargo.toml dosyanızda bunlarla ilgili bir değişiklik yapmadığınızı bilir. Kendi kodunuzda da bir değişiklik olmadığını bildiğinden onu da yeniden derlemez; yapılacak iş kalmadığı için doğrudan sonlanır.
src/main.rs dosyasını açıp küçük bir değişiklik yapar, kaydeder ve tekrar derlerseniz yalnızca iki satırlık bir çıktı görürsünüz:
$ cargo build
Compiling tahmin_oyunu v0.1.0 (file:///projects/tahmin_oyunu)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s
Bu satırlar, Cargo’nun derlemeyi yalnızca src/main.rs dosyasındaki ufak değişikliğiniz için güncellediğini gösterir. Bağımlılıklarınız değişmediğinden Cargo daha önce indirip derlediği çıktıları olduğu gibi yeniden kullanır.
Tekrarlanabilir Derlemeleri Sağlamak
Cargo, siz veya bir başkası kodunuzu her derlediğinde birebir aynı derleme çıktısını (artifact) yeniden üretebilmenizi sağlayan bir mekanizmaya sahiptir: Siz aksi yönde talimat verene kadar Cargo yalnızca kilitlediği bağımlılık sürümlerini kullanır. Örneğin gelecek hafta rand crate’inin 0.10.2 sürümünün çıktığını, bu sürümün önemli bir hata düzeltmesi içerdiğini ancak aynı zamanda kodunuzu bozacak bir gerileme (regression) barındırdığını varsayalım. Bu sorunu önlemek için Rust, cargo build komutunu ilk çalıştırdığınızda Cargo.lock dosyasını oluşturur; şu an tahmin_oyunu dizinimizde bu dosya mevcuttur.
Bir projeyi ilk kez derlediğinizde Cargo, ölçütlerinize uyan tüm bağımlılık sürümlerini hesaplar ve kesin sürüm numaralarını Cargo.lock dosyasına yazar. Projenizi ileride yeniden derlediğinizde Cargo Cargo.lock dosyasının var olduğunu görür ve sürümleri baştan çözmek yerine doğrudan bu dosyada kayıtlı sürümleri kullanır. Böylece otomatik olarak tekrarlanabilir bir derleme elde edersiniz. Başka bir deyişle, Cargo.lock dosyası sayesinde siz açıkça yükseltme yapana kadar projeniz 0.10.1 sürümünde kalacaktır. Cargo.lock dosyası tekrarlanabilir derlemeler açısından kritik öneme sahip olduğundan genellikle projenin geri kalan kaynak kodlarıyla birlikte sürüm kontrol sistemine (örneğin Git’e) dahil edilir.
Yeni Bir Sürüm Almak İçin Bir Crate’i Güncellemek
Bir crate’i gerçekten güncellemek istediğinizde Cargo bunun için update komutunu sunar. Bu komut mevcut Cargo.lock dosyasını yok sayar, Cargo.toml dosyasındaki sürüm kurallarınıza uyan en güncel sürümleri bulur ve bu yeni sürümleri Cargo.lock dosyasına yazar. Varsayılan olarak Cargo yalnızca 0.10.1’den büyük ve 0.11.0’dan küçük sürümleri arayacaktır. Örneğin rand crate’i 0.10.2 ve 0.999.0 olmak üzere iki yeni sürüm yayınlamış olsaydı, cargo update komutunu çalıştırdığınızda şu çıktıyı görürdünüz:
$ cargo update
Updating crates.io index
Locking 1 package to latest Rust 1.96.0 compatible version
Updating rand v0.10.1 -> v0.10.2 (available: v0.999.0)
Cargo 0.999.0 sürümünü dikkate almaz. Bu noktada Cargo.lock dosyanızda da artık kullandığınız rand sürümünün 0.10.2 olarak güncellendiğini görürsünüz. rand’in 0.999.0 sürümünü veya 0.999.x serisindeki herhangi bir sürümü kullanmak isteseydiniz Cargo.toml dosyasını şu şekilde güncellemeniz gerekirdi (ancak bu değişikliği gerçekte yapmayın; çünkü aşağıdaki örnekler rand 0.10 serisini kullandığınızı varsaymaktadır):
[dependencies]
rand = "0.999.0"
Bir sonraki cargo build çalıştırışınızda Cargo, mevcut crate kayıt dizinini güncelleyecek ve rand gereksinimlerinizi belirttiğiniz yeni sürüme göre yeniden değerlendirecektir.
Cargo ve ekosistemi hakkında söylenecek çok daha fazla şey var; bunları Bölüm 14’te ele alacağız, ancak şimdilik bilmeniz gerekenler bu kadar. Cargo kütüphaneleri yeniden kullanmayı son derece kolaylaştırdığı için Rust geliştiricileri (Rustaceans), birden çok paketin bir araya getirilmesiyle oluşan daha küçük ve odaklanmış projeler yazabilirler.
Rastgele Bir Sayı Üretmek
Şimdi tahmin edilecek sayıyı üretmek için rand crate’ini kullanmaya başlayalım. Bir sonraki adım, src/main.rs dosyasını Liste 2-3’te gösterildiği gibi güncellemektir.
use std::io;
use rand::prelude::*;
fn main() {
println!("Sayıyı tahmin et!");
let gizli_sayi = rand::rng().random_range(1..=100);
println!("Gizli sayı: {gizli_sayi}");
println!("Lütfen tahmininizi girin.");
let mut tahmin = String::new();
io::stdin()
.read_line(&mut tahmin)
.expect("Satır okunamadı");
println!("Tahmininiz: {tahmin}");
}
İlk olarak use rand::prelude::*; satırını ekliyoruz. prelude modülü, rand crate’inin en sık kullanılan öğelerini içerir; sondaki * işaretiyle birlikte bu use deyimi, modüldeki tüm öğeleri programımızın kapsamında doğrudan kullanılabilir hâle getirir.
Ardından ortaya iki yeni satır ekliyoruz. İlk satırda, kullanacağımız rastgele sayı üretecini bize veren rand::rng fonksiyonunu çağırıyoruz: Bu üreteç, çalışmakta olan mevcut thread’e (iş parçacığına) özeldir ve işletim sistemi tarafından tohumlanır (seeded). Sonrasında bu rastgele sayı üreteci üzerinde random_range metodunu çağırıyoruz. Bu metot, use rand::prelude::*; deyimiyle kapsama dahil ettiğimiz RngExt trait’i tarafından tanımlanmıştır. RngExt trait’i rastgele sayı üreteçlerinin uyguladığı metotları tanımlar ve bu metotları çağırabilmemiz için söz konusu trait’in kapsamda bulunması gerekir. Trait kavramını Bölüm 10’da ayrıntılı olarak inceleyeceğiz. random_range metodu argüman olarak bir aralık ifadesi (range expression) alır ve bu aralıkta rastgele bir sayı üretir. Burada kullandığımız aralık ifadesi baslangic..=bitis biçimindedir ve hem alt hem de üst sınırı aralığa dahil eder; dolayısıyla 1 ile 100 arasında bir sayı istemek için 1..=100 yazmamız gerekir.
Not: Bir crate’ten neleri kapsama dahil edeceğinizi ve hangi metot ya da fonksiyonları çağıracağınızı ezbere bilemezsiniz; bu nedenle her crate nasıl kullanılacağını açıklayan kendi belgelerine sahiptir. Cargo’nun çok kullanışlı bir diğer özelliği de cargo doc --open komutudur: Bu komut, projenizdeki tüm bağımlılıkların belgelerini yerel olarak derler ve tarayıcınızda açar. Örneğin rand crate’indeki diğer işlevleri merak ediyorsanız cargo doc --open komutunu çalıştırıp sol kenar çubuğundan rand’e tıklayabilirsiniz.
Eklediğimiz ikinci satır gizli sayıyı ekrana yazdırır. Programı geliştirirken test edebilmek açısından bu oldukça faydalıdır, ancak son sürümde bu satırı sileceğiz. Program başlar başlamaz doğru cevabı söylerse pek de bir tahmin oyunu sayılmaz!
Programı birkaç kez çalıştırmayı deneyin:
$ cargo run
Compiling tahmin_oyunu v0.1.0 (file:///projects/tahmin_oyunu)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
Running `target/debug/tahmin_oyunu`
Sayıyı tahmin et!
Gizli sayı: 7
Lütfen tahmininizi girin.
4
Tahmininiz: 4
$ cargo run
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
Running `target/debug/tahmin_oyunu`
Sayıyı tahmin et!
Gizli sayı: 83
Lütfen tahmininizi girin.
5
Tahmininiz: 5
Her çalıştırmada farklı rastgele sayılar görmelisiniz ve bunların tamamı 1 ile 100 arasında olmalıdır. Derleme sırasında uyarılar alırsanız bunları güvenle göz ardı edebilirsiniz. Hata alırsanız lütfen Cargo.toml dosyanızda rand = "0.10.1" yazdığından emin olun; rand’in gelecekteki ana sürümleri farklı bir API sunabilir, ancak 0.10 serisindeki herhangi bir sürüm bu bölümdeki kodla çalışacaktır.
Not: İnternetteki daha eski Rust kaynaklarında veya kod örneklerinde rand crate’inin 0.8 sürümüne ait use rand::Rng; ve rand::thread_rng().gen_range(1..=100) kullanımlarıyla karşılaşabilirsiniz. rand 0.9 sürümüyle rand::thread_rng() yerine rand::rng(), gen_range yerine random_range kullanılmaya başlanmış; 0.10 sürümünde ise random_range gibi metotları sağlayan trait Rng yerine RngExt adını almıştır; bu kitaptaki use rand::prelude::*; satırı bu trait’i kapsama dahil eder.
Tahmini Gizli Sayıyla Karşılaştırmak
Artık elimizde hem kullanıcı girdisi hem de rastgele bir sayı olduğuna göre bu ikisini karşılaştırabiliriz. Bu adım Liste 2-4’te gösterilmiştir. Açıklayacağımız nedenlerden ötürü bu kodun şu an için henüz derlenmeyeceğine dikkat edin.
use std::cmp::Ordering;
use std::io;
use rand::prelude::*;
fn main() {
// --snip--
println!("Sayıyı tahmin et!");
let gizli_sayi = rand::rng().random_range(1..=100);
println!("Gizli sayı: {gizli_sayi}");
println!("Lütfen tahmininizi girin.");
let mut tahmin = String::new();
io::stdin()
.read_line(&mut tahmin)
.expect("Satır okunamadı");
println!("Tahmininiz: {tahmin}");
match tahmin.cmp(&gizli_sayi) {
Ordering::Less => println!("Çok küçük!"),
Ordering::Greater => println!("Çok büyük!"),
Ordering::Equal => println!("Kazandınız!"),
}
}
İlk olarak standart kütüphaneden std::cmp::Ordering adlı türü kapsama dahil eden bir use deyimi daha ekliyoruz. Ordering türü de bir enum’dır ve Less (daha küçük), Greater (daha büyük) ve Equal (eşit) varyantlarına sahiptir. İki değeri karşılaştırdığınızda ortaya çıkabilecek üç olası sonuç bunlardır.
Ardından dosyanın sonuna Ordering türünü kullanan beş yeni satır ekliyoruz. cmp metodu iki değeri karşılaştırır ve karşılaştırılabilen her değer üzerinde çağrılabilir. Karşılaştırmak istediğiniz değere bir referans alır: Burada tahmin değişkenini gizli_sayi ile karşılaştırıyoruz. Ardından use deyimiyle kapsama aldığımız Ordering enum’ının bir varyantını döndürür. tahmin ve gizli_sayi değerleriyle yapılan cmp çağrısından hangi Ordering varyantının döndüğüne göre ne yapacağımıza karar vermek için bir match ifadesi kullanıyoruz.
Bir match ifadesi kollardan (match arms) oluşur. Her kol, eşleştirilecek bir desenden (pattern) ve match’e verilen değer o kolun desenine uyduğunda çalıştırılacak koddan meydana gelir. Rust, match ifadesine verilen değeri alır ve sırayla her bir kolun deseniyle karşılaştırır. Desenler ve match yapısı Rust’ın çok güçlü özellikleridir: Kodunuzun karşılaşabileceği çeşitli durumları açıkça ifade etmenizi ve tüm olasılıkları eksiksiz ele aldığınızdan emin olmanızı sağlarlar. Bu özellikler sırasıyla Bölüm 6 ve Bölüm 19’da ayrıntılı olarak incelenecektir.
Burada kullandığımız match ifadesinin nasıl çalıştığını bir örnek üzerinden adım adım izleyelim. Diyelim ki kullanıcı 50 tahmininde bulundu ve bu turda rastgele üretilen gizli sayı 38 oldu.
Kod 50 ile 38’i karşılaştırdığında, 50 sayısı 38’den büyük olduğu için cmp metodu Ordering::Greater döndürür. match ifadesi Ordering::Greater değerini alır ve her kolun desenini sırayla kontrol etmeye başlar. İlk kolun deseni olan Ordering::Less’e bakar ve Ordering::Greater değerinin Ordering::Less ile eşleşmediğini görür; dolayısıyla bu koldaki kodu atlayıp bir sonraki kola geçer. İkinci kolun deseni Ordering::Greater’dır ve elimizdeki Ordering::Greater değeriyle eşleşir! Bu koldaki kod çalıştırılır ve ekrana Çok büyük! yazdırılır. İlk başarılı eşleşmenin ardından match ifadesi sona erdiğinden bu senaryoda son kola hiç bakılmaz.
Ancak Liste 2-4’teki kod henüz derlenmeyecektir. Gelin deneyelim:
$ cargo build
Compiling libc v0.2.186
Compiling getrandom v0.4.3
Compiling rand_core v0.10.1
Compiling cfg-if v1.0.0
Compiling chacha20 v0.10.1
Compiling rand v0.10.1
Compiling tahmin_oyunu v0.1.0 (file:///projects/tahmin_oyunu)
error[E0308]: mismatched types
--> src/main.rs:23:22
|
23 | match tahmin.cmp(&gizli_sayi) {
| --- ^^^^^^^^^^^ expected `&String`, found `&{integer}`
| |
| arguments to this method are incorrect
|
= note: expected reference `&String`
found reference `&{integer}`
note: method defined here
--> /rustc/58cf8f9c35b355199727627d07ae34a4e2846d67/library/core/src/cmp.rs:1012:7
For more information about this error, try `rustc --explain E0308`.
error: could not compile `tahmin_oyunu` (bin "tahmin_oyunu") due to 1 previous error
Hata mesajının özü uyumsuz türler (mismatched types) bulunduğunu bildiriyor. Rust güçlü ve statik bir tür sistemine sahiptir. Bununla birlikte tür çıkarımı (type inference) yeteneği de vardır. let mut tahmin = String::new() yazdığımızda Rust, tahmin değişkeninin bir String olması gerektiğini kendiliğinden çıkardı ve türü açıkça yazmamızı istemedi. Öte yandan gizli_sayi bir sayı türüdür. Rust’ta 1 ile 100 arasında değer tutabilen birden fazla sayı türü bulunur: 32 bitlik işaretli tam sayı i32, 32 bitlik işaretsiz tam sayı u32, 64 bitlik tam sayı i64 ve diğerleri. Aksi belirtilmedikçe Rust tam sayılar için varsayılan olarak i32 kullanır; başka bir yerde derleyicinin farklı bir sayısal tür çıkarmasına yol açacak bir tür bilgisi vermediğiniz sürece gizli_sayi değişkeninin türü i32 olur. Hatanın nedeni, Rust’ın bir metin (String) ile bir sayıyı doğrudan karşılaştıramamasıdır.
Not: Derleyici çıktısındaki expected &String, found &{integer} (&String referansı beklenirken &{integer} tam sayı referansı bulundu) ifadesine dikkat edin. cmp metodunu String türündeki tahmin değişkeni üzerinde çağırdığımız için (tahmin.cmp(&gizli_sayi)), derleyici argümanın da aynı türden (&String) olmasını bekler ve &gizli_sayi tam sayı referansı geldiğinde tür uyuşmazlığı hatası verir.
Sonuç olarak programın girdi olarak okuduğu String değerini bir sayı türüne dönüştürmek istiyoruz ki gizli sayıyla sayısal olarak karşılaştırabilelim. Bunu main fonksiyonunun gövdesine şu satırı ekleyerek yapıyoruz:
Dosya adı: src/main.rs
use std::cmp::Ordering;
use std::io;
use rand::prelude::*;
fn main() {
println!("Sayıyı tahmin et!");
let gizli_sayi = rand::rng().random_range(1..=100);
println!("Gizli sayı: {gizli_sayi}");
println!("Lütfen tahmininizi girin.");
// --snip--
let mut tahmin = String::new();
io::stdin()
.read_line(&mut tahmin)
.expect("Satır okunamadı");
let tahmin: u32 = tahmin.trim().parse().expect("Lütfen bir sayı yazın!");
println!("Tahmininiz: {tahmin}");
match tahmin.cmp(&gizli_sayi) {
Ordering::Less => println!("Çok küçük!"),
Ordering::Greater => println!("Çok büyük!"),
Ordering::Equal => println!("Kazandınız!"),
}
}
Eklediğimiz satır şudur:
let tahmin: u32 = tahmin.trim().parse().expect("Lütfen bir sayı yazın!");
Burada yine tahmin adında bir değişken oluşturuyoruz. İyi ama programda zaten tahmin adında bir değişken yok muydu? Evet, var; ancak Rust önceki tahmin değerini yenisiyle gölgelememize (shadowing) olanak tanır. Gölgeleme, bizi tahmin_str ve tahmin gibi iki ayrı değişken oluşturmaya zorlamak yerine aynı tahmin adını yeniden kullanmamızı sağlar. Bu özelliği Bölüm 3’te daha ayrıntılı ele alacağız; şimdilik gölgelemenin en sık bir değeri bir türden başka bir türe dönüştürürken kullanıldığını bilmeniz yeterlidir.
Bu yeni değişkeni tahmin.trim().parse() ifadesine bağlıyoruz. Eşittirin sağındaki ifadede geçen tahmin, kullanıcı girdisini metin olarak tutan ilk tahmin değişkenini temsil eder. Bir String örneği üzerindeki trim metodu, metnin başındaki ve sonundaki tüm boşluk karakterlerini temizler. Yalnızca sayısal veri tutabilen u32 türüne dönüştürme yapmadan önce bu adımı mutlaka uygulamamız gerekir; çünkü kullanıcının read_line çağrısını tamamlayıp tahminini göndermek için enter tuşuna basması gerekir ve bu da string’in sonuna bir satır sonu karakteri ekler. Örneğin kullanıcı 5 yazıp enter’a bastığında tahmin gerçekte şöyle görünür: 5\n. Buradaki \n “yeni satır” (newline) karakterini temsil eder. (Windows’ta enter tuşuna basmak satır başı ve yeni satır anlamına gelen \r\n dizisini ekler.) trim metodu \n veya \r\n karakterlerini (ve varsa fazladan boşlukları) temizleyerek geriye yalnızca 5 kalmasını sağlar.
String’ler üzerindeki parse metodu, bir metni başka bir türe dönüştürür. Burada onu bir metni sayıya çevirmek için kullanıyoruz. parse pek çok farklı sayı türü üretebildiğinden, let tahmin: u32 yazarak Rust’a tam olarak hangi sayı türünü istediğimizi bildirmemiz gerekir. tahmin adından sonraki iki nokta üst üste (:), değişkenin türünü açıkça belirteceğimizi (type annotation) söyler. Rust’ta çeşitli yerleşik tam sayı türleri vardır; burada gördüğünüz u32, işaretsiz ve 32 bitlik bir tam sayıdır. Küçük pozitif sayılar için iyi bir varsayılan tercihtir. Diğer sayı türlerini Bölüm 3’te öğreneceksiniz.
Üstelik bu programda tahmin için yaptığımız u32 tür bildirimi ve aşağıda gizli_sayi ile yapılan karşılaştırma sayesinde Rust, gizli_sayi değişkeninin de bir u32 olması gerektiği çıkarımını otomatik olarak yapar. Böylece karşılaştırma artık aynı türden iki değer arasında gerçekleşir!
parse metodu yalnızca mantıksal olarak sayıya dönüştürülebilecek karakterler üzerinde çalışabilir ve bu yüzden kolayca hatayla karşılaşabilir. Örneğin string içinde A👍% yazıyor olsaydı bunu bir sayıya çevirmenin hiçbir yolu olmazdı. Başarısız olma ihtimali bulunduğundan parse metodu da tıpkı read_line metodu gibi (daha önce “Potansiyel Bir Hatayı Result ile Ele Almak” kısmında incelediğimiz üzere) bir Result türü döndürür. Bu Result değerini de yine expect metodunu kullanarak aynı şekilde ele alacağız. Eğer parse metodu verilen metinden bir sayı üretemediği için Err varyantı döndürürse expect çağrısı oyunu durdurup verdiğimiz hata mesajını ekrana yazdıracaktır. Eğer parse metni başarıyla sayıya dönüştürürse Result’ın Ok varyantını döndürür ve expect de Ok içindeki sayıyı çıkarıp bize verir.
Şimdi programı çalıştıralım:
$ cargo run
Compiling tahmin_oyunu v0.1.0 (file:///projects/tahmin_oyunu)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.26s
Running `target/debug/tahmin_oyunu`
Sayıyı tahmin et!
Gizli sayı: 58
Lütfen tahmininizi girin.
76
Tahmininiz: 76
Çok büyük!
Güzel! Tahminden önce boşluk bırakılmış olmasına rağmen program kullanıcının 76 tahmininde bulunduğunu sorunsuzca anladı. Farklı girdilerle programın davranışını doğrulamak için birkaç kez çalıştırın: Sayıyı doğru tahmin edin, çok büyük bir sayı girin ve çok küçük bir sayı girin.
Oyunun büyük kısmı artık çalışıyor, ancak kullanıcı yalnızca tek bir tahmin yapabiliyor. Bir döngü ekleyerek bunu değiştirelim!
Döngüyle Birden Fazla Tahmine İzin Vermek
loop anahtar kelimesi sonsuz bir döngü oluşturur. Kullanıcılara sayıyı bulmaları için birden fazla şans tanımak üzere bir döngü ekleyelim:
Dosya adı: src/main.rs
use std::cmp::Ordering;
use std::io;
use rand::prelude::*;
fn main() {
println!("Sayıyı tahmin et!");
let gizli_sayi = rand::rng().random_range(1..=100);
// --snip--
println!("Gizli sayı: {gizli_sayi}");
loop {
println!("Lütfen tahmininizi girin.");
// --snip--
let mut tahmin = String::new();
io::stdin()
.read_line(&mut tahmin)
.expect("Satır okunamadı");
let tahmin: u32 = tahmin.trim().parse().expect("Lütfen bir sayı yazın!");
println!("Tahmininiz: {tahmin}");
match tahmin.cmp(&gizli_sayi) {
Ordering::Less => println!("Çok küçük!"),
Ordering::Greater => println!("Çok büyük!"),
Ordering::Equal => println!("Kazandınız!"),
}
}
}
Gördüğünüz gibi, tahmin isteme satırından itibaren her şeyi bir loop bloğunun içine taşıdık. Döngü içindeki satırların her birini dörder boşluk daha girintilediğinizden emin olun ve programı yeniden çalıştırın. Program artık sonsuza dek yeni bir tahmin isteyecektir; tabii bu da beraberinde yeni bir sorun getirir: Kullanıcı oyundan çıkamıyor gibi görünüyor!
Kullanıcı ctrl-C klavye kısayolunu kullanarak programı her zaman yarıda kesebilir. Ancak “Tahmini Gizli Sayıyla Karşılaştırmak” kısmındaki parse açıklamasında değindiğimiz üzere, bu doymak bilmez canavardan kaçmanın bir yolu daha vardır: Kullanıcı sayı olmayan bir girdi yazarsa program çöker. Aşağıda gösterildiği gibi oyundan çıkmak için şimdilik bu davranıştan yararlanabiliriz:
$ cargo run
Compiling tahmin_oyunu v0.1.0 (file:///projects/tahmin_oyunu)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.23s
Running `target/debug/tahmin_oyunu`
Sayıyı tahmin et!
Gizli sayı: 59
Lütfen tahmininizi girin.
45
Tahmininiz: 45
Çok küçük!
Lütfen tahmininizi girin.
60
Tahmininiz: 60
Çok büyük!
Lütfen tahmininizi girin.
59
Tahmininiz: 59
Kazandınız!
Lütfen tahmininizi girin.
cikis
thread 'main' (6694925) panicked at src/main.rs:22:49:
Lütfen bir sayı yazın!: ParseIntError { kind: InvalidDigit }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
cikis yazmak gerçekten de oyundan çıkmanızı sağlar; ancak fark edeceğiniz üzere sayı olmayan herhangi bir başka girdi de aynı çökme sonucunu doğuracaktır. Bu en hafif tabirle pek ideal bir çözüm değildir; üstelik doğru sayı tahmin edildiğinde de oyunun kendiliğinden durmasını istiyoruz.
Doğru Tahminden Sonra Çıkmak
Kullanıcı kazandığında oyunun sonlanması için bir break deyimi ekleyelim:
Dosya adı: src/main.rs
use std::cmp::Ordering;
use std::io;
use rand::prelude::*;
fn main() {
println!("Sayıyı tahmin et!");
let gizli_sayi = rand::rng().random_range(1..=100);
println!("Gizli sayı: {gizli_sayi}");
loop {
println!("Lütfen tahmininizi girin.");
let mut tahmin = String::new();
io::stdin()
.read_line(&mut tahmin)
.expect("Satır okunamadı");
let tahmin: u32 = tahmin.trim().parse().expect("Lütfen bir sayı yazın!");
println!("Tahmininiz: {tahmin}");
// --snip--
match tahmin.cmp(&gizli_sayi) {
Ordering::Less => println!("Çok küçük!"),
Ordering::Greater => println!("Çok büyük!"),
Ordering::Equal => {
println!("Kazandınız!");
break;
}
}
}
}
Kazandınız! satırından hemen sonra break eklemek, kullanıcı gizli sayıyı doğru tahmin ettiğinde programın döngüden çıkmasını sağlar. Döngü main fonksiyonunun son kısmı olduğundan döngüden çıkmak aynı zamanda programın sonlanması anlamına gelir.
Geçersiz Girdiyi Ele Almak
Oyunun davranışını biraz daha iyileştirmek için, kullanıcı sayı olmayan bir girdi yazdığında programı çökertmek yerine bu geçersiz girdiyi yok sayıp kullanıcının tahmin etmeye devam edebilmesini sağlayalım. Bunu Liste 2-5’te gösterildiği gibi tahmin değişkeninin String’den u32’ye dönüştürüldüğü satırı değiştirerek yapabiliriz.
use std::cmp::Ordering;
use std::io;
use rand::prelude::*;
fn main() {
println!("Sayıyı tahmin et!");
let gizli_sayi = rand::rng().random_range(1..=100);
println!("Gizli sayı: {gizli_sayi}");
loop {
println!("Lütfen tahmininizi girin.");
let mut tahmin = String::new();
// --snip--
io::stdin()
.read_line(&mut tahmin)
.expect("Satır okunamadı");
let tahmin: u32 = match tahmin.trim().parse() {
Ok(sayi) => sayi,
Err(_) => continue,
};
println!("Tahmininiz: {tahmin}");
// --snip--
match tahmin.cmp(&gizli_sayi) {
Ordering::Less => println!("Çok küçük!"),
Ordering::Greater => println!("Çok büyük!"),
Ordering::Equal => {
println!("Kazandınız!");
break;
}
}
}
}
Hata anında programı çökertmekten hatayı gerçekten ele almaya geçmek için expect çağrısını bir match ifadesiyle değiştiriyoruz. parse metodunun bir Result türü döndürdüğünü ve Result’ın Ok ile Err varyantlarına sahip bir enum olduğunu hatırlayın. Burada, tıpkı cmp metodunun döndürdüğü Ordering sonucunda yaptığımız gibi bir match ifadesi kullanıyoruz.
Eğer parse metni başarıyla bir sayıya dönüştürebilirse elde edilen sayıyı içeren bir Ok değeri döndürür. Bu Ok değeri ilk kolun Ok(sayi) deseniyle eşleşir ve match ifadesi parse’ın üretip Ok içine koyduğu sayi değerini dışarı döndürür. Bu sayı da oluşturduğumuz yeni tahmin değişkenine tam istediğimiz şekilde atanır.
Eğer parse metni bir sayıya dönüştüremezse hata hakkında ek bilgi taşıyan bir Err değeri döndürür. Err değeri ilk match kolundaki Ok(sayi) deseniyle eşleşmez, ancak ikinci koldaki Err(_) deseniyle eşleşir. Alt çizgi (_) her değeri yakalayan (catch-all) bir joker desendir; bu örnekte içinde hangi hata bilgisi bulunursa bulunsun tüm Err değerleriyle eşleşmek istediğimizi belirtiyoruz. Böylece program ikinci kolun kodu olan continue ifadesini çalıştırır; bu da programa loop döngüsünün bir sonraki adımına geçip kullanıcıdan yeniden tahmin istemesini söyler. Kısacası program, parse sırasında oluşabilecek tüm hataları zarifçe yok saymış olur!
Artık programdaki her şey beklendiği gibi çalışmalıdır. Hemen deneyelim:
$ cargo run
Compiling tahmin_oyunu v0.1.0 (file:///projects/tahmin_oyunu)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s
Running `target/debug/tahmin_oyunu`
Sayıyı tahmin et!
Gizli sayı: 61
Lütfen tahmininizi girin.
10
Tahmininiz: 10
Çok küçük!
Lütfen tahmininizi girin.
99
Tahmininiz: 99
Çok büyük!
Lütfen tahmininizi girin.
foo
Lütfen tahmininizi girin.
61
Tahmininiz: 61
Kazandınız!
Harika! Son bir küçük dokunuşla tahmin oyununu tamamlayacağız. Programın hâlâ gizli sayıyı ekrana yazdırdığını hatırlayın. Test aşamasında bu çok işimize yaradı ama gerçek oyunu bozuyor. Gizli sayıyı yazdıran println! satırını silelim. Liste 2-6 oyunun tamamlanmış son kodunu göstermektedir.
use std::cmp::Ordering;
use std::io;
use rand::prelude::*;
fn main() {
println!("Sayıyı tahmin et!");
let gizli_sayi = rand::rng().random_range(1..=100);
loop {
println!("Lütfen tahmininizi girin.");
let mut tahmin = String::new();
io::stdin()
.read_line(&mut tahmin)
.expect("Satır okunamadı");
let tahmin: u32 = match tahmin.trim().parse() {
Ok(sayi) => sayi,
Err(_) => continue,
};
println!("Tahmininiz: {tahmin}");
match tahmin.cmp(&gizli_sayi) {
Ordering::Less => println!("Çok küçük!"),
Ordering::Greater => println!("Çok büyük!"),
Ordering::Equal => {
println!("Kazandınız!");
break;
}
}
}
}
Bu noktada tahmin oyununu başarıyla geliştirdiniz. Tebrikler!
Özet
Bu proje sizi pek çok yeni Rust kavramıyla uygulamalı olarak tanıştırmanın keyifli bir yoluydu: let, match, fonksiyonlar, harici crate’lerin kullanımı ve daha fazlası. İzleyen bölümlerde bu kavramları çok daha ayrıntılı olarak öğreneceksiniz. Bölüm 3; değişkenler, veri türleri ve fonksiyonlar gibi çoğu programlama dilinde bulunan kavramları ele alır ve bunların Rust’ta nasıl kullanıldığını gösterir. Bölüm 4, Rust’ı diğer dillerden ayıran temel özellik olan sahipliği (ownership) inceler. Bölüm 5 struct’ları ve metot sözdizimini ele alırken Bölüm 6 enum’ların nasıl çalıştığını açıklar.
Yaygın Programlama Kavramları
Bu bölüm, neredeyse her programlama dilinde karşımıza çıkan kavramları ve bunların Rust’ta nasıl çalıştığını ele alır. Birçok programlama dili özünde pek çok ortak noktaya sahiptir. Bu bölümde sunulan kavramların hiçbiri yalnızca Rust’a özgü değildir; ancak bunları Rust bağlamında inceleyecek ve kullanımlarına ilişkin yerleşik kuralları açıklayacağız.
Bu bölümde özellikle değişkenler, temel veri türleri, fonksiyonlar, yorumlar ve kontrol akışı konularını öğreneceksiniz. Bu yapı taşları her Rust programında yer alacağından, bunları erkenden kavramak yola sağlam bir temelle çıkmanızı sağlayacaktır.
Anahtar Kelimeler
Tıpkı diğer dillerde olduğu gibi Rust dilinde de yalnızca dilin kendisi tarafından kullanılmak üzere ayrılmış bir dizi anahtar kelime (keyword) bulunur. Bu kelimeleri doğrudan değişken veya fonksiyon adı olarak kullanamayacağınızı unutmayın. Anahtar kelimelerin büyük çoğunluğunun özel bir anlamı vardır ve Rust programlarınızda çeşitli görevleri yerine getirmek için bunları kullanacaksınız; birkaçının ise şu an için herhangi bir işlevi yoktur ancak gelecekte Rust’a eklenebilecek özellikler için şimdiden ayrılmıştır. Anahtar kelimelerin tam listesini Ek A bölümünde bulabilirsiniz.
Yanlışlıkla bir anahtar kelimeyi (örneğin type, match veya loop) değişken ya da fonksiyon adı olarak tanımlarsanız derleyici expected identifier, found keyword (tanımlayıcı bekleniyordu, anahtar kelime bulundu) hatası verir. Harici bir veri biçimiyle (örneğin JSON içindeki "type" alanıyla) veya başka bir dilde yazılmış kodlarla entegre olurken bir anahtar kelimeyi isim olarak kullanmanız zorunlu olursa, kelimenin önüne r# ön ekini getirerek (r#type gibi) Ek A’da anlatılan ham tanımlayıcı (raw identifier) sözdiziminden yararlanabilirsiniz.
Değişkenler ve Değiştirilebilirlik
Değişkenler ve Değiştirilebilirlik
“Değerleri Değişkenlerle Saklamak” bölümünde değindiğimiz gibi, değişkenler varsayılan olarak değiştirilemezdir (immutable). Bu, Rust’ın kodunuzu dilin sunduğu güvenlik ve kolay eşzamanlılık (concurrency) avantajlarından yararlanacak biçimde yazmanız için size yaptığı pek çok küçük yönlendirmeden biridir. Bununla birlikte, değişkenlerinizi değiştirilebilir (mutable) yapma seçeneğiniz her zaman vardır. Gelin Rust’ın sizi değiştirilemezliği tercih etmeye nasıl ve neden teşvik ettiğini, ayrıca bazen bundan neden vazgeçmek isteyebileceğinizi inceleyelim.
Bir değişken değiştirilemez olduğunda, bir isme bir değer bağlandıktan sonra o değeri bir daha değiştiremezsiniz. Bunu görmek için projects dizininizde cargo new degiskenler komutunu kullanarak degiskenler adında yeni bir proje oluşturun.
Ardından yeni degiskenler dizininizde src/main.rs dosyasını açın ve içeriğini şimdilik derlenmeyecek olan aşağıdaki kodla değiştirin:
Dosya adı: src/main.rs
fn main() {
let x = 5;
println!("x'in değeri: {x}");
x = 6;
println!("x'in değeri: {x}");
}
Dosyayı kaydedin ve cargo run komutuyla programı çalıştırın. Aşağıdaki çıktıda gösterildiği gibi bir değiştirilemezlik hatası mesajı almalısınız:
$ cargo run
Compiling degiskenler v0.1.0 (file:///projects/degiskenler)
error[E0384]: cannot assign twice to immutable variable `x`
--> src/main.rs:4:5
|
2 | let x = 5;
| - first assignment to `x`
3 | println!("x'in değeri: {x}");
4 | x = 6;
| ^^^^^ cannot assign twice to immutable variable
|
help: consider making this binding mutable
|
2 | let mut x = 5;
| +++
For more information about this error, try `rustc --explain E0384`.
error: could not compile `degiskenler` (bin "degiskenler") due to 1 previous error
Bu örnek, derleyicinin programlarınızdaki hataları bulmanıza nasıl yardımcı olduğunu gösterir. Derleyici hataları zaman zaman can sıkıcı olabilir; ancak bu hatalar yalnızca programınızın yapmasını istediğiniz işi henüz güvenli bir biçimde gerçekleştirmediği anlamına gelir, sizin iyi bir programcı olmadığınız anlamına gelmez! Deneyimli Rust geliştiricileri (Rustaceans) bile hâlâ derleyici hataları alırlar.
Değiştirilemez x değişkenine ikinci bir değer atamaya çalıştığınız için cannot assign twice to immutable variable `x` (x değiştirilemez değişkenine ikinci kez atama yapılamaz) hata mesajını aldınız.
Not: Rust derleyicisinin hata mesajları sorunu çözmeniz için gereken ipuçlarını adım adım sunar: error[E0384] hatanın kodunu, --> src/main.rs:4:5 hatanın tam konumunu (4. satır, 5. sütun), altındaki işaretler ise ilk atamanın nerede yapıldığını (first assignment to `x`) ve kural ihlalinin hangi satırda gerçekleştiğini gösterir. Ayrıca help: (yardım) satırı let mut x = 5; şeklinde doğrudan uygulanabilecek bir çözüm önerir. Bu hata kodu hakkında daha ayrıntılı açıklamayı ve ek örnekleri görmek isterseniz terminalinizde rustc --explain E0384 komutunu çalıştırabilirsiniz.
Değiştirilemez olarak tanımlanmış bir değeri değiştirmeye çalıştığımızda derleme zamanı (compile-time) hataları almamız son derece önemlidir; çünkü tam da bu durum kodda hatalara (bug) yol açabilir. Kodumuzun bir bölümü bir değerin asla değişmeyeceği varsayımıyla çalışırken başka bir bölümü o değeri değiştirirse, ilk bölümün tasarlandığı işi doğru yapmaması olasıdır. Bu tür bir hatanın nedenini sonradan bulmak çok zor olabilir; özellikle de ikinci kod parçası değeri yalnızca bazen değiştiriyorsa. Rust derleyicisi, bir değerin değişmeyeceğini belirttiğinizde gerçekten değişmeyeceğini garanti eder; dolayısıyla bunu kendiniz takip etmek zorunda kalmazsınız. Böylece kodunuz üzerinden akıl yürütmek çok daha kolaylaşır.
Ancak değiştirilebilirlik çok faydalı olabilir ve kod yazmayı daha pratik hâle getirebilir. Değişkenler varsayılan olarak değiştirilemez olsa da, Bölüm 2’de yaptığınız gibi değişken adının önüne mut ekleyerek onları değiştirilebilir yapabilirsiniz. mut eklemek aynı zamanda kodun başka bölümlerinin bu değişkenin değerini değiştireceğini açıkça göstererek kodu gelecekte okuyacak kişilere niyetinizi iletir.
Örneğin src/main.rs dosyasını aşağıdaki gibi değiştirelim:
Dosya adı: src/main.rs
fn main() {
let mut x = 5;
println!("x'in değeri: {x}");
x = 6;
println!("x'in değeri: {x}");
}
Programı şimdi çalıştırdığımızda şu çıktıyı elde ederiz:
$ cargo run
Compiling degiskenler v0.1.0 (file:///projects/degiskenler)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s
Running `target/debug/degiskenler`
x'in değeri: 5
x'in değeri: 6
mut kullanıldığında x değişkenine bağlı değeri 5’ten 6’ya değiştirmemize izin verilir. Sonuç olarak değiştirilebilirliği kullanıp kullanmamaya karar vermek size kalmıştır ve içinde bulunduğunuz durumda neyin en anlaşılır olduğunu düşündüğünüze bağlıdır.
Sabitleri Bildirmek
Değiştirilemez değişkenler gibi, sabitler (constants) de bir isme bağlı olan ve değiştirilmesine izin verilmeyen değerlerdir; ancak sabitler ile değişkenler arasında birkaç önemli fark vardır.
İlk olarak, sabitlerle birlikte mut kullanmanıza izin verilmez. Sabitler yalnızca varsayılan olarak değil, her zaman değiştirilemezdir. Sabitleri let anahtar kelimesi yerine const anahtar kelimesini kullanarak bildirirsiniz ve değerin türü mutlaka açıkça belirtilmelidir. Türleri ve tür bildirimlerini (type annotations) bir sonraki bölüm olan “Veri Türleri” kısmında ele alacağız; bu yüzden şimdilik ayrıntılar konusunda endişelenmeyin, yalnızca sabitlerde türü her zaman belirtmeniz gerektiğini bilin.
Sabitler, küresel kapsam (global scope) da dahil olmak üzere herhangi bir kapsamda bildirilebilir; bu da onları kodun pek çok bölümünün bilmesi gereken ortak değerler için kullanışlı kılar.
Son fark ise sabitlere yalnızca bir sabit ifadenin (constant expression), yani derleme zamanında değerlendirilebilen bir ifadenin atanabilmesidir; yalnızca çalışma zamanında (runtime) hesaplanabilecek bir değer sabite atanamaz.
İşte bir sabit bildirimi örneği:
#![allow(unused)]
fn main() {
const SANIYE_CINSINDEN_UC_SAAT: u32 = 60 * 60 * 3;
}
Sabitin adı SANIYE_CINSINDEN_UC_SAAT’tir; değeri ise 60 (bir dakikadaki saniye sayısı), 60 (bir saatteki dakika sayısı) ve 3 (bu programda saymak istediğimiz saat sayısı) sayılarının çarpımı olarak belirlenmiştir. Rust’ın sabitler için adlandırma geleneği, tüm harfleri büyük yazmak ve kelimeler arasında alt çizgi kullanmaktır (SCREAMING_SNAKE_CASE). Derleyici, derleme zamanında belirli bir dizi işlemi hesaplayabilir; bu sayede sabiti doğrudan 10_800 değerine eşitlemek yerine, anlaşılması ve doğrulanması çok daha kolay bir biçimde yazmayı seçebiliriz. Sabitleri bildirirken hangi işlemlerin kullanılabileceği hakkında daha fazla bilgi için Rust Referansı’nın sabit değerlendirme bölümüne bakabilirsiniz.
Sabitler, bildirildikleri kapsam içinde programın çalıştığı süre boyunca geçerlidir. Bu özellik; bir oyundaki herhangi bir oyuncunun kazanabileceği azami puan veya ışık hızı gibi, uygulama alanınızda programın birden fazla bölümünün bilmesi gerekebilecek değerler için sabitleri yararlı kılar.
Programınız boyunca doğrudan koda yazılmış sabit değerleri const ile isimlendirmek, bu değerin anlamını kodun gelecekteki bakımcılarına iletmek açısından faydalıdır. Ayrıca bu değerin gelecekte güncellenmesi gerektiğinde kodunuzda değiştirmeniz gereken yalnızca tek bir yer olmasını sağlar.
Not: Değiştirilemez bir let değişkeni ile const arasındaki pratik farkı
şöyle özetleyebiliriz: let yalnızca fonksiyon ve blok kapsamlarında
tanımlanabilir, derleyicinin tür çıkarımından yararlanabilir ve kullanıcı
girdisi ya da dosya okuma gibi çalışma zamanında üretilen bir değeri (sonradan
değiştirilmemek üzere) bağlayabilir. const ise modül ve dosya düzeyinde
küresel olarak da bildirilebilir, açık tür bildirimi zorunludur ve değeri
derleme zamanında hesaplanabilen bir ifade olmalıdır. Sabit için bütün
kullanımların paylaştığı tek bir bellek adresi garanti edilmez. Rust’ta ayrıca
static anahtar kelimesiyle bildirilen ve program boyunca bellekte sabit bir
adreste tutulan küresel değişkenler de vardır; bunları Bölüm 20’de güvenli
olmayan Rust’ı incelerken ele alacağız.
Gölgeleme
Bölüm 2’deki tahmin oyunu uygulamasında gördüğünüz gibi, daha önce tanımlanmış bir değişkenle aynı isimde yeni bir değişken bildirebilirsiniz. Rust geliştiricileri ilk değişkenin ikincisi tarafından gölgelendiğini (shadowed) söyler; bu da değişkenin adını kullandığınızda derleyicinin göreceği değişkenin artık ikincisi olduğu anlamına gelir. Özünde ikinci değişken ilkini gölgede bırakır ve kendisi de gölgelenene ya da bulunduğu kapsam sona erene kadar o değişken adının tüm kullanımlarını devralır. Bir değişkeni, aynı değişken adını kullanıp let anahtar kelimesini tekrarlayarak aşağıdaki gibi gölgeleyebiliriz:
Dosya adı: src/main.rs
fn main() {
let x = 5;
let x = x + 1;
{
let x = x * 2;
println!("İç kapsamdaki x'in değeri: {x}");
}
println!("x'in değeri: {x}");
}
Bu program önce x değişkenini 5 değerine bağlar. Ardından let x = kullanımını tekrarlayarak yeni bir x değişkeni oluşturur; önceki değeri alıp üzerine 1 ekler ve böylece x’in değeri 6 olur. Sonrasında süslü parantezlerle oluşturulan bir iç kapsamda, üçüncü let deyimi de x’i gölgeleyerek yeni bir değişken oluşturur ve önceki değeri 2 ile çarparak x’e 12 değerini verir. Bu iç kapsam sona erdiğinde içteki gölgeleme biter ve dış kapsamdaki, değeri hâlâ 6 olan x yeniden görünür hâle gelir. Burada bir değişkenin değeri geri alınmaz; aynı adlı farklı bağlamalar arasında kapsam değişir. Programı çalıştırdığımızda şu çıktıyı alırız:
$ cargo run
Compiling degiskenler v0.1.0 (file:///projects/degiskenler)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.15s
Running `target/debug/degiskenler`
İç kapsamdaki x'in değeri: 12
x'in değeri: 6
Gölgeleme, bir değişkeni mut olarak işaretlemekten farklıdır; çünkü let anahtar kelimesini kullanmadan bu değişkene yanlışlıkla yeniden değer atamaya çalışırsak derleme zamanı hatası alırız. let kullanarak bir değer üzerinde birkaç dönüştürme işlemi gerçekleştirebilir, ancak bu dönüştürmeler tamamlandıktan sonra değişkenin yine değiştirilemez kalmasını sağlayabiliriz.
mut ile gölgeleme arasındaki diğer önemli fark ise şudur: let anahtar kelimesini tekrar kullandığımızda gerçekte yepyeni bir değişken oluşturduğumuz için, aynı ismi korurken değerin türünü değiştirebiliriz. Örneğin programımız kullanıcıdan, metin parçaları arasında kaç boşluk istediğini boşluk karakterleri girerek göstermesini istesin; biz de ardından bu girdiyi bir sayı olarak saklamak isteyelim:
fn main() {
let bosluklar = " ";
let bosluklar = bosluklar.len();
}
İlk bosluklar değişkeni bir string dilimi (&str) türündedir, ikinci bosluklar değişkeni ise bir sayı (usize) türündedir. Gölgeleme böylece bizi bosluklar_metin ve bosluklar_sayi gibi farklı isimler türetmek zorunda kalmaktan kurtarır; bunun yerine daha yalın olan bosluklar ismini yeniden kullanabiliriz. Oysa aynı işi burada gösterildiği gibi mut kullanarak yapmaya çalışırsak derleme zamanı hatası alırız:
fn main() {
let mut bosluklar = " ";
bosluklar = bosluklar.len();
}
Hata mesajı, bir değişkenin türünü değiştirmemize izin verilmediğini (mismatched types — uyuşmayan türler) belirtir:
$ cargo run
Compiling degiskenler v0.1.0 (file:///projects/degiskenler)
error[E0308]: mismatched types
--> src/main.rs:3:17
|
2 | let mut bosluklar = " ";
| ----- expected due to this value
3 | bosluklar = bosluklar.len();
| ^^^^^^^^^^^^^^^ expected `&str`, found `usize`
For more information about this error, try `rustc --explain E0308`.
error: could not compile `degiskenler` (bin "degiskenler") due to 1 previous error
Not: Buradaki temel ayrımı netleştirelim: mut mevcut değişkenin bellekteki değerini aynı türde kalmak koşuluyla günceller; gölgeleme (let) ise aynı ismi kullanan ve gerekirse farklı bir türde de olabilen yeni bir değişken oluşturur. Ayrıca örnekteki .len() metodunun metindeki karakter sayısını değil, UTF-8 bayt uzunluğunu (usize) döndürdüğüne dikkat edin: ASCII boşluk karakterlerinin (' ') her biri 1 bayt olduğundan " ".len() ifadesi 3 döndürür; ancak 'ç', 'ğ', 'ı', 'ö', 'ş', 'ü' gibi Türkçe harfler UTF-8 kodlamasında 2 bayt yer kapladığından örneğin "şğü".len() çağrısı 6 döndürür (bu konuyu Bölüm 8’de ayrıntılarıyla inceleyeceğiz).
Artık değişkenlerin nasıl çalıştığını incelediğimize göre, değişkenlerin sahip olabileceği diğer veri türlerine göz atalım.
Veri Türleri
Veri Türleri
Rust’taki her değer belirli bir veri türüne (data type) sahiptir; bu bilgi Rust’a ne tür bir veriyle çalıştığını ve o veriyi nasıl işleyeceğini söyler. Bu bölümde iki veri türü alt kümesini inceleyeceğiz: skaler ve bileşik türler.
Rust’ın statik türlemeli (statically typed) bir dil olduğunu unutmayın; yani derleyicinin tüm değişkenlerin türlerini derleme zamanında bilmesi gerekir. Derleyici çoğu durumda değere ve onu nasıl kullandığımıza bakarak hangi türü istediğimizi kendiliğinden çıkarabilir. Ancak Bölüm 2’deki “Tahmini Gizli Sayıyla Karşılaştırmak” kısmında parse metodunu kullanarak bir String değerini sayısal bir türe dönüştürdüğümüzde olduğu gibi pek çok farklı türün mümkün olduğu durumlarda, aşağıdaki gibi açık bir tür bildirimi (type annotation) eklememiz gerekir:
#![allow(unused)]
fn main() {
let tahmin: u32 = "42".parse().expect("Bir sayı değil!");
}
Yukarıdaki kodda gösterilen : u32 tür bildirimini eklemezsek Rust aşağıdaki hatayı görüntüler; bu mesaj, derleyicinin hangi türü kullanmak istediğimizi anlamak için bizden daha fazla bilgiye ihtiyaç duyduğunu gösterir:
$ cargo build
Compiling no_type_annotations v0.1.0 (file:///projects/no_type_annotations)
error[E0284]: type annotations needed
--> src/main.rs:2:9
|
2 | let tahmin = "42".parse().expect("Bir sayı değil!");
| ^^^^^^ ----- type must be known at this point
|
= note: cannot satisfy `<_ as FromStr>::Err == _`
= note: the type must also implement `FromStr`
note: required by a bound in `core::str::<impl str>::parse`
--> /rustc/58cf8f9c35b355199727627d07ae34a4e2846d67/library/core/src/str/mod.rs:2782:4
help: consider giving `tahmin` an explicit type
|
2 | let tahmin: /* Type */ = "42".parse().expect("Bir sayı değil!");
| ++++++++++++
For more information about this error, try `rustc --explain E0284`.
error: could not compile `no_type_annotations` (bin "no_type_annotations") due to 1 previous error
Not: Derleyicinin ürettiği error[E0284]: type annotations needed (tür bildirimi gerekiyor) hatası, "42".parse() çağrısının u32, i32, f64 gibi metinden dönüştürülebilen (FromStr trait’ini uygulayan) pek çok farklı türe karşılık gelebileceğini ve tek başına yeterli ipucu bulunmadığını belirtir. Derleyici hatanın altındaki help satırında, değişken adının yanına : /* Type */ biçiminde açık bir tür yazmamızı önerir.
Diğer veri türleri için de farklı tür bildirimleri göreceksiniz.
Skaler Türler
Skaler (scalar) bir tür tek bir değeri temsil eder. Rust’ta dört temel skaler tür bulunur: tam sayılar, kayan noktalı sayılar, Boolean’lar ve karakterler. Bu türleri diğer programlama dillerinden de tanıyor olabilirsiniz. Gelin Rust’ta nasıl çalıştıklarına yakından bakalım.
Tam Sayı Türleri
Tam sayı (integer), kesirli bileşeni olmayan bir sayıdır. Bölüm 2’de tam sayı türlerinden biri olan u32 türünü kullanmıştık. Bu tür bildirimi, ilişkili değerin bellekte 32 bitlik alan kaplayan işaretsiz (unsigned) bir tam sayı olması gerektiğini belirtir (işaretli (signed) tam sayı türleri u yerine i harfiyle başlar). Tablo 3-1, Rust’taki yerleşik tam sayı türlerini gösterir. Bir tam sayı değerinin türünü bildirmek için bu varyantlardan herhangi birini kullanabiliriz.
Tablo 3-1: Rust’taki Tam Sayı Türleri
| Uzunluk | İşaretli | İşaretsiz |
|---|---|---|
| 8 bit | i8 | u8 |
| 16 bit | i16 | u16 |
| 32 bit | i32 | u32 |
| 64 bit | i64 | u64 |
| 128 bit | i128 | u128 |
| Mimariye bağlı | isize | usize |
Her tam sayı türünün belirli bir boyutu ve değer aralığı vardır. İşaretli türler negatif sayıları, sıfırı ve pozitif sayıları; işaretsiz türler ise sıfırı ve pozitif sayıları temsil eder. Buradaki “işaret”, sayının kaynak kodda mutlaka + veya - ile yazılması gerektiğini değil, türün negatif değerleri temsil edip edemediğini belirtir. İşaretli sayılar bellekte ikiye tümleyen (two’s complement) temsili kullanılarak saklanır.
Her işaretli varyant, n ilgili varyantın kullandığı bit sayısı olmak üzere, −(2n − 1) ile 2n − 1 − 1 (sınırlar dahil) aralığındaki sayıları saklayabilir. Dolayısıyla bir i8, −(27) ile 27 − 1 aralığındaki, yani −128 ile 127 arasındaki sayıları tutabilir. İşaretsiz varyantlar ise 0 ile 2n − 1 aralığındaki sayıları saklayabilir; örneğin bir u8, 0 ile 28 − 1 aralığındaki, yani 0 ile 255 arasındaki sayıları tutabilir.
Ek olarak isize ve usize türlerinin boyutu, programınızın çalıştığı bilgisayarın mimarisine bağlıdır: 64 bitlik bir mimaride 64 bit, 32 bitlik bir mimaride ise 32 bittir.
Tam sayı sabit değerlerini (integer literals) Tablo 3-2’de gösterilen biçimlerden herhangi biriyle yazabilirsiniz. Birden fazla sayısal türe uyabilen sayı sabit değerlerinin, türü açıkça belirlemek amacıyla 57u8 örneğinde olduğu gibi bir tür son eki (type suffix) alabileceğine dikkat edin. Ayrıca sayı sabit değerlerinde okunabilirliği artırmak için görsel ayırıcı olarak _ karakteri kullanılabilir; örneğin 1_000 yazmak, 1000 yazmakla birebir aynı değere karşılık gelir.
Tablo 3-2: Rust’taki Tam Sayı Sabit Değerleri
| Sayı sabit değerleri | Örnek |
|---|---|
| Onluk | 98_222 |
| On altılık | 0xff |
| Sekizlik | 0o77 |
| İkilik | 0b1111_0000 |
Bayt (yalnızca u8) | b'A' |
Peki hangi tam sayı türünü kullanacağınızı nasıl bileceksiniz? Emin değilseniz Rust’ın varsayılanları genellikle iyi bir başlangıç noktasıdır: Tam sayı türleri varsayılan olarak i32 kabul edilir. isize veya usize kullanacağınız başlıca durum ise bir koleksiyonu indekslemektir.
Tam Sayı Taşması
Diyelim ki 0 ile 255 arasında değerler tutabilen u8 türünde bir değişkeniniz
var. Bu değişkenin değerini bu aralığın dışına çıkaracak bir işlem yaparsanız
(örneğin 255 olan değerini 1 artırarak 256 yapmaya çalışırsanız) tam
sayı taşması (integer overflow) meydana gelir ve bu durum derleme moduna göre
iki farklı davranıştan biriyle sonuçlanır. (Doğrudan let x: u8 = 256; gibi
aralık dışı bir sabit değer yazmak ise taşma sayılmaz; derleyici böyle bir
satırı daha baştan literal out of range for `u8` hatasıyla reddeder.)
Hata ayıklama (debug) modunda derleme yaptığınızda Rust, tam sayı taşmasını
yakalayan kontroller ekler ve çalışma zamanında taşma gerçekleşirse
programınızın panik yapmasına neden olur. Panikleme (panicking), normal
kontrol akışını kesen bir hata mekanizmasıdır; her hata sonucu veya başarısız
çıkış bir panik değildir. Panikleri Bölüm 9’daki “panic! ile Kurtarılamayan
Hatalar” kısmında daha
derinlemesine inceleyeceğiz.
--release bayrağıyla yayın (release) modunda derleme yaptığınızda ise
Rust, varsayılan olarak bu tam sayı taşması kontrollerini eklemez. Bu
davranış profildeki overflow-checks ayarıyla değiştirilebilir; burada
varsayılan profilleri karşılaştırıyoruz. Kontroller kapalıyken taşma
meydana gelirse Rust ikiye tümleyen sarması (two’s complement wrapping) uygular. Kısacası,
türün tutabileceği en büyük değeri aşan sayılar başa dönerek türün alabileceği
en küçük değere “sarılır”. Bir u8 için 256 değeri 0, 257 değeri 1 olur
ve bu şekilde devam eder. Program paniklemez ancak değişken büyük olasılıkla
beklemediğiniz bir değer alır. Tam sayı taşmasının bu sarma davranışına
güvenerek kod yazmak hata olarak kabul edilir.
Taşma olasılığını açıkça ele almak istediğinizde, standart kütüphanenin ilkel sayısal türler için sunduğu şu metot ailelerini kullanabilirsiniz:
wrapping_*metotlarıyla tüm derleme modlarında sarma işlemi yapmak (örneğin255u8.wrapping_add(1)çağrısı0döndürür).checked_*metotlarıyla taşma durumundaNonedeğeri döndürmek (örneğin255u8.checked_add(1)çağrısıNonedöndürür).overflowing_*metotlarıyla hem sarmalanmış değeri hem de taşma olup olmadığını belirten birbooldeğerini birlikte döndürmek (örneğin255u8.overflowing_add(1)çağrısı(0, true)döndürür).saturating_*metotlarıyla değerin alt veya üst sınırında doygunluğa ulaşarak sabitlenmesini (saturate) sağlamak (örneğin255u8.saturating_add(1)çağrısı255döndürür).
Kayan Noktalı Türler
Rust’ta ayrıca ondalık basamak içeren kayan noktalı sayılar (floating-point numbers) için iki ilkel (primitive) tür bulunur. Rust’ın kayan noktalı türleri sırasıyla 32 bit ve 64 bit boyutundaki f32 ve f64’tür. Varsayılan tür f64’tür; çünkü modern işlemcilerde f32 ile neredeyse aynı hızda çalışırken daha yüksek duyarlılık (precision) sunar. Tüm kayan noktalı türler işaretlidir.
İşte kayan noktalı sayıların kullanımını gösteren bir örnek:
Dosya adı: src/main.rs
fn main() {
let x = 2.0; // f64
let y: f32 = 3.0; // f32
}
Kayan noktalı sayılar IEEE-754 standardına göre temsil edilir. f32 türü tek duyarlıklı (single-precision), f64 türü ise çift duyarlıklı (double-precision) bir kayan noktalı sayıdır.
Sayısal İşlemler
Rust, tüm sayı türleri için bekleyeceğiniz temel matematiksel işlemleri destekler: toplama, çıkarma, çarpma, bölme ve kalan (mod alma). Tam sayı bölmesi, sonucu sıfıra doğru en yakın tam sayıya yuvarlar; yani kesirli kısmı atar (truncate). Aşağıdaki kod, her bir sayısal işlemi bir let deyiminde nasıl kullanacağınızı gösterir:
Dosya adı: src/main.rs
fn main() {
// toplama
let toplam = 5 + 10;
// çıkarma
let fark = 95.5 - 4.3;
// çarpma
let carpim = 4 * 30;
// bölme
let bolum = 56.7 / 32.2;
let kesilmis = -5 / 3; // Sonuç -1 olur
// kalan
let kalan = 43 % 5;
}
Bu deyimlerdeki her bir ifade matematiksel bir operatör kullanır ve değerlendirilerek tek bir değer üretir; ardından bu değer ilgili değişkene bağlanır. Ek B, Rust’ın sunduğu tüm operatörlerin tam bir listesini içerir.
Not: Rust, farklı sayısal türler arasında örtük (otomatik) dönüşüm yapmaz:
i32 türündeki bir değeri i64 türündeki bir değerle doğrudan toplamaya
çalışırsanız derleyici cannot add `i64` to `i32` hatası verir. Böyle
durumlarda değerleri açıkça ortak bir türe dönüştürmeniz gerekir. Örneğin
i32 değerini i64 türüne kayıpsız dönüştürmek için i64::from(x) + y
yazabilirsiniz. as ile dönüşüm de mümkündür (x as i64 + y); ancak
daraltıcı tam sayı dönüşümleri veri kaybına yol açabilir. Hedef türe sığmayan
bir değeri hata olarak ele almak istiyorsanız TryFrom veya TryInto
kullanabilirsiniz; bu trait’leri ilerleyen bölümlerde göreceğiz. Ayrıca bir
tam sayıyı çalışma zamanında sıfıra bölmek, hem hata ayıklama hem de yayın modunda attempt to divide by zero mesajıyla panik üretir; kayan noktalı sayılarda ise sıfıra
bölme paniklemez, IEEE-754 kurallarına göre inf ya da NaN değerini
döndürür.
Boolean Türü
Diğer pek çok programlama dilinde olduğu gibi Rust’ta da Boolean türünün iki olası değeri vardır: true ve false. Boolean değerler bellekte bir bayt yer kaplar. Rust’ta Boolean türü bool anahtar kelimesiyle belirtilir. Örneğin:
Dosya adı: src/main.rs
fn main() {
let t = true;
let f: bool = false; // açık tür bildirimiyle
}
Boolean değerlerini kullanmanın başlıca yolu if ifadesi gibi koşullu yapılardır. if ifadelerinin Rust’ta nasıl çalıştığını “Kontrol Akışı” bölümünde ele alacağız.
Karakter Türü
Rust’ın char türü, dilin en temel alfabetik veri türüdür. İşte char değerleri tanımlamaya dair birkaç örnek:
Dosya adı: src/main.rs
fn main() {
let c = 'z';
let z: char = 'ℤ'; // açık tür bildirimiyle
let kalp_gozlu_kedi = '😻';
}
Çift tırnak kullanan string sabitlerinin (string literals) aksine, char sabitlerini tek tırnakla belirttiğimize dikkat edin. Rust’ın char türü 4 bayt boyutundadır ve bir Unicode skaler değerini temsil eder; bu sayede yalnızca ASCII karakterlerini değil, çok daha fazlasını ifade edebilir. Aksanlı ve noktalı harfler; Çince, Japonca ve Korece karakterler; emojiler ve sıfır genişlikli boşluklar Rust’ta geçerli birer char değeridir. Unicode skaler değerleri U+0000 ile U+D7FF ve U+E000 ile U+10FFFF (sınırlar dahil) aralıklarını kapsar. Bununla birlikte “karakter” aslında Unicode standardında doğrudan tanımlı bir kavram değildir; dolayısıyla bir “karakterin” ne olduğuna dair günlük sezgimiz Rust’taki char tanımıyla her zaman birebir örtüşmeyebilir. Bu konuyu Bölüm 8’deki “String’lerle UTF-8 Kodlanmış Metin Saklama” kısmında ayrıntılı olarak inceleyeceğiz.
Not: Türkçeye özgü 'ç', 'ğ', 'ı', 'İ', 'ö', 'ş', 'ü' harfleri ve '😻' gibi tek skaler değerden oluşan emojiler geçerli birer char sabitidir; her char değeri, hangi karakteri tuttuğundan bağımsız olarak bellekte 4 bayt yer kaplar. Buna karşılık Bölüm 8’de göreceğimiz UTF-8 kodlamalı String ve &str metinlerinde karakterler değişken uzunlukta saklanır (örneğin ASCII harfler 1 bayt, 'ğ' ve 'ş' gibi Türkçe harfler 2 bayt, '😻' emojisi ise 4 bayt yer kaplar). Ayrıca birden fazla Unicode skaler değerinin birleşimiyle oluşturulan bazı birleşik emojiler (örneğin "👨👩👧👦") tek bir char içine sığmaz.
Bileşik Türler
Bileşik türler (compound types) birden fazla değeri tek bir tür altında gruplayabilir. Rust’ta iki ilkel bileşik tür vardır: demetler (tuples) ve diziler (arrays).
Demet Türü
Demet (tuple), farklı türlerde olabilen bir grup değeri tek bir bileşik tür altında bir araya getirmenin genel bir yoludur. Demetlerin sabit bir uzunluğu vardır: Bir kez bildirildikten sonra boyutları büyüyemez veya küçülemez.
Parantez içine virgülle ayrılmış bir değer listesi yazarak bir demet oluştururuz. Demetteki her konumun kendine ait bir türü vardır ve farklı konumlardaki değerlerin türlerinin aynı olması gerekmez. Aşağıdaki örnekte isteğe bağlı tür bildirimlerini de ekledik:
Dosya adı: src/main.rs
fn main() {
let tup: (i32, f64, u8) = (500, 6.4, 1);
}
Bir demet tek bir bileşik öğe olarak kabul edildiğinden tup değişkeni demetin tamamına bağlanır. Demetin içindeki tekil değerleri elde etmek için desen eşleştirme (pattern matching) kullanarak bir demet değerini aşağıdaki gibi parçalayabiliriz:
Dosya adı: src/main.rs
fn main() {
let tup = (500, 6.4, 1);
let (x, y, z) = tup;
println!("y'nin değeri: {y}");
}
Bu program önce bir demet oluşturup onu tup değişkenine bağlar. Ardından let ile birlikte bir desen kullanarak tup değerini alır ve üç ayrı değişkene (x, y ve z) ayırır. Tek bir demeti üç parçaya ayırdığı için bu işleme yapı sökümü (destructuring) denir. Son olarak program, 6.4 olan y değerini ekrana yazdırır.
Bir demet elemanına, demetin adından sonra bir nokta (.) ve erişmek istediğimiz değerin indeksini yazarak doğrudan da ulaşabiliriz. Örneğin:
Dosya adı: src/main.rs
fn main() {
let x: (i32, f64, u8) = (500, 6.4, 1);
let bes_yuz = x.0;
let alti_nokta_dort = x.1;
let bir = x.2;
}
Bu program x demetini oluşturur ve ardından her bir elemanına kendi indeksi üzerinden erişir. Çoğu programlama dilinde olduğu gibi bir demetteki ilk indeks 0’dır.
Hiçbir değer içermeyen boş demetin özel bir adı vardır: birim (unit). Hem bu değer hem de karşılık gelen türü () biçiminde yazılır; boş bir değeri veya boş bir dönüş türünü temsil eder. İfadeler, başka bir değer döndürmediklerinde örtük olarak (implicitly) birim değerini döndürürler.
Not: Birim tür (), C veya Java gibi dillerdeki void kavramına benzer şekilde anlamlı bir değer taşınmadığını belirtir; ancak void’den farklı olarak gerçek ve somut bir türdür. Yalnızca tek bir olası değere (()) sahip olduğundan derleyici onun çalışma zamanında bellekte yer kaplamasına ihtiyaç duymaz (boyutu 0 bayttır). Dönüş türü yazılmayan fonksiyonların gerçek dönüş türü ()’dir.
Dizi Türü
Birden fazla değerden oluşan bir koleksiyon elde etmenin bir diğer yolu da dizi (array) kullanmaktır. Demetten farklı olarak bir dizinin tüm elemanları aynı türde olmalıdır. Diğer bazı dillerdeki dizilerin aksine, Rust’taki diziler sabit uzunlukludur.
Bir dizideki değerleri köşeli parantezler içinde virgülle ayrılmış bir liste hâlinde yazarız:
Dosya adı: src/main.rs
fn main() {
let a = [1, 2, 3, 4, 5];
}
Diziler, verilerinizin öbek (heap) yerine —şimdiye kadar gördüğümüz diğer türlerde olduğu gibi— yığın (stack) üzerinde ayrılmasını istediğinizde (Bölüm 4’te yığın ve öbeği daha ayrıntılı ele alacağız) veya her zaman sabit sayıda elemana sahip olduğunuzdan emin olmak istediğinizde kullanışlıdır. Ancak bir dizi, vektör (vector) türü kadar esnek değildir. Vektör, standart kütüphane tarafından sağlanan ve içeriği öbek üzerinde tutulduğu için boyutunun büyümesine veya küçülmesine izin verilen benzer bir koleksiyon türüdür. Dizi mi yoksa vektör mü kullanacağınızdan emin değilseniz büyük olasılıkla bir vektör kullanmalısınız. Bölüm 8 vektörleri daha ayrıntılı olarak inceler.
Bununla birlikte, eleman sayısının değişmeyeceğini bildiğiniz durumlarda diziler daha uygundur. Örneğin bir programda ay adlarını kullanıyorsanız, her zaman 12 eleman içereceğini bildiğiniz için büyük olasılıkla vektör yerine bir dizi tercih edersiniz:
#![allow(unused)]
fn main() {
let aylar = ["Ocak", "Şubat", "Mart", "Nisan", "Mayıs", "Haziran", "Temmuz",
"Ağustos", "Eylül", "Ekim", "Kasım", "Aralık"];
}
Bir dizinin türünü köşeli parantezler içinde her elemanın türünü, ardından bir noktalı virgülü ve dizideki eleman sayısını belirterek yazarız:
#![allow(unused)]
fn main() {
let a: [i32; 5] = [1, 2, 3, 4, 5];
}
Burada i32 her bir elemanın türüdür. Noktalı virgülden sonraki 5 sayısı ise dizinin beş eleman içerdiğini belirtir.
Tüm elemanları aynı değere sahip olacak bir diziyi, köşeli parantez içinde önce başlangıç değerini, ardından noktalı virgülü ve son olarak dizinin uzunluğunu yazarak da başlatabilirsiniz:
#![allow(unused)]
fn main() {
let a = [3; 5];
}
a adındaki bu dizi, başlangıçta tamamı 3 değerine ayarlanmış 5 eleman içerecektir. Bu ifade let a = [3, 3, 3, 3, 3]; yazmakla birebir aynıdır, ancak çok daha özlüdür.
Dizi Elemanlarına Erişim
Bir dizi, yığın üzerinde ayrılabilen, boyutu derleme zamanında bilinen sabit büyüklükte tek parça bir bellek bloğudur. İndeksleme kullanarak bir dizinin elemanlarına şu şekilde erişebilirsiniz:
Dosya adı: src/main.rs
fn main() {
let a = [1, 2, 3, 4, 5];
let ilk = a[0];
let ikinci = a[1];
}
Bu örnekte ilk adlı değişken, dizinin [0] indeksindeki değer olan 1 değerini alır. ikinci adlı değişken ise dizinin [1] indeksindeki 2 değerini alır.
Geçersiz Dizi Elemanlarına Erişim
Bir dizinin sonunu aşan bir indeksle dizi elemanına erişmeye çalışırsanız ne olacağına bakalım. Kullanıcıdan bir dizi indeksi almak için Bölüm 2’deki tahmin oyununa benzer aşağıdaki kodu çalıştırdığınızı düşünelim:
Dosya adı: src/main.rs
use std::io;
fn main() {
let a = [1, 2, 3, 4, 5];
println!("Lütfen bir dizi indeksi girin.");
let mut indeks = String::new();
io::stdin()
.read_line(&mut indeks)
.expect("Satırı okuyamadı!");
let indeks: usize = indeks
.trim()
.parse()
.expect("Girilen indeks bir sayı değildi!");
let element = a[indeks];
println!("{indeks} indeksindeki elemanın değeri: {element}");
}
Bu kod sorunsuzca derlenir. Kodu cargo run ile çalıştırıp 0, 1, 2, 3 veya 4 değerlerinden birini girerseniz program dizide o indekse karşılık gelen değeri ekrana yazdırır. Ancak dizinin sınırlarını aşan bir sayı (örneğin 10) girerseniz şuna benzer bir çıktı görürsünüz:
thread 'main' panicked at src/main.rs:19:19:
index out of bounds: the len is 5 but the index is 10
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Program, indeksleme işleminde geçersiz bir değerin kullanıldığı noktada bir çalışma zamanı hatasıyla karşılaştı; bir hata mesajı yazdırarak sonlandı ve en sondaki println! deyimini çalıştırmadı. İndeksleme yoluyla bir elemana erişmeye çalıştığınızda Rust, belirttiğiniz indeksin dizi uzunluğundan küçük olup olmadığını kontrol eder. İndeks dizi uzunluğuna eşit veya ondan büyükse Rust panikler. Özellikle bu örnekte olduğu gibi derleyicinin, kodu daha sonra çalıştıracak kullanıcının hangi sayıyı gireceğini derleme zamanında bilmesi imkânsız olduğundan bu kontrolün çalışma zamanında yapılması gerekir.
Not: Panik çıktısındaki index out of bounds: the len is 5 but the index is 10 mesajı “indeks sınırların dışında: dizinin uzunluğu 5 ama istenen indeks 10” anlamına gelir. Programı çökertmek (panic!) yerine sınır aşımını güvenli biçimde kontrol etmek istediğinizde, Bölüm 6 ve Bölüm 8’de göreceğimiz Option türünü döndüren a.get(indeks) metodunu kullanabilirsiniz.
Bu davranış, Rust’ın bellek güvenliği ilkelerinin iş başındaki somut bir örneğidir. Pek çok alt seviye dilde bu tür bir sınır kontrolü yapılmaz ve yanlış bir indeks verdiğinizde geçersiz bellek bölgelerine erişilebilir. Rust, geçersiz bellek erişimine izin verip çalışmaya devam etmek yerine programı derhal sonlandırarak sizi bu tür güvenlik açıklarına ve belirsiz davranışlara karşı korur. Bölüm 9’da Rust’ın hata yönetimi mekanizmalarını ve ne panikleyen ne de geçersiz bellek erişimine izin veren okunaklı, güvenli kodları nasıl yazabileceğinizi daha ayrıntılı olarak ele alacağız.
Fonksiyonlar
Fonksiyonlar
Fonksiyonlar Rust kodunda son derece yaygındır. Dildeki en önemli fonksiyonlardan birini zaten gördünüz: pek çok programın giriş noktası olan main fonksiyonu. Ayrıca yeni fonksiyonlar bildirmenizi sağlayan fn anahtar kelimesiyle de karşılaştınız.
Rust kodunda fonksiyon ve değişken adları için geleneksel yazım biçimi olarak tüm harflerin küçük yazıldığı ve kelimelerin alt çizgiyle (_) ayrıldığı snake case (yilan_bicimi) stili kullanılır. İşte örnek bir fonksiyon tanımı içeren bir program:
Dosya adı: src/main.rs
fn main() {
println!("Merhaba, dünya!");
baska_bir_fonksiyon();
}
fn baska_bir_fonksiyon() {
println!("Başka bir fonksiyon.");
}
Rust’ta bir fonksiyonu fn anahtar kelimesini, ardından fonksiyon adını ve bir çift parantezi yazarak tanımlarız. Süslü parantezler ({}), derleyiciye fonksiyon gövdesinin nerede başlayıp nerede bittiğini bildirir.
Tanımladığımız herhangi bir fonksiyonu, adını ve ardından bir çift parantez yazarak çağırabiliriz. baska_bir_fonksiyon programda tanımlı olduğu için main fonksiyonunun içinden çağrılabilir. Kaynak kodda baska_bir_fonksiyon fonksiyonunu main fonksiyonundan sonra tanımladığımıza dikkat edin; ondan önce de tanımlayabilirdik. Rust, fonksiyonlarınızı dosyanın neresinde tanımladığınızla ilgilenmez; yalnızca çağıran kodun görebileceği bir kapsam (scope) içinde tanımlanmış olmaları yeterlidir.
Fonksiyonları daha yakından incelemek için fonksiyonlar adında yeni bir ikili (binary) proje başlatalım. baska_bir_fonksiyon örneğini src/main.rs dosyasına yerleştirip çalıştırdığınızda aşağıdaki çıktıyı görmelisiniz:
$ cargo run
Compiling fonksiyonlar v0.1.0 (file:///projects/fonksiyonlar)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.09s
Running `target/debug/fonksiyonlar`
Merhaba, dünya!
Başka bir fonksiyon.
Satırlar, main fonksiyonunda yer aldıkları sırayla çalıştırılır. Önce "Merhaba, dünya!" mesajı yazdırılır, ardından baska_bir_fonksiyon çağrılır ve kendi mesajı ekrana yazdırılır.
Parametreler
Fonksiyonları, fonksiyon imzasının (signature) bir parçası olan özel değişkenlere, yani parametrelere (parameters) sahip olacak şekilde tanımlayabiliriz. Bir fonksiyonun parametreleri olduğunda, bu parametreler için fonksiyona somut değerler iletebilirsiniz. Teknik olarak bu somut değerlere argüman (argument) denir; ancak günlük kullanımda geliştiriciler hem fonksiyon tanımındaki değişkenler hem de fonksiyon çağrılırken iletilen somut değerler için parametre ve argüman kelimelerini sıklıkla birbirinin yerine kullanır.
baska_bir_fonksiyon fonksiyonunun bu sürümünde bir parametre ekliyoruz:
Dosya adı: src/main.rs
fn main() {
baska_bir_fonksiyon(5);
}
fn baska_bir_fonksiyon(x: i32) {
println!("x'in değeri: {x}");
}
Bu programı çalıştırmayı deneyin; aşağıdaki çıktıyı almalısınız:
$ cargo run
Compiling fonksiyonlar v0.1.0 (file:///projects/fonksiyonlar)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.09s
Running `target/debug/fonksiyonlar`
x'in değeri: 5
baska_bir_fonksiyon bildiriminin x adında bir parametresi vardır ve x’in türü i32 olarak belirtilmiştir. baska_bir_fonksiyon fonksiyonuna 5 değerini ilettiğimizde, println! makrosu, biçimlendirme metnindeki (format string) içinde x bulunan süslü parantez çiftinin yerine 5 değerini koyar.
Fonksiyon imzalarında her parametrenin türünü mutlaka bildirmelisiniz. Bu, Rust’ın tasarımında bilinçli olarak alınmış bir karardır: Fonksiyon tanımlarında tür bildirimini zorunlu kılmak, derleyicinin kodun diğer kısımlarında hangi türü kastettiğinizi anlamak için sizden neredeyse hiçbir zaman tür bildirimi istememesi anlamına gelir. Ayrıca derleyici, fonksiyonun hangi türleri beklediğini bildiğinde çok daha açıklayıcı hata mesajları üretebilir.
Not: Bir fonksiyonun fn anahtar kelimesi, adı, parametre listesi (adları ve türleri) ile varsa dönüş türünden (-> Tür) oluşan başlık kısmına fonksiyon imzası denir. Rust derleyicisi fonksiyon gövdelerinin içinde güçlü bir tür çıkarımı (type inference) yaparken fonksiyon sınırlarında açık tür bildirimi ister. Böylece her fonksiyon imzası kendi başına okunabilen net bir sözleşme görevi görür; bir fonksiyonun gövdesinde yapılan değişiklikler imzayı sessizce değiştirip çağıran kodları beklenmedik biçimde bozamaz.
Birden fazla parametre tanımlarken parametre bildirimlerini aşağıdaki gibi virgülle ayırın:
Dosya adı: src/main.rs
fn main() {
etiketli_olcum_yazdir(5, 'h');
}
fn etiketli_olcum_yazdir(deger: i32, birim_etiketi: char) {
println!("Ölçüm değeri: {deger}{birim_etiketi}");
}
Bu örnek, iki parametre alan etiketli_olcum_yazdir adında bir fonksiyon oluşturur. İlk parametre deger adındadır ve i32 türündedir. İkinci parametre ise birim_etiketi adındadır ve char türündedir. Fonksiyon daha sonra hem deger hem de birim_etiketi değerlerini içeren bir metin yazdırır.
Gelin bu kodu çalıştırmayı deneyelim. fonksiyonlar projenizin src/main.rs dosyasındaki mevcut programı yukarıdaki örnekle değiştirin ve cargo run komutuyla çalıştırın:
$ cargo run
Compiling fonksiyonlar v0.1.0 (file:///projects/fonksiyonlar)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.09s
Running `target/debug/fonksiyonlar`
Ölçüm değeri: 5h
Fonksiyonu deger için 5 ve birim_etiketi için 'h' değerleriyle çağırdığımızdan program çıktısı bu değerleri içerir.
Deyimler ve İfadeler
Fonksiyon gövdeleri, isteğe bağlı olarak sonunda bir ifade (expression) bulunan bir dizi deyimden (statement) oluşur. Şimdiye kadar ele aldığımız fonksiyonların sonunda bir ifade yer almıyordu; ancak bir deyimin parçası olarak ifadelerle karşılaştınız. Rust ifade tabanlı (expression-based) bir dil olduğundan bu ayrımı anlamak son derece önemlidir. Diğer pek çok dilde aynı ayrım bulunmaz; bu nedenle deyimlerin ve ifadelerin ne olduğuna ve aralarındaki farkların fonksiyon gövdelerini nasıl etkilediğine yakından bakalım.
- Deyimler, belirli bir eylemi gerçekleştiren ve değer döndürmeyen komutlardır.
- İfadeler, değerlendirilerek bir sonuç değeri üreten kod parçalarıdır.
Gelin bazı örneklere bakalım.
Aslında deyimleri ve ifadeleri daha önce de kullandık. let anahtar kelimesiyle bir değişken oluşturup ona değer atamak bir deyimdir. Liste 3-1’de let y = 6; bir deyimdir.
fn main() {
let y = 6;
}
main fonksiyonu bildirimiFonksiyon tanımları da birer deyimdir; yukarıdaki örneğin tamamı başlı başına bir deyimdir. (Ancak birazdan göreceğimiz gibi bir fonksiyonu çağırmak deyim değildir.)
Deyimler değer döndürmez. Bu nedenle aşağıdaki kodun yapmaya çalıştığı gibi bir let deyimini başka bir değişkene atayamazsınız; derleme hatası alırsınız:
Dosya adı: src/main.rs
fn main() {
let x = (let y = 6);
}
Bu programı çalıştırdığınızda alacağınız hata şu şekilde görünür:
$ cargo run
Compiling fonksiyonlar v0.1.0 (file:///projects/fonksiyonlar)
error: expected expression, found `let` statement
--> src/main.rs:2:14
|
2 | let x = (let y = 6);
| ^^^
|
= note: only supported directly in conditions of `if` and `while` expressions
warning: unnecessary parentheses around assigned value
--> src/main.rs:2:13
|
2 | let x = (let y = 6);
| ^ ^
|
= note: `#[warn(unused_parens)]` (part of `#[warn(unused)]`) on by default
help: remove these parentheses
|
2 - let x = (let y = 6);
2 + let x = let y = 6;
|
warning: `fonksiyonlar` (bin "fonksiyonlar") generated 1 warning
error: could not compile `fonksiyonlar` (bin "fonksiyonlar") due to 1 previous error; 1 warning emitted
let y = 6 deyimi bir değer döndürmediğinden x değişkeninin bağlanabileceği herhangi bir değer yoktur (derleyici de expected expression, found `let` statement yani “ifade bekleniyordu, let deyimi bulundu” diyerek tam olarak bu noktaya işaret eder). Bu davranış, atama işleminin atanan değeri döndürdüğü C ve Ruby gibi dillerden farklıdır. O dillerde x = y = 6 yazarak hem x hem de y değişkenlerinin 6 değerini almasını sağlayabilirsiniz; Rust’ta ise bu geçerli değildir.
İfadeler değerlendirilerek bir değer üretir ve Rust’ta yazacağınız kodun geri kalanının büyük çoğunluğunu oluşturur. Örneğin 5 + 6 gibi matematiksel bir işlemi düşünün; bu işlem değerlendirildiğinde 11 değerini üreten bir ifadedir. İfadeler, deyimlerin bir parçası olabilir: Liste 3-1’deki let y = 6; deyiminde yer alan 6, değerlendirildiğinde 6 değerini üreten bir ifadedir. Bir fonksiyonu çağırmak bir ifadedir. Bir makroyu çağırmak bir ifadedir. Süslü parantezlerle oluşturulan yeni bir kapsam bloğu da bir ifadedir; örneğin:
Dosya adı: src/main.rs
fn main() {
let y = {
let x = 3;
x + 1
};
println!("y'nin değeri: {y}");
}
Buradaki ifade:
{
let x = 3;
x + 1
}
bu örnekte değerlendirilerek 4 değerini üreten bir bloktur. Bu değer, let deyiminin bir parçası olarak y değişkenine bağlanır. Şimdiye kadar gördüğünüz çoğu satırın aksine, x + 1 satırının sonunda noktalı virgül (;) bulunmadığına dikkat edin. İfadelerin sonunda noktalı virgül yer almaz. Bir ifadenin sonuna noktalı virgül eklerseniz onu bir deyime dönüştürürsünüz ve artık bir değer döndürmez. Şimdi fonksiyonların dönüş değerlerini ve ifadeleri incelerken bu kuralı aklınızda bulundurun.
Not: Yukarıdaki let y = { ... }; örneğinde noktalı virgüllerin konumuna dikkat edin: Bloğun içindeki x + 1 satırının sonunda noktalı virgül yoktur; çünkü bu satır bloğun üreteceği değeri belirleyen sonuç ifadesidir. Kapanan süslü parantezin (};) hemen sonundaki noktalı virgül ise dıştaki let y = ...; atama deyimini sonlandırır. Kısacası Rust’ta ; yalnızca görsel bir satır sonu işareti değil, bir ifadeyi değer döndürmeyen bir deyime dönüştüren (ve sonunda sonuç ifadesi kalmayan bloğun birim tür () üretmesine yol açan) sözdizimsel bir araçtır.
Dönüş Değerleri Olan Fonksiyonlar
Fonksiyonlar, kendilerini çağıran koda değer döndürebilir. Dönüş değerlerine isim vermeyiz ancak türlerini bir ok işaretinden (->) sonra mutlaka bildirmemiz gerekir. Rust’ta bir fonksiyonun dönüş değeri, fonksiyon gövdesi bloğundaki son ifadenin değeriyle aynıdır. return anahtar kelimesini kullanıp bir değer belirterek bir fonksiyondan erken dönebilirsiniz; ancak çoğu fonksiyon son ifadeyi örtük olarak (implicitly) döndürür. İşte değer döndüren bir fonksiyon örneği:
Dosya adı: src/main.rs
fn bes() -> i32 {
5
}
fn main() {
let x = bes();
println!("x'in değeri: {x}");
}
bes fonksiyonunun içinde hiçbir fonksiyon çağrısı, makro, hatta let deyimi bile yoktur; yalnızca tek başına 5 sayısı yer alır. Bu, Rust’ta tamamen geçerli bir fonksiyondur. Fonksiyonun dönüş türünün de -> i32 olarak belirtildiğine dikkat edin. Bu kodu çalıştırmayı deneyin; çıktı şöyle görünmelidir:
$ cargo run
Compiling fonksiyonlar v0.1.0 (file:///projects/fonksiyonlar)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.09s
Running `target/debug/fonksiyonlar`
x'in değeri: 5
bes fonksiyonundaki 5, fonksiyonun dönüş değeridir; bu yüzden dönüş türü i32 olarak tanımlanmıştır. Bunu biraz daha ayrıntılı inceleyelim. Burada iki önemli nokta vardır: İlk olarak, let x = bes(); satırı bir değişkene ilk değerini vermek için bir fonksiyonun dönüş değerini kullandığımızı gösterir. bes fonksiyonu 5 döndürdüğü için bu satır aşağıdakiyle birebir aynıdır:
#![allow(unused)]
fn main() {
let x = 5;
}
İkinci olarak, bes fonksiyonu hiçbir parametre almaz ve dönüş değerinin türünü tanımlar; ancak fonksiyonun gövdesi, değerini döndürmek istediğimiz bir ifade olduğu için sonunda noktalı virgül bulunmayan tek bir 5 sayısından oluşur.
Başka bir örneğe bakalım:
Dosya adı: src/main.rs
fn main() {
let x = arti_bir(5);
println!("x'in değeri: {x}");
}
fn arti_bir(x: i32) -> i32 {
x + 1
}
Bu kodu çalıştırmak ekrana x'in değeri: 6 yazdıracaktır. Peki x + 1 satırının sonuna bir noktalı virgül koyarak onu bir ifadeden bir deyime dönüştürürsek ne olur?
Dosya adı: src/main.rs
fn main() {
let x = arti_bir(5);
println!("x'in değeri: {x}");
}
fn arti_bir(x: i32) -> i32 {
x + 1;
}
Bu kodu derlemek aşağıdaki gibi bir hata üretecektir:
$ cargo run
Compiling fonksiyonlar v0.1.0 (file:///projects/fonksiyonlar)
error[E0308]: mismatched types
--> src/main.rs:7:24
|
7 | fn arti_bir(x: i32) -> i32 {
| -------- ^^^ expected `i32`, found `()`
| |
| implicitly returns `()` as its body has no tail or `return` expression
8 | x + 1;
| - help: remove this semicolon to return this value
For more information about this error, try `rustc --explain E0308`.
error: could not compile `fonksiyonlar` (bin "fonksiyonlar") due to 1 previous error
Ana hata mesajı olan error[E0308]: mismatched types (uyuşmayan türler), bu koddaki temel sorunu açıkça ortaya koyar. arti_bir fonksiyonunun tanımı bir i32 döndüreceğini bildirir; ancak deyimler bir değer üretmez ve bu durum boş demet olan birim tür () ile temsil edilir (expected `i32`, found `()`). Derleyicinin belirttiği gibi, fonksiyon gövdesinde noktalı virgülsüz bir sonuç ifadesi (tail expression) veya return bulunmadığından fonksiyon örtük olarak () döndürür; bu da fonksiyon imzasındaki -> i32 tanımıyla çelişerek derleme hatasına yol açar. Çıktının sonundaki help: remove this semicolon to return this value (bu değeri döndürmek için bu noktalı virgülü kaldırın) satırı ise hatayı nasıl düzelteceğimizi doğrudan gösterir.
Not: İdiyomatik Rust kodunda return anahtar kelimesi genellikle yalnızca bir koşul sağlandığında fonksiyonun sonuna gelmeden erken çıkış yapmak için kullanılır. Fonksiyonun son satırında return x + 1; yazmak derlense de Rust topluluğunda gereksiz kabul edilir (hatta cargo clippy aracı bu kullanımda uyarı verir); bunun yerine son satırı x + 1 biçiminde noktalı virgülsüz bir sonuç ifadesi olarak bırakmak standart yaklaşımdır.
Yorumlar
Yorumlar
Tüm programcılar kodlarını kolay anlaşılır kılmak için çaba gösterir; ancak bazen ek açıklamalar yapmak gerekir. Bu gibi durumlarda programcılar kaynak kodlarına derleyicinin yok sayacağı, fakat kodu okuyan kişilerin faydalı bulabileceği yorumlar (comments) bırakırlar.
İşte basit bir yorum:
#![allow(unused)]
fn main() {
// merhaba, dünya
}
Rust’ta yaygın yorum biçimi iki eğik çizgiyle (//) başlar ve satır sonuna kadar devam eder. Birden fazla satırı bu biçimde yorumlamak için her satırın başına // ekleriz:
#![allow(unused)]
fn main() {
// Burada oldukça karmaşık bir işlem yapıyoruz; bunu açıklamak için birden
// fazla yorum satırına ihtiyaç duyacak kadar uzun! Vay canına! Umarım bu
// yorum burada neler olup bittiğini yeterince açıklar.
}
Yorumlar, kod içeren satırların sonuna da yerleştirilebilir:
Dosya adı: src/main.rs
fn main() {
let sansli_sayi = 7; // Bugün kendimi şanslı hissediyorum.
}
Ancak yorumların çoğunlukla bu biçimde, yani açıkladıkları kodun hemen üstünde ayrı bir satırda kullanıldığını görürsünüz:
Dosya adı: src/main.rs
fn main() {
// Bugün kendimi şanslı hissediyorum.
let sansli_sayi = 7;
}
Rust’ta ayrıca Bölüm 14’teki “Crates.io’da Bir Crate Yayınlama” kısmında ele alacağımız belgelendirme yorumları (documentation comments) adında başka bir yorum türü daha vardır.
Not: Rust’taki yorum biçimleri kullanım amacına göre birbirinden ayrılır:
//(Satır yorumu): Kaynak kodu okuyan geliştiricilere yönelik standart yorum biçimidir; derleyici tarafından tamamen yok sayılır./* ... */(Blok yorumu): C benzeri dillerden aşina olabileceğiniz bu sözdizimi Rust’ta da geçerlidir ve üstelik iç içe (/* dış /* iç */ dış */) kullanılabilir. Ancak Rust ekosisteminde çok satırlı açıklamalar için de//tercih edilir;/* ... */daha çok hata ayıklarken bir kod parçasını geçici olarak devre dışı bırakmak için kullanılır.///ve//!(Belgelendirme yorumları): Sıradan yorumların aksine derleyici vecargo docaracı tarafından işlenerek Markdown destekli HTML API belgelerine dönüştürülür; ayrıca içlerindeki örnek kod bloklarıcargo testçalıştırıldığında otomatik olarak test edilir.///kendisinden sonra gelen öğeyi (örneğin bir fonksiyonu veyastruct’ı),//!ise içinde bulunduğu kapsayıcı öğeyi (örneğin crate kökünü veya bir modülü) belgelendirir. Bu nedenle sıradan birletdeyiminin üstünde yanlışlıkla///kullanırsanız derleyiciunused doc comment(kullanılmayan belgelendirme yorumu) uyarısı verir.
Kontrol Akışı
Kontrol Akışı
Bir koşulun true olup olmamasına bağlı olarak belirli bir kodu çalıştırabilme ve bir koşul true olduğu sürece bir kodu tekrar tekrar yürütebilme olanağı, çoğu programlama dilinin temel yapı taşlarındandır. Rust kodunun yürütme akışını kontrol etmenizi sağlayan en yaygın yapılar if ifadeleri ve döngülerdir.
if İfadeleri
Bir if ifadesi, koşullara bağlı olarak kodunuzu dallandırmanıza olanak tanır. Bir koşul belirtir ve programa, “Bu koşul sağlanırsa şu kod bloğunu çalıştır; koşul sağlanmazsa bu kod bloğunu çalıştırma,” demiş olursunuz.
if ifadesini incelemek için projects dizininizde dallanmalar adında yeni bir proje oluşturun. src/main.rs dosyasına aşağıdaki kodu yazın:
Dosya adı: src/main.rs
fn main() {
let sayi = 3;
if sayi < 5 {
println!("koşul doğru");
} else {
println!("koşul yanlış");
}
}
Tüm if ifadeleri if anahtar kelimesiyle başlar ve ardından bir koşul gelir. Bu örnekte koşul, sayi değişkeninin 5’ten küçük bir değere sahip olup olmadığını kontrol eder. Koşul true olduğunda çalıştırılacak kod bloğunu, koşuldan hemen sonra süslü parantezler ({}) içine yerleştiririz. if ifadelerindeki koşullarla ilişkili kod bloklarına, tıpkı Bölüm 2’deki “Tahmini Gizli Sayıyla Karşılaştırmak” kısmında ele aldığımız match ifadelerindeki kollar gibi bazen kollar (arms) adı verilir.
Not: C, C++, Java veya JavaScript gibi dillerin aksine Rust’ta if koşulunun etrafına parantez yazılmaz (if (sayi < 5) yerine doğrudan if sayi < 5 yazılır). Koşulu gereksiz parantez içine alırsanız derleyici kodunuzu yine derler ancak gereksiz parantez kullanıldığına dair bir uyarı (unnecessary parentheses around `if` condition) verir.
İsteğe bağlı olarak —bu örnekte yaptığımız gibi— koşulun false olarak değerlendirilmesi durumunda programın çalıştıracağı alternatif bir kod bloğu sunmak üzere bir else ifadesi de ekleyebiliriz. Bir else ifadesi sağlamazsanız ve koşul false çıkarsa program if bloğunu doğrudan atlar ve sıradaki kod parçasına geçer.
Bu kodu çalıştırmayı deneyin; aşağıdaki çıktıyı görmelisiniz:
$ cargo run
Compiling dallanmalar v0.1.0 (file:///projects/dallanmalar)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.09s
Running `target/debug/dallanmalar`
koşul doğru
Şimdi ne olacağını görmek için sayi değişkeninin değerini koşulu false yapacak bir değerle değiştirmeyi deneyelim:
fn main() {
let sayi = 7;
if sayi < 5 {
println!("koşul doğru");
} else {
println!("koşul yanlış");
}
}
Programı tekrar çalıştırın ve çıktıya bakın:
$ cargo run
Compiling dallanmalar v0.1.0 (file:///projects/dallanmalar)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.09s
Running `target/debug/dallanmalar`
koşul yanlış
Bu koddaki koşulun mutlaka bir bool olması gerektiğini de vurgulamakta fayda var. Koşul bool türünde değilse derleme hatası alırız. Örneğin aşağıdaki kodu çalıştırmayı deneyin:
Dosya adı: src/main.rs
fn main() {
let sayi = 3;
if sayi {
println!("sayi'nın değeri üç");
}
}
Bu kez if koşulu 3 değerini üretir ve Rust derleyicisi bir hata verir:
$ cargo run
Compiling dallanmalar v0.1.0 (file:///projects/dallanmalar)
error[E0308]: mismatched types
--> src/main.rs:4:8
|
4 | if sayi {
| ^^^^ expected `bool`, found integer
For more information about this error, try `rustc --explain E0308`.
error: could not compile `dallanmalar` (bin "dallanmalar") due to 1 previous error
Derleyici hatası (expected `bool`, found integer), Rust’ın bir bool beklediğini ancak bunun yerine bir tam sayıyla karşılaştığını bildirir. Ruby ve JavaScript gibi dillerin aksine Rust, Boolean olmayan türleri otomatik olarak Boolean değerine dönüştürmeye (“truthy/falsy” değerlendirmesi yapmaya) çalışmaz. Her zaman açık davranmalı ve if yapısına koşul olarak doğrudan bir bool ifadesi vermelisiniz. Örneğin if kod bloğunun yalnızca bir sayı 0’a eşit olmadığında çalışmasını istiyorsak if ifadesini aşağıdaki gibi değiştirebiliriz:
Dosya adı: src/main.rs
fn main() {
let sayi = 3;
if sayi != 0 {
println!("sayı sıfırdan farklı bir şeydi");
}
}
Bu kodu çalıştırmak ekrana sayı sıfırdan farklı bir şeydi mesajını yazdıracaktır.
else if ile Birden Fazla Koşulu Ele Almak
if ve else yapılarını bir else if ifadesinde birleştirerek birden fazla koşulu kontrol edebilirsiniz. Örneğin:
Dosya adı: src/main.rs
fn main() {
let sayi = 6;
if sayi % 4 == 0 {
println!("sayi 4'e bölünebilir");
} else if sayi % 3 == 0 {
println!("sayi 3'e bölünebilir");
} else if sayi % 2 == 0 {
println!("sayi 2'ye bölünebilir");
} else {
println!("sayi 4'e, 3'e veya 2'ye bölünemez");
}
}
Bu programın izleyebileceği dört olası yürütme yolu vardır. Kodu çalıştırdıktan sonra aşağıdaki çıktıyı görmelisiniz:
$ cargo run
Compiling dallanmalar v0.1.0 (file:///projects/dallanmalar)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.09s
Running `target/debug/dallanmalar`
sayi 3'e bölünebilir
Bu program çalıştığında her bir if ifadesini sırayla kontrol eder ve koşulu true olarak değerlendirilen ilk gövdeyi yürütür. 6 sayısı 2’ye de tam bölünebildiği hâlde ne sayi 2'ye bölünebilir çıktısını ne de else bloğundaki sayi 4'e, 3'e veya 2'ye bölünemez metnini gördüğümüze dikkat edin. Bunun nedeni, Rust’ın yalnızca koşulu true olan ilk bloğu çalıştırması ve bir eşleşme bulduktan sonra geri kalan koşulları kontrol bile etmemesidir.
Çok fazla else if ifadesi kullanmak kodunuzu karmaşıklaştırabilir; bu yüzden birden fazla else if kullanmanız gerektiğinde kodunuzu yeniden düzenlemek (refactoring) isteyebilirsiniz. Bölüm 6, bu gibi durumlar için match adında güçlü bir Rust dallanma yapısını tanıtmaktadır.
Bir let Deyiminde if Kullanmak
if bir ifade (expression) olduğundan, Liste 3-2’de görüldüğü gibi ürettiği sonucu bir değişkene atamak üzere onu bir let deyiminin (statement) sağ tarafında kullanabiliriz.
fn main() {
let durum = true;
let sayi = if durum { 5 } else { 6 };
println!("sayi'nın değeri: {sayi}");
}
if ifadesinin sonucunu bir değişkene atamaksayi değişkeni, if ifadesinin ürettiği sonuca bağlı olarak bir değere bağlanacaktır. Ne olacağını görmek için bu kodu çalıştırın:
$ cargo run
Compiling dallanmalar v0.1.0 (file:///projects/dallanmalar)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.15s
Running `target/debug/dallanmalar`
sayi'nın değeri: 5
Kod bloklarının içlerindeki son ifadenin değerini ürettiğini ve sayıların da tek başlarına birer ifade olduğunu hatırlayın. Bu örnekte tüm if ifadesinin değeri, hangi kod bloğunun çalıştırıldığına bağlıdır. Dolayısıyla if ifadesinin her bir kolundan sonuç olarak dönebilecek değerlerin aynı türde olması gerekir; Liste 3-2’de hem if kolunun hem de else kolunun ürettiği sonuçlar i32 tam sayısıydı. Türler aşağıdaki örnekte olduğu gibi birbiriyle uyumsuzsa derleme hatası alırız:
Dosya adı: src/main.rs
fn main() {
let durum = true;
let sayi = if durum { 5 } else { "altı" };
println!("sayi'nın değeri: {sayi}");
}
Bu kodu derlemeye çalıştığımızda bir hata alırız. if ve else kolları birbiriyle uyumsuz değer türlerine sahiptir ve Rust derleyicisi programdaki sorunun tam olarak nerede olduğunu gösterir:
$ cargo run
Compiling dallanmalar v0.1.0 (file:///projects/dallanmalar)
error[E0308]: `if` and `else` have incompatible types
--> src/main.rs:4:38
|
4 | let sayi = if durum { 5 } else { "altı" };
| - ^^^^^^ expected integer, found `&str`
| |
| expected because of this
For more information about this error, try `rustc --explain E0308`.
error: could not compile `dallanmalar` (bin "dallanmalar") due to 1 previous error
if bloğundaki ifade bir tam sayı üretirken else bloğundaki ifade bir string dilimi (&str) üretir. Bu kod derlenmez (`if` and `else` have incompatible types); çünkü değişkenler tek bir türe sahip olmak zorundadır ve Rust’ın sayi değişkeninin hangi türde olduğunu derleme zamanında kesin olarak bilmesi gerekir. sayi değişkeninin türünü bilmek, derleyicinin sayi değişkenini kullandığımız her yerde türün geçerli olduğunu doğrulamasını sağlar. sayi’nin türü yalnızca çalışma zamanında belirlenseydi Rust bu doğrulamayı yapamazdı; derleyicinin herhangi bir değişken için birden fazla varsayımsal türü takip etmesi gerekseydi hem derleyici çok daha karmaşık olurdu hem de kod hakkında daha az garanti verebilirdi.
Not: Rust’ta if doğrudan değer üreten bir ifade olduğu için C, Java veya JavaScript’teki üçlü koşul operatörüne (durum ? 5 : 6) ihtiyaç duyulmaz; if durum { 5 } else { 6 } sözdizimi aynı işi çok daha okunaklı biçimde yapar. Liste 3-2’de { 5 } ve { 6 } bloklarının içinde noktalı virgül bulunmadığına (böylece birim tür () yerine i32 döndürdüklerine), en sondaki noktalı virgülün (};) ise dıştaki let deyimini sonlandırdığına dikkat edin. Ayrıca bir değer üretmek üzere kullanılan her if ifadesinin mutlaka bir else kolu olmalıdır.
Döngülerle Tekrar
Bir kod bloğunu birden fazla kez çalıştırmak çoğu zaman işe yarar. Rust bu görev için, döngü gövdesindeki kodu sonuna kadar çalıştırıp ardından hemen başa dönerek devam eden çeşitli döngüler (loops) sunar. Döngülerle denemeler yapmak için donguler adında yeni bir proje oluşturalım.
Rust’ta üç tür döngü vardır: loop, while ve for. Gelin her birini sırayla deneyelim.
loop ile Kodu Tekrarlamak
loop anahtar kelimesi, Rust’a bir kod bloğunu sonsuza dek ya da siz açıkça durmasını söyleyene kadar tekrar tekrar çalıştırmasını bildirir.
Örnek olarak donguler dizininizdeki src/main.rs dosyasını aşağıdaki gibi görünecek şekilde değiştirin:
Dosya adı: src/main.rs
fn main() {
loop {
println!("tekrar!");
}
}
Bu programı çalıştırdığımızda, programı elle durdurana kadar ekrana aralıksız olarak tekrar! yazdırıldığını görürüz. Çoğu terminal, sürekli bir döngü içinde çalışan programı yarıda kesmek (interrupt) için ctrl-C klavye kısayolunu destekler. Gelin deneyelim:
$ cargo run
Compiling donguler v0.1.0 (file:///projects/donguler)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.08s
Running `target/debug/donguler`
tekrar!
tekrar!
tekrar!
tekrar!
^Ctekrar!
^C sembolü, ctrl-C tuşlarına bastığınız noktayı gösterir.
Kesme sinyali ulaştığında yürütmenin döngünün neresinde olduğuna bağlı olarak ^C işaretinden sonra tekrar! kelimesinin bir kez daha yazdırıldığını görebilir veya görmeyebilirsiniz.
Neyse ki Rust, kod içinden döngüden çıkmanın bir yolunu da sunar. Programa döngüyü yürütmeyi ne zaman bırakacağını bildirmek için döngünün içine break anahtar kelimesini yerleştirebilirsiniz. Bölüm 2’deki “Doğru Tahminden Sonra Çıkmak” kısmında geliştirdiğimiz tahmin oyununda, kullanıcı doğru sayıyı bulup oyunu kazandığında programdan çıkmak için break kullandığımızı hatırlayın.
Tahmin oyununda ayrıca continue anahtar kelimesini de kullanmıştık; continue, bir döngü içinde programa mevcut döngü adımında (iteration) geriye kalan kodları atlamasını ve doğrudan bir sonraki adıma geçmesini söyler.
Döngülerden Değer Döndürmek
loop yapısının kullanım alanlarından biri, örneğin bir thread’in (iş parçacığı) işini tamamlayıp tamamlamadığını kontrol etmek gibi başarısız olabileceğini bildiğiniz bir işlemi yeniden denemektir. Üstelik bu işlemin sonucunu döngünün dışına, kodunuzun geri kalanına aktarmanız da gerekebilir. Bunu yapmak için döngüyü durdurmak amacıyla kullandığınız break ifadesinden sonra döndürülmesini istediğiniz değeri yazabilirsiniz; aşağıda gösterildiği gibi bu değer, kullanabilmeniz için döngünün dışına döndürülür:
fn main() {
let mut sayac = 0;
let sonuc = loop {
sayac += 1;
if sayac == 10 {
break sayac * 2;
}
};
println!("sonuc değeri: {sonuc}");
}
Döngüden önce sayac adında değiştirilebilir bir değişken bildirip ilk değerini 0 olarak veriyoruz. Ardından döngüden dönecek değeri tutmak üzere sonuc adında bir değişken tanımlıyoruz. Döngünün her adımında sayac değişkenine 1 ekliyor ve sayac değerinin 10’a eşit olup olmadığını kontrol ediyoruz. Eşit olduğunda break anahtar kelimesini sayac * 2 değeriyle birlikte kullanıyoruz. Döngü bloğundan sonra, üretilen değeri sonuc değişkenine atayan let deyimini sonlandırmak için bir noktalı virgül (};) koyuyoruz. Son olarak sonuc değişkenindeki değeri, yani bu örnekte 20 değerini ekrana yazdırıyoruz.
Bir döngünün içinden return de yapabilirsiniz. Ancak break yalnızca içinde bulunulan döngüden çıkarken, return her zaman içinde bulunulan fonksiyondan tamamen çıkar.
Not: break deger; ile döngüden değer döndürme özelliği yalnızca loop ifadelerinde geçerlidir; while ve for döngülerinden bu yolla özel bir değer döndürülemez (bu döngüler her zaman birim tür () üretir). Çünkü derleyici bir loop bloğundan yalnızca break ile çıkılabileceğini garanti edebilirken, while ve for döngülerinin koşulu daha ilk adımda false çıkıp hiç break çalıştırmadan da sonlanabilir.
Döngü Etiketleriyle Belirsizliği Gidermek
İç içe geçmiş döngüleriniz varsa break ve continue varsayılan olarak o noktadaki en içteki döngüye uygulanır. İsteğe bağlı olarak bir döngü üzerinde döngü etiketi (loop label) tanımlayabilir ve ardından break veya continue ile birlikte bu etiketi kullanarak söz konusu anahtar kelimelerin en içteki döngü yerine etiketli döngüye uygulanmasını sağlayabilirsiniz. Döngü etiketleri mutlaka tek tırnak işareti (') ile başlamalıdır. İşte iç içe iki döngü içeren bir örnek:
fn main() {
let mut ileri_sayim = 0;
'yukari_sayma: loop {
println!("İleri sayım = {ileri_sayim}");
let mut geri_sayim = 10;
loop {
println!("Geri sayım = {geri_sayim}");
if geri_sayim == 9 {
break;
}
if ileri_sayim == 2 {
break 'yukari_sayma;
}
geri_sayim -= 1;
}
ileri_sayim += 1;
}
println!("Son İlerleme = {ileri_sayim}");
}
Dıştaki döngü 'yukari_sayma etiketine sahiptir ve ileri_sayim değişkeniyle 0’dan 2’ye kadar yukarı doğru sayar. Etiketi olmayan içteki döngü ise geri_sayim değişkeniyle 10’dan 9’a kadar geriye doğru sayar. Etiket belirtmeyen ilk break yalnızca içteki döngüden çıkar. break 'yukari_sayma; deyimi ise doğrudan dıştaki döngüyü sonlandırır. Bu kod aşağıdaki çıktıyı üretir:
$ cargo run
Compiling donguler v0.1.0 (file:///projects/donguler)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.06s
Running `target/debug/donguler`
İleri sayım = 0
Geri sayım = 10
Geri sayım = 9
İleri sayım = 1
Geri sayım = 10
Geri sayım = 9
İleri sayım = 2
Geri sayım = 10
Son İlerleme = 2
while ile Koşullu Döngüleri Sadeleştirmek
Bir programın döngü içinde bir koşulu değerlendirmesi sıklıkla gerekir. Koşul true olduğu sürece döngü çalışmaya devam eder; koşul true olmaktan çıktığında ise program break çağırarak döngüyü durdurur. Böyle bir davranışı loop, if, else ve break yapılarını bir arada kullanarak uygulamak mümkündür; dilerseniz bunu şimdi bir programda deneyebilirsiniz. Ancak bu kullanım kalıbı o kadar yaygındır ki Rust bunun için while döngüsü adı verilen yerleşik bir dil yapısı sunar. Liste 3-3’te döngüyü üç kez çalıştırmak, her adımda geriye doğru saymak ve döngü bittikten sonra bir mesaj yazdırıp çıkmak için while kullanıyoruz.
fn main() {
let mut sayi = 3;
while sayi != 0 {
println!("{sayi}!");
sayi -= 1;
}
println!("KALKIŞ!!!");
}
true olduğu sürece kod çalıştırmak için while döngüsü kullanmakBu yapı, loop, if, else ve break bir arada kullanıldığında gerekecek iç içe blokların çoğunu ortadan kaldırır ve kodu çok daha okunaklı kılar. Bir koşul true olarak değerlendirildiği sürece kod çalışır; aksi takdirde döngüden çıkılır.
for ile Bir Koleksiyon Üzerinde Döngü Kurmak
Bir dizi gibi bir koleksiyonun elemanları üzerinde döngü kurmak için while yapısını da kullanabilirsiniz. Örneğin Liste 3-4’teki döngü, a dizisindeki her bir elemanı ekrana yazdırır.
fn main() {
let a = [10, 20, 30, 40, 50];
let mut indeks = 0;
while indeks < 5 {
println!("değer: {}", a[indeks]);
indeks += 1;
}
}
while döngüsü kullanarak koleksiyonun her bir elemanı üzerinde gezinmekBurada kod, dizideki elemanların indekslerini yukarı doğru sayar. 0 indeksinden başlar ve dizideki son indekse ulaşana dek (yani indeks < 5 koşulu artık true olmayana kadar) döngüyü sürdürür. Bu kodu çalıştırmak dizideki her bir elemanı yazdıracaktır:
$ cargo run
Compiling donguler v0.1.0 (file:///projects/donguler)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.15s
Running `target/debug/donguler`
değer: 10
değer: 20
değer: 30
değer: 40
değer: 50
Beklendiği gibi beş dizi elemanının tamamı terminalde görünür. indeks değişkeni bir noktada 5 değerine ulaşsa da döngü, diziden altıncı bir değeri okumaya çalışmadan hemen önce sonlanır.
Ne var ki bu yaklaşım hataya açıktır; indeks değeri veya sınır koşulu yanlış yazılırsa programın çalışma zamanında paniklemesine (panic) yol açabiliriz. Örneğin a dizisinin tanımını dört elemanlı olacak şekilde değiştirip koşulu while indeks < 4 olarak güncellemeyi unutursanız kod panikler. Üstelik bu yöntem daha yavaştır; çünkü derleyici, döngünün her adımında a[indeks] erişimi sırasında indeksin dizi sınırları içinde olup olmadığını denetleyen ek çalışma zamanı kodu (bounds check) üretmek zorunda kalır.
Bunun yerine daha özlü ve güvenli bir alternatif olarak for döngüsü kullanabilir ve koleksiyondaki her öğe için belirli bir kodu çalıştırabilirsiniz. Bir for döngüsü Liste 3-5’teki gibi görünür.
fn main() {
let a = [10, 20, 30, 40, 50];
for element in a {
println!("değer: {element}");
}
}
for döngüsü kullanarak koleksiyonun her bir elemanı üzerinde gezinmekBu kodu çalıştırdığımızda Liste 3-4 ile birebir aynı çıktıyı görürüz. Daha da önemlisi, artık kodun güvenliğini artırdık ve dizinin sonunu aşmaktan ya da yeterince ilerlemeyip bazı öğeleri atlamaktan kaynaklanabilecek hata olasılıklarını tamamen ortadan kaldırdık. İndeksin her adımda dizi uzunluğuyla karşılaştırılmasına gerek kalmadığından, for döngülerinden üretilen makine kodu da daha verimli çalışır.
Ayrıca for döngüsü kullandığınızda, dizideki eleman sayısını değiştirirseniz Liste 3-4’teki yöntemde olduğu gibi başka bir yerdeki sınır koşulunu güncellemeyi hatırlamanız gerekmez.
Not: for döngüsünde elemanın yanı sıra indeksine de ihtiyaç duyarsanız Liste 3-4’teki gibi elle sayaç tutmak yerine for (i, eleman) in a.iter().enumerate() yazabilirsiniz; enumerate metodu her adımda (indeks, eleman) biçiminde bir demet üretir. Bu yöntemi Bölüm 4’te dilimleri incelerken kullanacağız.
for döngülerinin sunduğu güvenlik ve sadelik, onları Rust’ta en sık kullanılan döngü yapısı hâline getirir. Liste 3-3’te while döngüsüyle yazdığımız geri sayım örneğinde olduğu gibi belirli bir kodu sabit sayıda çalıştırmak istediğiniz durumlarda bile çoğu Rust geliştiricisi (Rustacean) for döngüsünü tercih eder. Bunu yapmanın yolu, standart kütüphane tarafından sağlanan ve bir sayıdan başlayıp diğer sayıya gelmeden önceki tüm sayıları sırayla üreten Range (aralık) türünü kullanmaktır.
İşte bir for döngüsü ve aralığı tersine çeviren, henüz değinmediğimiz rev metodu kullanılarak yazılmış geri sayım örneği:
Dosya adı: src/main.rs
fn main() {
for sayi in (1..4).rev() {
println!("{sayi}!");
}
println!("KALKIŞ!!!");
}
Bu kod biraz daha hoş görünüyor, değil mi?
Not: Rust’ta aralık ifadelerinin iki yaygın biçimi vardır: Yukarıdaki (1..4) ifadesi üst sınırı aralığa dahil etmez (1, 2 ve 3 üretir; .rev() çağrısıyla 3, 2, 1 sırasına çevrilir). Üst sınırın da dahil edilmesini istediğinizde —Bölüm 2’deki 1..=100 örneğinde olduğu gibi— ..= sözdizimini kullanarak (1..=3).rev() yazabilirsiniz.
Özet
Başardınız! Bu oldukça kapsamlı bir bölümdü: Değişkenleri, skaler ve bileşik veri türlerini, fonksiyonları, yorumları, if ifadelerini ve döngüleri öğrendiniz! Bu bölümde ele alınan kavramlarla pratik yapmak için aşağıdaki işlevleri yerine getiren programlar geliştirmeyi deneyin:
- Sıcaklıkları Fahrenheit ve Celsius (Santigrat) birimleri arasında dönüştürün.
- n. Fibonacci sayısını üretin.
- Şarkıdaki tekrarlardan yararlanarak “The Twelve Days of Christmas” (Noel’in On İki Günü) şarkısının sözlerini ekrana yazdırın.
Not: Bu alıştırmaları çözerken işinize yarayabilecek birkaç küçük ipucu:
- Sıcaklık dönüşümü: Tam sayı bölmesinde ondalık kısmın kesilmemesi için (
5 / 9işleminin0üretmesi gibi) sıcaklık değerlerindef64kayan noktalı sayı türünü ve5.0 / 9.0gibi ondalıklı sabitleri kullanmaya dikkat edin. - Fibonacci sayısı:
0, 1, 1, 2, 3, 5, 8...dizisinde bir sonraki sayıyı hesaplarkenlet mutile tanımlanmış iki değişkeni bir döngü içinde güncelleyebilir; büyük n değerlerinde tam sayı taşmasını önlemek içinu64veyau128türlerini tercih edebilirsiniz. - Şarkı sözleri: Her kıtada önceki günlerin hediyeleri sondan başa doğru tekrarlandığından, gün adlarını ve hediyeleri sabit boyutlu dizilerde (
[&str; 12]) saklayıp dıştakifordöngüsüyle günleri ileriye (0..12), içtekifordöngüsüyle de.rev()metodunu kullanarak önceki hediyeleri geriye doğru gezebilirsiniz.
Devam etmeye hazır olduğunuzda, diğer programlama dillerinde yaygın olarak bulunmayan Rust’a özgü temel bir kavramı inceleyeceğiz: sahiplik (ownership).
Sahipliği Anlamak
Sahiplik (ownership), Rust’ın en ayırt edici özelliğidir ve dilin geri kalanı üzerinde derin etkilere sahiptir. Rust’ın bir çöp toplayıcıya (garbage collector) ihtiyaç duymadan bellek güvenliği garantisi sunmasını sağlar; bu nedenle sahipliğin nasıl çalıştığını anlamak büyük önem taşır. Bu bölümde sahipliğin yanı sıra onunla yakından ilişkili birkaç özelliği daha ele alacağız: ödünç alma (borrowing), dilimler (slices) ve Rust’ın verileri bellekte nasıl yerleştirdiği.
Not: Bu bölüm, Rust’ı diğer programlama dillerinden ayıran en temel tasarım felsefesine giriş niteliğindedir. C ve C++ gibi dillerde bellek yönetimi doğrudan geliştiricinin sorumluluğundayken, Java, Python veya Go gibi dillerde çalışma zamanında devreye giren bir çöp toplayıcı kullanılır. Rust ise bellek güvenliğini derleme zamanında, çalışma zamanında ek yük oluşturmadan denetleyen ödünç alma denetleyicisi (borrow checker) ile üçüncü bir yol açar. İlk başta bu kurallar alışılmadık gelebilir; ancak karşılaşacağınız her derleyici hatası, çalışma zamanında ortaya çıkabilecek zorlu bir bellek hatasını henüz kodunuz derlenirken yakalayan bir güvenlik ağıdır. Bu bölümdeki yığın (stack), öbek (heap), sahiplik ve referans kavramlarını sindirerek ilerlemek, kitabın geri kalanındaki tüm konuları çok daha rahat kavramanızı sağlayacaktır.
Sahiplik Nedir?
Sahiplik Nedir?
Sahiplik (ownership), bir Rust programının belleği nasıl yönettiğini belirleyen kurallar bütünüdür. Tüm programlar, çalışırken bilgisayarın belleğini nasıl kullandıklarını bir şekilde yönetmek zorundadır. Bazı dillerde program çalışırken artık kullanılmayan belleği düzenli olarak tarayıp temizleyen bir çöp toplama (garbage collection) mekanizması bulunur; bazı dillerde ise programcının belleği açıkça ayırması ve serbest bırakması gerekir. Rust üçüncü bir yaklaşım benimser: Bellek, derleyicinin derleme zamanında denetlediği kurallardan oluşan bir sahiplik sistemi aracılığıyla yönetilir. Bu kurallardan herhangi biri ihlal edilirse program derlenmez. Sahiplik sisteminin sunduğu özelliklerin hiçbiri, çalışırken programınızı yavaşlatmaz.
Sahiplik pek çok programcı için yeni bir kavram olduğundan buna alışmak biraz zaman alabilir. İyi haber şu ki Rust ve sahiplik sisteminin kuralları konusunda deneyim kazandıkça, güvenli ve verimli kodları doğal bir akışla geliştirmek sizin için giderek kolaylaşacaktır. Pes etmeden devam edin!
Sahipliği kavradığınızda, Rust’ı benzersiz kılan diğer özellikleri anlamak için sağlam bir temele sahip olacaksınız. Bu bölümde, çok yaygın bir veri yapısına odaklanan örnekler üzerinden sahipliği öğreneceksiniz: String’ler.
Yığın ve Öbek
Pek çok programlama dili, yığın (stack) ve öbek (heap) hakkında sık sık düşünmenizi gerektirmez. Ancak Rust gibi bir sistem programlama dilinde, bir değerin yığında mı yoksa öbekte mi tutulduğu dilin nasıl davrandığını ve neden belirli tasarım kararları almanız gerektiğini doğrudan etkiler. Bu bölümün ilerleyen kısımlarında sahipliğin bazı yönlerini yığın ve öbekle ilişkilendirerek açıklayacağımız için, ön hazırlık olarak burada kısa bir özet sunuyoruz.
Hem yığın hem de öbek, kodunuzun çalışma zamanında kullanabileceği bellek bölümleridir; ancak farklı biçimlerde yapılandırılmışlardır. Yığın, değerleri aldığı sırayla saklar ve ters sırayla çıkarır. Bu çalışma biçimine son giren ilk çıkar (last in, first out — LIFO) adı verilir. Üst üste dizilmiş tabakları düşünün: Yeni tabak eklediğinizde yığının en üstüne koyarsınız; bir tabağa ihtiyacınız olduğunda da yine en üstten alırsınız. Ortadan veya en alttan tabak eklemeye ya da çıkarmaya çalışmak pek iyi sonuç vermez! Yığına veri eklemeye yığına itme (pushing onto the stack), veri çıkarmaya ise yığından çıkarma (popping off the stack) denir. Yığında saklanan tüm verilerin derleme zamanında bilinen, sabit bir boyuta sahip olması gerekir. Derleme zamanında boyutu bilinmeyen veya çalışma zamanında boyutu değişebilecek veriler ise bunun yerine öbekte saklanmalıdır.
Öbek daha az düzenlidir: Öbeğe veri koymak istediğinizde belirli miktarda alan talep edersiniz. Bellek ayırıcı (memory allocator), öbekte yeterince büyük boş bir alan bulur, burayı kullanımda olarak işaretler ve o konumun adresi olan bir işaretçi (pointer) döndürür. Bu işleme öbekte yer ayırma (allocating on the heap) denir ve bazen kısaca yer ayırma (allocating) olarak adlandırılır (değerleri yığına itmek yer ayırma olarak kabul edilmez). Öbeğe işaret eden işaretçinin kendisi bilinen ve sabit bir boyuta sahip olduğundan bu işaretçiyi yığında saklayabilirsiniz; ancak asıl veriye ulaşmak istediğinizde işaretçiyi takip etmeniz gerekir. Bir restorana gittiğinizi düşünün: İçeri girdiğinizde grubunuzdaki kişi sayısını söylersiniz, görevli de herkese yetecek boş bir masa bulup sizi oraya yönlendirir. Grubunuzdan biri geç gelirse, sizi bulmak için hangi masaya oturtulduğunuzu sorması yeterlidir.
Yığına veri itmek, öbekte yer ayırmaktan daha hızlıdır; çünkü ayırıcının yeni veriyi saklamak için asla boş yer araması gerekmez — bu konum her zaman yığının en üstüdür. Buna karşılık öbekte yer ayırmak daha fazla iş gerektirir; çünkü ayırıcı önce veriyi tutacak büyüklükte boş bir alan bulmalı, ardından bir sonraki ayırma işlemine hazırlanmak için gerekli kayıtları tutmalıdır.
Öbekteki verilere erişmek, genellikle yığındaki verilere erişmekten daha yavaştır; çünkü hedefe ulaşmak için bir işaretçiyi takip etmeniz gerekir. Güncel işlemciler bellekte bir noktadan diğerine daha az atladıklarında çok daha hızlı çalışırlar. Restoran benzetmesine devam edersek, birden fazla masadan sipariş alan bir garsonu düşünün: En verimli yöntem, bir sonraki masaya geçmeden önce tek bir masadaki tüm siparişleri almaktır. Önce A masasından bir sipariş, sonra B masasından bir sipariş, ardından tekrar A’dan ve tekrar B’den sipariş almak süreci ciddi biçimde yavaşlatacaktır. Benzer şekilde bir işlemci de birbirinden uzakta duran veriler yerine (öbekte olabileceği gibi), birbirine yakın konumlanmış veriler üzerinde (yığında olduğu gibi) çalışırken görevini genellikle çok daha iyi yerine getirir.
Kodunuz bir fonksiyon çağırdığında, fonksiyona aktarılan değerler (öbekteki verilere işaret eden olası işaretçiler de dahil) ve fonksiyonun yerel değişkenleri yığına itilir. Fonksiyon sona erdiğinde ise bu değerler yığından çıkarılır.
Kodun hangi bölümlerinin öbekteki hangi verileri kullandığını takip etmek, öbekteki yinelenen veri miktarını en aza indirmek ve bellek alanınızın tükenmemesi için öbekte artık kullanılmayan verileri temizlemek; tümü sahiplik sisteminin çözdüğü sorunlardır. Sahipliği bir kez kavradığınızda yığın ve öbek üzerinde çok sık düşünmeniz gerekmeyecektir. Yine de sahipliğin temel amacının öbek verilerini yönetmek olduğunu bilmek, bu sistemin neden böyle çalıştığını anlamanıza büyük ölçüde yardımcı olur.
Sahiplik Kuralları
Öncelikle sahiplik kurallarına göz atalım. Bu kuralları somutlaştıran örnekler üzerinde çalışırken aşağıdaki üç maddeyi aklınızda bulundurun:
- Rust’taki her değerin bir sahibi (owner) vardır.
- Aynı anda yalnızca tek bir sahip olabilir.
- Sahip kapsam dışına çıktığında değer düşürülür (
dropedilir).
Değişken Kapsamı
Temel Rust sözdizimini geride bıraktığımıza göre, bundan sonraki örneklerin tamamına fn main() { kodunu dahil etmeyeceğiz; dolayısıyla örnekleri kendiniz deneyerek takip ediyorsanız kodları bir main fonksiyonunun içine yerleştirmeyi unutmayın. Böylece örneklerimiz daha özlü kalacak ve basmakalıp kodlar yerine doğrudan asıl ayrıntılara odaklanabileceğiz.
Sahipliğin ilk örneği olarak bazı değişkenlerin kapsamını (scope) inceleyeceğiz. Kapsam, bir öğenin program içinde geçerli olduğu aralıktır. Aşağıdaki değişkeni ele alalım:
#![allow(unused)]
fn main() {
let metin = "merhaba";
}
metin değişkeni bir string sabitine (string literal) başvurur; string sabitlerinde metnin değeri doğrudan programımızın kaynak koduna gömülüdür. Bu değişken, tanımlandığı noktadan içinde bulunduğu kapsamın sonuna kadar geçerlidir. Liste 4-1, metin değişkeninin nerelerde geçerli olacağını yorum satırlarıyla gösteren bir programı sunmaktadır.
fn main() {
{
// metin burada geçerli değil, henüz tanımlanmadı
let metin = "merhaba"; // metin bu noktadan itibaren geçerlidir
// metin ile ilgili işlemler yap
} // bu kapsam biter ve metin artık geçerli değildir
}
Başka bir deyişle, burada dikkat edilmesi gereken iki önemli an vardır:
metinkapsama girdiğinde geçerlidir.- Kapsam dışına çıkana kadar geçerliliğini korur.
Bu noktaya kadar kapsamlar ile değişkenlerin geçerlilik süresi arasındaki ilişki diğer programlama dillerindekine oldukça benzer. Şimdi String türünü tanıtarak bu temelin üzerine yeni kavramlar inşa edeceğiz.
String Türü
Sahiplik kurallarını göstermek için Bölüm 3’teki “Veri Türleri” bölümünde ele aldıklarımızdan daha karmaşık bir veri türüne ihtiyacımız var. Daha önce incelediğimiz türlerin hepsi bilinen bir boyuta sahiptir; yığında saklanabilir, kapsamları bittiğinde yığından çıkarılabilir ve kodun başka bir bölümü aynı değeri farklı bir kapsamda kullanmak istediğinde yeni ve bağımsız bir örnek oluşturmak üzere hızlıca kopyalanabilir. Biz ise öbekte saklanan verileri incelemek ve Rust’ın bu verileri ne zaman temizleyeceğini nasıl bildiğini keşfetmek istiyoruz; String türü bunun için harika bir örnektir.
Burada String türünün yalnızca sahiplikle ilgili yönlerine odaklanacağız. Bu özellikler, ister standart kütüphane tarafından sağlansın ister sizin tarafınızdan oluşturulsun, diğer karmaşık veri türleri için de geçerlidir. String türünün sahiplik dışındaki yönlerini Bölüm 8’de ayrıntılı olarak ele alacağız.
Bir metin değerinin programımıza doğrudan yazıldığı string sabitlerini zaten görmüştük. String sabitleri kullanışlıdır ancak metinlerle çalışmak isteyebileceğimiz her durum için uygun değildir. Bunun bir nedeni değiştirilemez olmalarıdır. Bir diğer nedeni ise kodumuzu yazarken her metin değerini önceden bilemememizdir: Örneğin kullanıcıdan bir girdi alıp saklamak istediğimizde ne yapacağız? Rust, tam da bu gibi durumlar için String türüne sahiptir. Bu tür, öbek üzerinde ayrılan verileri yönetir ve bu sayede derleme zamanında boyutu bilinmeyen miktarda metni saklayabilir. Aşağıdaki gibi from fonksiyonunu kullanarak bir string sabitinden String oluşturabilirsiniz:
#![allow(unused)]
fn main() {
let metin = String::from("merhaba");
}
Çift iki nokta üst üste (::) operatörü, string_from gibi bir isim kullanmak yerine bu özel from fonksiyonunu String türünün ad alanı (namespace) altında çağırmamıza olanak tanır. Bu sözdizimini Bölüm 5’teki “Metotlar” bölümünde ve Bölüm 7’de modüllerle ad alanlarını incelerken “Modül Ağacındaki Bir Öğeye Başvurmak İçin Yollar” bölümünde daha ayrıntılı ele alacağız.
Bu tür bir string değiştirilebilir:
fn main() {
let mut metin = String::from("merhaba");
metin.push_str(", dünya!"); // push_str() bir String'e sabit metin ekler
println!("{metin}"); // bu `merhaba, dünya!` yazdıracak
}
Peki buradaki fark nedir? Neden String değiştirilebilirken string sabitleri değiştirilemez? Aradaki fark, bu iki türün belleği ele alış biçiminden kaynaklanır.
Bellek ve Ayırma
Bir string sabitinde içeriği derleme zamanında bildiğimiz için metin doğrudan son çalıştırılabilir ikili dosyaya gömülür. String sabitlerinin hızlı ve verimli olmasının nedeni budur. Bu kullanımda metnin içeriği baştan bilindiği için çalışma zamanında ayrıca bellek ayırmaya gerek yoktur. Ne yazık ki derleme zamanında boyutu bilinmeyen ve program çalışırken boyutu değişebilecek her bir metin parçası için ikili dosyanın içine sabit bir bellek bloğu koyamayız.
String türünde ise değiştirilebilir ve büyüyebilir bir metin parçasını desteklemek amacıyla, içeriği tutmak üzere öbekte derleme zamanında bilinmeyen miktarda bellek ayırmamız gerekir. Bu durum iki gereklilik doğurur:
- Bellek, çalışma zamanında bellek ayırıcıdan talep edilmelidir.
Stringile işimiz bittiğinde bu belleği ayırıcıya iade etmenin bir yoluna ihtiyacımız vardır.
İlk kısım bizim tarafımızdan başlatılır: String::from fonksiyonunu çağırdığımızda, arka plandaki uygulaması ihtiyaç duyduğu belleği talep eder. Bu kısım programlama dillerinin neredeyse tamamında ortaktır.
Ancak ikinci kısım dilden dile farklılaşır. Bir çöp toplayıcıya (garbage collector — GC) sahip dillerde GC, artık kullanılmayan belleği takip edip temizler ve bizim bunu düşünmemiz gerekmez. Çöp toplayıcısı olmayan çoğu dilde ise belleğin ne zaman artık kullanılmadığını tespit etmek ve tıpkı talep ederken yaptığımız gibi açıkça kod çağırarak onu serbest bırakmak bizim sorumluluğumuzdadır. Bunu hatasız yapmak tarihsel olarak zorlu bir programlama problemi olmuştur. Unutursak belleği boşa harcarız. Çok erken serbest bırakırsak elimizde geçersiz bir değişken kalır. İki kez serbest bırakırsak bu da ayrı bir hatadır. Tam olarak bir allocate (ayırma) işlemini tam olarak bir free (serbest bırakma) işlemiyle eşleştirmemiz gerekir.
Rust ise farklı bir yol izler: Bellek, kendisine sahip olan değişken kapsam dışına çıktığı anda otomatik olarak iade edilir. İşte Liste 4-1’deki kapsam örneğimizin string sabiti yerine String kullanan sürümü:
fn main() {
{
let metin = String::from("merhaba"); // metin bu noktadan itibaren geçerlidir
// metin ile ilgili işlemler yap
} // bu kapsam artık bitti ve metin artık
// geçerli değil
}
String değerimizin kullandığı belleği ayırıcıya iade edebileceğimiz doğal bir nokta vardır: metin değişkeni kapsam dışına çıktığı an. Bir değişken kapsam dışına çıktığında Rust bizim yerimize özel bir fonksiyon çağırır. drop adı verilen bu fonksiyon, String türünün yazarının belleği iade edecek kodu yerleştirdiği yerdir. Rust, kapanış süslü parantezinde drop fonksiyonunu otomatik olarak çağırır.
Not: C++ dilinde, bir öğenin ömrünün sonunda kaynakları serbest bırakma kalıbına kimi zaman Kaynak Edinimi İlklendirmedir (Resource Acquisition Is Initialization — RAII) adı verilir. Daha önce RAII kalıplarını kullandıysanız Rust’taki drop fonksiyonu size oldukça tanıdık gelecektir.
Bu kalıbın Rust kodunun yazılma biçimi üzerinde derin bir etkisi vardır. Şu an basit görünebilir; ancak öbekte ayırdığımız veriyi birden fazla değişkenin kullanmasını istediğimiz daha karmaşık durumlarda kodun davranışı beklenmedik gelebilir. Gelin şimdi bu durumları yakından inceleyelim.
Değişkenlerin ve Verilerin Taşıma ile Etkileşimi
Rust’ta birden fazla değişken aynı veriyle farklı biçimlerde etkileşime girebilir. Liste 4-2, tam sayılar kullanan bir örneği göstermektedir.
fn main() {
let x = 5;
let y = x;
}
x değişkeninin tam sayı değerini y değişkenine atamaBu kodun ne yaptığını muhtemelen tahmin edebiliriz: “5 değerini x değişkenine bağla; ardından x içindeki değerin bir kopyasını oluşturup y değişkenine bağla.” Artık elimizde x ve y olmak üzere iki değişken vardır ve ikisi de 5 değerine eşittir. Gerçekleşen şey de tam olarak budur; çünkü tam sayılar bilinen, sabit bir boyuta sahip basit değerlerdir ve bu iki 5 değeri doğrudan yığına itilir.
Şimdi aynı işlemin String sürümüne bakalım:
fn main() {
let metin1 = String::from("merhaba");
let metin2 = metin1;
}
Bu kod önceki örneğe çok benzediği için çalışma şeklinin de aynı olacağını varsayabiliriz: Yani ikinci satırın metin1 içindeki değerin bir kopyasını oluşturup metin2 değişkenine bağlayacağını düşünebiliriz. Ancak burada gerçekleşen şey tam olarak bu değildir.
String için perde arkasında neler olup bittiğini görmek üzere Şekil 4-1’e göz atın. Bir String, solda gösterildiği gibi üç bileşenden oluşur: metnin içeriğini tutan belleğe işaret eden bir işaretçi (ptr), bir uzunluk (len) ve bir kapasite (capacity). Bu veri grubu yığında saklanır. Sağda ise metnin asıl içeriğini barındıran öbek belleği yer alır.
Şekil 4-1: metin1 değişkenine bağlı "merhaba" değerini tutan bir String’in bellekteki temsili
Uzunluk (len), String içeriğinin o anda bayt cinsinden ne kadar bellek kullandığını belirtir. Kapasite (capacity) ise String’in bellek ayırıcıdan aldığı toplam bellek miktarının bayt cinsinden değeridir. Uzunluk ile kapasite arasındaki fark önemlidir, ancak bu bağlamda belirleyici olmadığından şimdilik kapasiteyi göz ardı edebiliriz.
Not: Şekil 4-1’de kod örneğiyle aynı "merhaba" metni kullanılır. Bu metin 7 ASCII karakterden oluştuğu için uzunluğu 7 bayttır; bu örnekte gösterilen kapasite de 7 bayttır. len alanı karakter sayısını değil bayt sayısını tutar. Örneğin UTF-8 kodlamasında "dünya" 5 harfli olmasına rağmen 'ü' 2 bayt tuttuğundan 6 bayt (len = 6) uzunluğundadır.
metin1’i metin2’ye atadığımızda String’in yığın verisi kopyalanır; yani yığında bulunan işaretçiyi, uzunluğu ve kapasiteyi kopyalarız. İşaretçinin gösterdiği öbek üzerindeki veriyi ise kopyalamayız. Başka bir deyişle, bellekteki veri yerleşimi Şekil 4-2’deki gibi görünür.
Şekil 4-2: metin1 değişkeninin işaretçi, uzunluk ve kapasite değerlerinin kopyasına sahip olan metin2 değişkeninin bellekteki temsili
Bellekteki görünüm, Rust öbek verisini de kopyalasaydı ortaya çıkacak olan Şekil 4-3’teki gibi değildir. Eğer Rust böyle davransaydı, öbekteki verinin büyük olduğu durumlarda metin2 = metin1 ataması çalışma zamanı performansı açısından son derece maliyetli olabilirdi.
Şekil 4-3: Rust öbek verisini de kopyalasaydı metin2 = metin1 işleminin oluşturacağı alternatif bellek görünümü
Daha önce, bir değişken kapsam dışına çıktığında Rust’ın otomatik olarak drop fonksiyonunu çağırdığını ve o değişkene ait öbek belleğini temizlediğini belirtmiştik. Ancak Şekil 4-2’de her iki veri işaretçisinin de aynı bellek konumunu gösterdiğini görüyoruz. Bu ciddi bir sorundur: metin2 ve metin1 kapsam dışına çıktığında her ikisi de aynı belleği serbest bırakmaya çalışacaktır. Bu durum çift serbest bırakma (double free) hatası olarak bilinir ve daha önce değindiğimiz bellek güvenliği hatalarından biridir. Aynı belleği iki kez serbest bırakmak bellek bozulmasına (memory corruption) ve bunun sonucunda güvenlik açıklarına yol açabilir.
Bellek güvenliğini sağlamak amacıyla let metin2 = metin1; satırından sonra Rust, metin1 değişkenini artık geçerli kabul etmez. Dolayısıyla metin1 kapsam dışına çıktığında Rust’ın herhangi bir şeyi serbest bırakması gerekmez. metin2 oluşturulduktan sonra metin1’i kullanmaya çalıştığınızda ne olduğuna bakın; kod derlenmeyecektir:
fn main() {
let metin1 = String::from("merhaba");
let metin2 = metin1;
println!("{metin1}, dünya!");
}
Rust geçersiz kılınmış değişkeni kullanmanızı engellediği için şuna benzer bir hata alırsınız:
$ cargo run
Compiling sahipligi_anlamak v0.1.0 (file:///projects/sahipligi_anlamak)
error[E0382]: borrow of moved value: `metin1`
--> src/main.rs:5:16
|
2 | let metin1 = String::from("merhaba");
| ------ move occurs because `metin1` has type `String`, which does not implement the `Copy` trait
3 | let metin2 = metin1;
| ------ value moved here
4 |
5 | println!("{metin1}, dünya!");
| ^^^^^^ value borrowed here after move
|
help: consider cloning the value if the performance cost is acceptable
|
3 | let metin2 = metin1.clone();
| ++++++++
For more information about this error, try `rustc --explain E0382`.
error: could not compile `sahipligi_anlamak` (bin "sahipligi_anlamak") due to 1 previous error
Not: Derleyicinin ürettiği error[E0382]: borrow of moved value: `metin1` (taşınmış değerin ödünç alınması) hatasını adım adım okumak oldukça öğreticidir: Derleyici önce String türünün Copy trait’ini uygulamadığını, bu yüzden let metin2 = metin1; satırında değerin metin2’ye taşındığını (value moved here) belirtir. Ardından println! makrosunun metin1 değerini ekrana yazdırmak için ödünç almaya çalıştığı noktayı (value borrowed here after move) işaret eder. Ödünç alma (borrowing) kavramını bir sonraki bölümde ayrıntılı olarak inceleyeceğiz.
Diğer programlama dilleriyle çalışırken sığ kopya (shallow copy) ve derin kopya (deep copy) terimlerini duyduysanız; öbekteki veriyi kopyalamadan yalnızca işaretçiyi, uzunluğu ve kapasiteyi kopyalama fikri kulağa sığ kopya yapmak gibi gelebilir. Ancak Rust aynı zamanda ilk değişkeni geçersiz kıldığı için bu işlem sığ kopya olarak değil, taşıma (move) olarak adlandırılır. Bu örnekte metin1’in metin2’ye taşındığını söyleriz. Dolayısıyla bellekte gerçekte olan durum Şekil 4-4’te gösterilmiştir.
Şekil 4-4: metin1 geçersiz kılındıktan sonra bellekteki temsil
Bu yaklaşım sorunumuzu tamamen çözer! Yalnızca metin2 geçerli kaldığından, kapsam dışına çıktığında belleği yalnızca o serbest bırakır ve çift serbest bırakma tehlikesi ortadan kalkar.
Buna ek olarak bu mekanizmanın ortaya koyduğu önemli bir tasarım tercihi daha vardır: Rust, verilerinizin “derin” kopyalarını asla kendiliğinden oluşturmaz. Dolayısıyla Rust’ta gerçekleşen her türlü otomatik kopyalamanın çalışma zamanı performansı açısından düşük maliyetli olduğu güvenle varsayılabilir.
Kapsam ve Atama
Kapsam, sahiplik ve belleğin drop fonksiyonu aracılığıyla serbest bırakılması arasındaki ilişkinin tersi de geçerlidir. Mevcut bir değişkene tamamen yeni bir değer atadığınızda Rust, önceki değer üzerinde derhal drop fonksiyonunu çağırır ve o değerin belleğini anında serbest bırakır. Örneğin şu kodu inceleyelim:
fn main() {
let mut metin = String::from("merhaba");
metin = String::from("selam");
println!("{metin}, dünya!");
}
Başlangıçta metin adında bir değişken tanımlıyor ve ona "merhaba" değerini tutan bir String bağlıyoruz. Hemen ardından "selam" değerine sahip yeni bir String oluşturup metin değişkenine atıyoruz. Bu noktada öbekteki ilk değere başvuran hiçbir değişken kalmamıştır. Şekil 4-5, bu atama sonrasında yığın ve öbek verilerinin durumunu göstermektedir:
Şekil 4-5: Başlangıçtaki değer bütünüyle yenisiyle değiştirildikten sonra bellekteki temsil
Böylece ilk string anında kapsam dışına çıkmış olur. Rust bu değer üzerinde drop fonksiyonunu çalıştırır ve belleği hemen serbest bırakılır. Son satırda değeri yazdırdığımızda ekranda "selam, dünya!" çıktısını görürüz.
Değişkenlerin ve Verilerin Clone ile Etkileşimi
Yalnızca yığın verisini değil, String’in öbek verisini de gerçekten derinlemesine kopyalamak istiyorsak clone adındaki yaygın metodu kullanabiliriz. Metot sözdizimini Bölüm 5’te ele alacağız; ancak metotlar pek çok programlama dilinde ortak bir özellik olduğundan muhtemelen daha önce karşılaşmışsınızdır.
İşte clone metodunun çalışırkenki bir örneği:
fn main() {
let metin1 = String::from("merhaba");
let metin2 = metin1.clone();
println!("metin1 = {metin1}, metin2 = {metin2}");
}
Bu kod sorunsuzca çalışır ve öbek verisinin gerçekten kopyalandığı Şekil 4-3’teki davranışı açıkça üretir.
Kodda bir clone çağrısı gördüğünüzde, arka planda o türe özgü bir kopyalama kodunun çalıştırıldığını ve bu işlemin performans açısından maliyetli olabileceğini bilirsiniz. Bu çağrı, sıradan bir atamadan farklı bir işlem yapıldığının açık bir görsel göstergesidir.
Yalnızca Yığındaki Veri: Copy
Henüz değinmediğimiz bir ayrıntı daha var. Bir kısmını Liste 4-2’de gördüğümüz, tam sayılar kullanan şu kod derlenir ve tamamen geçerlidir:
fn main() {
let x = 5;
let y = x;
println!("x = {x}, y = {y}");
}
Ancak bu kod az önce öğrendiklerimizle çelişiyor gibi görünmektedir: clone çağrısı yapmadığımız hâlde x hâlâ geçerlidir ve y içine taşınmamıştır.
Bunun nedeni, bu örnekteki x ve y değişkenlerinin türü olan i32’nin Copy trait’ini uygulamasıdır. let y = x; atamasında tam sayı değeri kopyalanır ve x geçerli kalır. Bir i32 değeri, String gibi öbekteki ayrı bir verinin sahibi değildir; bu nedenle bitlerini kopyalamak değerin tamamını kopyalamak için yeterlidir. Bu tam sayı örneğinde derin kopyalama ile sığ kopyalama arasında fark yoktur ve clone çağırmaya gerek duymayız.
Burada belirleyici olan, türün Copy trait’ini uygulamasıdır; değerin yığında tutulması veya boyutunun derleme zamanında bilinmesi tek başına yeterli değildir. Örneğin String’in yığında tutulan kısmının boyutu da derleme zamanında bilinir. Ancak yığındaki bu kısım, metnin öbekteki asıl verisine işaret eden bir işaretçinin yanı sıra uzunluk ve kapasite bilgilerini de içerir. İşaretçi, asıl verinin bellekteki adresini tutar; işaretçileri ve akıllı işaretçileri Bölüm 15’te daha ayrıntılı inceleyeceğiz. Yalnızca yığındaki bitleri kopyalamak, öbekteki veriyi çoğaltmaz. String, Copy uygulamadığı için bir String değeri başka bir değişkene atandığında sahiplik taşınır ve önceki değişken geçersiz olur.
Rust, tam sayılar gibi bit düzeyinde kopyalanabilen türlerin uygulayabildiği Copy adında özel bir trait sunar (trait’leri Bölüm 10’da ayrıntılı olarak inceleyeceğiz). Bir tür Copy trait’ini uyguluyorsa onu kullanan değişkenler taşınmaz; bunun yerine önemsiz bir maliyetle kopyalanır ve başka bir değişkene atandıktan sonra da geçerli kalmaya devam eder.
Bir türün kendisi ya da parçalarından herhangi biri Drop trait’ini uygulamışsa Rust o türü Copy ile işaretlememize izin vermez. Değer kapsam dışına çıktığında özel bir temizlik işlemi yapılması gereken bir türe Copy trait’ini eklemeye çalışırsak derleme zamanı hatası alırız. Kendi türlerinizde bu trait’i uygulamak üzere ilgili türetme özniteliğini (#[derive(Copy)]) nasıl ekleyeceğinizi öğrenmek için Ek C’deki “Türetilebilir Trait’ler” bölümüne bakabilirsiniz. (Copy, Clone trait’inin bir alt trait’i olduğundan bir türün Copy uygulayabilmesi için Clone da uygulaması gerekir. Bu iki trait genellikle birlikte türetilir: #[derive(Clone, Copy)]; elle uygulamak da mümkündür.)
Peki hangi türler Copy trait’ini uygular? Emin olmak için ilgili türün dokümantasyonunu kontrol edebilirsiniz; ancak genel kural olarak basit skaler değerlerden oluşan her grup Copy uygulayabilirken, öbekte bellek ayırma gerektiren veya bir sistem kaynağını temsil eden hiçbir tür Copy uygulayamaz. Copy trait’ini uygulayan başlıca türler şunlardır:
u32gibi tüm tam sayı türleri.truevefalsedeğerlerine sahip Boolean türübool.f64gibi tüm kayan noktalı sayı türleri.- Karakter türü
char. - Yalnızca
Copyuygulayan türleri barındırmaları koşuluyla demetler (tuples). Örneğin(i32, i32)türüCopytrait’ini uygular, ancak(i32, String)uygulamaz.
Not: Özetle Rust’ta = ile yapılan her atama işlemi yığındaki bitleri hızlıca kopyalar; fark derleyicinin kaynak değişkene nasıl davrandığındadır: Tür Copy ise (i32, bool, (i32, f64) gibi) kaynak değişken geçerli kalır; Copy değilse (String, Vec<T> gibi) sahiplik yeni değişkene taşınır ve kaynak değişken geçersiz kılınır. Öbekteki verinin de kopyalanması istendiğinde ise .clone() açıkça çağrılmalıdır.
Sahiplik ve Fonksiyonlar
Bir değeri fonksiyona aktarmanın çalışma mekaniği, bir değeri değişkene atamakla oldukça benzerdir. Bir değişkeni fonksiyona geçirmek, tıpkı atama işleminde olduğu gibi değeri ya taşır ya da kopyalar. Liste 4-3, değişkenlerin nerelerde kapsama girip çıktığını gösteren açıklamalarla bu durumu örneklemektedir.
fn main() {
let metin = String::from("merhaba"); // metin kapsama girer
sahipligi_alir(metin); // metin'in değeri fonksiyona taşınır...
// ... ve bu yüzden burada artık geçerli değildir
let x = 5; // x kapsama girer
kopyasini_olustur(x); // i32 Copy trait'ini uyguladığı için,
// x fonksiyona TAŞINMAZ,
// bu yüzden x'i daha sonra kullanmak sorun olmaz.
} // Burada x kapsamdan çıkar, sonra da metin. Ancak metin'in değeri taşındığı için,
// özel bir şey olmaz.
fn sahipligi_alir(bir_metin: String) {
// bir_metin kapsama girer
println!("{bir_metin}");
} // Burada bir_metin kapsamdan çıkar ve `drop` çağrılır. Arkadaki
// bellek serbest bırakılır.
fn kopyasini_olustur(bir_tamsayi: i32) {
// bir_tamsayi kapsama girer
println!("{bir_tamsayi}");
} // Burada bir_tamsayi kapsamdan çıkar. Özel bir şey olmaz.
sahipligi_alir çağrısından sonra metin değişkenini kullanmaya çalışsaydık Rust derleme zamanı hatası verirdi. Bu statik kontroller bizi çalışma zamanı hatalarından korur. metin ve x değişkenlerini nerelerde kullanabileceğinizi ve sahiplik kurallarının sizi nerelerde engellediğini bizzat görmek için main fonksiyonuna bu değişkenleri kullanan kodlar eklemeyi deneyin.
Dönüş Değerleri ve Kapsam
Fonksiyonlardan değer döndürmek de sahipliği aktarabilir. Liste 4-4, bir değer döndüren fonksiyon örneğini Liste 4-3’tekine benzer kapsam açıklamalarıyla göstermektedir.
fn main() {
let metin1 = sahiplik_verir(); // sahiplik_verir dönüş değerini
// metin1'e taşır
let metin2 = String::from("merhaba"); // metin2 kapsama girer
let metin3 = alir_ve_geri_verir(metin2); // metin2, alir_ve_geri_verir'e taşınır,
// o da dönüş değerini
// metin3'e taşır
} // Burada metin3 kapsamdan çıkar ve düşürülür (drop). metin2 taşındığı için hiçbir şey
// olmaz. metin1 kapsamdan çıkar ve düşürülür.
fn sahiplik_verir() -> String {
// sahiplik_verir dönüş değerini
// onu çağıran fonksiyona
// taşıyacaktır
let bir_metin = String::from("senin"); // bir_metin kapsama girer
bir_metin // bir_metin döndürülür ve
// çağıran fonksiyona
// taşınır
}
// Bu fonksiyon bir String alır ve bir String döndürür.
fn alir_ve_geri_verir(bir_metin: String) -> String {
// bir_metin kapsama
// girer
bir_metin // bir_metin döndürülür ve çağıran fonksiyona taşınır
}
Bir değişkenin sahipliği her seferinde aynı kalıbı izler: Bir değeri başka bir değişkene atamak onu taşır. Öbekte veri barındıran bir değişken kapsam dışına çıktığında, verinin sahipliği başka bir değişkene taşınmadığı sürece söz konusu değer drop tarafından temizlenir.
Bu yöntem çalışsa da her fonksiyonda sahipliği alıp ardından tekrar geri döndürmek biraz yorucudur. Bir fonksiyonun değeri kullanmasına izin vermek ama sahipliğini devralmasını istemiyorsak ne yapacağız? Fonksiyona gönderdiğimiz her şeyi daha sonra tekrar kullanabilmek için — fonksiyon gövdesinde üretilen asıl sonuç verilerinin yanı sıra — bir de geri döndürmek zorunda kalmak epey zahmetlidir.
Rust, Liste 4-5’te gösterildiği gibi bir demet kullanarak birden fazla değer döndürmemize olanak tanır.
fn main() {
let metin1 = String::from("merhaba");
let (metin2, uzunluk) = uzunlugu_hesapla(metin1);
println!("'{metin2}' metninin uzunluğu: {uzunluk}.");
}
fn uzunlugu_hesapla(metin: String) -> (String, usize) {
let uzunluk = metin.len(); // len() bir String'in uzunluğunu döndürür
(metin, uzunluk)
}
Ancak bu yaklaşım, çok sık karşılaşılacak bir ihtiyaç için gereğinden fazla zahmet ve kalabalık kod demektir. Neyse ki Rust, sahipliği devretmeden bir değeri kullanabilmemiz için özel bir özelliğe sahiptir: referanslar.
Referanslar ve Ödünç Alma
Referanslar ve Ödünç Alma
Liste 4-5’teki demet kullanan kodun temel sorunu şudur: String değeri uzunlugu_hesapla fonksiyonuna taşındığı için, fonksiyon çağrısından sonra bu String’i hâlâ kullanabilmek adına onu çağıran fonksiyona geri döndürmek zorunda kalırız. Bunun yerine, String değerine bir referans verebiliriz. Referans (reference), bellekteki bir adreste saklanan veriye ulaşmak için takip edebileceğimiz bir adres olması bakımından işaretçiye (pointer) benzer; o adresteki verinin sahibi ise başka bir değişkendir. Sıradan bir işaretçiden farklı olarak referansın, kendi ömrü boyunca belirli bir türden geçerli bir değere işaret edeceği garanti edilir.
Değerin sahipliğini devralmak yerine parametre olarak bir değere referans kabul eden uzunlugu_hesapla fonksiyonunu şu şekilde tanımlayıp kullanabilirsiniz:
fn main() {
let metin1 = String::from("merhaba");
let uzunluk = uzunlugu_hesapla(&metin1);
println!("'{metin1}' metninin uzunluğu: {uzunluk}.");
}
fn uzunlugu_hesapla(metin: &String) -> usize {
metin.len()
}
Öncelikle, değişken bildirimindeki ve fonksiyonun dönüş değerindeki tüm demet kodlarının ortadan kalktığına dikkat edin. İkinci olarak, uzunlugu_hesapla fonksiyonuna &metin1 aktardığımıza ve fonksiyon tanımında String yerine &String kullandığımıza bakın. Bu & işaretleri (ampersand) referansları temsil eder ve bir değerin sahipliğini üstlenmeden ona başvurmanıza olanak tanır. Şekil 4-6 bu kavramı görselleştirmektedir.
Şekil 4-6: String türündeki metin1 değişkenine işaret eden &String türündeki metin diyagramı
Not: & kullanarak referans oluşturmanın tam tersi başvuru çözme (dereferencing) işlemidir ve bu işlem başvuru çözme operatörü olan * ile gerçekleştirilir. Başvuru çözme operatörünün bazı kullanımlarını Bölüm 8’de görecek, başvuru çözmenin ayrıntılarını ise Bölüm 15’te inceleyeceğiz.
Şimdi buradaki fonksiyon çağrısına daha yakından bakalım:
fn main() {
let metin1 = String::from("merhaba");
let uzunluk = uzunlugu_hesapla(&metin1);
println!("'{metin1}' metninin uzunluğu: {uzunluk}.");
}
fn uzunlugu_hesapla(metin: &String) -> usize {
metin.len()
}
&metin1 sözdizimi, metin1’in değerine başvuran ancak onun sahibi olmayan bir referans oluşturmamızı sağlar. Referans bu değerin sahibi olmadığından, kullanımı sona erdiğinde işaret ettiği değer düşürülmez (drop edilmez).
Benzer şekilde fonksiyon imzası da metin parametresinin türünün bir referans olduğunu belirtmek için & kullanır. Şimdi koda açıklayıcı yorumlar ekleyelim:
fn main() {
let metin1 = String::from("merhaba");
let uzunluk = uzunlugu_hesapla(&metin1);
println!("'{metin1}' metninin uzunluğu: {uzunluk}.");
}
fn uzunlugu_hesapla(metin: &String) -> usize {
// metin bir String'e referanstır
metin.len()
} // Burada, metin kapsam dışına çıkar. Ancak metin referans verdiği şeyin
// sahipliğine sahip olmadığı için String düşürülmez (drop edilmez).
metin değişkeninin geçerli olduğu kapsam herhangi bir fonksiyon parametresinin kapsamıyla aynıdır; ancak metin değerin sahibi olmadığı için, kullanımı bittiğinde referansın işaret ettiği değer düşürülmez. Fonksiyonlar asıl değerler yerine referansları parametre olarak aldığında, sahipliği geri vermek amacıyla değerleri döndürmemize gerek kalmaz; çünkü sahipliği zaten hiç devralmamışızdır.
Referans oluşturma eylemine ödünç alma (borrowing) adını veririz. Tıpkı günlük hayatta olduğu gibi, bir başkasının sahip olduğu bir eşyayı ondan ödünç alabilirsiniz. İşiniz bittiğinde ise onu geri vermeniz gerekir; eşyanın sahibi siz değilsinizdir.
Peki ödünç aldığımız bir şeyi değiştirmeye çalışırsak ne olur? Liste 4-6’daki kodu deneyin. Şimdiden söyleyelim: Çalışmayacaktır!
fn main() {
let metin = String::from("merhaba");
degistir(&metin);
}
fn degistir(bir_metin: &String) {
bir_metin.push_str(", dünya");
}
İşte hata:
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0596]: cannot borrow `*bir_metin` as mutable, as it is behind a `&` reference
--> src/main.rs:8:5
|
8 | bir_metin.push_str(", dünya");
| ^^^^^^^^^ `bir_metin` is a `&` reference, so it cannot be borrowed as mutable
|
help: consider changing this to be a mutable reference
|
7 | fn degistir(bir_metin: &mut String) {
| +++
For more information about this error, try `rustc --explain E0596`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error
Değişkenler varsayılan olarak nasıl değiştirilemez ise referanslar da öyledir. Bir değere yalnızca sıradan (değiştirilemez) bir referansımız varsa o değeri değiştirmemize izin verilmez (hata mesajındaki *bir_metin ifadesi bir_metin referansının işaret ettiği asıl değeri temsil eder; derleyici, bu değer bir & referansı arkasında olduğu için değiştirilebilir olarak ödünç alınamayacağını bildirir).
Değiştirilebilir Referanslar
Liste 4-6’daki kodu, ödünç alınan değeri değiştirmemize izin verecek şekilde birkaç küçük dokunuşla — bir değiştirilebilir referans (mutable reference) kullanarak — düzeltebiliriz:
fn main() {
let mut metin = String::from("merhaba");
degistir(&mut metin);
}
fn degistir(bir_metin: &mut String) {
bir_metin.push_str(", dünya");
}
İlk olarak metin değişkenini mut olarak tanımlarız. Ardından degistir fonksiyonunu çağırdığımız yerde &mut metin ifadesiyle değiştirilebilir bir referans oluşturur ve fonksiyon imzasını da bir_metin: &mut String yazarak değiştirilebilir referans kabul edecek biçimde güncelleriz. Bu imza, degistir fonksiyonunun ödünç aldığı değeri değiştireceğini açıkça ortaya koyar.
Değiştirilebilir referansların çok önemli bir kısıtlaması vardır: Bir değere ait etkin bir değiştirilebilir referansınız varsa, o değere başka hiçbir referansınız olamaz. metin değişkenine iki ayrı değiştirilebilir referans oluşturmayı deneyen aşağıdaki kod derlenmeyecektir:
fn main() {
let mut metin = String::from("merhaba");
let r1 = &mut metin;
let r2 = &mut metin;
println!("{r1}, {r2}");
}
İşte hata:
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0499]: cannot borrow `metin` as mutable more than once at a time
--> src/main.rs:5:14
|
4 | let r1 = &mut metin;
| ---------- first mutable borrow occurs here
5 | let r2 = &mut metin;
| ^^^^^^^^^^ second mutable borrow occurs here
6 |
7 | println!("{r1}, {r2}");
| -- first borrow later used here
For more information about this error, try `rustc --explain E0499`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error
Bu hata mesajı, metin değişkenini aynı anda birden fazla kez değiştirilebilir olarak ödünç alamayacağımız (cannot borrow `metin` as mutable more than once at a time) için kodun geçersiz olduğunu belirtir. İlk değiştirilebilir ödünç alma işlemi r1 içindedir ve println! satırında kullanılana kadar geçerliliğini korumak zorundadır; ancak biz bu değiştirilebilir referansın oluşturulması ile kullanılması arasında, r1 ile aynı veriyi ödünç alan r2 içinde ikinci bir değiştirilebilir referans oluşturmaya çalıştık.
Aynı veriye aynı anda birden fazla değiştirilebilir referans oluşturulmasını engelleyen bu kısıtlama, veri değiştirmeye izin verir ancak bunu son derece kontrollü bir biçimde yapar. Çoğu programlama dili verileri dilediğiniz zaman değiştirmenize izin verdiğinden, yeni Rust geliştiricilerinin (Rustaceans) alışmakta zorlandığı konulardan biri budur. Bu kısıtlamanın en büyük getirisi ise Rust’ın derleme zamanında veri yarışlarını önleyebilmesidir. Veri yarışı (data race), yarış koşuluna (race condition) benzer ve şu üç davranış bir arada gerçekleştiğinde ortaya çıkar:
- İki veya daha fazla işaretçi aynı veriye aynı anda erişir.
- İşaretçilerden en az biri veriye yazmak için kullanılır.
- Veriye erişimi senkronize edecek herhangi bir mekanizma kullanılmaz.
Veri yarışları tanımsız davranışa (undefined behavior) yol açar ve çalışma zamanında izlerini sürüp teşhis etmek ve düzeltmek son derece güç olabilir. Rust ise veri yarışı barındıran kodları derlemeyi reddederek bu sorunu henüz ortaya çıkmadan engeller!
Her zaman olduğu gibi, süslü parantezler kullanarak yeni bir kapsam oluşturabilir ve böylece eşzamanlı olmamak kaydıyla birden fazla değiştirilebilir referans tanımlayabiliriz:
fn main() {
let mut metin = String::from("merhaba");
{
let r1 = &mut metin;
} // r1 burada kapsam dışına çıkar, bu yüzden hiçbir sorun olmadan yeni bir referans yapabiliriz.
let r2 = &mut metin;
}
Rust, değiştirilebilir ve değiştirilemez referansların bir arada kullanılması için de benzer bir kural uygular. Aşağıdaki kod derleme hatasıyla sonuçlanır:
fn main() {
let mut metin = String::from("merhaba");
let r1 = &metin; // sorun yok
let r2 = &metin; // sorun yok
let r3 = &mut metin; // BÜYÜK SORUN
println!("{r1}, {r2}, ve {r3}");
}
İşte hata:
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `metin` as mutable because it is also borrowed as immutable
--> src/main.rs:6:14
|
4 | let r1 = &metin; // sorun yok
| ------ immutable borrow occurs here
5 | let r2 = &metin; // sorun yok
6 | let r3 = &mut metin; // BÜYÜK SORUN
| ^^^^^^^^^^ mutable borrow occurs here
7 |
8 | println!("{r1}, {r2}, ve {r3}");
| -- immutable borrow later used here
For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error
Gördüğünüz gibi, aynı değere ait değiştirilemez bir referansımız varken aynı zamanda değiştirilebilir bir referansımız da olamaz!
Değiştirilemez bir referansı kullanan kod bölümleri, ellerindeki değerin bir anda altlarından kayıp değişmesini beklemez. Buna karşılık, birden fazla değiştirilemez referansa izin verilir; çünkü veriyi yalnızca okuyan hiçbir referansın, diğerlerinin okuduğu veriyi etkileme imkânı yoktur.
Bir referansın kapsamının tanımlandığı noktada başladığına ve o referansın son kez kullanıldığı noktaya kadar devam ettiğine dikkat edin. Örneğin aşağıdaki kod sorunsuzca derlenir; çünkü değiştirilemez referansların son kullanımı, değiştirilebilir referans tanımlanmadan hemen önceki println! satırındadır:
fn main() {
let mut metin = String::from("merhaba");
let r1 = &metin; // sorun yok
let r2 = &metin; // sorun yok
println!("{r1} ve {r2}");
// r1 ve r2 değişkenleri bu noktadan sonra kullanılmayacak.
let r3 = &mut metin; // sorun yok
println!("{r3}");
}
Değiştirilemez r1 ve r2 referanslarının kapsamları, son kez kullanıldıkları println! çağrısından hemen sonra sona erer; bu da değiştirilebilir r3 referansı oluşturulmadan öncedir. Bu kapsamlar birbiriyle çakışmadığından koda izin verilir: Derleyici, süslü parantezle belirlenen blok sonuna henüz gelinmemiş olsa bile referansın o noktadan sonra artık kullanılmadığını anlayabilir.
Not: Rust derleyicisinin ödünç alma denetleyicisi (borrow checker), bir referansın geçerlilik süresini yalnızca süslü parantezlerin ({ ... }) oluşturduğu sözcüksel blok sınırlarına göre değil, referansın kod akışı içinde gerçekte son kullanıldığı yere göre hesaplar. Rust dünyasında Sözcüksel Olmayan Ömürler (Non-Lexical Lifetimes — NLL) olarak adlandırılan bu özellik sayesinde, işi biten referanslar için fazladan iç içe { ... } blokları açmanıza gerek kalmaz. Yukarıdaki örnekte let r3 = &mut metin; satırından sonra println!("{r1}"); gibi bir satır daha ekleseydik, r1’in kapsamı o satıra kadar uzayacak, r3 ile çakışacak ve derleyici E0502 hatası üretecekti.
Ödünç alma hataları zaman zaman zorlayıcı görünse de bunların aslında Rust derleyicisinin olası bir hatayı erkenden (çalışma zamanı yerine derleme zamanında) yakalayıp sorunun tam olarak nerede olduğunu size göstermesinden kaynaklandığını unutmayın. Bu sayede, programınız çalışırken verilerinizin neden beklediğiniz gibi görünmediğinin izini sürmek zorunda kalmazsınız.
Sarkan Referanslar
İşaretçilerin doğrudan kullanıldığı dillerde, bellekteki bir alanı serbest bırakırken o alana yönelen işaretçiyi korumaya devam ederek yanlışlıkla bir sarkan işaretçi (dangling pointer) — yani artık bir başkasına tahsis edilmiş olabilecek geçersiz bir bellek konumunu gösteren işaretçi — oluşturmak oldukça kolaydır. Rust’ta ise derleyici, referansların hiçbir zaman sarkan referans (dangling reference) hâline gelmeyeceğini garanti eder: Bir veriye referansınız varsa derleyici, referansın kendisi kapsam dışına çıkmadan önce verinin kapsam dışına çıkmamasını sağlar.
Rust’ın bunları derleme zamanı hatasıyla nasıl engellediğini görmek için sarkan bir referans oluşturmayı deneyelim:
fn main() {
let hicbir_seye_referans = sarkan_isaretci();
}
fn sarkan_isaretci() -> &String {
let metin = String::from("merhaba");
&metin
}
İşte hata:
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0106]: missing lifetime specifier
--> src/main.rs:5:25
|
5 | fn sarkan_isaretci() -> &String {
| ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime, but this is uncommon unless you're returning a borrowed value from a `const` or a `static`
|
5 | fn sarkan_isaretci() -> &'static String {
| +++++++
help: instead, you are more likely to want to return an owned value
|
5 - fn sarkan_isaretci() -> &String {
5 + fn sarkan_isaretci() -> String {
|
For more information about this error, try `rustc --explain E0106`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error
Bu hata mesajı henüz ele almadığımız bir özelliğe değinmektedir: ömürler (lifetimes). Ömürleri Bölüm 10’da ayrıntılı olarak inceleyeceğiz. Ancak ömürlerle ilgili kısımları şimdilik bir kenara bırakırsanız, mesaj bu kodun neden sorunlu olduğuna dair temel ipucunu açıkça verir:
this function's return type contains a borrowed value, but there is no value
for it to be borrowed from
Başka bir deyişle derleyici, bu fonksiyonun dönüş türünün ödünç alınmış bir değer içerdiğini ancak kendisinden ödünç alınabilecek hiçbir değer bulunmadığını bildirir (sarkan_isaretci fonksiyonu hiçbir parametre almadığından, döndürülen referansın dışarıdaki geçerli bir veriden ödünç alınmış olması imkânsızdır).
sarkan_isaretci kodumuzun her aşamasında tam olarak neler olup bittiğine daha yakından bakalım:
fn main() {
let hicbir_seye_referans = sarkan_isaretci();
}
fn sarkan_isaretci() -> &String {
// sarkan_isaretci, bir String'e referans döndürür
let metin = String::from("merhaba"); // metin yeni bir String'dir
&metin // String'e, yani metin'e bir referans döndürüyoruz
} // Burada metin kapsam dışına çıkar ve düşürülür (drop edilir), bu yüzden belleği gider.
// Tehlike!
metin değişkeni sarkan_isaretci fonksiyonunun içinde oluşturulduğu için, sarkan_isaretci kodu tamamlandığında metin değişkeninin belleği serbest bırakılacaktır. Ancak biz ona ait bir referansı döndürmeye çalıştık. Bu durum, döndürülen referansın geçersiz bir String’e işaret edeceği anlamına gelir. Bu hiç iyi bir durum değildir ve Rust bunu yapmamıza izin vermez.
Buradaki çözüm, referans yerine doğrudan String değerini döndürmektir:
fn main() {
let metin = sarkmayan_isaretci();
}
fn sarkmayan_isaretci() -> String {
let metin = String::from("merhaba");
metin
}
Bu kod çalışır: String değerinin sahipliği çağıran fonksiyona taşındığı için ona ait öbek belleği bu fonksiyondan dönülürken serbest bırakılmaz. Bellek, değerin yeni sahibi onu düşürdüğünde serbest bırakılır.
Referans Kuralları
Referanslar hakkında ele aldıklarımızı özetleyelim:
- Herhangi bir anda, bir veriye ait ya yalnızca bir tane değiştirilebilir referansa ya da istediğiniz sayıda değiştirilemez referansa sahip olabilirsiniz.
- Referanslar her zaman geçerli olmalıdır.
Not: İlk kuralı akılda tutmanın en sezgisel yolu onu paylaşımlı okuma veya
tekelci yazma ilkesi olarak düşünmektir: Bir veriyi aynı anda dilediğiniz
kadar referans üzerinden güvenle okuyabilirsiniz (&T); ancak veriyi
değiştirmek istediğinizde (&mut T), değişiklik süresince o veriye erişebilen
tek etkin referans sizinki olmalıdır. Örneğimizde yerel String değişkenini
&mut ile ödünç alabilmek için bağlamayı let mut ile tanımladık. Elinizde
zaten bir &mut T varsa, referansı tutan bağlamanın kendisi mut olmasa da
işaret ettiği değeri değiştirebilirsiniz; bağlamayı yeniden atamak ile
referansın işaret ettiği veriyi değiştirmek farklı işlemlerdir.
Sırada, farklı bir referans türüne bakacağız: dilimler.
Dilim (Slice) Türü
Dilim (Slice) Türü
Dilimler (slices), bir koleksiyon içindeki bitişik bir eleman dizisine referans vermenizi sağlar. Dilim bir tür referanstır; dolayısıyla verinin sahipliğini üstlenmez.
Küçük bir programlama problemi düşünelim: Boşluklarla ayrılmış kelimelerden oluşan bir string alan ve bu string içinde bulduğu ilk kelimeyi döndüren bir fonksiyon yazın. Fonksiyon string içinde hiç boşluk bulamazsa tüm string tek bir kelime demektir; bu durumda string’in tamamı döndürülmelidir.
Not: Dilimleri tanıtmak amacıyla bu bölümde metinlerin yalnızca ASCII karakterlerden oluştuğunu varsayıyoruz; UTF-8 işleme konusunu Bölüm 8’deki “String’lerle UTF-8 Kodlanmış Metin Saklama” bölümünde kapsamlı biçimde ele alacağız.
Dilimlerin hangi sorunu çözdüğünü anlamak için bu fonksiyonun imzasını dilim kullanmadan nasıl yazacağımız üzerinde düşünelim:
fn ilk_kelime(metin: &String) -> ?
ilk_kelime fonksiyonu &String türünde bir parametre alır. Sahipliğe ihtiyacımız olmadığı için bu tercih gayet uygundur. (İdiyomatik Rust’ta fonksiyonlar gerçekten gerekmedikçe argümanlarının sahipliğini almaz; bunun nedenleri ilerledikçe netleşecektir.) Peki bu fonksiyondan ne döndürmeliyiz? Bir string’in bir kısmını ifade etmenin henüz doğrudan bir yolu yoktur. Bununla birlikte, kelimenin sonunu belirten boşluğun indeksini döndürebiliriz. Gelin bunu Liste 4-7’de gösterildiği gibi deneyelim.
fn ilk_kelime(metin: &String) -> usize {
let baytlar = metin.as_bytes();
for (i, &oge) in baytlar.iter().enumerate() {
if oge == b' ' {
return i;
}
}
metin.len()
}
fn main() {}
String parametresi içindeki bir bayt indeksini döndüren ilk_kelime fonksiyonuString değerini eleman eleman tarayıp her bir değerin boşluk olup olmadığını kontrol etmemiz gerektiğinden, as_bytes metoduyla String içindeki UTF-8 baytlarını &[u8] türünde bir dilim olarak ödünç alırız. Bu işlem metni kopyalamaz veya yeni bir dizi oluşturmaz:
fn ilk_kelime(metin: &String) -> usize {
let baytlar = metin.as_bytes();
for (i, &oge) in baytlar.iter().enumerate() {
if oge == b' ' {
return i;
}
}
metin.len()
}
fn main() {}
Ardından iter metodunu kullanarak bu bayt dizisi üzerinde bir iteratör (iterator) oluştururuz:
fn ilk_kelime(metin: &String) -> usize {
let baytlar = metin.as_bytes();
for (i, &oge) in baytlar.iter().enumerate() {
if oge == b' ' {
return i;
}
}
metin.len()
}
fn main() {}
İteratörleri Bölüm 13’te ayrıntılı olarak inceleyeceğiz. Şimdilik iter metodunun bir koleksiyondaki her elemanı sırayla döndürdüğünü, enumerate metodunun ise iter sonucunu sarmalayarak her elemanı bunun yerine bir demetin parçası olarak döndürdüğünü bilmeniz yeterlidir. enumerate çağrısından dönen demetin ilk elemanı indeks, ikinci elemanı ise ilgili elemana ait referanstır. Bu yöntem, indeksi kendi başımıza hesaplamaktan biraz daha pratiktir.
enumerate metodu bir demet döndürdüğü için bu demeti parçalarına ayırmak (destructure) amacıyla desenleri (patterns) kullanabiliriz. Desenleri Bölüm 6’da daha ayrıntılı ele alacağız. for döngüsünde, demetteki indeks için i ve demetteki tekil bayt için &oge içeren bir desen belirtiriz. .iter().enumerate() çağrısından elemana bir referans elde ettiğimiz için desende & kullanırız.
for döngüsünün içinde, bayt sabiti (byte literal) sözdizimini (b' ') kullanarak boşluğu temsil eden baytı ararız. Bir boşluk bulursak bu konumu döndürürüz. Aksi takdirde metin.len() çağrısıyla string’in uzunluğunu döndürürüz:
fn ilk_kelime(metin: &String) -> usize {
let baytlar = metin.as_bytes();
for (i, &oge) in baytlar.iter().enumerate() {
if oge == b' ' {
return i;
}
}
metin.len()
}
fn main() {}
Artık string içindeki ilk kelimenin bittiği yerin indeksini bulabiliyoruz, ancak önemli bir sorun var. Tek başına bir usize döndürüyoruz; oysa bu sayı yalnızca &String bağlamında anlamlıdır. Başka bir deyişle, String’den bağımsız ayrı bir değer olduğu için gelecekte hâlâ geçerli kalacağının hiçbir garantisi yoktur. Liste 4-7’deki ilk_kelime fonksiyonunu kullanan Liste 4-8’deki programı inceleyelim.
fn ilk_kelime(metin: &String) -> usize {
let baytlar = metin.as_bytes();
for (i, &oge) in baytlar.iter().enumerate() {
if oge == b' ' {
return i;
}
}
metin.len()
}
fn main() {
let mut metin = String::from("merhaba dünya");
let kelime = ilk_kelime(&metin); // kelime 7 değerini alacak
metin.clear(); // bu, String'i boşaltır ve onu "" değerine eşitler
// kelime burada hala 7 değerine sahip, ancak metin'in 7 değeriyle
// anlamlı bir şekilde kullanabileceğimiz hiçbir içeriği kalmadı,
// yani kelime artık tamamen geçersiz!
}
ilk_kelime fonksiyonu çağrısından dönen sonucu saklayıp ardından String içeriğini değiştirmekBu program hiçbir hata vermeden derlenir; hatta metin.clear() çağrısından sonra kelime değişkenini kullansaydık bile derlenirdi. kelime değişkeni metin’in durumuna hiçbir şekilde bağlı olmadığından hâlâ 7 değerini tutar. İlk kelimeyi çıkarmak amacıyla metin değişkeniyle birlikte bu 7 değerini kullanmaya çalışabilirdik; ancak biz 7 sayısını kelime değişkenine kaydettikten sonra metin’in içeriği değiştiği için bu bir mantık hatası (bug) olurdu.
kelime içindeki indeksin metin içindeki veriyle uyumunun bozulabileceği konusunda sürekli endişelenmek yorucu ve hataya açıktır! Bir ikinci_kelime fonksiyonu yazdığımızda bu indeksleri yönetmek daha da kırılgan bir hâl alır. Böyle bir fonksiyonun imzası şuna benzemek zorunda kalırdı:
fn ikinci_kelime(metin: &String) -> (usize, usize) {
Şimdi hem bir başlangıç hem de bir bitiş indeksini takip ediyoruz; üstelik verinin belirli bir durumundan hesaplanmış ama o duruma hiçbir biçimde bağlı olmayan daha da fazla değerimiz var. Artık ortalıkta dolaşan ve birbiriyle uyumlu tutulması gereken üç ilişkisiz değişken bulunuyor.
Neyse ki Rust’ın bu soruna bir çözümü vardır: string dilimleri.
String Dilimleri
Bir string dilimi (string slice), bir String içindeki elemanların bitişik bir dizisine referanstır ve şöyle görünür:
fn main() {
let metin = String::from("merhaba dunya");
let merhaba = &metin[0..7];
let dunya = &metin[8..13];
}
merhaba değişkeni tüm String’e referans vermek yerine, fazladan eklenen [0..7] kısmında belirtilen String bölümüne referans verir. Dilimleri köşeli parantezler içinde [başlangıç_indeksi..bitiş_indeksi] biçiminde bir aralık belirterek oluştururuz; burada başlangıç_indeksi dilimdeki ilk konumu, bitiş_indeksi ise dilimdeki son konumun bir fazlasını ifade eder. Dilim veri yapısı dahili olarak dilimin başlangıç konumunu ve uzunluğunu saklar; bu uzunluk bitiş_indeksi eksi başlangıç_indeksi değerine karşılık gelir. Dolayısıyla let dunya = &metin[8..13]; örneğinde dunya, metin değişkeninin 8. indeksindeki bayta yönelen bir işaretçi ve 5 uzunluk değerini barındıran bir dilim olur. (Bu örnekte "dünya" yerine bilerek yalnızca ASCII karakterlerden oluşan "dunya" yazdık; nedenini aşağıdaki UTF-8 notunda göreceksiniz.)
Şekil 4-7 bu bellek yapısını bir diyagramla göstermektedir.
Şekil 4-7: metin = String::from("merhaba dunya") değerinin &metin[8..13] bölümüne başvuran dunya string dilimi
Rust’ın .. aralık sözdiziminde 0. indeksten başlamak istiyorsanız iki noktadan önceki sayıyı yazmayabilirsiniz. Başka bir deyişle, aşağıdaki iki satır birbirine eşdeğerdir:
#![allow(unused)]
fn main() {
let metin = String::from("merhaba");
let dilim = &metin[0..2];
let dilim = &metin[..2];
}
Aynı mantıkla, diliminiz String’in son baytını da içeriyorsa sondaki sayıyı yazmayabilirsiniz. Yani şu iki satır da birbirine eşdeğerdir:
#![allow(unused)]
fn main() {
let metin = String::from("merhaba");
let uzunluk = metin.len();
let dilim = &metin[3..uzunluk];
let dilim = &metin[3..];
}
Tüm string’in bir dilimini almak için her iki değeri de atlayabilirsiniz. Dolayısıyla şunlar da eşdeğerdir:
#![allow(unused)]
fn main() {
let metin = String::from("merhaba");
let uzunluk = metin.len();
let dilim = &metin[0..uzunluk];
let dilim = &metin[..];
}
Not: String dilimi aralık indeksleri geçerli UTF-8 karakter sınırlarına denk gelmelidir. Çok baytlı (multibyte) bir karakterin tam ortasından string dilimi oluşturmaya çalışırsanız programınız çalışma zamanında bir panik hatasıyla sonlanır.
Türkçe metinlerle çalışırken bu kural özellikle önemlidir: UTF-8 kodlamasında ASCII karakterler 1 bayt yer kaplarken 'ç', 'ğ', 'ı', 'ö', 'ş', 'ü' (ve büyük harf karşılıkları olan 'Ç', 'Ğ', 'İ', 'Ö', 'Ş', 'Ü') bellekte 2 bayt yer kaplar. Örneğin let metin = String::from("çalışma"); tanımladığınızda ilk harf olan 'ç' bellekte 0 ve 1 numaralı baytlara yayılır (0..2). Bu nedenle &metin[0..1] yazarak yalnızca ilk baytı dilimlemeye çalışırsanız Rust, karakter bütünlüğünün bozulmasını önlemek için çalışma zamanında end byte index 1 is not a char boundary; it is inside 'ç' (bytes 0..2 of string) hatasıyla panikler; "ç" harfini dilimlemek için &metin[0..2] kullanmanız gerekir. Benzer şekilde "dünya" kelimesi 5 harfli olmasına rağmen 'ü' 2 bayt tuttuğundan bellekte 6 bayt (len = 6) uzunluğundadır.
Tüm bu bilgileri göz önünde bulundurarak ilk_kelime fonksiyonunu bir dilim döndürecek biçimde yeniden yazalım. “String dilimi” anlamına gelen tür &str şeklinde yazılır:
fn ilk_kelime(metin: &String) -> &str {
let baytlar = metin.as_bytes();
for (i, &oge) in baytlar.iter().enumerate() {
if oge == b' ' {
return &metin[0..i];
}
}
&metin[..]
}
fn main() {}
Kelimenin bitiş indeksini tıpkı Liste 4-7’de yaptığımız gibi ilk boşluk karakterini arayarak buluruz. Bir boşluk bulduğumuzda, başlangıç ve bitiş indeksleri olarak sırasıyla string’in başlangıcını ve boşluğun indeksini kullanarak bir string dilimi döndürürüz; boşluk bulamazsak da &metin[..] ile string’in tamamını dilim olarak döndürürüz.
Artık ilk_kelime fonksiyonunu çağırdığımızda, arka plandaki asıl veriye doğrudan bağlı tek bir değer elde ederiz. Bu değer, dilimin başlangıç noktasına yönelen bir referanstan ve dilimdeki eleman (bayt) sayısından oluşur.
Bir dilim döndürmek ikinci_kelime fonksiyonu için de aynı şekilde işe yarar:
fn ikinci_kelime(metin: &String) -> &str {
Derleyici String içine yönelen referansların geçerli kalmasını güvence altına alacağından, artık yanlış kullanılması çok daha zor ve yalın bir API’ye sahibiz. Liste 4-8’deki programda ilk kelimenin bitiş indeksini aldıktan sonra string’i temizlediğimiz ve elimizdeki indeksin geçersiz kaldığı hatayı hatırlıyor musunuz? O kod mantıksal açıdan hatalıydı ama anında hiçbir hata vermemişti; sorun ancak boşaltılmış bir string üzerinde ilk kelime indeksini kullanmaya devam ettiğimizde sonradan ortaya çıkacaktı. Dilimler bu hatayı imkânsız kılar ve kodumuzda bir sorun olduğunu çok daha erken fark etmemizi sağlar. ilk_kelime’nin dilim döndüren sürümünü aynı senaryoda kullanmak derleme zamanı hatası üretir:
fn ilk_kelime(metin: &String) -> &str {
let baytlar = metin.as_bytes();
for (i, &oge) in baytlar.iter().enumerate() {
if oge == b' ' {
return &metin[0..i];
}
}
&metin[..]
}
fn main() {
let mut metin = String::from("merhaba dünya");
let kelime = ilk_kelime(&metin);
metin.clear(); // hata!
println!("ilk kelime: {kelime}");
}
İşte derleyici hatası:
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `metin` as mutable because it is also borrowed as immutable
--> src/main.rs:19:5
|
17 | let kelime = ilk_kelime(&metin);
| ------ immutable borrow occurs here
18 |
19 | metin.clear(); // hata!
| ^^^^^^^^^^^^^ mutable borrow occurs here
20 |
21 | println!("ilk kelime: {kelime}");
| ------ immutable borrow later used here
For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error
Ödünç alma kurallarını hatırlayalım: Bir değere ait değiştirilemez bir referansımız varken, aynı değere değiştirilebilir bir referans alamayız. clear metodunun String’i kısaltıp boşaltması gerektiği için değiştirilebilir bir referans alması gerekir. (Kodda açıkça &mut metin yazmadığımıza dikkat edin: clear gibi metotlar &mut self imzasıyla tanımlandığından Rust, metin.clear() çağrısında bu değiştirilebilir ödünç almayı sizin yerinize otomatik olarak yapar; metot sözdizimini Bölüm 5’te ele alacağız.) clear çağrısından sonraki println! satırı ise kelime içindeki referansı kullanır; dolayısıyla değiştirilemez referansın o noktada hâlâ etkin olması zorunludur. Rust, clear çağrısındaki değiştirilebilir referans (mutable borrow occurs here) ile kelime içindeki değiştirilemez referansın (immutable borrow occurs here) aynı anda var olmasına izin vermez ve derleme başarısız olur. Rust yalnızca API’mizi kullanımı daha kolay bir hâle getirmekle kalmamış, koskoca bir hata sınıfını daha derleme zamanında ortadan kaldırmıştır!
Dilim Olarak String Sabitleri
Daha önce string sabitlerinin derlenen ikili dosyanın (binary) içinde saklandığından söz ettiğimizi hatırlayın. Artık dilimleri tanıdığımıza göre string sabitlerini tam olarak anlayabiliriz:
#![allow(unused)]
fn main() {
let metin = "Merhaba, dünya!";
}
Buradaki metin değişkeninin türü &str’dir: İkili dosyanın tam o noktasına işaret eden bir dilimdir. String sabitlerinin değiştirilemez olmasının nedeni de budur; &str değiştirilemez bir referanstır.
Parametre Olarak String Dilimleri
Hem string sabitlerinin hem de String değerlerinin dilimlerini alabileceğimizi bilmek, bizi ilk_kelime fonksiyonunda yapabileceğimiz bir iyileştirmeye daha — fonksiyonun imzasına — götürür:
fn ilk_kelime(metin: &String) -> &str {
Daha deneyimli bir Rust geliştiricisi bunun yerine Liste 4-9’da gösterilen imzayı yazar; çünkü bu imza aynı fonksiyonu hem &String değerleri hem de &str değerleri üzerinde kullanmamıza olanak tanır.
fn ilk_kelime(metin: &str) -> &str {
let baytlar = metin.as_bytes();
for (i, &oge) in baytlar.iter().enumerate() {
if oge == b' ' {
return &metin[0..i];
}
}
&metin[..]
}
fn main() {
let benim_metnim = String::from("merhaba dünya");
// `ilk_kelime`, `String`'lerin dilimleri (kısmi veya tam) üzerinde çalışır.
let kelime = ilk_kelime(&benim_metnim[0..7]);
let kelime = ilk_kelime(&benim_metnim[..]);
// `ilk_kelime` ayrıca, `String`'lerin tam dilimlerine eşdeğer olan
// `String` referansları üzerinde de çalışır.
let kelime = ilk_kelime(&benim_metnim);
let sabit_metnim = "merhaba dünya";
// `ilk_kelime`, string sabitlerinin dilimleri (kısmi veya tam) üzerinde de çalışır.
let kelime = ilk_kelime(&sabit_metnim[0..7]);
let kelime = ilk_kelime(&sabit_metnim[..]);
// String sabitleri zaten string dilimi (`&str`) olduğu için,
// dilim sözdizimi olmadan doğrudan da çalışır!
let kelime = ilk_kelime(sabit_metnim);
}
metin parametresinin türü olarak string dilimi kullanarak ilk_kelime fonksiyonunu iyileştirmekElimizde bir string dilimi varsa onu doğrudan argüman olarak geçirebiliriz. Elimizde bir String varsa ya bu String’in bir dilimini ya da doğrudan String’e ait bir referansı geçirebiliriz. Bu esneklik, Bölüm 15’teki “Fonksiyon ve Metotlarda Deref Zorlaması Kullanmak” bölümünde ele alacağımız deref zorlaması (deref coercion) özelliğinden yararlanır.
Not: Özetle deref zorlaması, derleyicinin &String türündeki bir referansı gerektiğinde hiçbir ek çalışma zamanı maliyeti veya bellek kopyalaması olmadan otomatik olarak &str dilimine dönüştürmesidir (&benim_metnim ifadesi &benim_metnim[..] ile aynı sonucu verir). Bu nedenle günlük Rust kodlarında, bir fonksiyon metnin sahipliğini almak veya onu değiştirmek zorunda değilse parametre türü olarak &String yerine her zaman &str tercih edilir.
Bir fonksiyonu String referansı yerine string dilimi alacak şekilde tanımlamak, hiçbir işlevsellik kaybı yaşatmadan API’mizi daha genel ve kullanışlı hâle getirir:
fn ilk_kelime(metin: &str) -> &str {
let baytlar = metin.as_bytes();
for (i, &oge) in baytlar.iter().enumerate() {
if oge == b' ' {
return &metin[0..i];
}
}
&metin[..]
}
fn main() {
let benim_metnim = String::from("merhaba dünya");
// `ilk_kelime`, `String`'lerin dilimleri (kısmi veya tam) üzerinde çalışır.
let kelime = ilk_kelime(&benim_metnim[0..7]);
let kelime = ilk_kelime(&benim_metnim[..]);
// `ilk_kelime` ayrıca, `String`'lerin tam dilimlerine eşdeğer olan
// `String` referansları üzerinde de çalışır.
let kelime = ilk_kelime(&benim_metnim);
let sabit_metnim = "merhaba dünya";
// `ilk_kelime`, string sabitlerinin dilimleri (kısmi veya tam) üzerinde de çalışır.
let kelime = ilk_kelime(&sabit_metnim[0..7]);
let kelime = ilk_kelime(&sabit_metnim[..]);
// String sabitleri zaten string dilimi (`&str`) olduğu için,
// dilim sözdizimi olmadan doğrudan da çalışır!
let kelime = ilk_kelime(sabit_metnim);
}
Diğer Dilimler
String dilimleri, tahmin edebileceğiniz gibi stringlere özgüdür. Ancak daha genel bir dilim türü de vardır. Şu diziyi ele alalım:
#![allow(unused)]
fn main() {
let d = [1, 2, 3, 4, 5];
}
Tıpkı bir string’in belirli bir bölümüne başvurmak isteyebileceğimiz gibi, bir dizinin belirli bir bölümüne de başvurmak isteyebiliriz. Bunu şu şekilde yaparız:
#![allow(unused)]
fn main() {
let d = [1, 2, 3, 4, 5];
let dilim = &d[1..3];
assert_eq!(dilim, &[2, 3]);
}
Bu dilim &[i32] türündedir. İlk elemana yönelen bir referansı ve bir uzunluk bilgisini yığında saklayarak tıpkı string dilimleri gibi çalışır. Bu tür dilimleri diğer pek çok koleksiyon türüyle birlikte sıkça kullanacaksınız. Bu koleksiyonları Bölüm 8’de vektörleri incelerken ayrıntılı olarak ele alacağız.
Özet
Sahiplik, ödünç alma ve dilim kavramları Rust programlarında bellek güvenliğini derleme zamanında güvence altına alır. Rust dili, tıpkı diğer sistem programlama dilleri gibi bellek kullanımınız üzerinde size tam denetim verir; ancak verinin sahibi kapsam dışına çıktığında veriyi otomatik olarak temizlemesi sayesinde bu denetimi elde etmek için fazladan bellek yönetimi kodu yazmak ve bu kodların hatalarını ayıklamak zorunda kalmazsınız.
Sahiplik kavramı Rust’ın diğer pek çok parçasının nasıl çalıştığını doğrudan etkiler; bu nedenle kitabın geri kalanı boyunca bu kavramlara sık sık geri döneceğiz. Şimdi Bölüm 5’e geçelim ve birbiriyle ilişkili veri parçalarını bir struct içinde gruplandırmaya bakalım.
İlgili Verileri Yapılandırmak İçin Struct Kullanımı
Bir struct (yapı), anlamlı bir bütün oluşturan ilişkili birden fazla değeri bir araya getirip isimlendirmenizi sağlayan özel bir veri türüdür. Nesne yönelimli bir programlama diline aşinaysanız struct, bir nesnenin veri niteliklerine benzer. Bu bölümde, şimdiye kadar öğrendiklerinizden yola çıkarak demet (tuple) türü ile struct’ları karşılaştıracak, benzerlik ve farklarını ortaya koyacak ve verileri gruplandırmak için struct’ların ne zaman daha iyi bir seçenek olduğunu göstereceğiz.
Struct’ların nasıl tanımlanacağını ve örnekleneceğini inceleyeceğiz. Bir struct türüne ait davranışları belirlemek için ilişkili fonksiyonların (associated functions) —özellikle de metot olarak adlandırılan ilişkili fonksiyon türünün— nasıl tanımlanacağını ele alacağız. Struct’lar ve Bölüm 6’da göreceğimiz enum’lar, programınızın ele aldığı probleme özgü yeni türler oluşturmanın temel yapı taşlarıdır; bu türler sayesinde Rust’ın derleme zamanı tür denetiminden tam anlamıyla yararlanabilirsiniz.
Not: C#, Java veya Python gibi nesne yönelimli dillerden geliyorsanız Rust’ta
class anahtar kelimesinin ve klasik sınıf kalıtımı mekanizmasının
bulunmadığını fark edeceksiniz. Rust, verinin bellekteki yapısını (struct
tanımı) ve o veri üzerinde çalışan davranışları (impl blokları içindeki
metotlar ve ilişkili fonksiyonlar) birbirinden net biçimde ayırır; türler
arası ortak davranışları ise Bölüm 10’da göreceğimiz trait sistemiyle
sağlar. Üstelik ham verileri anlamlı struct türleri altında gruplamak
çalışma zamanında ek bir bellek ya da hız maliyeti getirmez; buna karşılık
verilere anlamlı alan adları kazandırır. Ayrıca farklı kavramlar için ayrı
struct türleri tanımlamak, bu türlerin yanlışlıkla birbirinin yerine
kullanılmasını derleme aşamasında engeller; aynı türdeki iki alanın değerini
karıştırmayı ise derleyici her zaman saptayamaz.
Struct Tanımlama ve Örnekleme
Struct Tanımlama ve Örnekleme
Struct’lar, birbiriyle ilişkili birden fazla değeri bir arada tutmaları bakımından “Demet Türü” bölümünde ele aldığımız demetlere benzer. Tıpkı demetlerde olduğu gibi, bir struct’ın parçaları farklı türlerde olabilir. Ancak demetlerden farklı olarak struct içinde her bir veri parçasına bir ad verirsiniz; böylece değerlerin ne anlama geldiği açıkça anlaşılır. Bu isimlendirme sayesinde struct’lar demetlerden daha esnektir: Bir örneğin değerlerini belirtmek ya da onlara erişmek için verilerin sırasına bağımlı kalmazsınız.
Bir struct tanımlamak için struct anahtar kelimesini yazar ve struct’ın tamamına bir ad veririz. Struct’ın adı, bir araya getirilen veri parçalarının ortak anlamını ifade etmelidir. Ardından süslü parantezler içinde, alan (field) olarak adlandırdığımız veri parçalarının adlarını ve türlerini tanımlarız. Örneğin Liste 5-1, bir kullanıcı hesabına ait bilgileri tutan bir struct tanımını göstermektedir.
struct Kullanici {
aktif: bool,
kullanici_adi: String,
eposta: String,
giris_sayisi: u64,
}
fn main() {}
Kullanici struct’ı tanımıTanımladığımız bir struct’ı kullanmak için, her bir alana somut değerler vererek o struct’ın bir örneğini (instance) oluştururuz. Örnek oluştururken önce struct’ın adını yazar, ardından içine anahtar: değer çiftlerini yerleştirdiğimiz süslü parantezler açarız; buradaki anahtarlar alanların adları, değerler ise o alanlarda saklamak istediğimiz verilerdir. Alanları, struct tanımındaki bildirim sırasıyla yazmak zorunda değiliz. Struct tanımı, tür için genel bir şablon gibidir; örnekler ise bu şablonu belirli verilerle doldurarak o türe ait somut değerler üretir. Örneğin Liste 5-2’de gösterildiği gibi belirli bir kullanıcı tanımlayabiliriz.
struct Kullanici {
aktif: bool,
kullanici_adi: String,
eposta: String,
giris_sayisi: u64,
}
fn main() {
let kullanici1 = Kullanici {
aktif: true,
kullanici_adi: String::from("birkullaniciadi123"),
eposta: String::from("birisi@example.com"),
giris_sayisi: 1,
};
}
Kullanici struct’ının bir örneğini oluşturmaBir struct içinden belirli bir değeri okumak için nokta gösterimini (dot notation) kullanırız. Örneğin bu kullanıcının e-posta adresine erişmek istediğimizde kullanici1.eposta yazarız. Eğer örnek değiştirilebilir (mut) olarak tanımlanmışsa, nokta gösterimiyle belirli bir alana yeni bir değer atayarak o alanı güncelleyebiliriz. Liste 5-3, değiştirilebilir bir Kullanici örneğinin eposta alanındaki değerin nasıl değiştirileceğini göstermektedir.
struct Kullanici {
aktif: bool,
kullanici_adi: String,
eposta: String,
giris_sayisi: u64,
}
fn main() {
let mut kullanici1 = Kullanici {
aktif: true,
kullanici_adi: String::from("birkullaniciadi123"),
eposta: String::from("birisi@example.com"),
giris_sayisi: 1,
};
kullanici1.eposta = String::from("baskaeposta@example.com");
}
Kullanici örneğinin eposta alanındaki değeri değiştirmeÖrneğin tamamının değiştirilebilir olması gerektiğine dikkat edin; Rust yalnızca belirli alanları değiştirilebilir olarak işaretlememize izin vermez. Struct örneği oluşturmak da bir ifade olduğundan, her ifadede olduğu gibi, fonksiyon gövdesinin son ifadesi olarak struct’ın yeni bir örneğini oluşturabilir ve bu örneği örtük olarak döndürebiliriz.
Not: Rust’ta değiştirilebilirlik (mut), struct tanımındaki tekil alanların değil, örneği tutan değişken bağlamasının (veya &mut referansının) bir özelliğidir; bu nedenle struct Kullanici { mut eposta: String } gibi alan bazlı bir sözdizimi geçerli değildir. Bu tasarım sayesinde aynı Kullanici türünü kodun bir yerinde tamamen değiştirilemez (let kullanici1 = ...), başka bir yerinde ise değiştirilebilir (let mut kullanici2 = ...) olarak güvenle kullanabilirsiniz. Değiştirilemez bir referans arkasındayken yalnızca belirli bir alanı güncellemenin gerektiği özel senaryolar içinse Bölüm 15’te içsel değiştirilebilirlik (interior mutability) desenini (Cell<T> ve RefCell<T>) inceleyeceğiz.
Liste 5-4, verilen e-posta ve kullanıcı adıyla bir Kullanici örneği döndüren kullanici_olustur fonksiyonunu göstermektedir. aktif alanına true, giris_sayisi alanına ise 1 değeri atanır.
struct Kullanici {
aktif: bool,
kullanici_adi: String,
eposta: String,
giris_sayisi: u64,
}
fn kullanici_olustur(eposta: String, kullanici_adi: String) -> Kullanici {
Kullanici {
aktif: true,
kullanici_adi: kullanici_adi,
eposta: eposta,
giris_sayisi: 1,
}
}
fn main() {
let kullanici1 = kullanici_olustur(
String::from("birisi@example.com"),
String::from("birkullaniciadi123"),
);
}
Kullanici örneği döndüren kullanici_olustur fonksiyonuFonksiyon parametrelerine struct alanlarıyla aynı adları vermek mantıklıdır; ancak eposta ve kullanici_adi adlarını hem alan adı hem de değişken olarak ikişer kez yazmak biraz zahmetlidir. Struct daha fazla alana sahip olsaydı her bir adı bu şekilde yinelemek daha da can sıkıcı hâle gelirdi. Neyse ki bunun için kullanışlı bir kısayol vardır!
Alan Başlatma Kısayolunu Kullanma
Liste 5-4’teki parametre adları ile struct alan adları birebir aynı olduğundan, alan başlatma kısayolu (field init shorthand) sözdizimini kullanarak kullanici_olustur fonksiyonunu yeniden yazabiliriz. Liste 5-5’teki yeni sürüm öncekiyle aynı şekilde çalışır, ancak kullanici_adi ve eposta adlarını tekrar etmez.
struct Kullanici {
aktif: bool,
kullanici_adi: String,
eposta: String,
giris_sayisi: u64,
}
fn kullanici_olustur(eposta: String, kullanici_adi: String) -> Kullanici {
Kullanici {
aktif: true,
kullanici_adi,
eposta,
giris_sayisi: 1,
}
}
fn main() {
let kullanici1 = kullanici_olustur(
String::from("birisi@example.com"),
String::from("birkullaniciadi123"),
);
}
kullanici_adi ve eposta parametreleri struct alanlarıyla aynı ada sahip olduğu için alan başlatma kısayolunu kullanan kullanici_olustur fonksiyonuBurada, eposta adında bir alanı bulunan Kullanici struct’ının yeni bir örneğini oluşturuyoruz. eposta alanının değerini, kullanici_olustur fonksiyonunun eposta parametresindeki değere eşitlemek istiyoruz. Alan adı ile parametre adı aynı olduğu için eposta: eposta yerine yalnızca eposta yazmamız yeterlidir.
Struct Güncelleme Sözdizimi ile Örnekler Oluşturma
Aynı türdeki mevcut bir örneğin değerlerinin çoğunu koruyup yalnızca birkaçını değiştirerek yeni bir struct örneği oluşturmak çoğu zaman işe yarar. Bunu struct güncelleme sözdizimi (struct update syntax) ile yapabilirsiniz.
Öncelikle Liste 5-6’da, güncelleme sözdizimini kullanmadan sıradan yöntemle kullanici2 içinde yeni bir Kullanici örneğini nasıl oluşturacağımızı görelim. eposta alanı için yeni bir değer belirliyor, geri kalan alanlarda ise Liste 5-2’de oluşturduğumuz kullanici1 örneğindeki değerlerin aynısını kullanıyoruz.
struct Kullanici {
aktif: bool,
kullanici_adi: String,
eposta: String,
giris_sayisi: u64,
}
fn main() {
// --snip--
let kullanici1 = Kullanici {
eposta: String::from("birisi@example.com"),
kullanici_adi: String::from("birkullaniciadi123"),
aktif: true,
giris_sayisi: 1,
};
let kullanici2 = Kullanici {
aktif: kullanici1.aktif,
kullanici_adi: kullanici1.kullanici_adi,
eposta: String::from("baska@example.com"),
giris_sayisi: kullanici1.giris_sayisi,
};
}
kullanici1’deki değerlerden biri hariç tümünü kullanarak yeni bir Kullanici örneği oluşturmaStruct güncelleme sözdizimini kullanarak, Liste 5-7’de gösterildiği gibi aynı sonucu çok daha az kodla elde edebiliriz. .. sözdizimi, açıkça değer verilmeyen geri kalan alanların belirtilen örnekteki karşılıklarıyla aynı değere sahip olacağını ifade eder.
struct Kullanici {
aktif: bool,
kullanici_adi: String,
eposta: String,
giris_sayisi: u64,
}
fn main() {
// --snip--
let kullanici1 = Kullanici {
eposta: String::from("birisi@example.com"),
kullanici_adi: String::from("birkullaniciadi123"),
aktif: true,
giris_sayisi: 1,
};
let kullanici2 = Kullanici {
eposta: String::from("baska@example.com"),
..kullanici1
};
}
eposta değeri belirleyip geri kalan alanları kullanici1 örneğinden almak için struct güncelleme sözdizimini kullanmaListe 5-7’deki kod da kullanici2 içinde eposta alanı farklı, ancak kullanici_adi, aktif ve giris_sayisi alanları kullanici1 ile aynı değerlere sahip bir örnek oluşturur. Kalan tüm alanların değerlerinin kullanici1’deki karşılık gelen alanlardan alınacağını belirtmek için ..kullanici1 en sonda yer almalıdır; ancak struct tanımındaki alan sırasından bağımsız olarak istediğimiz sayıda alana dilediğimiz sırada açıkça değer verebiliriz.
Struct güncelleme sözdiziminin tıpkı bir atama işlemi gibi = kullandığına dikkat edin; bunun nedeni, verileri “Değişkenlerin ve Verilerin Taşıma ile Etkileşimi” bölümünde gördüğümüz şekilde taşımasıdır. Bu örnekte kullanici2 oluşturulduktan sonra artık kullanici1 örneğini bir bütün olarak kullanamayız; çünkü kullanici1’in kullanici_adi alanındaki String değeri kullanici2 içine taşınmıştır. Eğer kullanici2’ye hem eposta hem de kullanici_adi için yeni String değerleri verseydik ve böylece kullanici1 içinden yalnızca aktif ile giris_sayisi alanlarını alsaydık, kullanici2 oluşturulduktan sonra kullanici1 tamamen geçerli kalmaya devam ederdi. Hem aktif hem de giris_sayisi, Copy trait’ini uygulayan türlerde olduğundan “Yalnızca Yığındaki Veri: Copy” bölümünde ele aldığımız kurallar devreye girerdi. Ayrıca mevcut örneğimizde kullanici1.eposta alanını hâlâ kullanabiliriz; çünkü bu alanın değeri kullanici1 dışına taşınmamıştır.
Not: Derleyicinin bir struct örneğindeki bazı alanların taşındığını, bazılarının ise yerinde kaldığını alan bazında takip etmesine kısmi taşıma (partial move) denir. Liste 5-7’de ..kullanici1 sonrasında kullanici1.kullanici_adi taşındığı için kullanici1 değişkenini bir bütün olarak kullanmaya çalışırsanız (örneğin onu değer olarak başka bir fonksiyona aktarırsanız) error[E0382]: use of partially moved value: `kullanici1` (kısmen taşınmış değerin kullanımı) hatası alırsınız; bütünü ödünç almaya çalıştığınızda ise hata borrow of partially moved value biçiminde olur. Buna karşılık taşınmayan kullanici1.eposta alanına veya Copy olan kullanici1.aktif ve kullanici1.giris_sayisi alanlarına tek tek erişmeye devam edebilirsiniz. Eğer kullanici1 örneğinin tamamının geçerli kalmasını istiyorsanız, String alanını açıkça klonlayarak (kullanici_adi: kullanici1.kullanici_adi.clone()) yeni örneği oluşturabilirsiniz.
Demet Struct’ları ile Farklı Türler Oluşturma
Rust ayrıca demetlere benzeyen ve demet struct’ları (tuple structs) olarak adlandırılan struct’ları da destekler. Demet struct’ları, struct adının kazandırdığı ek anlama sahiptir; ancak alanlarının isimleri yoktur, yalnızca alan türleri belirtilir. Demet struct’ları, demetin tamamına bir ad vererek onu diğer demetlerden ayrı bir tür hâline getirmek istediğinizde ve her bir alanı normal bir struct’taki gibi isimlendirmenin gereksiz yere uzun ya da yinelemeli olacağı durumlarda kullanışlıdır.
Bir demet struct’ı tanımlamak için struct anahtar kelimesiyle başlayıp struct adını yazar, ardından parantez içinde demetteki türleri belirtirsiniz. Örneğin aşağıda Renk ve Nokta adında iki demet struct’ı tanımlayıp kullanıyoruz:
struct Renk(i32, i32, i32);
struct Nokta(i32, i32, i32);
fn main() {
let siyah = Renk(0, 0, 0);
let orijin = Nokta(0, 0, 0);
}
siyah ve orijin değerlerinin, farklı demet struct’larının örnekleri oldukları için farklı türlere sahip olduğuna dikkat edin. Tanımladığınız her struct, içerdiği alanlar aynı türlerde olsa bile başlı başına ayrı bir türdür. Örneğin her iki tür de üç adet i32 değerinden oluşmasına rağmen, Renk türünde bir parametre bekleyen bir fonksiyona argüman olarak bir Nokta veremezsiniz. Bunun dışında demet struct örnekleri demetlere benzer: Onları tekil parçalarına ayırabilir ve belirli bir değere erişmek için nokta (.) işaretinin ardından indeks numarasını yazabilirsiniz. Ancak sıradan demetlerden farklı olarak demet struct’ları, yapı sökümü (destructuring) yaparken struct’ın tür adını belirtmenizi gerektirir. Örneğin orijin noktasındaki değerleri x, y ve z adlı değişkenlere ayırmak için let Nokta(x, y, z) = orijin; yazarız.
Not: Tek bir alana sahip demet struct’ları (örneğin struct Metre(f64); ve
struct Saniye(f64); ya da struct KullaniciId(u64);), Rust ekosisteminde
newtype (yeni tür) deseni olarak sıkça kullanılır. Bu sarmalayıcı türler,
içerdikleri değerin etrafında yeni bir tür sınırı oluşturur ve genellikle ek
çalışma zamanı maliyeti getirmez. Bununla birlikte, varsayılan Rust temsilinde
aynı bellek yerleşimi ve çağrı biçimi garanti edilmez; böyle bir güvence
gerektiğinde uygun tek alanlı türlerde #[repr(transparent)] kullanılır. Bu
türler, metre beklenen bir fonksiyona yanlışlıkla saniye gönderilmesi gibi
birimleri veya kimlikleri karıştırma hatalarını derleme zamanında yakalamanızı
sağlar. Bu desenin gelişmiş kullanımlarını Bölüm 20’de yeniden ele alacağız.
Birim Benzeri Struct’ları Tanımlama
Hiçbir alanı olmayan struct’lar da tanımlayabilirsiniz! Bunlara, “Demet Türü” bölümünde değindiğimiz birim türe (()) benzer biçimde davrandıkları için birim benzeri struct’lar (unit-like structs) adı verilir. Birim benzeri struct’lar, bir tür üzerinde bir trait uygulamanız gerektiğinde ancak türün kendisinde saklamak istediğiniz herhangi bir veri bulunmadığında oldukça işe yarar. Trait’leri Bölüm 10’da ele alacağız. Aşağıda HerZamanEsit adlı birim benzeri bir struct’ı tanımlayıp örnekliyoruz:
struct HerZamanEsit;
fn main() {
let ozne = HerZamanEsit;
}
HerZamanEsit türünü tanımlamak için struct anahtar kelimesini, istediğimiz adı ve ardından noktalı virgülü yazarız. Süslü parantezlere veya normal parantezlere gerek yoktur! Ardından ozne değişkeninde bir HerZamanEsit örneği elde etmek için de benzer şekilde, herhangi bir süslü ya da normal parantez kullanmadan doğrudan tanımladığımız adı yazarız. İleride bu tür için, HerZamanEsit türünün her örneğinin diğer tüm türlerin her örneğine daima eşit kabul edileceği bir davranış uygulayacağımızı düşünün; böyle bir davranış, örneğin testlerde sonucu önceden bilinen bir karşılaştırma elde etmek için işe yarayabilir. Böyle bir davranışı uygulamak için struct içinde herhangi bir veri tutmamıza gerek yoktur! Bölüm 10’da trait’leri nasıl tanımlayacağınızı ve birim benzeri struct’lar da dahil olmak üzere herhangi bir tür üzerinde nasıl uygulayacağınızı göreceksiniz.
Struct Verilerinin Sahipliği
Liste 5-1’deki Kullanici struct tanımında, &str string dilimi türü yerine verinin sahibi olan String türünü kullandık. Bu bilinçli bir tercihtir; çünkü bu struct’ın her örneğinin kendi verilerinin tamamına sahip olmasını ve bu verilerin, struct’ın kendisi geçerli olduğu sürece geçerli kalmasını istiyoruz.
Struct’ların başka bir değişkene ait verilere referans saklaması da mümkündür; ancak bunun için Bölüm 10’da inceleyeceğimiz bir Rust özelliği olan ömürlerin (lifetimes) kullanılması gerekir. Ömürler, bir struct tarafından referans verilen verilerin struct var olduğu sürece geçerli kalmasını garanti eder. Diyelim ki aşağıdaki src/main.rs dosyasında olduğu gibi, ömür belirtmeden bir struct içinde referans saklamayı denediniz; bu kod derlenmez:
struct Kullanici {
aktif: bool,
kullanici_adi: &str,
eposta: &str,
giris_sayisi: u64,
}
fn main() {
let kullanici1 = Kullanici {
aktif: true,
kullanici_adi: "birkullaniciadi123",
eposta: "birisi@example.com",
giris_sayisi: 1,
};
}
Derleyici, ömür belirteçlerinin eksik olduğunu bildiren bir hata verecektir:
$ cargo run
Compiling structs v0.1.0 (file:///projects/structs)
error[E0106]: missing lifetime specifier
--> src/main.rs:3:20
|
3 | kullanici_adi: &str,
| ^ expected named lifetime parameter
|
help: consider introducing a named lifetime parameter
|
1 ~ struct Kullanici<'a> {
2 | aktif: bool,
3 ~ kullanici_adi: &'a str,
|
error[E0106]: missing lifetime specifier
--> src/main.rs:4:13
|
4 | eposta: &str,
| ^ expected named lifetime parameter
|
help: consider introducing a named lifetime parameter
|
1 ~ struct Kullanici<'a> {
2 | aktif: bool,
3 | kullanici_adi: &str,
4 ~ eposta: &'a str,
|
For more information about this error, try `rustc --explain E0106`.
error: could not compile `structs` (bin "structs") due to 2 previous errors
Bölüm 10’da struct’lar içinde referans saklayabilmeniz için bu hataları (missing lifetime specifier — ömür belirteci eksik) nasıl gidereceğinizi ayrıntılı olarak ele alacağız; şimdilik bu tür hataları &str gibi referanslar yerine String gibi sahipli türler kullanarak çözeceğiz.
Struct Kullanan Örnek Bir Program
Struct Kullanan Örnek Bir Program
Struct’ları ne zaman kullanmak isteyebileceğimizi anlamak için bir dikdörtgenin alanını hesaplayan bir program yazalım. İşe tekil değişkenler kullanarak başlayacak, ardından programı adım adım yeniden düzenleyerek (refactoring) bunların yerine struct kullanır hâle getireceğiz.
Cargo ile dikdortgenler adında yeni bir ikili proje oluşturalım; bu proje piksel cinsinden belirtilen bir dikdörtgenin genişliğini ve yüksekliğini alıp alanını hesaplayacak. Liste 5-8, projemizin src/main.rs dosyasında tam olarak bunu yapmanın bir yolunu gösteren kısa bir programdır.
fn main() {
let genislik1 = 30;
let yukseklik1 = 50;
println!(
"Dikdörtgenin alanı {} kare pikseldir.",
alan(genislik1, yukseklik1)
);
}
fn alan(genislik: u32, yukseklik: u32) -> u32 {
genislik * yukseklik
}
Şimdi bu programı cargo run komutuyla çalıştırın:
$ cargo run
Compiling dikdortgenler v0.1.0 (file:///projects/dikdortgenler)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.42s
Running `target/debug/dikdortgenler`
Dikdörtgenin alanı 1500 kare pikseldir.
Bu kod, her bir boyutu argüman olarak verip alan fonksiyonunu çağırarak dikdörtgenin alanını doğru biçimde hesaplar; ancak kodu daha anlaşılır ve okunabilir kılmak için yapabileceğimiz çok şey var.
Kodun asıl sorunu alan fonksiyonunun imzasında açıkça görülür:
fn main() {
let genislik1 = 30;
let yukseklik1 = 50;
println!(
"Dikdörtgenin alanı {} kare pikseldir.",
alan(genislik1, yukseklik1)
);
}
fn alan(genislik: u32, yukseklik: u32) -> u32 {
genislik * yukseklik
}
alan fonksiyonunun tek bir dikdörtgenin alanını hesaplaması beklenir; oysa yazdığımız fonksiyon iki ayrı parametre alıyor ve programımızın hiçbir yerinde bu parametrelerin birbiriyle ilişkili olduğu belirtilmiyor. Genişlik ve yüksekliği bir arada gruplamak kodu hem daha okunabilir hem de daha yönetilebilir hâle getirecektir. Bölüm 3’teki “Demet Türü” kısmında bunu yapmanın bir yolunu zaten görmüştük: demet kullanmak.
Demetlerle Yeniden Düzenleme
Liste 5-9, programımızın demet kullanan yeni bir sürümünü göstermektedir.
fn main() {
let dikdortgen1 = (30, 50);
println!("Dikdörtgenin alanı {} kare pikseldir.", alan(dikdortgen1));
}
fn alan(boyutlar: (u32, u32)) -> u32 {
boyutlar.0 * boyutlar.1
}
Bir açıdan bu program daha iyidir. Demetler veriye biraz yapı kazandırmamızı sağlar ve artık fonksiyona yalnızca tek bir argüman geçiriyoruz. Ancak başka bir açıdan bu sürüm daha belirsizdir: Demetler elemanlarına ad vermez; bu yüzden demetin parçalarına indeks numarasıyla (boyutlar.0, boyutlar.1) erişmemiz gerekir; bu da yapılan hesaplamanın ne olduğunu ilk bakışta görmeyi zorlaştırır.
Genişlik ile yüksekliği birbiriyle karıştırmak alan hesaplamasında sonucu değiştirmez, ancak dikdörtgeni ekrana çizmek isteseydik kesinlikle sorun yaratırdı! Genişlik değerinin 0 indeksinde, yükseklik değerinin ise 1 indeksinde yer aldığını sürekli aklımızda tutmamız gerekirdi. Kodumuzu bir başkası kullanacak olsaydı bunu çözmesi ve aklında tutması daha da güçleşirdi. Verimizin anlamını koda yansıtmadığımız için hata yapmaya çok daha açık bir yapı ortaya çıkmıştır.
Struct’larla Yeniden Düzenleme
Verileri etiketleyerek onlara anlam kazandırmak için struct’ları kullanırız. Liste 5-10’da gösterildiği gibi, kullandığımız demeti hem bütününe hem de her bir parçasına ad verdiğimiz bir struct’a dönüştürebiliriz.
struct Dikdortgen {
genislik: u32,
yukseklik: u32,
}
fn main() {
let dikdortgen1 = Dikdortgen {
genislik: 30,
yukseklik: 50,
};
println!("Dikdörtgenin alanı {} kare pikseldir.", alan(&dikdortgen1));
}
fn alan(dikdortgen: &Dikdortgen) -> u32 {
dikdortgen.genislik * dikdortgen.yukseklik
}
Dikdortgen struct’ı tanımlamaBurada bir struct tanımlayıp ona Dikdortgen adını verdik. Süslü parantezler içinde alanları, her ikisi de u32 türünde olan genislik ve yukseklik olarak belirledik. Ardından main içinde genişliği 30, yüksekliği 50 olan somut bir Dikdortgen örneği oluşturduk.
alan fonksiyonumuz artık dikdortgen adını verdiğimiz tek bir parametre alıyor ve bu parametrenin türü bir Dikdortgen struct örneğinin değiştirilemez referansıdır (&Dikdortgen). Bölüm 4’te değindiğimiz gibi, struct’ın sahipliğini devralmak yerine onu ödünç almak istiyoruz. Böylece main fonksiyonu dikdortgen1’in sahipliğini elinde tutar ve onu kullanmaya devam edebilir; hem fonksiyon imzasında hem de fonksiyonu çağırdığımız yerde & kullanmamızın nedeni budur.
alan fonksiyonu, Dikdortgen örneğinin genislik ve yukseklik alanlarına erişir (ödünç alınan bir struct örneğinin alanlarına erişmenin o alanlardaki değerleri taşımadığına dikkat edin; struct’ların kodda sıkça ödünç alınmasının başlıca nedenlerinden biri budur). alan fonksiyonumuzun imzası artık tam olarak ne kastettiğimizi söyler: Dikdortgen’in alanını, onun genislik ve yukseklik alanlarını kullanarak hesapla. Bu yapı genişlik ve yüksekliğin birbiriyle ilişkili olduğunu açıkça ortaya koyar ve 0 ile 1 gibi demet indeksleri yerine değerlere açıklayıcı adlar verir. Kodun anlaşılırlığı açısından bu net bir kazanımdır.
Türetilen Trait’lerle İşlevsellik Ekleme
Programımızda hata ayıklarken (debugging) bir Dikdortgen örneğini ekrana yazdırıp tüm alanlarının değerlerini görebilmek çok işe yarardı. Liste 5-11’de, önceki bölümlerde yaptığımız gibi println! makrosunu kullanmayı deniyoruz. Ne var ki bu kod derlenmeyecektir.
struct Dikdortgen {
genislik: u32,
yukseklik: u32,
}
fn main() {
let dikdortgen1 = Dikdortgen {
genislik: 30,
yukseklik: 50,
};
println!("dikdortgen1 şöyledir: {dikdortgen1}");
}
Dikdortgen örneğini ekrana yazdırmayı denemeBu kodu derlediğimizde şu temel mesajı içeren bir hata alırız (Dikdortgen, std::fmt::Display trait’ini uygulamıyor):
error[E0277]: `Dikdortgen` doesn't implement `std::fmt::Display`
println! makrosu pek çok farklı biçimlendirme yapabilir ve süslü parantezler ({}) varsayılan olarak println! makrosuna, doğrudan son kullanıcıya yönelik çıktı üretmek için tasarlanmış Display adlı biçimlendirmeyi kullanmasını söyler. Şimdiye kadar gördüğümüz ilkel türler varsayılan olarak Display trait’ini uygular; çünkü 1 sayısını ya da başka herhangi bir ilkel türü kullanıcıya göstermenin tek bir doğal yolu vardır. Ancak struct’larda println! makrosunun çıktıyı nasıl biçimlendirmesi gerektiği net değildir, çünkü çok sayıda gösterim olasılığı bulunur: Alanlar arasında virgül olmalı mı, olmamalı mı? Süslü parantezler yazdırılmalı mı? Tüm alanlar gösterilmeli mi, yoksa bazıları gizlenmeli mi? Bu belirsizlik nedeniyle Rust ne istediğimizi tahmin etmeye çalışmaz ve struct’lar, println! ile {} yer tutucusuyla birlikte kullanılabilecek hazır bir Display uygulamasına sahip değildir.
Hata çıktısını okumaya devam edersek derleyicinin sunduğu şu yararlı yardım (help) ve not (note) mesajlarını görürüz:
help: the trait `std::fmt::Display` is not implemented for `Dikdortgen`
--> src/main.rs:1:1
|
1 | struct Dikdortgen {
| ^^^^^^^^^^^^^^^^^
= note: in format strings you may be able to use `{:?}` (or `{:#?}` for pretty-print) instead
Hadi deneyelim! println! makro çağrımız artık println!("dikdortgen1 şöyledir: {dikdortgen1:?}"); şeklinde görünecektir. Süslü parantezlerin içine :? biçim belirtecini (specifier) eklemek, println! makrosuna Debug adlı çıktı biçimini kullanmak istediğimizi bildirir. Debug trait’i, kodumuzda hata ayıklarken struct’ımızın değerini geliştiriciler için yararlı olacak bir biçimde yazdırmamıza olanak tanır.
Kodu bu değişiklikle derleyelim. Aksi gibi, yine bir hata alıyoruz (Dikdortgen, Debug trait’ini uygulamıyor):
error[E0277]: `Dikdortgen` doesn't implement `Debug`
Ancak derleyici bize yine yol gösteren bir yardım mesajı sunar (Dikdortgen tanımına #[derive(Debug)] eklemeyi önerir):
= help: the trait `Debug` is not implemented for `Dikdortgen`
help: consider annotating `Dikdortgen` with `#[derive(Debug)]`
|
1 + #[derive(Debug)]
2 | struct Dikdortgen {
|
Rust hata ayıklama bilgilerini yazdırma işlevselliğini gerçekten barındırır; ancak bu işlevselliği kendi struct’ımız için etkinleştirmek istiyorsak bunu açıkça belirtmemiz gerekir. Bunun için Liste 5-12’de gösterildiği gibi struct tanımının hemen üstüne #[derive(Debug)] dış özniteliğini (outer attribute) ekleriz.
#[derive(Debug)]
struct Dikdortgen {
genislik: u32,
yukseklik: u32,
}
fn main() {
let dikdortgen1 = Dikdortgen {
genislik: 30,
yukseklik: 50,
};
println!("dikdortgen1 şöyledir: {dikdortgen1:?}");
}
Debug trait’ini türetmek için öznitelik ekleme ve Dikdortgen örneğini hata ayıklama biçimlendirmesiyle yazdırmaArtık programı çalıştırdığımızda herhangi bir hata almayız ve aşağıdaki çıktıyı görürüz:
$ cargo run
Compiling dikdortgenler v0.1.0 (file:///projects/dikdortgenler)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
Running `target/debug/dikdortgenler`
dikdortgen1 şöyledir: Dikdortgen { genislik: 30, yukseklik: 50 }
Harika! En şık çıktı olmayabilir, ancak bu örneğin tüm alanlarının değerlerini açıkça gösteriyor; bu da hata ayıklama sırasında kesinlikle işimize yarayacaktır. Daha büyük struct’larla çalıştığımızda okuması daha kolay bir çıktı elde etmek isteriz; böyle durumlarda println! biçimlendirme metninde {:?} yerine {:#?} kullanabiliriz. Bu örnekte {:#?} biçimini kullanmak aşağıdaki çıktıyı üretir:
$ cargo run
Compiling dikdortgenler v0.1.0 (file:///projects/dikdortgenler)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
Running `target/debug/dikdortgenler`
dikdortgen1 şöyledir: Dikdortgen {
genislik: 30,
yukseklik: 50,
}
Not: println! ve format! gibi biçimlendirme makrolarındaki üç temel gösterimi kısaca şöyle özetleyebiliriz:
{}(std::fmt::Display): Son kullanıcıya yönelik temiz metin çıktısı üretir. Özel türler için nasıl görüneceği belirsiz olduğundan#[derive(Display)]diye otomatik bir türetme yoktur; Bölüm 10’da trait’leri öğrendikten sonraDisplaytrait’ini kendi türleriniz için elle uygulayabilirsiniz (bunun bir örneğini Bölüm 20’de göreceğiz).{:?}(std::fmt::Debug): Geliştiriciye yönelik tek satırlık hata ayıklama çıktısı üretir;#[derive(Debug)]özniteliğiyle otomatik olarak türetilebilir (türün tüm alanları daDebuguyguladığı sürece).{:#?}(okunaklıDebug): Çok alanlı veya iç içe geçmiş büyük veri yapılarını, her alanı girintili olarak ayrı bir satıra yerleştirip okunaklı biçimde yazdırır.
Debug biçimini kullanarak bir değeri yazdırmanın bir diğer yolu da dbg! makrosunu kullanmaktır. Referans alan println! makrosunun aksine dbg! makrosu bir ifadenin sahipliğini alır, kodunuzda dbg! çağrısının yapıldığı dosya ve satır numarasını o ifadenin hesaplanan değeriyle birlikte yazdırır ve ardından değerin sahipliğini geri döndürür.
Not: dbg! makrosu, standart çıktı akışına (stdout) yazan println! makrosunun aksine standart hata akışına (stderr) yazar. Böylece örneğin cargo run > cikti.txt komutuyla programın normal çıktısını bir dosyaya yönlendirseniz bile dbg! ile ürettiğiniz hata ayıklama mesajları dosyayı kirletmez ve doğrudan terminal ekranında görünmeye devam eder. Bölüm 12’deki “Hataları Standart Hata Çıktısına Yönlendirme” kısmında stderr ve stdout akışlarını daha ayrıntılı ele alacağız.
Aşağıda, hem dikdortgen1 içindeki struct’ın tamamının değeriyle hem de genislik alanına atanan ara değerle ilgilendiğimiz bir örnek yer alıyor:
#[derive(Debug)]
struct Dikdortgen {
genislik: u32,
yukseklik: u32,
}
fn main() {
let olcek = 2;
let dikdortgen1 = Dikdortgen {
genislik: dbg!(30 * olcek),
yukseklik: 50,
};
dbg!(&dikdortgen1);
}
30 * olcek ifadesini dbg! içine alabiliriz; dbg! ifadenin değerinin sahipliğini geri döndürdüğü için genislik alanı, orada dbg! çağrısı hiç yokmuş gibi aynı değeri alacaktır. Bir sonraki çağrıda ise dbg! makrosunun dikdortgen1’in sahipliğini almasını istemediğimiz için dikdortgen1’e bir referans (&dikdortgen1) geçiriyoruz. Bu örneğin çıktısı şöyle görünür:
$ cargo run
Compiling dikdortgenler v0.1.0 (file:///projects/dikdortgenler)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.61s
Running `target/debug/dikdortgenler`
[src/main.rs:10:19] 30 * olcek = 60
[src/main.rs:14:5] &dikdortgen1 = Dikdortgen {
genislik: 60,
yukseklik: 50,
}
Çıktının ilk kısmının, 30 * olcek ifadesinde hata ayıkladığımız src/main.rs dosyasının 10. satırından geldiğini ve ifadenin sonucunun 60 olduğunu görebiliriz (tamsayılar için tanımlı Debug biçimlendirmesi yalnızca sayının kendisini yazdırır). src/main.rs dosyasının 14. satırındaki dbg! çağrısı ise Dikdortgen struct’ına referans olan &dikdortgen1’in değerini yazdırır. Bu çıktı, Dikdortgen türünün okunaklı (pretty) Debug biçimlendirmesini kullanır. Kodunuzun çalışma zamanında ne yaptığını anlamaya çalışırken dbg! makrosu gerçekten çok işinize yarayabilir!
Not: dbg! makrosunun sahipliği alıp geri döndürmesi (T -> T), onu başka bir ifadenin tam ortasına (genislik: dbg!(30 * olcek)) geçici değişken tanımlamadan yerleştirebilmenizi sağlar. Ancak dbg! makrosunu Copy olmayan bir değişkenle, tek başına ve noktalı virgülle biten bir deyim olarak (dbg!(dikdortgen1);) çağırırsanız, dikdortgen1’in sahipliği dbg! içine taşınır ve dönen değer hiçbir değişkene bağlanmadığı için o satırın sonunda düşürülür (drop). Değişkeni sonraki satırlarda da kullanmaya devam etmek istediğinizde yukarıdaki gibi dbg!(&dikdortgen1); şeklinde referans geçirmeniz (veya let dikdortgen1 = dbg!(dikdortgen1); ile sahipliği yeniden bağlamanız) gerekir.
Debug trait’ine ek olarak Rust, özel türlerimize yararlı davranışlar kazandırabilmemiz için derive özniteliğiyle birlikte kullanabileceğimiz pek çok trait sunar. Bu trait’ler ve sağladıkları davranışlar Ek C’de listelenmiştir. Bölüm 10’da bu trait’leri özelleştirilmiş davranışlarla nasıl uygulayacağınızı ve kendi trait’lerinizi nasıl oluşturacağınızı inceleyeceğiz. Ayrıca derive dışında pek çok başka öznitelik de vardır; daha fazla bilgi için Rust Referansı’ndaki “Attributes” (Öznitelikler) bölümüne bakabilirsiniz.
alan fonksiyonumuz oldukça özel amaçlıdır: Yalnızca dikdörtgenlerin alanını hesaplar. Başka hiçbir türle çalışmayacağı için bu davranışı Dikdortgen struct’ımıza daha sıkı biçimde bağlamak çok daha yerinde olur. Gelin alan fonksiyonunu Dikdortgen türümüz üzerinde tanımlı bir alan metoduna dönüştürerek bu kodu yeniden düzenlemeye nasıl devam edebileceğimize bakalım.
Metotlar
Metotlar
Metotlar fonksiyonlara benzer: Onları da fn anahtar kelimesi ve bir adla bildiririz; parametreleri ve bir dönüş değeri olabilir, ayrıca başka bir yerden çağrıldıklarında çalıştırılacak kodu barındırırlar. Fonksiyonlardan farklı olarak metotlar bir struct’ın (ya da sırasıyla Bölüm 6 ve Bölüm 18’de ele alacağımız bir enum’ın veya trait nesnesinin) bağlamında tanımlanır ve ilk parametreleri her zaman metodun üzerinde çağrıldığı struct örneğini temsil eden self olur.
Metot Sözdizimi
Parametre olarak bir Dikdortgen örneği alan alan fonksiyonunu değiştirelim ve bunun yerine Liste 5-13’te gösterildiği gibi Dikdortgen struct’ı üzerinde tanımlı bir alan metodu oluşturalım.
#[derive(Debug)]
struct Dikdortgen {
genislik: u32,
yukseklik: u32,
}
impl Dikdortgen {
fn alan(&self) -> u32 {
self.genislik * self.yukseklik
}
}
fn main() {
let dikdortgen1 = Dikdortgen {
genislik: 30,
yukseklik: 50,
};
println!("Dikdörtgenin alanı {} kare pikseldir.", dikdortgen1.alan());
}
Dikdortgen struct’ı üzerinde bir alan metodu tanımlamaFonksiyonu Dikdortgen bağlamında tanımlamak üzere Dikdortgen için bir impl (uygulama / implementation) bloğu başlatırız. Bu impl bloğu içindeki her şey Dikdortgen türüyle ilişkilendirilir. Ardından alan fonksiyonunu impl süslü parantezlerinin içine taşır ve ilk (bu örnekte aynı zamanda tek) parametreyi hem imzada hem de gövdenin her yerinde self olacak şekilde değiştiririz. Daha önce alan fonksiyonunu çağırıp dikdortgen1’i argüman olarak geçirdiğimiz main fonksiyonunda ise artık Dikdortgen örneğimiz üzerindeki alan metodunu çağırmak için metot sözdizimini (method syntax) kullanabiliriz. Metot sözdizimi bir örnekten hemen sonra gelir: Bir nokta koyar, ardından metodun adını, parantezleri ve varsa argümanları yazarız.
alan metodunun imzasında dikdortgen: &Dikdortgen yerine &self kullanırız. Buradaki &self aslında self: &Self ifadesinin kısaltmasıdır. Bir impl bloğu içinde Self türü, o impl bloğunun ait olduğu türün takma adıdır. Metotların ilk parametresi Self türünde ve self adında olmak zorundadır; bu nedenle Rust, ilk parametre konumunda bunu yalnızca self adıyla kısaltmanıza olanak tanır. Yine de tıpkı dikdortgen: &Dikdortgen kullanımında olduğu gibi, bu metodun Self örneğini ödünç aldığını belirtmek için self kısaltmasının önüne & koymamız gerektiğine dikkat edin. Metotlar, diğer herhangi bir parametrede olduğu gibi self’in sahipliğini alabilir, burada yaptığımız gibi self’i değiştirilemez biçimde ödünç alabilir veya self’i değiştirilebilir biçimde ödünç alabilir.
Fonksiyon sürümünde &Dikdortgen kullanmamızla aynı nedenden ötürü burada da &self seçtik: Sahipliği devralmak istemiyoruz; struct içindeki verileri yalnızca okumak istiyoruz, onlara yazmak istemiyoruz. Metodun yaptığı işin bir parçası olarak üzerinde çağrıldığı örneği değiştirmesini isteseydik ilk parametre olarak &mut self kullanırdık. İlk parametre olarak yalnızca self yazıp örneğin sahipliğini alan bir metot tanımlamak nadirdir; bu teknik genellikle metot self’i başka bir şeye dönüştürdüğünde ve dönüşümden sonra çağıran kodun orijinal örneği kullanmasını engellemek istediğinizde tercih edilir.
Not: Bir metot tanımlarken ilk parametreyi seçmek için şu pratik ölçütü kullanabilirsiniz:
&self(varsayılan tercih): Metot yalnızca örneğin alanlarını okuyup bir sonuç hesaplıyorsa kullanılır. Çağıran kod örneğin sahipliğini korur ve metot çağrısından sonra da örneği kullanmaya devam edebilir.&mut self: Metot örneğin alanlarını yerinde güncelliyorsa kullanılır. Bu tür bir metodu çağırabilmek için ilgili değişkeninlet mut dikdortgen1 = ...şeklinde değiştirilebilir tanımlanmış olması gerekir.self(tüketen metot): Metot mevcut örneği tüketip içindeki veriyi başka bir türe dönüştürüyorsa (örneğin birString’i ham bayt vektörüne dönüştürenString::into_bytes) veya bir kaynağı kapatıp geçersiz kılıyorsa kullanılır; derleyici çağrıdan sonra eski örneğin yanlışlıkla tekrar kullanılmasını derleme zamanında engeller.
Metot sözdizimini kullanabilmek ve her metodun imzasında self’in türünü yinelemek zorunda kalmamak bir yana, fonksiyonlar yerine metot kullanmanın asıl nedeni kod düzenidir. Bir türün örneğiyle yapabileceğimiz her şeyi tek bir impl bloğunda toplarız; böylece kodumuzu ileride kullanacak kişilerin Dikdortgen’in yeteneklerini sunduğumuz kütüphanenin farklı yerlerinde aramak zorunda kalmasını önleriz.
Bir metoda struct’ın alanlarından biriyle aynı adı vermeyi de seçebileceğimize dikkat edin. Örneğin Dikdortgen üzerinde yine genislik adında bir metot tanımlayabiliriz:
#[derive(Debug)]
struct Dikdortgen {
genislik: u32,
yukseklik: u32,
}
impl Dikdortgen {
fn genislik(&self) -> bool {
self.genislik > 0
}
}
fn main() {
let dikdortgen1 = Dikdortgen {
genislik: 30,
yukseklik: 50,
};
if dikdortgen1.genislik() {
println!(
"Dikdörtgenin sıfırdan büyük bir genişliği var; değeri: {}",
dikdortgen1.genislik
);
}
}
Burada genislik metodunun, örnekteki genislik alanının değeri 0’dan büyükse true, 0 ise false döndürmesini seçtik: Aynı adlı bir metot içinde o alanı istediğimiz amaçla kullanabiliriz. main içinde dikdortgen1.genislik ifadesinin ardından parantez kullandığımızda Rust genislik metodunu kastettiğimizi anlar. Parantez kullanmadığımızda ise Rust genislik alanını kastettiğimizi bilir.
Her zaman olmasa da çoğu durumda, bir metoda bir alanla aynı adı verdiğimizde yalnızca alandaki değeri döndürmesini ve başka hiçbir şey yapmamasını isteriz. Bu tür metotlara alıcı metotlar (getters) denir ve Rust, bazı diğer dillerin aksine struct alanları için bunları otomatik olarak uygulamaz. Alıcı metotlar oldukça kullanışlıdır; çünkü alanı gizli (private), metodu ise açık (public) yaparak bu alana türün açık API’sinin bir parçası olarak salt okunur erişim sağlayabilirsiniz. Açık ve gizli kavramlarının ne olduğunu ve bir alanı ya da metodu nasıl açık veya gizli olarak belirleyeceğimizi Bölüm 7’de ele alacağız.
-> Operatörü Nerede?
C ve C++ dillerinde metot çağırmak için iki farklı operatör kullanılır: Metodu doğrudan nesne üzerinde çağırıyorsanız ., nesneyi gösteren bir işaretçi (pointer) üzerinden çağırıyorsanız ve önce işaretçinin başvurusunu çözmeniz (dereference) gerekiyorsa -> kullanırsınız. Başka bir deyişle, nesne bir işaretçiyse nesne->bir_seyler_yap() ifadesi (*nesne).bir_seyler_yap() ifadesine benzer.
Rust’ta -> operatörünün bir karşılığı yoktur; bunun yerine Rust’ta otomatik referanslama ve başvuru çözme (automatic referencing and dereferencing) adı verilen bir özellik bulunur. Metot çağırma işlemi, Rust’ta bu davranışın görüldüğü sayılı yerlerden biridir.
Mekanizma şöyle çalışır: nesne.bir_seyler_yap() şeklinde bir metot çağırdığınızda Rust, nesne’nin metodun imzasıyla eşleşmesi için gereken &, &mut veya * operatörlerini otomatik olarak ekler. Başka bir deyişle, aşağıdaki iki çağrı birbiriyle tamamen aynıdır:
#![allow(unused)]
fn main() {
#[derive(Debug,Copy,Clone)]
struct Nokta {
x: f64,
y: f64,
}
impl Nokta {
fn mesafe(&self, diger: &Nokta) -> f64 {
let x_karesi = f64::powi(diger.x - self.x, 2);
let y_karesi = f64::powi(diger.y - self.y, 2);
f64::sqrt(x_karesi + y_karesi)
}
}
let n1 = Nokta { x: 0.0, y: 0.0 };
let n2 = Nokta { x: 5.0, y: 6.5 };
n1.mesafe(&n2);
(&n1).mesafe(&n2);
}
İlk yazım çok daha temiz görünür. Bu otomatik referanslama davranışı sorunsuz çalışır, çünkü metotların net bir alıcısı (receiver) vardır: self’in türü. Bir metodun alıcısı ve adı belli olduğunda Rust, metodun okuma (&self), değiştirme (&mut self) veya tüketme (self) işlemlerinden hangisini yaptığını kesin olarak belirleyebilir. Rust’ın metot alıcıları için ödünç almayı örtük (implicit) hâle getirmesi, sahiplik kurallarını pratikte ergonomik kılan en önemli etkenlerden biridir.
Daha Fazla Parametre Alan Metotlar
Dikdortgen struct’ı üzerinde ikinci bir metot uygulayarak metot kullanımı konusunda pratik yapalım. Bu kez bir Dikdortgen örneğinin başka bir Dikdortgen örneğini almasını ve ikinci Dikdortgen tamamen self’in (birinci Dikdortgen’in) içine sığabiliyorsa true, aksi hâlde false döndürmesini istiyoruz. Yani kapsayabilir_mi metodunu tanımladıktan sonra Liste 5-14’te gösterilen programı yazabilmek istiyoruz.
fn main() {
let dikdortgen1 = Dikdortgen {
genislik: 30,
yukseklik: 50,
};
let dikdortgen2 = Dikdortgen {
genislik: 10,
yukseklik: 40,
};
let dikdortgen3 = Dikdortgen {
genislik: 60,
yukseklik: 45,
};
println!(
"dikdortgen1, dikdortgen2'yi kapsayabilir mi? {}",
dikdortgen1.kapsayabilir_mi(&dikdortgen2)
);
println!(
"dikdortgen1, dikdortgen3'ü kapsayabilir mi? {}",
dikdortgen1.kapsayabilir_mi(&dikdortgen3)
);
}
kapsayabilir_mi metodunun kullanımıdikdortgen2’nin her iki boyutu da dikdortgen1’in boyutlarından küçük olduğu, buna karşılık dikdortgen3 dikdortgen1’den daha geniş olduğu için beklenen çıktı şöyle görünmelidir:
dikdortgen1, dikdortgen2'yi kapsayabilir mi? true
dikdortgen1, dikdortgen3'ü kapsayabilir mi? false
Bir metot tanımlamak istediğimizi biliyoruz, dolayısıyla bu tanım impl Dikdortgen bloğunun içinde yer alacak. Metodun adı kapsayabilir_mi olacak ve parametre olarak başka bir Dikdortgen örneğinin değiştirilemez ödünç alımını kabul edecek. Parametrenin türünün ne olacağını metodu çağıran koda bakarak da anlayabiliriz: dikdortgen1.kapsayabilir_mi(&dikdortgen2) çağrısı argüman olarak bir Dikdortgen örneği olan dikdortgen2’nin değiştirilemez ödünç alımını, yani &dikdortgen2 ifadesini geçirir. Bu oldukça mantıklıdır; çünkü dikdortgen2’yi yalnızca okumamız yeterlidir (yazmamız gerekseydi değiştirilebilir bir ödünç alıma ihtiyaç duyardık) ve kapsayabilir_mi metodunu çağırdıktan sonra dikdortgen2’yi tekrar kullanabilmek için main fonksiyonunun sahipliği elinde tutmasını isteriz. kapsayabilir_mi metodunun dönüş değeri bir mantıksal değer (bool) olacak ve metodun uygulaması self’in genişlik ve yüksekliğinin diğer Dikdortgen örneğinin sırasıyla genişlik ve yüksekliğinden büyük olup olmadığını kontrol edecektir. Yeni kapsayabilir_mi metodunu Liste 5-13’teki impl bloğuna ekleyelim; sonuç Liste 5-15’te gösterilmiştir.
#[derive(Debug)]
struct Dikdortgen {
genislik: u32,
yukseklik: u32,
}
impl Dikdortgen {
fn alan(&self) -> u32 {
self.genislik * self.yukseklik
}
fn kapsayabilir_mi(&self, diger: &Dikdortgen) -> bool {
self.genislik > diger.genislik && self.yukseklik > diger.yukseklik
}
}
fn main() {
let dikdortgen1 = Dikdortgen {
genislik: 30,
yukseklik: 50,
};
let dikdortgen2 = Dikdortgen {
genislik: 10,
yukseklik: 40,
};
let dikdortgen3 = Dikdortgen {
genislik: 60,
yukseklik: 45,
};
println!(
"dikdortgen1, dikdortgen2'yi kapsayabilir mi? {}",
dikdortgen1.kapsayabilir_mi(&dikdortgen2)
);
println!(
"dikdortgen1, dikdortgen3'ü kapsayabilir mi? {}",
dikdortgen1.kapsayabilir_mi(&dikdortgen3)
);
}
Dikdortgen üzerinde başka bir Dikdortgen örneğini parametre olarak alan kapsayabilir_mi metodunu uygulamaBu kodu Liste 5-14’teki main fonksiyonuyla birlikte çalıştırdığımızda istediğimiz çıktıyı elde ederiz. Metotlar, imzada self parametresinden sonra eklediğimiz birden fazla parametre alabilir ve bu parametreler tıpkı fonksiyonlardaki parametreler gibi çalışır.
İlişkili Fonksiyonlar
Bir impl bloğu içinde tanımlanan tüm fonksiyonlara ilişkili fonksiyonlar (associated functions) denir; çünkü bu fonksiyonlar impl kelimesinden sonra adı verilen türle ilişkilendirilmiştir. İlk parametresi self olmayan (ve dolayısıyla metot olmayan) ilişkili fonksiyonlar da tanımlayabiliriz; çünkü bunların çalışmak için ilgili türün bir örneğine ihtiyacı yoktur. Bu tarz bir fonksiyonu daha önce zaten kullanmıştık: String türü üzerinde tanımlı olan String::from fonksiyonu.
Metot olmayan ilişkili fonksiyonlar çoğunlukla struct’ın yeni bir örneğini döndüren yapıcılar (constructors) olarak kullanılır. Bunlara sıklıkla new adı verilir, ancak new özel bir ad değildir ve dile yerleşik bir anahtar kelime olarak tanımlanmamıştır. Örneğin tek bir boyut parametresi alıp bunu hem genişlik hem de yükseklik olarak kullanan kare adında bir ilişkili fonksiyon sunmayı seçebiliriz; böylece aynı değeri iki kez belirtmek zorunda kalmadan kare bir Dikdortgen oluşturmayı kolaylaştırırız:
Dosya adı: src/main.rs
#[derive(Debug)]
struct Dikdortgen {
genislik: u32,
yukseklik: u32,
}
impl Dikdortgen {
fn kare(boyut: u32) -> Self {
Self {
genislik: boyut,
yukseklik: boyut,
}
}
}
fn main() {
let k = Dikdortgen::kare(3);
}
Dönüş türündeki ve fonksiyon gövdesindeki Self anahtar kelimeleri, impl anahtar kelimesinden sonra gelen türün — bu örnekte Dikdortgen türünün — takma adıdır.
Bu ilişkili fonksiyonu çağırmak için struct adıyla birlikte :: sözdizimini kullanırız; let k = Dikdortgen::kare(3); ifadesi buna bir örnektir. Bu fonksiyon struct’ın ad alanı (namespace) altında yer alır: :: sözdizimi hem ilişkili fonksiyonlar hem de modüllerin oluşturduğu ad alanları için kullanılır. Modülleri Bölüm 7’de ele alacağız.
Not: Rust’ta, diğer pek çok nesne yönelimli dilde bulunan özel bir new anahtar kelimesi ya da aynı adla farklı parametreler alan aşırı yüklenmiş (overloaded) yapıcılar yoktur. Bunun yerine Vec::new(), Vec::with_capacity(...), String::from(...) veya Dikdortgen::kare(...) gibi açıklayıcı adlara sahip ilişkili fonksiyonlar tanımlamak standart Rust idiyomudur; böylece bir değerin nasıl oluşturulduğu çağrı noktasında ilk bakışta anlaşılır. Ayrıca tanımda Self kullanmak, ileride struct’ın adını değiştirdiğinizde impl bloğu içindeki dönüş türlerini ve örneklemeleri tek tek güncelleme zahmetini ortadan kaldırır.
Birden Fazla impl Bloğu
Her struct’ın birden fazla impl bloğuna sahip olmasına izin verilir. Örneğin Liste 5-15, her metodun kendi ayrı impl bloğunda yer aldığı Liste 5-16’daki kodla tamamen eşdeğerdir.
#[derive(Debug)]
struct Dikdortgen {
genislik: u32,
yukseklik: u32,
}
impl Dikdortgen {
fn alan(&self) -> u32 {
self.genislik * self.yukseklik
}
}
impl Dikdortgen {
fn kapsayabilir_mi(&self, diger: &Dikdortgen) -> bool {
self.genislik > diger.genislik && self.yukseklik > diger.yukseklik
}
}
fn main() {
let dikdortgen1 = Dikdortgen {
genislik: 30,
yukseklik: 50,
};
let dikdortgen2 = Dikdortgen {
genislik: 10,
yukseklik: 40,
};
let dikdortgen3 = Dikdortgen {
genislik: 60,
yukseklik: 45,
};
println!(
"dikdortgen1, dikdortgen2'yi kapsayabilir mi? {}",
dikdortgen1.kapsayabilir_mi(&dikdortgen2)
);
println!(
"dikdortgen1, dikdortgen3'ü kapsayabilir mi? {}",
dikdortgen1.kapsayabilir_mi(&dikdortgen3)
);
}
impl bloğu kullanarak yeniden yazmaBurada bu metotları birden fazla impl bloğuna ayırmamızı gerektiren bir neden yok; ancak bu sözdizimi geçerlidir. Jenerik türleri ve trait’leri ele alacağımız Bölüm 10’da, birden fazla impl bloğunun yararlı olduğu bir durumu göreceğiz.
Özet
Struct’lar, çalıştığınız problem alanı için anlamlı özel türler oluşturmanızı sağlar. Struct kullanarak birbiriyle ilişkili veri parçalarını bir arada tutabilir ve kodunuzu anlaşılır kılmak için her bir parçaya açıklayıcı bir ad verebilirsiniz. impl bloklarında ise türünüzle ilişkili fonksiyonlar tanımlayabilirsiniz; metotlar da struct örneklerinizin sahip olacağı davranışı belirlemenize olanak tanıyan bir tür ilişkili fonksiyondur.
Ancak özel türler oluşturmanın tek yolu struct’lar değildir: Araç çantanıza yeni bir araç daha eklemek için şimdi Rust’ın enum özelliğine geçelim.
Enum’lar ve Desen Eşleştirme
Bu bölümde, kısaca enum olarak da adlandırılan numaralandırmaları inceleyeceğiz. Enum’lar, olası varyantlarını numaralandırarak bir tür tanımlamanıza olanak tanır. İlk olarak, bir enum’ın verilerle birlikte anlamı da nasıl kodlayabildiğini göstermek için bir enum tanımlayıp kullanacağız. Daha sonra, bir değerin bir şey ya da hiçbir şey olabileceğini ifade eden ve özellikle kullanışlı bir enum olan Option türünü keşfedeceğiz. Ardından, match ifadesindeki desen eşleştirmenin (pattern matching), bir enum’ın farklı değerleri için farklı kodlar çalıştırmayı nasıl kolaylaştırdığına bakacağız. Son olarak, kodunuzdaki enum’ları işlemek için kullanabileceğiniz pratik ve özlü birer kalıp olan if let ve let...else yapılarını ele alacağız.
Not: C, C++ veya Java gibi dillerden geliyorsanız enum kavramını çoğunlukla sabit seçeneklerden veya isimlendirilmiş tam sayılardan oluşan basit listeler olarak tanıyor olabilirsiniz. Rust’taki enum’lar ise fonksiyonel programlama dillerindeki cebirsel veri türlerine (algebraic data types) — özellikle de toplam türlere (sum types) — karşılık gelir ve çok daha güçlüdür. Bölüm 5’te gördüğümüz struct yapıları birden fazla alanı aynı anda bir arada tutarken (“bu ve şu”), bir enum değeri tanımlanan varyantlardan herhangi bir anda yalnızca biri olabilir (“bu veya şu”); üstelik her varyant kendi içinde birbirinden tamamen farklı tür ve sayıda veri taşıyabilir. Bu tasarım match ile yapılan desen eşleştirmeyle birleştiğinde, geçersiz durumları daha tür düzeyinde imkânsız kılmanıza ve olası her varyantın eksiksiz ele alındığını derleme zamanında doğrulamanıza olanak tanır.
Bir Enum Tanımlama
Bir Enum Tanımlama
Struct’lar (örneğin genislik ve yukseklik alanlarına sahip bir Dikdortgen) birbiriyle ilişkili alanları ve verileri bir arada gruplandırmanın bir yolunu sunarken, enum’lar bir değerin olası değerler kümesinden yalnızca biri olduğunu ifade etmenin bir yolunu sunar. Örneğin, Dikdortgen türünün Daire ve Ucgen’i de kapsayan olası şekiller kümesinden biri olduğunu belirtmek isteyebiliriz. Rust, bu olasılıkları bir enum olarak kodlamamıza olanak tanır.
Gelin kodda ifade etmek isteyebileceğimiz somut bir duruma bakalım ve bu senaryoda enum’ların neden struct’lardan daha kullanışlı ve uygun olduğunu görelim. Diyelim ki IP adresleriyle çalışmamız gerekiyor. Günümüzde IP adresleri için iki ana standart kullanılmaktadır: sürüm dört ve sürüm altı. Programımızın karşılaşacağı bir IP adresi için yalnızca bu iki olasılık söz konusu olduğundan, olası tüm varyantları tek tek numaralandırabiliriz (enumerate); numaralandırma (enumeration) kavramı da adını buradan alır.
Herhangi bir IP adresi ya sürüm dört ya da sürüm altı adres olabilir, ancak aynı anda ikisi birden olamaz. IP adreslerinin bu özelliği enum veri yapısını son derece uygun kılar; çünkü bir enum değeri tanımlı varyantlarından yalnızca biri olabilir. Öte yandan hem sürüm dört hem de sürüm altı adresler temelde hâlâ birer IP adresidir; dolayısıyla her tür IP adresi için geçerli durumları işleyen kodlarda her ikisi de aynı tür olarak ele alınmalıdır.
Bu kavramı kodda bir IpAdresTuru numaralandırması tanımlayıp bir IP adresinin alabileceği olası türleri — V4 ve V6 — listeleyerek ifade edebiliriz. Bunlar enum’ın varyantlarıdır:
enum IpAdresTuru {
V4,
V6,
}
fn main() {
let dort = IpAdresTuru::V4;
let alti = IpAdresTuru::V6;
yonlendir(IpAdresTuru::V4);
yonlendir(IpAdresTuru::V6);
}
fn yonlendir(ip_turu: IpAdresTuru) {}
IpAdresTuru, artık kodumuzun başka yerlerinde kullanabileceğimiz özel bir veri türüdür.
Enum Değerleri
IpAdresTuru enum’ının iki varyantından her birinin örneklerini şu şekilde oluşturabiliriz:
enum IpAdresTuru {
V4,
V6,
}
fn main() {
let dort = IpAdresTuru::V4;
let alti = IpAdresTuru::V6;
yonlendir(IpAdresTuru::V4);
yonlendir(IpAdresTuru::V6);
}
fn yonlendir(ip_turu: IpAdresTuru) {}
Enum varyantlarının, enum’ın tanımlayıcısının ad alanı (namespace) altında yer aldığına ve ikisini ayırmak için çift iki nokta üst üste (::) kullandığımıza dikkat edin. Bu oldukça kullanışlıdır; çünkü hem IpAdresTuru::V4 hem de IpAdresTuru::V6 değerleri artık aynı türdendir: IpAdresTuru. Böylece örneğin herhangi bir IpAdresTuru değeri alan bir fonksiyon tanımlayabiliriz:
enum IpAdresTuru {
V4,
V6,
}
fn main() {
let dort = IpAdresTuru::V4;
let alti = IpAdresTuru::V6;
yonlendir(IpAdresTuru::V4);
yonlendir(IpAdresTuru::V6);
}
fn yonlendir(ip_turu: IpAdresTuru) {}
Ardından bu fonksiyonu her iki varyantla da çağırabiliriz:
enum IpAdresTuru {
V4,
V6,
}
fn main() {
let dort = IpAdresTuru::V4;
let alti = IpAdresTuru::V6;
yonlendir(IpAdresTuru::V4);
yonlendir(IpAdresTuru::V6);
}
fn yonlendir(ip_turu: IpAdresTuru) {}
Enum kullanmanın başka avantajları da vardır. IP adresi türümüz üzerinde biraz daha düşündüğümüzde, şu anki hâliyle asıl IP adresi verisini saklamanın bir yolu olmadığını görürüz; yalnızca hangi türde olduğunu biliyoruz. Bölüm 5’te struct’ları yeni öğrendiğiniz için, bu sorunu Liste 6-1’de gösterildiği gibi struct kullanarak çözmeye yönelmeniz oldukça doğaldır.
fn main() {
enum IpAdresTuru {
V4,
V6,
}
struct IpAdres {
tur: IpAdresTuru,
adres: String,
}
let ev = IpAdres {
tur: IpAdresTuru::V4,
adres: String::from("127.0.0.1"),
};
let geridongu = IpAdres {
tur: IpAdresTuru::V6,
adres: String::from("::1"),
};
}
IpAdresTuru varyantını struct kullanarak saklamaBurada iki alana sahip bir IpAdres struct’ı tanımladık: IpAdresTuru (az önce tanımladığımız enum) türünde bir tur alanı ve String türünde bir adres alanı. Bu struct’ın iki örneğini oluşturduk. İlki olan ev, tur alanı olarak IpAdresTuru::V4 değerine ve ilişkili adres verisi olarak 127.0.0.1 metnine sahiptir. İkinci örnek olan geridongu ise tur değeri olarak IpAdresTuru enum’ının diğer varyantı olan V6’yı ve bununla ilişkili ::1 adresini barındırır. tur ve adres değerlerini bir arada tutmak için bir struct kullandık; böylece varyant ile adres verisi birbirine bağlanmış oldu.
Ancak aynı kavramı yalnızca bir enum kullanarak temsil etmek çok daha özlüdür: Bir struct’ın içine enum yerleştirmek yerine, veriyi doğrudan her bir enum varyantının içine koyabiliriz. IpAdres enum’ının bu yeni tanımı, hem V4 hem de V6 varyantlarının kendileriyle ilişkili birer String değerine sahip olacağını belirtir:
fn main() {
enum IpAdres {
V4(String),
V6(String),
}
let ev = IpAdres::V4(String::from("127.0.0.1"));
let geridongu = IpAdres::V6(String::from("::1"));
}
Veriyi doğrudan enum’ın her bir varyantına eklediğimiz için fazladan bir struct tanımlamaya gerek kalmaz. Burada enum’ların çalışma biçimine dair önemli bir ayrıntıyı görmek de kolaylaşır: Tanımladığımız her enum varyantının adı, aynı zamanda o enum’ın bir örneğini oluşturan bir fonksiyon hâline gelir. Yani IpAdres::V4(), bir String argümanı alan ve IpAdres türünde bir örnek döndüren bir fonksiyon çağrısıdır. Enum’ı tanımlamamızın bir sonucu olarak bu yapıcı (constructor) fonksiyonlar bizim için otomatik olarak tanımlanır.
Struct yerine enum kullanmanın bir avantajı daha vardır: Her varyant farklı türlerde ve farklı sayıda ilişkili veriye sahip olabilir. Sürüm dört IP adresleri her zaman 0 ile 255 arasında değerler alan dört sayısal bileşenden oluşur. V4 adreslerini dört adet u8 değeri olarak saklamak, V6 adreslerini ise yine tek bir String değeri olarak ifade etmek isteseydik bunu tek bir struct ile yapamazdık. Enum’lar bu durumu kolaylıkla çözer:
fn main() {
enum IpAdres {
V4(u8, u8, u8, u8),
V6(String),
}
let ev = IpAdres::V4(127, 0, 0, 1);
let geridongu = IpAdres::V6(String::from("::1"));
}
Sürüm dört ve sürüm altı IP adreslerini saklamak üzere veri yapıları tanımlamanın birkaç farklı yolunu gördük. Ne var ki IP adreslerini saklamak ve hangi türde olduklarını kodlamak o kadar yaygın bir ihtiyaçtır ki, standart kütüphanede doğrudan kullanabileceğimiz hazır bir tanım bulunur! Gelin standart kütüphanenin IpAddr türünü nasıl tanımladığına bakalım. Bizim tanımladığımız ve kullandığımız enum ile aynı yapıya ve varyantlara (V4 ve V6) sahiptir; ancak adres verilerini varyantların içine, her varyant için ayrı tanımlanmış iki farklı struct biçiminde gömer:
#![allow(unused)]
fn main() {
struct Ipv4Addr {
// --snip--
}
struct Ipv6Addr {
// --snip--
}
enum IpAddr {
V4(Ipv4Addr),
V6(Ipv6Addr),
}
}
Bu kod, bir enum varyantının içine her türden veriyi koyabileceğinizi gösterir: örneğin metinler, sayısal türler veya struct’lar. Hatta bir enum varyantının içine başka bir enum bile yerleştirebilirsiniz! Ayrıca gördüğünüz gibi, standart kütüphanedeki türler çoğu zaman sizin tasarlayabileceğiniz yapılardan çok daha karmaşık değildir.
Standart kütüphane IpAddr için bir tanım içerse de, standart kütüphanenin bu tanımını kendi kapsamımıza dahil etmediğimiz için kendi türümüzü (hatta aynı IpAddr adıyla bile) oluşturup herhangi bir isim çakışması yaşamadan kullanabileceğimize dikkat edin. Türleri kapsama dahil etme konusunu Bölüm 7’de daha ayrıntılı olarak ele alacağız.
Gelin Liste 6-2’deki bir diğer enum örneğine bakalım: Bu enum’ın varyantlarında birbirinden çok farklı veri türleri yer alır.
enum Mesaj {
Cik,
Tasi { x: i32, y: i32 },
Yaz(String),
RenkDegistir(i32, i32, i32),
}
fn main() {}
Mesaj enum’ıBu enum, farklı türlere sahip dört varyant içerir:
Cik: Kendisiyle ilişkili hiçbir veri barındırmaz.Tasi: Tıpkı bir struct gibi isimlendirilmiş alanlara sahiptir.Yaz: Tek birStringiçerir.RenkDegistir: Üç adeti32değeri içerir.
Liste 6-2’deki gibi varyantlara sahip bir enum tanımlamak, farklı struct türleri tanımlamaya benzer; aradaki fark, enum’ın struct anahtar kelimesini kullanmaması ve tüm varyantların tek bir Mesaj türü altında bir arada gruplandırılmasıdır. Aşağıdaki struct’lar, önceki enum varyantlarının tuttuğu verilerin birebir aynısını tutabilirdi:
struct CikMesaji; // birim struct
struct TasiMesaji {
x: i32,
y: i32,
}
struct YazMesaji(String); // demet struct'ı
struct RenkDegistirMesaji(i32, i32, i32); // demet struct'ı
fn main() {}
Ancak her biri kendi ayrı türüne sahip olan bu farklı struct’ları kullansaydık, bu mesaj türlerinden herhangi birini kabul edecek bir fonksiyonu, tek bir tür olan Liste 6-2’deki Mesaj enum’ı ile tanımladığımız kadar kolay tanımlayamazdık.
Enum’lar ile struct’lar arasında bir benzerlik daha vardır: Tıpkı struct’lar üzerinde impl kullanarak metotlar tanımlayabildiğimiz gibi, enum’lar üzerinde de metotlar tanımlayabiliriz. İşte Mesaj enum’ımız üzerinde tanımlayabileceğimiz cagir adlı bir metot:
fn main() {
enum Mesaj {
Cik,
Tasi { x: i32, y: i32 },
Yaz(String),
RenkDegistir(i32, i32, i32),
}
impl Mesaj {
fn cagir(&self) {
// metot gövdesi burada tanımlanabilir
}
}
let m = Mesaj::Yaz(String::from("merhaba"));
m.cagir();
}
Metodun gövdesi, metodun üzerinde çağrıldığı değeri elde etmek için self parametresini kullanır. Bu örnekte Mesaj::Yaz(String::from("merhaba")) değerine sahip bir m değişkeni oluşturduk; m.cagir() çalıştığında cagir metodunun gövdesindeki self tam olarak bu değeri temsil edecektir.
Şimdi standart kütüphanede çok yaygın ve son derece kullanışlı olan başka bir enum’a bakalım: Option.
Option Enum’ı
Bu alt bölümde, standart kütüphane tarafından tanımlanan bir diğer enum olan Option türünü örnek olay olarak inceleyeceğiz. Option türü, bir değerin var olabileceği (bir şey olduğu) ya da yok olabileceği (hiçbir şey olmadığı) çok yaygın senaryoyu kodlar.
Örneğin, boş olmayan bir listedeki ilk öğeyi isterseniz bir değer elde edersiniz. Boş bir listedeki ilk öğeyi isterseniz hiçbir şey elde edemezsiniz. Bu kavramı doğrudan tür sistemi üzerinden ifade etmek, ele almanız gereken tüm durumları gerçekten ele alıp almadığınızı derleyicinin denetleyebilmesi anlamına gelir; bu özellik, diğer programlama dillerinde son derece sık karşılaşılan yazılım hatalarını daha derleme aşamasında önleyebilir.
Programlama dili tasarımı çoğu zaman hangi özellikleri dile dahil ettiğiniz üzerinden düşünülür; ancak dışarıda bıraktığınız özellikler de bir o kadar önemlidir. Rust, diğer pek çok dilde bulunan null özelliğine sahip değildir. Null, ortada hiçbir değer bulunmadığını belirten özel bir değerdir. Null kavramına sahip dillerde değişkenler her zaman iki durumdan birinde olabilir: null veya null olmayan.
Null kavramının mucidi Tony Hoare, 2009 yılındaki “Null Referanslar: Milyar Dolarlık Hata” (“Null References: The Billion Dollar Mistake”) başlıklı sunumunda bu durumu şöyle anlatmıştır:
Buna kendi milyar dolarlık hatam (billion-dollar mistake) diyorum. O dönemde nesne yönelimli bir dilde referanslar için ilk kapsamlı tür sistemini tasarlıyordum. Amacım, derleyici tarafından otomatik olarak yürütülen denetimlerle tüm referans kullanımlarının kesinlikle güvenli olmasını sağlamaktı. Ne var ki uygulaması çok kolay olduğu için sisteme bir null referans ekleme cazibesine karşı koyamadım. Bu tercih; son kırk yılda muhtemelen bir milyar dolarlık zarara ve sıkıntıya mal olan sayısız hataya, güvenlik açığına ve sistem çökmesine yol açtı.
Null değerleriyle ilgili temel sorun şudur: Null olan bir değeri sanki geçerli (null olmayan) bir değermiş gibi kullanmaya çalışırsanız bir tür çalışma zamanı hatasıyla karşılaşırsınız. Null veya null olmama durumu dilin her yerine yayılmış olduğundan, bu tür bir hatayı yapmak son derece kolaydır.
Bununla birlikte null’ın ifade etmeye çalıştığı kavram hâlâ yararlıdır: Herhangi bir nedenle o an geçersiz ya da mevcut olmayan bir değeri temsil etmek.
Dolayısıyla asıl sorun kavramın kendisinde değil, bu kavramın uygulanış biçimindedir. Bu nedenle Rust’ta null yoktur; ancak bir değerin var veya yok olma durumunu tür düzeyinde kodlayan bir enum vardır. Bu enum Option<T> olarak adlandırılır ve standart kütüphanede şu şekilde tanımlanır:
#![allow(unused)]
fn main() {
enum Option<T> {
None,
Some(T),
}
}
Option<T> enum’ı o kadar kullanışlıdır ki her Rust programında kendiliğinden kapsamda bulunan öğeler kümesi olan prelude (ön tanımlı kapsam) içine bile dahil edilmiştir; yani onu use ile açıkça kapsama dahil etmenize gerek yoktur. Üstelik varyantları da prelude içindedir: Some ve None varyantlarını Option:: öneki yazmadan doğrudan kullanabilirsiniz. Yine de Option<T> sıradan bir enum’dır; Some(T) ve None da hâlâ Option<T> türünün varyantlarıdır.
<T> sözdizimi, Rust’ın henüz ele almadığımız bir özelliğidir. Bu, jenerik (generic) bir tür parametresidir; jenerik türleri Bölüm 10’da ayrıntılı olarak inceleyeceğiz. Şimdilik bilmeniz gereken tek şey şudur: <T>, Option enum’ının Some varyantının herhangi bir türden tek bir veri tutabileceği ve T yerine geçen her somut türün Option<T> türünün tamamını farklı bir tür hâline getirdiği anlamına gelir. Sayı ve karakter (char) türlerini tutmak için Option değerlerinin kullanıldığı bazı örneklere bakalım:
fn main() {
let bir_sayi = Some(5);
let bir_karakter = Some('e');
let olmayan_sayi: Option<i32> = None;
}
bir_sayi değişkeninin türü Option<i32>’dir. bir_karakter değişkeninin türü ise bundan farklı bir tür olan Option<char>’dır. Some varyantının içine somut bir değer verdiğimiz için Rust bu türleri kendiliğinden çıkarabilir. olmayan_sayi değişkeninde ise Rust bizden Option türünün tamamını açıkça belirtmemizi ister: Derleyici, yalnızca bir None değerine bakarak karşılık gelen Some varyantının hangi türde değer tutacağını çıkaramaz. Burada olmayan_sayi değişkeninin Option<i32> türünde olmasını istediğimizi Rust’a açıkça bildiririz.
Elimizde bir Some değeri olduğunda, ortada bir değer bulunduğunu ve bu değerin Some içinde tutulduğunu biliriz. Elimizde bir None değeri olduğunda ise bu, bir bakıma null ile aynı anlama gelir: Geçerli bir değerimiz yoktur. Öyleyse Option<T> kullanmak, null’a sahip olmaktan neden daha iyidir?
Kısaca söylemek gerekirse, Option<T> ile T (burada T herhangi bir tür olabilir) birbirinden farklı türler olduğu için derleyici, bir Option<T> değerini sanki kesinlikle geçerli bir T değeriymiş gibi doğrudan kullanmamıza izin vermez. Örneğin aşağıdaki kod, bir i8 değerine doğrudan bir Option<i8> eklemeye çalıştığı için derlenmeyecektir:
fn main() {
let x: i8 = 5;
let y: Option<i8> = Some(5);
let toplam = x + y;
}
Bu kodu çalıştırdığımızda şuna benzer bir hata mesajı alırız:
$ cargo run
Compiling enums v0.1.0 (file:///projects/enums)
error[E0277]: cannot add `Option<i8>` to `i8`
--> src/main.rs:5:20
|
5 | let toplam = x + y;
| ^ no implementation for `i8 + Option<i8>`
|
= help: the trait `Add<Option<i8>>` is not implemented for `i8`
help: the following other types implement trait `Add<Rhs>`
--> /rustc/58cf8f9c35b355199727627d07ae34a4e2846d67/library/core/src/ops/arith.rs:98:8
|
= note: `i8` implements `Add`
::: /rustc/58cf8f9c35b355199727627d07ae34a4e2846d67/library/core/src/ops/arith.rs:113:0
|
= note: in this macro invocation
--> /rustc/58cf8f9c35b355199727627d07ae34a4e2846d67/library/core/src/internal_macros.rs:22:8
|
= note: `&i8` implements `Add<i8>`
::: /rustc/58cf8f9c35b355199727627d07ae34a4e2846d67/library/core/src/internal_macros.rs:33:8
|
= note: `i8` implements `Add<&i8>`
::: /rustc/58cf8f9c35b355199727627d07ae34a4e2846d67/library/core/src/internal_macros.rs:44:8
|
= note: `&i8` implements `Add`
= note: this error originates in the macro `add_impl` (in Nightly builds, run with -Z macro-backtrace for more info)
For more information about this error, try `rustc --explain E0277`.
error: could not compile `enums` (bin "enums") due to 1 previous error
İlk bakışta oldukça yoğun bir çıktı! Özünde bu hata mesajı (cannot add `Option<i8>` to `i8`), i8 ile Option<i8> farklı türler olduğu için Rust’ın bu iki değeri nasıl toplayacağını bilmediğini (yani i8 türü için Option<i8> ile toplama işlemini tanımlayan Add<Option<i8>> trait’inin uygulanmadığını) belirtir. Rust’ta i8 gibi bir türde değere sahip olduğumuzda, derleyici her zaman geçerli bir değere sahip olduğumuzu garanti eder. Bu değeri kullanmadan önce null olup olmadığını kontrol etmek zorunda kalmadan güvenle ilerleyebiliriz. Yalnızca elimizde bir Option<i8> (veya üzerinde çalıştığımız değerin türü her neyse) olduğunda bir değerin bulunmama ihtimalini düşünmemiz gerekir; derleyici de değeri kullanmadan önce bu durumu ele aldığımızdan emin olur.
Başka bir deyişle, bir Option<T> üzerinde T türüne ait işlemleri gerçekleştirebilmek için önce onu bir T değerine dönüştürmeniz gerekir. Bu zorunluluk, null kaynaklı en yaygın hatalardan birini yakalamaya yardımcı olur: Gerçekte null olan bir değerin null olmadığını varsaymak.
Null olabilecek bir değeri yanlışlıkla geçerli varsayma riskini ortadan kaldırmak, kodunuza çok daha fazla güvenmenizi sağlar. Yok olma ihtimali taşıyan bir değere sahip olmak istediğinizde, o değerin türünü Option<T> yaparak bu durumu açıkça tercih etmeniz gerekir. Ardından bu değeri her kullandığınızda, değerin mevcut olmadığı (None) durumu açıkça ele almanız zorunludur. Türü Option<T> olmayan her yerde ise değerin geçerli olduğunu güvenle varsayabilirsiniz. Bu yaklaşım, null kavramının kontrolsüz yaygınlığını sınırlamak ve Rust kodunun güvenliğini artırmak adına bilinçli olarak alınmış bir tasarım kararıdır.
Not: C veya C++ gibi dillerden geliyorsanız, null işaretçi yerine Option enum’ı kullanmanın bellekte fazladan bir varyant etiketi (discriminant) yer kaplayıp kaplamadığını merak edebilirsiniz. Rust derleyicisi, boş işaretçi optimizasyonu (null pointer optimization) (daha genel adıyla niş optimizasyonu / niche optimization) sayesinde bu ek maliyeti pek çok tür için tamamen ortadan kaldırır. Rust’ta referanslar (&T, &mut T), Box<T> gibi akıllı işaretçiler ve fonksiyon işaretçileri hiçbir zaman geçerli bir 0 (boş) bellek adresine işaret edemez. Derleyici bu “geçersiz sıfır bit desenini” None varyantını temsil etmek için kullanır; böylece örneğin Option<&T> bellekte &T ile birebir aynı boyutta yer kaplar. Başka bir deyişle, C/C++’taki boş olabilen işaretçilerle aynı bellek verimliliğini, sıfır çalışma zamanı ek yükü ve tam derleme zamanı tür güvenliğiyle elde edersiniz.
Peki Option<T> türünde bir değere sahip olduğunuzda, içindeki T değerini kullanabilmek için onu Some varyantından nasıl çıkarırsınız? Option<T> enum’ı, farklı senaryolarda işinize yarayacak çok sayıda metoda sahiptir; bunların tamamını dokümantasyonundan inceleyebilirsiniz. Option<T> üzerindeki metotlara aşina olmak, Rust yolculuğunuzda size büyük kolaylık sağlayacaktır.
Not: Günlük Rust kodlarında her seferinde uzun match ifadeleri yazmak yerine Option<T>’nin sunduğu hazır yardımcı metotlardan sıkça yararlanırsınız. Örneğin; değerin varlığını sorgulamak için is_some() ve is_none(), None durumunda varsayılan bir değer sağlamak için unwrap_or(varsayilan) (veya varsayılan değeri yalnızca gerektiğinde hesaplamak için Bölüm 13’te göreceğimiz unwrap_or_else), içteki değeri Option sarmalayıcısından çıkarmadan dönüştürmek için map(...), değerin None olması durumunda programı hata mesajıyla durdurmak içinse Bölüm 9’da inceleyeceğimiz expect("mesaj") ve unwrap() metotları en sık karşılaşacağınız araçlardır.
Genel olarak bir Option<T> değerini kullanmak için her bir varyantı ele alacak kodlara sahip olmak istersiniz: Yalnızca elinizde bir Some(T) değeri olduğunda çalışacak ve içteki T değerini kullanmasına izin verilen bir kod; bir de yalnızca None değeriniz olduğunda çalışacak ve elinde kullanılabilir bir T değeri bulunmayan başka bir kod. match ifadesi, enum’larla birlikte kullanıldığında tam olarak bu işi yapan bir kontrol akışı yapısıdır: Değerin, enum’ın hangi varyantı olduğuna bağlı olarak farklı kod dallarını çalıştırır ve bu kodlar eşleşen değerin içindeki verileri doğrudan kullanabilir.
match Kontrol Akışı Yapısı
match Kontrol Akışı Yapısı
Rust, bir değeri bir dizi desenle (pattern) karşılaştırmanıza ve hangi desenin eşleştiğine bağlı olarak ilgili kodu çalıştırmanıza olanak tanıyan match adında son derece güçlü bir kontrol akışı yapısına sahiptir. Desenler; sabit değerlerden, değişken adlarından, joker desenlerden (wildcards) ve daha pek çok bileşenden oluşabilir; Bölüm 19 tüm farklı desen türlerini ve ne işe yaradıklarını ayrıntılı olarak ele alır. match yapısının gücü, desenlerin yüksek ifade yeteneğinden ve derleyicinin olası tüm durumların eksiksiz biçimde ele alındığını doğrulamasından gelir.
Bir match ifadesini bozuk para ayırma makinesi gibi düşünebilirsiniz: Madeni paralar, üzerinde farklı boyutlarda delikler sıralanan bir raydan aşağı kayar ve her para, karşısına çıkıp içine sığabildiği ilk delikten aşağı düşer. Aynı şekilde değerler de bir match ifadesindeki her bir desenden sırayla geçer ve değerin “uyduğu” ilk desende, çalıştırılmak üzere o desenle ilişkili kod bloğuna düşer.
Madeni paralardan söz etmişken gelin match kullanımı için onları örnek alalım! Liste 6-3’te gösterildiği gibi, türü bilinmeyen bir ABD madeni parasını alan ve tıpkı para sayma makinesi gibi hangi madeni para olduğunu belirleyip değerini kuruş (sent) cinsinden döndüren bir fonksiyon yazabiliriz.
enum MadeniPara {
Kurus,
BesKurus,
OnKurus,
YirmiBesKurus,
}
fn kurus_degeri(para: MadeniPara) -> u8 {
match para {
MadeniPara::Kurus => 1,
MadeniPara::BesKurus => 5,
MadeniPara::OnKurus => 10,
MadeniPara::YirmiBesKurus => 25,
}
}
fn main() {}
match ifadesiGelin kurus_degeri fonksiyonundaki match ifadesini adım adım inceleyelim. İlk olarak match anahtar kelimesini ve ardından bir ifadeyi yazarız; bu örnekte söz konusu ifade para değeridir. Bu yapı if ile kullanılan koşul ifadesine çok benzer, ancak aralarında büyük bir fark vardır: if yapısında koşulun bir Boole (bool) değeri üretmesi gerekirken, burada ifade herhangi bir türde olabilir. Bu örnekteki para değerinin türü, ilk satırda tanımladığımız MadeniPara enum’ıdır.
Ardından match kolları (match arms) gelir. Bir kol iki bölümden oluşur: bir desen ve çalıştırılacak kod. Buradaki ilk kolun deseni MadeniPara::Kurus değeridir; hemen ardından deseni ve çalıştırılacak kodu birbirinden ayıran => operatörü gelir. Bu koldaki kod yalnızca 1 değeridir. Her kol bir sonrakinden virgülle ayrılır.
match ifadesi çalıştırıldığında, elde edilen değeri her bir kolun deseniyle sırasıyla karşılaştırır. Bir desen değerle eşleşirse o desenle ilişkili kod çalıştırılır. Desen değerle eşleşmezse, tıpkı bozuk para ayırma makinesinde olduğu gibi akış bir sonraki kola geçer. İhtiyaç duyduğumuz kadar kol tanımlayabiliriz: Liste 6-3’te match ifademizin dört kolu vardır.
Her kolla ilişkili kod bir ifadedir (expression) ve eşleşen koldaki ifadenin ürettiği değer, match ifadesinin tamamı için döndürülen değer olur.
Liste 6-3’te olduğu gibi her kolun yalnızca tek bir değer ürettiği kısa durumlarda match kollarında genellikle süslü parantez kullanmayız. Bir match kolunda birden fazla satır kod çalıştırmak istiyorsanız süslü parantez kullanmanız gerekir; bu durumda kolu takip eden virgül isteğe bağlıdır. Örneğin aşağıdaki kod, fonksiyon her MadeniPara::Kurus değeriyle çağrıldığında ekrana "Şanslı kuruş!" yazdırır, ancak yine de bloğun son ifadesi olan 1 değerini döndürür:
enum MadeniPara {
Kurus,
BesKurus,
OnKurus,
YirmiBesKurus,
}
fn kurus_degeri(para: MadeniPara) -> u8 {
match para {
MadeniPara::Kurus => {
println!("Şanslı kuruş!");
1
}
MadeniPara::BesKurus => 5,
MadeniPara::OnKurus => 10,
MadeniPara::YirmiBesKurus => 25,
}
}
fn main() {}
Not: Tıpkı Bölüm 3’te gördüğümüz if gibi match de bir ifadedir. Dolayısıyla ürettiği sonucu doğrudan bir değişkene atayabilir (let tutar = match para { ... };) veya burada olduğu gibi fonksiyonun dönüş değeri olarak kullanabilirsiniz. Derleyicinin derleme zamanında tek ve kesin bir tür belirleyebilmesi için tüm match kollarının aynı türü üretmesi zorunludur; aksi hâlde E0308 (match arms have incompatible types — match kolları uyumsuz türlere sahip) hatası alırsınız. Özellikle süslü parantezli kollarda son satırdaki 1 ifadesinin sonuna yanlışlıkla noktalı virgül (;) koymamaya dikkat edin: 1; yazarsanız o kol birim türü (()) üretirken diğer kollar u8 üreteceği için kod derlenmeyecektir.
Değerlere Bağlanan Desenler
match kollarının bir diğer kullanışlı özelliği, desenle eşleşen değerlerin içindeki parçalara bağlanabilmeleridir. Enum varyantlarının barındırdığı değerleri bu sayede dışarı çıkarabiliriz.
Örnek olarak enum varyantlarımızdan birini içinde veri tutacak şekilde değiştirelim. Amerika Birleşik Devletleri, 1999’dan 2008’e kadar 50 eyaletin her biri için bir yüzünde farklı tasarımlar bulunan çeyreklikler (25 kuruşluk/sentlik madeni paralar) bastı. Başka hiçbir madeni parada eyalet tasarımı yer almadığından bu ek bilgi yalnızca çeyrekliklerde bulunur. Bu bilgiyi Liste 6-4’te yaptığımız gibi YirmiBesKurus varyantını içinde bir Eyalet değeri barındıracak şekilde değiştirerek enum tanımımıza ekleyebiliriz.
#[derive(Debug)] // birazdan eyaleti inceleyebilmek için
enum Eyalet {
Alabama,
Alaska,
// --snip--
}
enum MadeniPara {
Kurus,
BesKurus,
OnKurus,
YirmiBesKurus(Eyalet),
}
fn main() {}
YirmiBesKurus varyantının ayrıca bir Eyalet değeri tuttuğu MadeniPara enum’ıBir arkadaşımızın 50 eyalet çeyrekliğinin tamamını biriktirmeye çalıştığını düşünelim. Bozuk paralarımızı türlerine göre ayırırken her çeyreklikle ilişkili eyaletin adını da yüksek sesle söyleyeceğiz; böylece arkadaşımızın koleksiyonunda henüz olmayan bir eyalet çıkarsa onu koleksiyonuna ekleyebilecek.
Aşağıdaki kodun match ifadesinde, MadeniPara::YirmiBesKurus varyantının değerleriyle eşleşen desene eyalet adında bir değişken ekliyoruz. Bir MadeniPara::YirmiBesKurus değeri eşleştiğinde eyalet değişkeni o çeyrekliğin eyalet değerine bağlanır. Ardından bu koldaki kod içinde eyalet değişkenini aşağıdaki gibi kullanabiliriz:
#[derive(Debug)]
enum Eyalet {
Alabama,
Alaska,
// --snip--
}
enum MadeniPara {
Kurus,
BesKurus,
OnKurus,
YirmiBesKurus(Eyalet),
}
fn kurus_degeri(para: MadeniPara) -> u8 {
match para {
MadeniPara::Kurus => 1,
MadeniPara::BesKurus => 5,
MadeniPara::OnKurus => 10,
MadeniPara::YirmiBesKurus(eyalet) => {
println!("{eyalet:?} eyaletinden çeyreklik!");
25
}
}
}
fn main() {
kurus_degeri(MadeniPara::YirmiBesKurus(Eyalet::Alaska));
}
kurus_degeri(MadeniPara::YirmiBesKurus(Eyalet::Alaska)) çağrısı yaptığımızda para parametresi MadeniPara::YirmiBesKurus(Eyalet::Alaska) değerini alır. Bu değeri her bir match koluyla sırayla karşılaştırdığımızda MadeniPara::YirmiBesKurus(eyalet) koluna ulaşana kadar hiçbir desen eşleşmez. Bu noktada eyalet değişkeni Eyalet::Alaska değerine bağlanır. Böylece bu değişkeni println! ifadesinde kullanabilir ve MadeniPara enum’ının YirmiBesKurus varyantı içindeki eyalet verisini elde edebiliriz.
Not: Sahipliği sizde olan bir enum üzerinde doğrudan match para yaptığınızda, Eyalet veya String gibi Copy trait’ini uygulamayan iç veriler desendeki değişkene (eyalet) taşınır (move). Bu nedenle match bloğundan sonra para değişkenini tekrar kullanamazsınız. Eğer enum’ın sahipliğini tüketmeden yalnızca içindeki veriyi okumak istiyorsanız referans üzerinden eşleştirme (match ¶) yapabilirsiniz. Rust’ın eşleştirme ergonomisi (match ergonomics) özelliği sayesinde match ¶ yazdığınızda deseni değiştirmeden yine MadeniPara::YirmiBesKurus(eyalet) şeklinde yazabilirsiniz; derleyici eyalet değişkenini veriyi taşımak yerine otomatik olarak &Eyalet referansı biçiminde bağlar.
Option<T> match Deseni
Önceki bölümde Option<T> kullanırken Some varyantının içindeki T değerini elde etmek istemiştik; tıpkı MadeniPara enum’ında yaptığımız gibi Option<T> türünü de match kullanarak işleyebiliriz! Bu kez madeni paraları karşılaştırmak yerine Option<T> varyantlarını karşılaştıracağız, ancak match ifadesinin çalışma mantığı birebir aynı kalır.
Diyelim ki parametre olarak bir Option<i32> alan ve içinde bir değer varsa bu değere 1 ekleyen bir fonksiyon yazmak istiyoruz. Eğer içinde bir değer yoksa fonksiyon None döndürmeli ve başka hiçbir işlem yapmaya çalışmamalıdır.
match sayesinde bu fonksiyonu yazmak son derece kolaydır ve Liste 6-5’teki gibi görünür.
fn main() {
fn bir_ekle(x: Option<i32>) -> Option<i32> {
match x {
None => None,
Some(i) => Some(i + 1),
}
}
let bes = Some(5);
let alti = bir_ekle(bes);
let hicbiri = bir_ekle(None);
}
Option<i32> üzerinde match ifadesi kullanan bir fonksiyonbir_ekle fonksiyonunun ilk çağrılışını daha yakından inceleyelim. bir_ekle(bes) çağrısı yaptığımızda bir_ekle gövdesindeki x değişkeni Some(5) değerini alır. Ardından bu değeri her bir match koluyla sırayla karşılaştırırız:
fn main() {
fn bir_ekle(x: Option<i32>) -> Option<i32> {
match x {
None => None,
Some(i) => Some(i + 1),
}
}
let bes = Some(5);
let alti = bir_ekle(bes);
let hicbiri = bir_ekle(None);
}
Some(5) değeri None deseniyle eşleşmez; bu yüzden bir sonraki kola geçeriz:
fn main() {
fn bir_ekle(x: Option<i32>) -> Option<i32> {
match x {
None => None,
Some(i) => Some(i + 1),
}
}
let bes = Some(5);
let alti = bir_ekle(bes);
let hicbiri = bir_ekle(None);
}
Some(5) değeri Some(i) deseniyle eşleşir mi? Evet, eşleşir! İkisi de aynı varyanttır. i değişkeni Some içindeki değere bağlanır ve 5 değerini alır. Ardından bu match kolundaki kod çalıştırılır: i değerine 1 eklenir ve içinde toplam 6 değerini taşıyan yeni bir Some varyantı oluşturulup döndürülür.
Şimdi Liste 6-5’te x parametresinin None olduğu ikinci bir_ekle çağrısına bakalım. match ifadesine girer ve x değerini ilk kolla karşılaştırırız:
fn main() {
fn bir_ekle(x: Option<i32>) -> Option<i32> {
match x {
None => None,
Some(i) => Some(i + 1),
}
}
let bes = Some(5);
let alti = bir_ekle(bes);
let hicbiri = bir_ekle(None);
}
Eşleşti! Eklenecek bir değer olmadığından akış burada tamamlanır ve => operatörünün sağ tarafındaki None değeri döndürülür. İlk kol eşleştiği için diğer kollar hiç kontrol edilmez.
match ile enum’ları bir arada kullanmak pek çok senaryoda son derece işlevseldir. Rust kodlarında şu yapıyı çok sık göreceksiniz: Bir enum üzerinde match uygula, içindeki veriyi bir değişkene bağla ve bu veriye dayanarak ilgili kodu çalıştır. İlk başta biraz yabancı gelebilir, ancak bir kez alıştığınızda bu yapının kullandığınız tüm programlama dillerinde olmasını isteyeceksiniz. Rust kullanıcılarının her zaman en sevdiği özelliklerin başında gelir.
Eşleşmeler Kapsamlıdır
match ifadelerinin ele almamız gereken önemli bir yönü daha vardır: Kolların desenleri olası tüm durumları kapsamak zorundadır. bir_ekle fonksiyonumuzun hata barındıran ve derlenmeyecek olan aşağıdaki sürümüne bakalım:
fn main() {
fn bir_ekle(x: Option<i32>) -> Option<i32> {
match x {
Some(i) => Some(i + 1),
}
}
let bes = Some(5);
let alti = bir_ekle(bes);
let hicbiri = bir_ekle(None);
}
Burada None durumunu ele almadık; dolayısıyla bu kod hataya yol açacaktır. Neyse ki bu, Rust derleyicisinin anında yakalayabildiği türden bir hatadır. Bu kodu derlemeye çalıştığımızda aşağıdaki hatayı alırız:
$ cargo run
Compiling enums v0.1.0 (file:///projects/enums)
error[E0004]: non-exhaustive patterns: `None` not covered
--> src/main.rs:3:15
|
3 | match x {
| ^ pattern `None` not covered
|
note: `Option<i32>` defined here
--> /rustc/58cf8f9c35b355199727627d07ae34a4e2846d67/library/core/src/option.rs:598:0
::: /rustc/58cf8f9c35b355199727627d07ae34a4e2846d67/library/core/src/option.rs:602:4
|
= note: not covered
= note: the matched value is of type `Option<i32>`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
|
4 ~ Some(i) => Some(i + 1),
5 ~ None => todo!(),
|
For more information about this error, try `rustc --explain E0004`.
error: could not compile `enums` (bin "enums") due to 1 previous error
Not: Derleyicinin ürettiği error[E0004]: non-exhaustive patterns: `None` not covered mesajı, “kapsamlı olmayan desenler: None durumu kapsanmadı” anlamına gelir. Dikkat ederseniz help: satırında derleyici, eksik kalan kolu None => todo!(), şeklinde kodunuza nasıl ekleyebileceğinizi bile doğrudan gösterir.
Rust, olası her durumu kapsamadığımızı bilir; üstelik hangi deseni unuttuğumuzu bile tam olarak söyler! Rust’ta eşleşmeler kapsamlıdır (exhaustive): Kodun geçerli olabilmesi için olası her durumu sonuna kadar, eksiksiz biçimde ele almalıyız. Özellikle Option<T> söz konusu olduğunda, Rust None durumunu açıkça işlemeyi unutmamızı engelleyerek ortada bir değer yokken (null benzeri bir durumdayken) sanki geçerli bir değer varmış gibi varsayımda bulunmamızın önüne geçer; böylece önceki bölümde bahsettiğimiz milyar dolarlık hatayı derleme zamanında imkânsız hâle getirir.
Tümünü Yakalama Desenleri ve _ Yer Tutucusu
Enum’ları ve match yapısını kullanarak belirli birkaç değer için özel işlemler yapabilir, kalan diğer tüm değerler için ise tek bir varsayılan işlem uygulayabiliriz. Şöyle bir oyun geliştirdiğimizi düşünelim: Zar attığınızda 3 gelirse oyuncunuz hareket etmez, bunun yerine süslü yeni bir şapka kazanır. Eğer 7 gelirse oyuncunuz süslü şapkasını kaybeder. Diğer tüm zar değerlerinde ise oyuncunuz oyun tahtasında gelen sayı kadar kare ilerler. Aşağıda bu mantığı uygulayan bir match ifadesi yer alıyor; bu örnekte zar atışının sonucu rastgele üretilmek yerine doğrudan koda sabit olarak yazılmış ve diğer işlemlerin ayrıntıları konumuzun dışında olduğundan gövdesi boş fonksiyonlarla temsil edilmiştir:
fn main() {
let zar_atisi = 9;
match zar_atisi {
3 => suslu_sapka_ekle(),
7 => suslu_sapkayi_cikar(),
diger => oyuncuyu_tasi(diger),
}
fn suslu_sapka_ekle() {}
fn suslu_sapkayi_cikar() {}
fn oyuncuyu_tasi(kare_sayisi: u8) {}
}
İlk iki kolun desenleri 3 ve 7 sabit değerleridir. Kalan olası tüm değerleri kapsayan son kolun deseni ise diger adını verdiğimiz değişkendir. diger kolunda çalışan kod, yakalanan bu değişkeni oyuncuyu_tasi fonksiyonuna argüman olarak aktararak kullanır.
Bir u8 türünün alabileceği olası tüm değerleri tek tek listelememiş olsak da bu kod sorunsuzca derlenir; çünkü son desen özel olarak listelenmeyen tüm değerlerle eşleşir. Bu tümünü yakalama deseni (catch-all pattern), match ifadesinin kapsamlı olma zorunluluğunu karşılar. Desenler sırayla değerlendirildiği için tümünü yakalayan kolu en sona koymamız gerektiğine dikkat edin. Eğer bu kolu daha üst bir sıraya koysaydık ondan sonraki kollar asla çalışmazdı; nitekim tümünü yakalayan bir koldan sonra başka kollar eklerseniz Rust ulaşılamaz desen konusunda sizi uyarır!
Rust ayrıca tümünü yakalayan bir kol istediğimizde ancak yakalanan değeri kullanmak istemediğimizde başvurabileceğimiz özel bir desene sahiptir: _, herhangi bir değerle eşleşen fakat o değere bağlanmayan özel bir yer tutucu desendir. Bu desen Rust’a söz konusu değeri kullanmayacağımızı bildirir; böylece Rust ne değeri taşır ne de kullanılmayan bir değişken hakkında uyarı verir.
Gelin oyunun kurallarını değiştirelim: Artık 3 veya 7 dışında bir sayı atarsanız tekrar zar atmanız gerekiyor. Yakalanan değere artık ihtiyacımız olmadığından kodumuzu diger adlı değişken yerine _ kullanacak şekilde güncelleyebiliriz:
fn main() {
let zar_atisi = 9;
match zar_atisi {
3 => suslu_sapka_ekle(),
7 => suslu_sapkayi_cikar(),
_ => tekrar_zar_at(),
}
fn suslu_sapka_ekle() {}
fn suslu_sapkayi_cikar() {}
fn tekrar_zar_at() {}
}
Son kolda diğer tüm değerleri açıkça yok saydığımız için bu örnek de kapsamlılık şartını eksiksiz karşılar; unuttuğumuz hiçbir durum yoktur.
Son olarak oyunun kurallarını bir kez daha değiştirelim: 3 veya 7 dışında bir sayı attığınızda sıranızda başka hiçbir şey olmasın. Bunu, _ kolunda çalıştırılacak kod olarak birim değerini (“Demet Türü” bölümünde bahsettiğimiz boş demet türü ()) yazarak ifade edebiliriz:
fn main() {
let zar_atisi = 9;
match zar_atisi {
3 => suslu_sapka_ekle(),
7 => suslu_sapkayi_cikar(),
_ => (),
}
fn suslu_sapka_ekle() {}
fn suslu_sapkayi_cikar() {}
}
Burada Rust’a önceki kolların desenleriyle eşleşmeyen hiçbir değeri kullanmayacağımızı ve bu durumda herhangi bir kod çalıştırmak istemediğimizi açıkça belirtmiş oluyoruz.
Desenler ve eşleştirme hakkında Bölüm 19’da ele alacağımız daha pek çok ayrıntı var. Şimdilik, match ifadesinin biraz ayrıntılı ve uzun kalabildiği durumlarda oldukça pratik birer alternatif sunan if let ve let...else yapılarına geçeceğiz.
if let ve let...else ile Özlü Kontrol Akışı
if let ve let...else ile Özlü Kontrol Akışı
if let sözdizimi, if ve let anahtar kelimelerini birleştirerek yalnızca tek bir desenle eşleşen değerleri işleyip geri kalanları yok saymanın daha özlü bir yolunu sunar. Liste 6-6’da, max_ayari değişkenindeki Option<u8> değerini eşleştiren ancak yalnızca değer Some varyantı olduğunda kod çalıştırmak istediğimiz programı inceleyelim.
fn main() {
let max_ayari = Some(3u8);
match max_ayari {
Some(max) => println!("Maksimum değer {max} olarak ayarlandı"),
_ => (),
}
}
Some olduğunda kod çalıştıran bir match ifadesiDeğer Some ise, varyantın içindeki veriyi desendeki max değişkenine bağlayarak ekrana yazdırırız. None değeriyle ise hiçbir işlem yapmak istemiyoruz. Ancak match ifadesinin tüm durumları kapsama zorunluluğunu karşılamak için, tek bir varyantı işledikten sonra bile _ => () kolunu eklemek zorunda kalırız; bu da her seferinde yazılması can sıkıcı bir kalıp koddur.
Bunun yerine aynı mantığı if let kullanarak çok daha kısa bir biçimde yazabiliriz. Aşağıdaki kod, Liste 6-6’daki match ifadesiyle birebir aynı şekilde çalışır:
fn main() {
let max_ayari = Some(3u8);
if let Some(max) = max_ayari {
println!("Maksimum değer {max} olarak ayarlandı");
}
}
if let sözdizimi, aralarında eşittir (=) işareti bulunan bir desen ve bir ifade alır. Çalışma mantığı match ile aynıdır: Eşittir işaretinin sağındaki ifade match yapısına verilen değere, solundaki desen ise ilk match koluna karşılık gelir. Bu örnekte desen Some(max) şeklindedir ve max değişkeni Some varyantının içindeki değere bağlanır. Ardından max değişkenini, tıpkı eşdeğer match kolunda yaptığımız gibi if let bloğunun gövdesinde kullanabiliriz. if let bloğu içindeki kod, yalnızca değer belirtilen desenle eşleştiğinde çalışır.
if let kullanmak; daha az kod yazmak, daha az girinti kullanmak ve kalıp kodlardan kurtulmak demektir. Buna karşılık, match yapısının zorunlu kıldığı ve olası hiçbir durumu gözden kaçırmamanızı güvence altına alan kapsamlılık denetiminden vazgeçmiş olursunuz. match ile if let arasında seçim yapmak, içinde bulunduğunuz senaryoda ne amaçladığınıza ve kodda özlü bir anlatım kazanmanın kapsamlılık denetiminden vazgeçmeye değip değmeyeceğine bağlıdır.
Başka bir deyişle if let yapısını, değer belirli bir desenle eşleştiğinde kod çalıştıran ve diğer tüm değerleri yok sayıp geçen bir match ifadesi için sözdizimsel şeker (syntax sugar) olarak düşünebilirsiniz.
Bir if let ifadesine else bloğu da ekleyebiliriz. else bloğunun içindeki kod, if let ve else yapısına eşdeğer bir match ifadesindeki _ koluna yazacağımız kodla birebir aynı işlevi görür. Liste 6-4’te tanımladığımız ve YirmiBesKurus varyantının içinde bir Eyalet değeri barındırdığı MadeniPara enum’ını hatırlayın. Gördüğümüz çeyreklik (25 kuruşluk) dışındaki tüm madeni paraları saymak, aynı zamanda çeyrekliklerin hangi eyalete ait olduğunu duyurmak isteseydik bunu şöyle bir match ifadesiyle yazabilirdik:
#[derive(Debug)]
enum Eyalet {
Alabama,
Alaska,
// --snip--
}
enum MadeniPara {
Kurus,
BesKurus,
OnKurus,
YirmiBesKurus(Eyalet),
}
fn main() {
let para = MadeniPara::Kurus;
let mut sayac = 0;
match para {
MadeniPara::YirmiBesKurus(eyalet) => {
println!("{eyalet:?} eyaletinden çeyreklik!")
}
_ => sayac += 1,
}
}
Ya da aynı işlemi aşağıdaki gibi bir if let ve else ifadesiyle gerçekleştirebilirdik:
#[derive(Debug)]
enum Eyalet {
Alabama,
Alaska,
// --snip--
}
enum MadeniPara {
Kurus,
BesKurus,
OnKurus,
YirmiBesKurus(Eyalet),
}
fn main() {
let para = MadeniPara::Kurus;
let mut sayac = 0;
if let MadeniPara::YirmiBesKurus(eyalet) = para {
println!("{eyalet:?} eyaletinden çeyreklik!");
} else {
sayac += 1;
}
}
let...else ile “Mutlu Yolda” Kalmak
Programlamada sıkça karşılaşılan bir akış biçimi, beklenen bir değer mevcut olduğunda bazı hesaplamalar yapmak, aksi takdirde ise doğrudan varsayılan bir değer döndürmektir. Eyalet verisi taşıyan madeni paralar örneğimizden devam edelim: Çeyrekliğin üzerindeki eyaletin ne kadar eski olduğuna bakarak esprili bir mesaj üretmek isteseydik, Eyalet üzerinde eyaletin yaşını (belirli bir tarihte var olup olmadığını) kontrol eden şöyle bir metot tanımlayabilirdik:
#[derive(Debug)] // birazdan eyaleti inceleyebilmek için
enum Eyalet {
Alabama,
Alaska,
// --snip--
}
impl Eyalet {
fn su_tarihte_var_miydi(&self, yil: u16) -> bool {
match self {
Eyalet::Alabama => yil >= 1819,
Eyalet::Alaska => yil >= 1959,
// -- snip --
}
}
}
enum MadeniPara {
Kurus,
BesKurus,
OnKurus,
YirmiBesKurus(Eyalet),
}
fn eyalet_ceyrekligini_tanimla(para: MadeniPara) -> Option<String> {
if let MadeniPara::YirmiBesKurus(eyalet) = para {
if eyalet.su_tarihte_var_miydi(1900) {
Some(format!("{eyalet:?} Amerika'ya göre oldukça eski!"))
} else {
Some(format!("{eyalet:?} nispeten yeni."))
}
} else {
None
}
}
fn main() {
if let Some(tanim) =
eyalet_ceyrekligini_tanimla(MadeniPara::YirmiBesKurus(Eyalet::Alaska))
{
println!("{tanim}");
}
}
Ardından Liste 6-7’de olduğu gibi, madeni paranın türünü eşleştirmek için if let kullanabilir ve koşul gövdesinin kapsamında geçerli bir eyalet değişkeni bağlayabiliriz.
#[derive(Debug)] // birazdan eyaleti inceleyebilmek için
enum Eyalet {
Alabama,
Alaska,
// --snip--
}
impl Eyalet {
fn su_tarihte_var_miydi(&self, yil: u16) -> bool {
match self {
Eyalet::Alabama => yil >= 1819,
Eyalet::Alaska => yil >= 1959,
// -- snip --
}
}
}
enum MadeniPara {
Kurus,
BesKurus,
OnKurus,
YirmiBesKurus(Eyalet),
}
fn eyalet_ceyrekligini_tanimla(para: MadeniPara) -> Option<String> {
if let MadeniPara::YirmiBesKurus(eyalet) = para {
if eyalet.su_tarihte_var_miydi(1900) {
Some(format!("{eyalet:?} Amerika'ya göre oldukça eski!"))
} else {
Some(format!("{eyalet:?} nispeten yeni."))
}
} else {
None
}
}
fn main() {
if let Some(tanim) =
eyalet_ceyrekligini_tanimla(MadeniPara::YirmiBesKurus(Eyalet::Alaska))
{
println!("{tanim}");
}
}
if let içine yerleştirilmiş koşullarla bir eyaletin 1900 yılında var olup olmadığını kontrol etmeBu yaklaşım işimizi görür, ancak asıl mantığı if let bloğunun içine doğru iter; yapılacak işlemler karmaşıklaştıkça en üst düzeydeki dalların birbirleriyle nasıl ilişkili olduğunu takip etmek zorlaşabilir. Rust’ta ifadelerin değer üretme özelliğinden yararlanarak, Liste 6-8’de olduğu gibi if let ifadesinden ya eyalet değerini üretebilir ya da fonksiyondan erken dönüş yapabiliriz. (Benzer bir yapıyı match ile de kurabilirsiniz.)
#[derive(Debug)] // birazdan eyaleti inceleyebilmek için
enum Eyalet {
Alabama,
Alaska,
// --snip--
}
impl Eyalet {
fn su_tarihte_var_miydi(&self, yil: u16) -> bool {
match self {
Eyalet::Alabama => yil >= 1819,
Eyalet::Alaska => yil >= 1959,
// -- snip --
}
}
}
enum MadeniPara {
Kurus,
BesKurus,
OnKurus,
YirmiBesKurus(Eyalet),
}
fn eyalet_ceyrekligini_tanimla(para: MadeniPara) -> Option<String> {
let eyalet = if let MadeniPara::YirmiBesKurus(eyalet) = para {
eyalet
} else {
return None;
};
if eyalet.su_tarihte_var_miydi(1900) {
Some(format!("{eyalet:?} Amerika'ya göre oldukça eski!"))
} else {
Some(format!("{eyalet:?} nispeten yeni."))
}
}
fn main() {
if let Some(tanim) =
eyalet_ceyrekligini_tanimla(MadeniPara::YirmiBesKurus(Eyalet::Alaska))
{
println!("{tanim}");
}
}
if let kullanmaNe var ki bu yapıyı okumak da kendi içinde biraz yorucudur! if let ifadesinin bir dalı değişkene atanacak bir değer üretirken, diğer dalı fonksiyondan tamamen çıkış yapar.
Bu yaygın akış kalıbını çok daha temiz ifade edebilmek için Rust’ta let...else sözdizimi bulunur. let...else yapısı, if let sözdizimine çok benzer biçimde sol tarafta bir desen ve sağ tarafta bir ifade alır; ancak bir if bloğu yoktur, yalnızca else bloğu vardır. Desen eşleşirse, eşleşen değer desendeki değişkene doğrudan dış kapsamda bağlanır. Desen eşleşmezse program akışı else koluna girer ve bu kolun fonksiyondan dönerek (veya akışı keserek) mevcut kapsamdan çıkması gerekir.
Not: let...else yapısında eşleşen değişkenin (eyalet) yeni bir iç blok yerine doğrudan dış kapsama bağlanabilmesi, derleyicinin else bloğuna uyguladığı kesin bir kurala dayanır: else bloğu mevcut kontrol akışını mutlaka kesmek, yani ıraksamak (diverge) zorundadır. Bunun için else bloğunun return ile fonksiyondan dönmesi, bir döngü içindeyse break veya continue ile akışı yönlendirmesi ya da panic! / todo!() gibi normal biçimde sonlanmayan (! — asla türü üreten) bir ifadeyle bitmesi gerekir. Eğer else bloğunun normal şekilde tamamlanıp alt satıra geçmesine izin verilseydi eyalet değişkeni ilk değerini almamış olurdu; Rust derleyicisi bunu error[E0308]: `else` clause of `let...else` does not diverge (let...else yapısının else bloğu ıraksamıyor) hatasıyla derleme zamanında engeller.
Liste 6-9’da, if let yerine let...else kullanıldığında Liste 6-8’deki kodun nasıl göründüğünü inceleyebilirsiniz.
#[derive(Debug)] // birazdan eyaleti inceleyebilmek için
enum Eyalet {
Alabama,
Alaska,
// --snip--
}
impl Eyalet {
fn su_tarihte_var_miydi(&self, yil: u16) -> bool {
match self {
Eyalet::Alabama => yil >= 1819,
Eyalet::Alaska => yil >= 1959,
// -- snip --
}
}
}
enum MadeniPara {
Kurus,
BesKurus,
OnKurus,
YirmiBesKurus(Eyalet),
}
fn eyalet_ceyrekligini_tanimla(para: MadeniPara) -> Option<String> {
let MadeniPara::YirmiBesKurus(eyalet) = para else {
return None;
};
if eyalet.su_tarihte_var_miydi(1900) {
Some(format!("{eyalet:?} Amerika'ya göre oldukça eski!"))
} else {
Some(format!("{eyalet:?} nispeten yeni."))
}
}
fn main() {
if let Some(tanim) =
eyalet_ceyrekligini_tanimla(MadeniPara::YirmiBesKurus(Eyalet::Alaska))
{
println!("{tanim}");
}
}
let...else kullanmaBu sayede fonksiyonun ana gövdesinin, if let örneğindeki gibi iki dal arasında bambaşka kontrol akışlarına bölünmeden, girintisiz biçimde doğrudan “mutlu yolda” (happy path) — yani beklenen ana işleyiş akışında — kaldığına dikkat edin.
Programınızda match kullanarak ifade etmenin fazla ayrıntılı kaldığı bir mantıkla karşılaştığınızda, if let ve let...else yapılarının da Rust araç çantanızda bulunduğunu unutmayın.
Not: Bir kontrol akışı yapısı seçerken şu pratik ölçütleri kullanabilirsiniz:
match: Birden fazla varyantı ayrı ayrı ele almanız gerektiğinde veya ileride enum’a yeni bir varyant eklendiğinde derleyicinin sizi derleme hatasıyla uyarmasını (kapsamlılık denetimi) istediğinizdematchkullanın (ve_joker deseninden kaçının).if let: Yalnızca tek bir desenle ilgilenip orada kısa bir işlem yapacak, diğer tüm durumları yok sayacak (veya tek birelsebloğunda toplayacak) isenizif lettercih edin.let...else: Fonksiyonun devam edebilmesi için bir değerin belirli bir desenle eşleşmesi ön koşulsa (erken doğrulama), iç içe girintilerden kaçınıp çıkarılan veriyi doğrudan dış kapsamda kullanarak ana akışta (“mutlu yolda”) kalmak içinlet...elsekullanın.
Özet
Bu bölümde, numaralandırılmış bir değerler kümesinden yalnızca biri olabilen özel türler tanımlamak için enum’ların nasıl kullanılacağını ele aldık. Standart kütüphanedeki Option<T> türünün, hataları derleme zamanında önlemek üzere tür sisteminden yararlanmanıza nasıl yardımcı olduğunu gördük. Enum değerleri kendi içlerinde veri barındırdığında, ele almanız gereken durum sayısına bağlı olarak bu verileri çıkarmak ve kullanmak için match, if let veya let...else yapılarından yararlanabilirsiniz.
Rust programlarınız artık çalıştığınız problem alanındaki kavramları struct’lar ve enum’lar aracılığıyla ifade edebilir. API’nizde kullanmak üzere özel türler oluşturmak tür güvenliği sağlar: Derleyici, her fonksiyonun yalnızca beklediği türdeki değerleri almasını güvence altına alır.
Kullanıcılarınıza kullanımı anlaşılır ve yalnızca gerçekten ihtiyaç duyacakları öğeleri dışarıya açan iyi düzenlenmiş bir API sunabilmek için şimdi Rust’ın modül sistemine geçelim.
Paketler, Crate’ler ve Modüllerle Büyüyen Projeleri Yönetme
Büyük programlar yazdıkça kodunuzu düzenli tutmak giderek daha önemli hâle gelir. Birbiriyle ilişkili işlevleri gruplandırıp farklı özelliklere ait kodları birbirinden ayırarak, belirli bir özelliği uygulayan kodu nerede bulacağınızı ve bir özelliğin çalışma biçimini değiştirmek için nereye bakmanız gerektiğini netleştirirsiniz.
Şimdiye kadar yazdığımız programlar tek bir dosyadaki tek bir modül içindeydi. Bir proje büyüdükçe kodu önce birden fazla modüle, ardından da birden fazla dosyaya bölerek düzenlemelisiniz. Bir paket birden fazla ikili crate (binary crate) ve isteğe bağlı olarak bir kütüphane crate’i (library crate) içerebilir. Paket daha da büyüdükçe, bazı bölümlerini dış bağımlılık hâline gelecek ayrı crate’lere ayırabilirsiniz. Bu bölüm tüm bu teknikleri ele alır. Birlikte gelişen ve birbiriyle ilişkili paketlerden oluşan çok büyük projeler içinse Cargo, Bölüm 14’teki “Cargo Çalışma Alanları” kısmında inceleyeceğimiz çalışma alanlarını (workspaces) sunar.
Ayrıca uygulama ayrıntılarının kapsüllenmesini (encapsulation) de ele alacağız; kapsülleme, kodu daha üst düzeyde yeniden kullanmanıza olanak tanır: Bir işlemi bir kez uyguladıktan sonra diğer kodlar, iç işleyişin nasıl kurgulandığını bilmek zorunda kalmadan kodunuzu açık (public) arayüzü üzerinden çağırabilir. Kodunuzu yazış biçiminiz, hangi kısımların diğer kodların kullanımına açık olduğunu ve hangi kısımların değiştirme hakkını saklı tuttuğunuz gizli (private) uygulama ayrıntıları olduğunu belirler. Bu da zihninizde tutmanız gereken ayrıntı miktarını sınırlandırmanın bir diğer yoludur.
Bununla ilişkili bir diğer kavram da kapsamdır (scope): Kodun yazıldığı iç içe geçmiş bağlamda, “kapsam içinde” tanımlı kabul edilen belirli bir adlar kümesi bulunur. Kodu okurken, yazarken ve derlerken hem programcıların hem de derleyicilerin, belirli bir noktadaki belirli bir adın değişkene, fonksiyona, struct’a, enum’a, modüle, sabite ya da başka bir öğeye mi karşılık geldiğini ve o öğenin ne anlama geldiğini bilmesi gerekir. Yeni kapsamlar oluşturabilir ve hangi adların kapsam içinde veya kapsam dışında yer alacağını değiştirebilirsiniz. Aynı kapsamda aynı ada sahip iki öğe bulunamaz; olası ad çakışmalarını çözmek içinse çeşitli araçlar mevcuttur.
Rust; hangi ayrıntıların dışarıya açılacağı, hangilerinin gizli tutulacağı ve programlarınızdaki her kapsamda hangi adların bulunacağı da dahil olmak üzere kodunuzun organizasyonunu yönetmenize olanak tanıyan bir dizi özelliğe sahiptir. Bazen topluca modül sistemi (module system) olarak adlandırılan bu özellikler şunlardır:
- Paketler: Crate’leri derlemenize, test etmenize ve paylaşmanıza olanak tanıyan bir Cargo özelliğidir.
- Crate’ler: Bir kütüphane veya çalıştırılabilir dosya üreten bir modül ağacıdır.
- Modüller ve
use: Yolların organizasyonunu, kapsamını ve gizliliğini kontrol etmenizi sağlar. - Yollar: Struct, fonksiyon veya modül gibi bir öğeyi isimlendirme biçimidir.
Bu bölümde tüm bu özellikleri inceleyecek, birbirleriyle nasıl etkileşime girdiklerini ele alacak ve kapsamı yönetmek için bunları nasıl kullanacağınızı açıklayacağız. Bölümün sonunda modül sistemini sağlam biçimde kavrayacak ve kapsamlarla bir profesyonel gibi çalışabileceksiniz!
Not: Farklı programlama dillerinde “paket”, “kütüphane” ve “modül” kelimeleri zaman zaman birbirinin yerine kullanılsa da Rust’ta paket, crate, modül ve yol kavramları yukarıdan aşağıya net bir hiyerarşi oluşturur: Paket (Cargo.toml ile tanımlanan Cargo projesi), bir veya birden fazla crate barındırır; her crate (src/lib.rs veya src/main.rs kök dosyasından başlayan derleme birimi), kendi içinde mod bildirimleriyle dallanan bir modül ağacından oluşur; yollar (crate::... gibi) ise bu ağaçtaki öğelerin tam adresini ifade eder ve use ile mevcut kapsamda bu yollar için kısayol oluşturulabilir. Ayrıca Python veya JavaScript gibi dillerin aksine Rust’ta proje dizinine yalnızca yeni bir .rs dosyası eklemek onu otomatik olarak derlemeye dahil etmez; dosyanın modül ağacına mod anahtar kelimesiyle açıkça bağlanması gerekir.
Paketler ve Crate'ler
Paketler ve Crate’ler
Modül sisteminin ele alacağımız ilk parçaları paketler ve crate’lerdir.
Bir crate, Rust derleyicisinin tek seferde ele aldığı en küçük kod parçasıdır; yani bir derleme birimidir (compilation unit). cargo yerine doğrudan rustc çalıştırıp derleyiciye tek bir kaynak kod dosyası iletseniz bile (Bölüm 1’deki “Rust Programlamanın Temelleri” kısmında yaptığımız gibi), derleyici bu dosyayı bir crate olarak kabul eder. Crate’ler modüller içerebilir ve ilerleyen alt bölümlerde göreceğimiz gibi bu modüller, crate ile birlikte derlenen başka dosyalarda tanımlanmış olabilir.
Bir crate iki biçimden birinde olabilir: ikili crate (binary crate) veya kütüphane crate’i (library crate). İkili crate’ler, komut satırı aracı veya sunucu gibi doğrudan çalıştırılabilir bir dosyaya derleyebildiğiniz programlardır. Her ikili crate’in, program başlatıldığında ne yapılacağını belirleyen main adında bir fonksiyona sahip olması gerekir. Şimdiye kadar oluşturduğumuz tüm crate’ler ikili crate’lerdi.
Kütüphane crate’lerinin bir main fonksiyonu yoktur ve bu crate’ler çalıştırılabilir bir dosyaya derlenmez. Bunun yerine, birden fazla projeyle paylaşılması amaçlanan işlevselliği tanımlarlar. Örneğin, Bölüm 2’de kullandığımız rand crate’i rastgele sayı üretme işlevselliği sunar. Rust geliştiricileri çoğu zaman “crate” dediklerinde kütüphane crate’ini kasteder ve “crate” terimini genel programlama kavramı olan “kütüphane” ile eş anlamlı kullanırlar.
Crate kökü (crate root), Rust derleyicisinin derlemeye başladığı ve crate’inizin kök modülünü oluşturan kaynak dosyadır (modülleri “Modüllerle Kapsam ve Gizliliği Kontrol Etme” kısmında ayrıntılı olarak inceleyeceğiz).
Bir paket (package), belirli bir işlevsellik kümesi sunan bir veya daha fazla crate’ten oluşan bir bütündür. Her paket, içindeki crate’lerin nasıl derleneceğini tanımlayan bir Cargo.toml dosyası barındırır. Aslında Cargo’nun kendisi de kodunuzu derlemek için kullandığınız komut satırı aracına ait ikili crate’i barındıran bir pakettir. Cargo paketi aynı zamanda bu ikili crate’in bağımlı olduğu bir kütüphane crate’i de içerir. Başka projeler de Cargo komut satırı aracıyla aynı mantığı kullanmak için Cargo’nun kütüphane crate’ine bağımlı olabilir.
Bir paket dilediğiniz sayıda ikili crate içerebilir; ancak en fazla bir kütüphane crate’i barındırabilir. Öte yandan bir paket, ister kütüphane ister ikili crate olsun, en az bir crate içermek zorundadır.
Bir paket oluşturduğumuzda neler olduğuna adım adım bakalım. Önce cargo new benim-projem komutunu giriyoruz:
$ cargo new benim-projem
Created binary (application) `benim-projem` package
$ ls benim-projem
Cargo.toml
src
$ ls benim-projem/src
main.rs
cargo new benim-projem komutunu çalıştırdıktan sonra Cargo’nun neleri oluşturduğunu görmek için ls komutunu kullanıyoruz. Proje dizini olan benim-projem içinde, bize bir paket kazandıran Cargo.toml dosyası yer alır. Ayrıca main.rs dosyasını barındıran bir src dizini de bulunur. Cargo.toml dosyasını metin düzenleyicinizde açarsanız src/main.rs dosyasından hiç söz edilmediğini görürsünüz. Cargo, src/main.rs dosyasının paketle aynı adı taşıyan bir ikili crate’in crate kökü olduğu kuralını izler. Benzer şekilde Cargo, paket dizininde src/lib.rs dosyası varsa paketin yine kendisiyle aynı adı taşıyan bir kütüphane crate’i içerdiğini ve crate kökünün src/lib.rs olduğunu bilir. Cargo, kütüphaneyi veya ikili dosyayı derlemek üzere bu crate kök dosyalarını rustc derleyicisine iletir.
Burada yalnızca src/main.rs dosyasını barındıran bir paketimiz var; yani paket yalnızca benim-projem adında tek bir ikili crate içeriyor. Bir pakette hem src/main.rs hem de src/lib.rs bulunuyorsa o paket, her ikisi de paketle aynı adı taşıyan iki crate içerir: bir ikili crate ve bir kütüphane crate’i. Ayrıca src/bin dizinine dosyalar yerleştirerek bir paketin birden fazla ikili crate barındırmasını sağlayabilirsiniz; bu dizindeki her dosya ayrı bir ikili crate olur.
Not: Gerçek dünya Rust projelerinde (Bölüm 12’de de uygulayacağımız gibi) aynı pakette hem src/lib.rs hem de src/main.rs bulundurmak yaygın bir mimaridir. Bu düzende paylaşılan asıl iş mantığı src/lib.rs kütüphane crate’ine yerleştirilir; src/main.rs ikili crate’i ise yalnızca komut satırı argümanlarını okuyup kütüphanedeki açık (pub) öğeleri çağıran ince bir giriş noktası olarak tutulur. Paket adında benim-projem gibi kısa çizgi varsa Rust kodunda kütüphane crate’ine alt çizgiyle, yani benim_projem adıyla başvurulduğunu unutmayın. Doğrudan bir kütüphane paketi oluşturmak için cargo new --lib komutunu kullanabilirsiniz; src/bin/ dizinine birden fazla ikili crate (örneğin src/bin/sunucu.rs ve src/bin/istemci.rs) eklediğinizde ise çalıştırmak istediğiniz hedefi cargo run --bin sunucu komutuyla seçebilirsiniz.
Modüllerle Kapsam ve Gizliliği Kontrol Etme
Modüllerle Kapsam ve Gizliliği Kontrol Etme
Bu alt bölümde modüllerden ve modül sisteminin diğer parçalarından söz edeceğiz: öğeleri adlandırmanızı sağlayan yollar (paths), bir yolu kapsama dahil eden use anahtar kelimesi ve öğeleri dışarıya açık hâle getiren pub anahtar kelimesi. Ayrıca as anahtar kelimesini, harici paketleri ve glob operatörünü de ele alacağız.
Modüller Hızlı Başvuru Özeti
Ayrıntılara geçmeden önce modüllerin, yolların, use ve pub anahtar kelimelerinin derleyicide nasıl çalıştığına ve çoğu geliştiricinin kodunu nasıl düzenlediğine dair hızlı bir başvuru özeti sunuyoruz. Bu bölüm boyunca bu kuralların her birini örneklerle inceleyeceğiz; ancak modüllerin çalışma biçimini hatırlamak istediğinizde burası geri dönebileceğiniz harika bir başvuru noktasıdır.
- Crate kökünden başlama: Bir crate derlenirken derleyici, derlenecek kodu bulmak için önce crate kök dosyasına (kütüphane crate’leri için genellikle src/lib.rs, ikili crate’ler içinse src/main.rs) bakar.
- Modül bildirme: Crate kök dosyasında yeni modüller bildirebilirsiniz; örneğin
mod bahce;bildirimiyle bir “bahce” modülü bildirdiğinizi varsayalım. Derleyici, bu modülün kodunu şu konumlarda arar:- Satır içinde:
mod bahcebildiriminden sonra noktalı virgül yerine açılan süslü parantezlerin içinde - src/bahce.rs dosyasında
- src/bahce/mod.rs dosyasında
- Satır içinde:
- Alt modül bildirme: Crate kökü dışındaki herhangi bir dosyada alt modüller bildirebilirsiniz. Örneğin src/bahce.rs içinde
mod sebzeler;bildiriminde bulunabilirsiniz. Derleyici, alt modülün kodunu üst modülün adını taşıyan dizin altında şu konumlarda arar:- Satır içinde: doğrudan
mod sebzelerbildiriminin ardından, noktalı virgül yerine açılan süslü parantezlerin içinde - src/bahce/sebzeler.rs dosyasında
- src/bahce/sebzeler/mod.rs dosyasında
- Satır içinde: doğrudan
- Modüllerdeki kodlara giden yollar: Bir modül crate’inizin parçası hâline geldiğinde, gizlilik kuralları izin verdiği sürece aynı crate’in herhangi bir yerinden ilgili yolu kullanarak o modüldeki koda başvurabilirsiniz. Örneğin
bahcealtındakisebzelermodülünde yer alan birKuskonmaztürünecrate::bahce::sebzeler::Kuskonmazyoluyla ulaşılır. - Gizli ve açık erişim: Bir modülün içindeki kod, varsayılan olarak üst modüllerine karşı gizlidir (private). Bir modülü açık (public) hâle getirmek için
modyerinepub modile bildirin. Açık bir modülün içindeki öğeleri de dışarıya açmak için bildirimlerinin önünepubekleyin. useanahtar kelimesi: Bir kapsam içindeuseanahtar kelimesi, uzun yolların tekrarını azaltmak amacıyla öğeler için kısayollar oluşturur.crate::bahce::sebzeler::Kuskonmazyoluna başvurabilen herhangi bir kapsamdause crate::bahce::sebzeler::Kuskonmaz;ile bir kısayol tanımlayabilirsiniz; bundan sonra o kapsamda bu türü kullanmak için yalnızcaKuskonmazyazmanız yeterlidir.
Burada, bu kuralları somutlaştırmak için arka_bahce adlı bir ikili crate oluşturuyoruz. Yine arka_bahce adını taşıyan crate dizini şu dosya ve dizinleri içerir:
arka_bahce
├── Cargo.lock
├── Cargo.toml
└── src
├── bahce
│ └── sebzeler.rs
├── bahce.rs
└── main.rs
Bu örnekte crate kök dosyası src/main.rs dosyasıdır ve içeriği şöyledir:
use crate::bahce::sebzeler::Kuskonmaz;
pub mod bahce;
fn main() {
let bitki = Kuskonmaz {};
println!("{bitki:?} yetiştiriyorum!");
}
pub mod bahce; satırı, derleyiciye src/bahce.rs dosyasında bulduğu kodu derlemeye dahil etmesini söyler. Bu dosyanın içeriği şöyledir:
pub mod sebzeler;
Buradaki pub mod sebzeler; bildirimi de src/bahce/sebzeler.rs dosyasındaki kodun dahil edileceği anlamına gelir. O kod ise şöyledir:
#[derive(Debug)]
pub struct Kuskonmaz {}
Şimdi bu kuralların ayrıntılarına inelim ve pratikte nasıl çalıştıklarını görelim!
İlgili Kodu Modüllerde Gruplama
Modüller, okunabilirlik ve kolay yeniden kullanım amacıyla bir crate içindeki kodu düzenlememize olanak tanır. Bir modülün içindeki kod varsayılan olarak gizli olduğundan, modüller öğelerin gizliliğini (privacy) kontrol etmemizi de sağlar. Gizli öğeler, dışarıdan kullanıma kapalı olan iç uygulama ayrıntılarıdır. Modülleri ve içlerindeki öğeleri açık hâle getirmeyi seçebiliriz; böylece dışarıdaki kodların bunları kullanmasına ve bunlara bağımlı olmasına izin vermiş oluruz.
Örnek olarak bir restoranın işlevselliğini sunan bir kütüphane crate’i yazalım. Bir restoranın gerçek işleyişini kodlamaktan ziyade kodun organizasyonuna odaklanmak için fonksiyonların yalnızca imzalarını tanımlayacak, gövdelerini ise boş bırakacağız.
Restoran sektöründe bir işletmenin alanları genellikle ön kısım (müşteri salonu — front of house) ve arka kısım (mutfak ve geri plan — back of house) olarak ikiye ayrılır. Ön kısım, müşterilerin ağırlandığı alandır; karşılama görevlilerinin müşterileri masalara yerleştirdiği, garsonların sipariş ve ödeme aldığı, barmenlerin ise içecekleri hazırladığı bölümleri kapsar. Arka kısım ise şef ve aşçıların mutfakta yemek pişirdiği, bulaşıkçıların temizlik yaptığı ve yöneticilerin idari işleri yürüttüğü bölümdür.
Crate’imizi bu düzene göre yapılandırmak için fonksiyonlarını iç içe geçmiş modüller hâlinde organize edebiliriz. cargo new restoran --lib komutunu çalıştırarak restoran adında yeni bir kütüphane oluşturun. Ardından bazı modülleri ve fonksiyon imzalarını tanımlamak için Liste 7-1’deki kodu src/lib.rs dosyasına yazın; bu kod restoranın ön kısmını oluşturur.
mod restoran_on_kisim {
mod karsilama {
fn bekleme_listesine_ekle() {}
fn masaya_oturt() {}
}
mod servis {
fn siparis_al() {}
fn siparis_servis_et() {}
fn odeme_al() {}
}
}
restoran_on_kisim modülüBir modülü, mod anahtar kelimesinin ardından modülün adını (bu örnekte restoran_on_kisim) yazarak tanımlarız. Modülün gövdesi ise süslü parantezler içine yazılır. Bir modülün içine, buradaki karsilama ve servis modüllerinde olduğu gibi başka modüller yerleştirebiliriz. Modüller ayrıca struct’lar, enum’lar, sabitler, trait’ler ve Liste 7-1’deki gibi fonksiyonlar da dahil olmak üzere diğer öğelerin tanımlarını barındırabilir.
Modülleri kullanarak birbiriyle ilişkili tanımları bir araya toplayabilir ve bu tanımların neden ilişkili olduğunu verdiğimiz modül adıyla ifade edebiliriz. Bu kodu kullanan geliştiriciler, tüm tanımları baştan sona okumak zorunda kalmadan gruplar üzerinden kodda gezinebilir ve aradıkları tanımları çok daha kolay bulabilirler. Koda yeni bir işlevsellik ekleyecek geliştiriciler de programın düzenini bozmamak için yeni kodu nereye yerleştirmeleri gerektiğini bilirler.
Daha önce src/main.rs ve src/lib.rs dosyalarının crate kökü olarak adlandırıldığından bahsetmiştik. Bu adlandırmanın nedeni, bu iki dosyadan herhangi birinin içeriğinin, crate’in modül ağacı (module tree) olarak bilinen modül yapısının en tepesinde crate adında bir modül oluşturmasıdır.
Liste 7-2, Liste 7-1’deki yapının modül ağacını göstermektedir.
crate
└── restoran_on_kisim
├── karsilama
│ ├── bekleme_listesine_ekle
│ └── masaya_oturt
└── servis
├── siparis_al
├── siparis_servis_et
└── odeme_al
Bu ağaç, bazı modüllerin diğer modüllerin içinde nasıl iç içe yer aldığını gösterir; örneğin karsilama, restoran_on_kisim modülünün içindedir. Ağaç ayrıca bazı modüllerin birbirinin kardeşi (sibling) olduğunu, yani aynı modül içinde tanımlandığını da gösterir; karsilama ve servis, restoran_on_kisim içinde tanımlanmış kardeş modüllerdir. Bir A modülü B modülünün içindeyse; A’nın B modülünün alt modülü (çocuğu / child), B’nin ise A modülünün üst modülü (ebeveyni / parent) olduğunu söyleriz. Tüm modül ağacının, derleyici tarafından örtük olarak oluşturulan crate adlı kök modülün altında yer aldığına dikkat edin.
Modül ağacı size bilgisayarınızdaki dosya sisteminin dizin ağacını anımsatmış olabilir; bu çok yerinde bir benzetmedir! Tıpkı dosya sistemindeki dizinler gibi, kodunuzu düzenlemek için modülleri kullanırsınız. Yine bir dizindeki dosyalarda olduğu gibi, modüllerimizi bulmanın da bir yoluna ihtiyacımız vardır.
Not: Modül ağacı dosya sistemindeki dizin ağacına benzese de ikisi arasında önemli bir fark vardır: Dizin ağacı dosyaların diskteki fiziksel yerleşimini gösterirken, modül ağacı tamamen mod bildirimleriyle kurulan mantıksal bir hiyerarşidir. Nitekim Liste 7-1’de tek bir src/lib.rs dosyası içinde üç ayrı modül (restoran_on_kisim, karsilama ve servis) tanımladık; yani her modül için yeni bir dosya açmak zorunda değilsiniz ve tersine src/ dizinine eklediğiniz bir .rs dosyası da mod ile açıkça bildirilmedikçe modül ağacına dahil olmaz.
Ayrıca Rust’ta modüllerin ve öğelerin varsayılan olarak gizli (private by default) olması bilinçli bir API tasarımı güvencesidir: Hiçbir iç uygulama ayrıntısı yanlışlıkla dış dünyaya sızmaz; böylece pub ile açıkça dışarıya açmadığınız her kodu, kullanıcılarınızın kodunu bozma endişesi taşımadan güvenle yeniden düzenleyebilirsiniz. Projeleriniz büyüdüğünde crate’inizin modül ağacını ve öğelerin gizlilik durumunu terminalde Liste 7-2’dekine benzer bir ağaç görünümünde incelemek isterseniz topluluk aracı olan cargo-modules eklentisini (cargo install cargo-modules kurulumunun ardından cargo modules structure komutuyla) kullanabilirsiniz.
Modül Ağacındaki Bir Öğeye Başvurmak İçin Yollar
Modül Ağacındaki Bir Öğeye Başvurmak İçin Yollar
Rust’a modül ağacındaki bir öğeyi nerede bulacağını göstermek için, tıpkı bir dosya sisteminde gezinirken kullandığımız gibi bir yol (path) kullanırız. Bir fonksiyonu çağırmak için onun yolunu bilmemiz gerekir.
Bir yol iki biçimde yazılabilir:
- Mutlak yol (absolute path), crate kökünden başlayan tam yoldur; harici bir crate’teki kod için crate adıyla, içinde bulunduğumuz crate’teki kod içinse doğrudan
cratekelimesiyle başlar. - Göreli yol (relative path), geçerli modülden başlar ve
self,superya da geçerli modül içindeki bir tanımlayıcıyı kullanır.
Hem mutlak hem de göreli yollarda başlangıç öğesini, birbirinden çift iki nokta üst üste (::) ile ayrılmış bir veya daha fazla tanımlayıcı izler.
Liste 7-1’e geri dönelim ve bekleme_listesine_ekle fonksiyonunu çağırmak istediğimizi varsayalım. Bu aslında şu soruyu sormakla aynıdır: bekleme_listesine_ekle fonksiyonunun yolu nedir? Liste 7-3, Liste 7-1’in bazı modül ve fonksiyonları çıkarılmış hâlini içerir.
Crate kökünde tanımlanan restoranda_yemek_ye adlı yeni bir fonksiyondan bekleme_listesine_ekle fonksiyonunu çağırmanın iki yolunu göstereceğiz. Bu yollar doğrudur; ancak bu örneğin olduğu gibi derlenmesini engelleyecek bir sorun vardır. Nedenini birazdan açıklayacağız.
restoranda_yemek_ye fonksiyonu, kütüphane crate’imizin açık API’sinin bir parçası olduğu için onu pub anahtar kelimesiyle işaretliyoruz. “Yolları pub Anahtar Kelimesiyle Dışarıya Açma” kısmında pub hakkında daha fazla ayrıntıya gireceğiz.
mod restoran_on_kisim {
mod karsilama {
fn bekleme_listesine_ekle() {}
}
}
pub fn restoranda_yemek_ye() {
// Mutlak yol
crate::restoran_on_kisim::karsilama::bekleme_listesine_ekle();
// Göreli yol
restoran_on_kisim::karsilama::bekleme_listesine_ekle();
}
bekleme_listesine_ekle fonksiyonunu mutlak ve göreli yollar kullanarak çağırmarestoranda_yemek_ye içinde bekleme_listesine_ekle fonksiyonunu ilk çağırdığımızda mutlak bir yol kullanıyoruz. bekleme_listesine_ekle fonksiyonu restoranda_yemek_ye ile aynı crate içinde tanımlandığından, mutlak yolu başlatmak için crate anahtar kelimesini kullanabiliriz. Ardından bekleme_listesine_ekle fonksiyonuna ulaşana kadar birbirini izleyen modüllerin her birini yola dahil ederiz. Aynı yapıya sahip bir dosya sistemi hayal edebilirsiniz: bekleme_listesine_ekle programını çalıştırmak için /restoran_on_kisim/karsilama/bekleme_listesine_ekle yolunu belirtirdik; crate kökünden başlamak üzere crate adını kullanmak, kabuğunuzda dosya sistemi kökünden başlamak için / kullanmaya benzer.
restoranda_yemek_ye içinde bekleme_listesine_ekle fonksiyonunu ikinci kez çağırdığımızda ise göreli bir yol kullanıyoruz. Yol, modül ağacında restoranda_yemek_ye ile aynı seviyede tanımlanmış olan restoran_on_kisim modülünün adıyla başlar. Bunun dosya sistemindeki karşılığı restoran_on_kisim/karsilama/bekleme_listesine_ekle yolunu kullanmak olurdu. Bir modül adıyla başlamak, yolun göreli olduğu anlamına gelir.
Göreli mi yoksa mutlak yol mu kullanacağınız, projenize göre vereceğiniz bir karardır ve öğeyi tanımlayan kodu, o öğeyi kullanan koddan ayrı mı yoksa onunla birlikte mi taşıma olasılığınızın daha yüksek olduğuna bağlıdır. Örneğin restoran_on_kisim modülünü ve restoranda_yemek_ye fonksiyonunu birlikte musteri_deneyimi adlı bir modülün içine taşırsak, bekleme_listesine_ekle için yazdığımız mutlak yolu güncellememiz gerekir; ancak göreli yol geçerliliğini korur. Buna karşılık restoranda_yemek_ye fonksiyonunu tek başına yemek adlı ayrı bir modüle taşırsak, bekleme_listesine_ekle çağrısının mutlak yolu aynı kalır ama göreli yolun güncellenmesi gerekir. Kod tanımlarını ve öğe çağrılarını birbirinden bağımsız olarak taşıma ihtimalimiz daha yüksek olduğundan, genel tercihimiz mutlak yolları kullanmaktır.
Şimdi Liste 7-3’ü derlemeyi deneyelim ve henüz neden derlenmediğini öğrenelim! Aldığımız hatalar Liste 7-4’te gösterilmiştir.
$ cargo build
Compiling restoran v0.1.0 (file:///projects/restoran)
error[E0603]: module `karsilama` is private
--> src/lib.rs:9:31
|
9 | crate::restoran_on_kisim::karsilama::bekleme_listesine_ekle();
| ^^^^^^^^^ ---------------------- function `bekleme_listesine_ekle` is not publicly re-exported
| |
| private module
|
note: the module `karsilama` is defined here
--> src/lib.rs:2:5
|
2 | mod karsilama {
| ^^^^^^^^^^^^^
error[E0603]: module `karsilama` is private
--> src/lib.rs:12:24
|
12 | restoran_on_kisim::karsilama::bekleme_listesine_ekle();
| ^^^^^^^^^ ---------------------- function `bekleme_listesine_ekle` is not publicly re-exported
| |
| private module
|
note: the module `karsilama` is defined here
--> src/lib.rs:2:5
|
2 | mod karsilama {
| ^^^^^^^^^^^^^
For more information about this error, try `rustc --explain E0603`.
error: could not compile `restoran` (lib) due to 2 previous errors
Hata mesajları karsilama modülünün gizli olduğunu söylüyor (module `karsilama` is private). Başka bir deyişle, karsilama modülü ve bekleme_listesine_ekle fonksiyonu için yazdığımız yollar doğrudur; ancak gizli bölümlere erişim iznimiz olmadığı için Rust bunları kullanmamıza izin vermez. Rust’ta tüm öğeler (fonksiyonlar, metotlar, struct’lar, enum’lar, modüller ve sabitler) üst modüllere karşı varsayılan olarak gizlidir. Bir fonksiyon veya struct gibi bir öğeyi gizli yapmak istiyorsanız onu bir modülün içine yerleştirirsiniz.
Üst modüldeki öğeler alt modüllerin içindeki gizli öğeleri kullanamaz; ancak alt modüllerdeki öğeler kendi ata modüllerindeki öğeleri kullanabilir. Bunun nedeni, alt modüllerin kendi uygulama ayrıntılarını sarmalayıp gizlemesi, buna karşılık tanımlandıkları dış bağlamı görebilmesidir. Benzetmemize devam edecek olursak, gizlilik kurallarını bir restoranın arka ofisi gibi düşünebilirsiniz: Orada olup bitenler restoran müşterilerine karşı gizlidir, ancak ofis yöneticileri işlettikleri restorandaki her şeyi görebilir ve yönetebilir.
Rust’ta modül sistemi, iç uygulama ayrıntılarını gizlemek varsayılan davranış olacak şekilde tasarlanmıştır. Bu sayede içteki kodun hangi kısımlarını dıştaki kodu bozmadan değiştirebileceğinizi net biçimde bilirsiniz. Yine de Rust, bir öğeyi açık hâle getiren pub anahtar kelimesini kullanarak alt modüllerdeki kodun iç kısımlarını dıştaki ata modüllere açma seçeneğini de size sunar.
Yolları pub Anahtar Kelimesiyle Dışarıya Açma
Liste 7-4’te karsilama modülünün gizli olduğunu bildiren hataya geri dönelim. Üst modüldeki restoranda_yemek_ye fonksiyonunun, alt modüldeki bekleme_listesine_ekle fonksiyonuna erişebilmesini istiyoruz; bu yüzden karsilama modülünü Liste 7-5’te gösterildiği gibi pub anahtar kelimesiyle işaretliyoruz.
mod restoran_on_kisim {
pub mod karsilama {
fn bekleme_listesine_ekle() {}
}
}
// -- snip --
pub fn restoranda_yemek_ye() {
// Mutlak yol
crate::restoran_on_kisim::karsilama::bekleme_listesine_ekle();
// Göreli yol
restoran_on_kisim::karsilama::bekleme_listesine_ekle();
}
restoranda_yemek_ye içinden kullanabilmek için karsilama modülünü pub olarak bildirmeNe yazık ki Liste 7-5’teki kod da Liste 7-6’da gösterildiği gibi hâlâ derleyici hatalarıyla sonuçlanır.
$ cargo build
Compiling restoran v0.1.0 (file:///projects/restoran)
error[E0603]: function `bekleme_listesine_ekle` is private
--> src/lib.rs:10:42
|
10 | crate::restoran_on_kisim::karsilama::bekleme_listesine_ekle();
| ^^^^^^^^^^^^^^^^^^^^^^ private function
|
note: the function `bekleme_listesine_ekle` is defined here
--> src/lib.rs:3:9
|
3 | fn bekleme_listesine_ekle() {}
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^
error[E0603]: function `bekleme_listesine_ekle` is private
--> src/lib.rs:13:35
|
13 | restoran_on_kisim::karsilama::bekleme_listesine_ekle();
| ^^^^^^^^^^^^^^^^^^^^^^ private function
|
note: the function `bekleme_listesine_ekle` is defined here
--> src/lib.rs:3:9
|
3 | fn bekleme_listesine_ekle() {}
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^
For more information about this error, try `rustc --explain E0603`.
error: could not compile `restoran` (lib) due to 2 previous errors
Ne oldu? mod karsilama tanımının önüne pub anahtar kelimesini eklemek modülü açık hâle getirir. Bu değişiklikle birlikte restoran_on_kisim modülüne erişebiliyorsak karsilama modülüne de erişebiliriz. Ancak karsilama modülünün içeriği hâlâ gizlidir; modülü açık yapmak içindeki öğeleri otomatik olarak açık yapmaz. Bir modül üzerindeki pub anahtar kelimesi, yalnızca ata modüllerindeki kodların o modüle başvurmasına izin verir, içindeki koda erişmesini sağlamaz. Modüller birer kapsayıcı olduğundan yalnızca modülü açık hâle getirerek yapabileceğimiz pek bir şey yoktur; bir adım daha ileri gidip modül içindeki öğelerden birini veya birkaçını da açık hâle getirmeyi seçmeliyiz.
Liste 7-6’daki hatalar bekleme_listesine_ekle fonksiyonunun gizli olduğunu söylüyor (function `bekleme_listesine_ekle` is private). Gizlilik kuralları modüllerin yanı sıra struct’lar, enum’lar, fonksiyonlar ve metotlar için de geçerlidir.
Liste 7-7’de olduğu gibi tanımının önüne pub anahtar kelimesini ekleyerek bekleme_listesine_ekle fonksiyonunu da açık hâle getirelim.
mod restoran_on_kisim {
pub mod karsilama {
pub fn bekleme_listesine_ekle() {}
}
}
// -- snip --
pub fn restoranda_yemek_ye() {
// Mutlak yol
crate::restoran_on_kisim::karsilama::bekleme_listesine_ekle();
// Göreli yol
restoran_on_kisim::karsilama::bekleme_listesine_ekle();
}
mod karsilama ve fn bekleme_listesine_ekle tanımlarına pub anahtar kelimesini eklemek, fonksiyonu restoranda_yemek_ye içinden çağırmamızı sağlar.Artık kod derlenecektir! pub anahtar kelimesini eklemenin, gizlilik kuralları çerçevesinde bu yolları restoranda_yemek_ye içinde kullanmamıza neden izin verdiğini görmek için mutlak ve göreli yollara adım adım bakalım.
Mutlak yolda, crate’imizin modül ağacının kökü olan crate ile başlarız. restoran_on_kisim modülü crate kökünde tanımlanmıştır. restoran_on_kisim açık olmasa da restoranda_yemek_ye fonksiyonuyla aynı modülde tanımlandığından (yani ikisi aynı kapsamdaki kardeş öğeler olduğundan), restoranda_yemek_ye içinden restoran_on_kisim modülüne başvurabiliriz. Sırada pub ile işaretlenmiş karsilama modülü vardır. karsilama modülünün üst modülüne erişebildiğimiz için karsilama modülüne de erişebiliriz. Son olarak bekleme_listesine_ekle fonksiyonu pub ile işaretlenmiştir ve üst modülüne erişebildiğimiz için bu fonksiyon çağrısı sorunsuz çalışır!
Göreli yolda ise ilk adım dışında mantık mutlak yolla tamamen aynıdır: Yol, crate kökünden başlamak yerine restoran_on_kisim ile başlar. restoran_on_kisim modülü restoranda_yemek_ye ile aynı modül içinde tanımlandığından, restoranda_yemek_ye fonksiyonunun tanımlı olduğu modülden başlayan göreli yol geçerlidir. Ardından hem karsilama modülü hem de bekleme_listesine_ekle fonksiyonu pub ile işaretlendiği için yolun geri kalanı da erişilebilirdir ve fonksiyon çağrısı geçerlidir!
Not: Rust’ta görünürlük yalnızca “tamamen gizli” veya “herkese açık (pub)” seçenekleriyle sınırlı değildir. Bir öğeyi dış dünyaya (harici crate’lere) açmadan yalnızca kendi projeniz içinde görünür kılmak istediğinizde kısıtlı görünürlük belirleyicilerini kullanabilirsiniz: pub(crate) öğeyi yalnızca geçerli crate genelinde açık yapar, pub(super) yalnızca bir üst modüle açar, pub(in crate::yol) ise yalnızca belirtilen ata modülün alt ağacına erişim izni verir. Özellikle kütüphane geliştirirken iç modüllerin birbiriyle paylaştığı yardımcı tür ve fonksiyonları pub(crate) ile işaretlemek, dış API’yi kirletmeden modülerliği korumanın en yaygın yoludur.
Kütüphane crate’inizi diğer projelerin de kodunuzu kullanabilmesi için paylaşmayı planlıyorsanız açık API’niz, crate’inizin kullanıcılarıyla yaptığınız ve kodunuzla nasıl etkileşime girebileceklerini belirleyen sözleşmenizdir. İnsanların crate’inize güvenle bağımlı olmasını kolaylaştırmak adına açık API’nizdeki değişiklikleri yönetirken dikkat edilmesi gereken pek çok nokta vardır. Bu konular bu kitabın kapsamı dışındadır; konuyla ilgileniyorsanız Rust API Yönergeleri kılavuzunu inceleyebilirsiniz.
Hem İkili Hem de Kütüphane Crate’i İçeren Paketler İçin En İyi Uygulamalar
Bir paketin hem src/main.rs ikili crate kökünü hem de src/lib.rs kütüphane crate kökünü barındırabileceğinden ve her iki crate’in de varsayılan olarak paketin adını taşıyacağından bahsetmiştik. Genellikle bu düzende yapılandırılan paketlerde ikili crate, yalnızca çalıştırılabilir programı başlatıp kütüphane crate’inde tanımlanan kodu çağıracak kadar kod içerir. Kütüphane crate’inin kodu paylaşılabildiği için bu yapı, paketin sunduğu işlevselliğin büyük bölümünden diğer projelerin de yararlanabilmesini sağlar.
Modül ağacı src/lib.rs içinde tanımlanmalıdır. Böylece dışarıya açık her öğe, yollara paketin adıyla başlanarak ikili crate içinde kullanılabilir (örneğin src/main.rs içinden crate:: yerine restoran:: ile başvurulur). İkili crate, tıpkı tamamen harici bir crate gibi kütüphane crate’inin bir kullanıcısı hâline gelir ve yalnızca açık API’yi kullanabilir. Bu yaklaşım iyi bir API tasarlamanıza yardımcı olur; çünkü kütüphanenin yalnızca yazarı değil, aynı zamanda ilk istemcisisinizdir!
Bölüm 12’de, hem ikili crate hem de kütüphane crate’i içeren bir komut satırı programıyla bu organizasyon yaklaşımını uygulamalı olarak göstereceğiz.
super ile Göreli Yollara Başlama
Yolun başında super kullanarak geçerli modül veya crate kökü yerine doğrudan üst modülden başlayan göreli yollar oluşturabiliriz. Bu, bir dosya sistemi yoluna bir üst dizine çıkmak anlamına gelen .. sözdizimiyle başlamaya benzer. super kullanmak, üst modülde yer aldığını bildiğimiz bir öğeye başvurmamızı sağlar; modül üst modülüyle yakından ilişkiliyse ve üst modülün ileride modül ağacında başka bir konuma taşınma ihtimali varsa bu yöntem modül ağacını yeniden düzenlemeyi kolaylaştırır.
Bir şefin hatalı bir siparişi düzeltip bizzat müşteriye götürdüğü durumu modelleyen Liste 7-8’deki kodu ele alalım. restoran_arka_kisim modülünde tanımlanan yanlis_siparisi_duzelt fonksiyonu, super ile başlayan yolu belirterek üst modülde tanımlı siparisi_teslim_et fonksiyonunu çağırır.
fn siparisi_teslim_et() {}
mod restoran_arka_kisim {
fn yanlis_siparisi_duzelt() {
siparisi_pisir();
super::siparisi_teslim_et();
}
fn siparisi_pisir() {}
}
super ile başlayan göreli bir yol kullanarak fonksiyon çağırmayanlis_siparisi_duzelt fonksiyonu restoran_arka_kisim modülü içinde yer aldığından, restoran_arka_kisim modülünün üst modülüne — bu örnekte kök modül olan crate kapsamına — çıkmak için super kullanabiliriz. Oradan siparisi_teslim_et fonksiyonunu arar ve buluruz. Başardık! restoran_arka_kisim modülü ile siparisi_teslim_et fonksiyonunun birbiriyle ilişkili kalacağını ve crate’in modül ağacını yeniden düzenlemeye karar verirsek birlikte taşınacaklarını düşünüyoruz. Bu nedenle super kullandık; böylece bu kod gelecekte başka bir modüle taşındığında güncellememiz gereken yer sayısı daha az olacaktır.
Struct’ları ve Enum’ları Açık Yapma
Struct’ları ve enum’ları açık olarak tanımlamak için de pub anahtar kelimesini kullanabiliriz; ancak pub kullanımının struct’lar ve enum’larla ilgili birkaç ek ayrıntısı vardır. Bir struct tanımının önüne pub koyarsak struct’ın kendisini açık yaparız, fakat struct’ın alanları gizli kalmaya devam eder. Her bir alanı duruma göre tek tek açık yapıp yapmamayı seçebiliriz. Liste 7-9’da açık bir tost alanına ve gizli bir mevsim_meyvesi alanına sahip olan açık bir restoran_arka_kisim::Kahvalti struct’ı tanımladık. Bu yapı, müşterinin yemekle birlikte gelen ekmek türünü seçebildiği, ancak yemeğe hangi meyvenin eşlik edeceğine mevsime ve stok durumuna göre şefin karar verdiği gerçek bir restoran işleyişini modeller. Eldeki meyveler hızla değiştiği için müşteriler meyveyi seçemez, hatta hangi meyveyi alacaklarını bile göremez.
mod restoran_arka_kisim {
pub struct Kahvalti {
pub tost: String,
mevsim_meyvesi: String,
}
impl Kahvalti {
pub fn yaz(tost: &str) -> Kahvalti {
Kahvalti {
tost: String::from(tost),
mevsim_meyvesi: String::from("şeftali"),
}
}
}
}
pub fn restoranda_yemek_ye() {
// Çavdar tostu ile bir yaz kahvaltısı sipariş edin.
let mut ogun = restoran_arka_kisim::Kahvalti::yaz("Çavdar");
// Hangi ekmeği istediğimiz konusundaki fikrimizi değiştirelim.
ogun.tost = String::from("Buğday");
println!("Lütfen {} tost istiyorum", ogun.tost);
// Aşağıdaki satırın yorumunu kaldırırsak derlenmeyecektir; öğünle birlikte gelen
// mevsim meyvesini görmemize veya değiştirmemize izin verilmez.
// ogun.mevsim_meyvesi = String::from("yaban mersini");
}
restoran_arka_kisim::Kahvalti struct’ındaki tost alanı açık olduğu için restoranda_yemek_ye içinde nokta gösterimini kullanarak tost alanına yazabilir ve bu alanı okuyabiliriz. Buna karşılık mevsim_meyvesi alanı gizli olduğundan restoranda_yemek_ye içinde bu alanı kullanamayız. Nasıl bir derleyici hatası aldığınızı görmek için mevsim_meyvesi alanının değerini değiştiren satırı yorumdan çıkarmayı deneyin!
Ayrıca restoran_arka_kisim::Kahvalti gizli bir alana sahip olduğu için, bu struct’ın bir Kahvalti örneği oluşturan açık bir ilişkili fonksiyon sunması gerektiğine dikkat edin (burada yaz mevsimini ifade edecek şekilde ona yaz adını verdik). Kahvalti böyle bir fonksiyona sahip olmasaydı, restoranda_yemek_ye içinde gizli mevsim_meyvesi alanına değer atayamayacağımız için Kahvalti türünden bir örnek de oluşturamazdık.
Buna karşılık bir enum’ı açık yaparsak tüm varyantları da otomatik olarak açık olur. Liste 7-10’da gösterildiği gibi yalnızca enum anahtar kelimesinden önce pub yazmamız yeterlidir.
mod restoran_arka_kisim {
pub enum Meze {
Corba,
Salata,
}
}
pub fn restoranda_yemek_ye() {
let siparis1 = restoran_arka_kisim::Meze::Corba;
let siparis2 = restoran_arka_kisim::Meze::Salata;
}
Meze enum’ını açık hâle getirdiğimiz için restoranda_yemek_ye içinde Corba ve Salata varyantlarını doğrudan kullanabiliriz.
Varyantları açık olmadıkça enum’lar pek işe yaramaz; her seferinde tüm enum varyantlarına tek tek pub eklemek zorunda kalmak zahmetli olacağından enum varyantlarının varsayılan durumu açık olmaktır. Struct’lar ise alanları dışarıya açık olmadan da çoğu zaman oldukça kullanışlıdır; bu nedenle struct alanları, pub ile aksi belirtilmedikçe her şeyin varsayılan olarak gizli olduğu genel kuralı izler.
Not: pub struct ile pub enum arasındaki bu fark, iki veri yapısının tasarım amacından kaynaklanır. Bir struct alanlarını bir arada tutar ve çoğu zaman alanlar arasında korunması gereken tutarlılık kuralları (invariants) barındırır: En az bir alanı gizli bıraktığınızda dışarıdaki kod ne doğrudan Kahvalti { ... } sözdizimiyle denetimsiz bir örnek oluşturabilir ne de gizli alanı değiştirebilir (Liste 7-9’daki yorum satırını açtığınızda error[E0616]: field `mevsim_meyvesi` of struct `Kahvalti` is private hatası alırsınız); tüm örnekler Kahvalti::yaz gibi kuralları koruyan açık fonksiyonlar üzerinden oluşturulmak zorundadır. Benzer kural demet struct’ları için de geçerlidir; örneğin pub struct Nokta(pub i32, i32); tanımında ilk alan açıkken ikinci alan gizlidir. Bir enum ise olası durumlardan yalnızca birini temsil eder ve temel kullanım biçimi match ya da if let ile tüm varyantları kapsamlı (exhaustive) biçimde eşleştirmektir; varyantlardan biri gizli olsaydı dışarıdaki kod bu eşleştirmeyi yapamazdı. Bir enum varyantının taşıdığı verinin iç ayrıntılarını gizlemek istediğinizde ise varyantın içine alanları gizli bir pub struct yerleştirebilirsiniz.
Henüz ele almadığımız ve pub ile ilgili bir durum daha var; o da modül sisteminin son özelliği olan use anahtar kelimesidir. Önce use anahtar kelimesini tek başına inceleyecek, ardından pub ve use anahtar kelimelerini nasıl birleştireceğimizi göstereceğiz.
use Anahtar Kelimesi ile Yolları Kapsama Dahil Etme
use Anahtar Kelimesi ile Yolları Kapsama Dahil Etme
Fonksiyonları çağırmak için yolları her seferinde uzun uzun yazmak zahmetli ve tekrarlayıcı gelebilir. Liste 7-7’de bekleme_listesine_ekle fonksiyonuna ulaşmak için ister mutlak ister göreli yolu seçelim, bekleme_listesine_ekle fonksiyonunu her çağırmak istediğimizde restoran_on_kisim ve karsilama modüllerini de belirtmek zorundaydık. Neyse ki bu süreci basitleştirmenin bir yolu vardır: use anahtar kelimesiyle bir yol için tek seferlik bir kısayol oluşturabilir ve ardından aynı kapsamın her yerinde bu daha kısa adı kullanabiliriz.
Liste 7-11’de, restoranda_yemek_ye içinde bekleme_listesine_ekle fonksiyonunu çağırmak için yalnızca karsilama::bekleme_listesine_ekle yazmamız yeterli olacak şekilde crate::restoran_on_kisim::karsilama modülünü restoranda_yemek_ye fonksiyonunun bulunduğu kapsama dahil ediyoruz.
mod restoran_on_kisim {
pub mod karsilama {
pub fn bekleme_listesine_ekle() {}
}
}
use crate::restoran_on_kisim::karsilama;
pub fn restoranda_yemek_ye() {
karsilama::bekleme_listesine_ekle();
}
use ile kapsama dahil etmeBir kapsama use ile bir yol eklemek, dosya sisteminde sembolik bağlantı (symbolic link) oluşturmaya benzer. Crate köküne use crate::restoran_on_kisim::karsilama eklediğimizde, tıpkı karsilama modülü doğrudan crate kökünde tanımlanmış gibi karsilama adı o kapsamda geçerli bir isim hâline gelir. use ile kapsama dahil edilen yollar da diğer tüm yollar gibi gizlilik kurallarına tabidir.
use deyiminin kısayolu yalnızca yazıldığı kapsam için oluşturduğuna dikkat edin. Liste 7-12, restoranda_yemek_ye fonksiyonunu musteri adlı yeni bir alt modüle taşır; bu alt modül use deyiminden farklı bir kapsam oluşturduğu için fonksiyon gövdesi derlenmeyecektir.
mod restoran_on_kisim {
pub mod karsilama {
pub fn bekleme_listesine_ekle() {}
}
}
use crate::restoran_on_kisim::karsilama;
mod musteri {
pub fn restoranda_yemek_ye() {
karsilama::bekleme_listesine_ekle();
}
}
use deyimi yalnızca bulunduğu kapsamda geçerlidir.Derleyici hatası, kısayolun musteri modülü içinde artık geçerli olmadığını gösterir:
$ cargo build
Compiling restoran v0.1.0 (file:///projects/restoran)
error[E0433]: cannot find module or crate `karsilama` in this scope
--> src/lib.rs:11:9
|
11 | karsilama::bekleme_listesine_ekle();
| ^^^^^^^^^ use of unresolved module or unlinked crate `karsilama`
|
= help: if you wanted to use a crate named `karsilama`, use `cargo add karsilama` to add it to your `Cargo.toml`
help: consider importing this module through its public re-export
|
10 + use crate::karsilama;
|
warning: unused import: `crate::restoran_on_kisim::karsilama`
--> src/lib.rs:7:5
|
7 | use crate::restoran_on_kisim::karsilama;
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
For more information about this error, try `rustc --explain E0433`.
warning: `restoran` (lib) generated 1 warning
error: could not compile `restoran` (lib) due to 1 previous error; 1 warning emitted
Ayrıca use deyiminin kendi kapsamında artık kullanılmadığına dair bir uyarı (unused import) üretildiğine de dikkat edin! Bu sorunu çözmek için ya use deyimini de musteri modülünün içine taşıyabilir ya da musteri alt modülünün içinden üst modüldeki kısayola super::karsilama ile başvurabilirsiniz.
Not: Bir fonksiyonun içindeki { ... } bloklarının aksine mod musteri { ... } bildirimi yeni ve bağımsız bir modül ad alanı başlatır; bu nedenle üst modülde use ile tanımlanan kısayollar alt modüllerde doğrudan (önek olmadan) görünmez. Derleyici çıktısındaki cannot find module or crate `karsilama` in this scope (bu kapsamda karsilama modülü veya crate’i bulunamıyor) hatasının nedeni budur. Bununla birlikte alt modüller üst modüllerin gizli öğelerine erişebildiği için super::karsilama veya derleyicinin önerdiği use crate::karsilama; yoluyla üst modüldeki kısayolu kullanabilirsiniz. Aynı kapsam kuralı diğer yönde de işler: Bir use deyimi yalnızca dosyanın başına değil, bir fonksiyon gövdesinin içine de yazılabilir; bu durumda oluşturulan kısayol yalnızca o fonksiyon bloğunda geçerli olur.
Deyimsel use Yolları Oluşturma
Liste 7-11’de, Liste 7-13’teki gibi aynı sonucu elde etmek üzere use yolunu doğrudan bekleme_listesine_ekle fonksiyonuna kadar uzatmak yerine neden use crate::restoran_on_kisim::karsilama yazıp ardından restoranda_yemek_ye içinde karsilama::bekleme_listesine_ekle çağrısı yaptığımızı merak etmiş olabilirsiniz.
mod restoran_on_kisim {
pub mod karsilama {
pub fn bekleme_listesine_ekle() {}
}
}
use crate::restoran_on_kisim::karsilama::bekleme_listesine_ekle;
pub fn restoranda_yemek_ye() {
bekleme_listesine_ekle();
}
bekleme_listesine_ekle fonksiyonunu deyimsel olmayan bir biçimde use ile kapsama dahil etmeHem Liste 7-11 hem de Liste 7-13 aynı işi görse de bir fonksiyonu use ile kapsama dahil etmenin deyimsel (idiomatic) yolu Liste 7-11’deki kullanımdır. Fonksiyonun üst modülünü use ile kapsama dahil etmek, fonksiyonu çağırırken bu üst modülü belirtmemizi gerektirir. Çağrı noktasında üst modülü yazmak, tam yolun tekrarını en aza indirirken fonksiyonun yerel olarak tanımlanmadığını da açıkça gösterir. Liste 7-13’teki kodda ise bekleme_listesine_ekle fonksiyonunun nerede tanımlandığı belirsizdir.
Öte yandan struct’ları, enum’ları ve fonksiyon dışındaki diğer öğeleri use ile kapsama dahil ederken öğenin tam yolunu belirtmek deyimseldir. Liste 7-14, standart kütüphanedeki HashMap struct’ını bir ikili crate’in kapsamına dahil etmenin deyimsel yolunu gösterir.
use std::collections::HashMap;
fn main() {
let mut harita = HashMap::new();
harita.insert(1, 2);
}
HashMap struct’ını deyimsel biçimde kapsama dahil etmeBu kullanım geleneğinin arkasında teknik bir zorunluluk yoktur; yalnızca zaman içinde yerleşmiş bir uzlaşıdır ve geliştiriciler Rust kodunu bu biçimde okuyup yazmaya alışmıştır.
Bu geleneğin istisnası, use deyimleriyle aynı ada sahip iki öğeyi aynı kapsama dahil etmemiz gereken durumlardır; çünkü Rust aynı kapsamda isim çakışmasına izin vermez. Liste 7-15, aynı adı taşıyan ancak farklı üst modüllerde bulunan iki Result türünün kapsama nasıl dahil edileceğini ve bunlara nasıl başvurulacağını gösterir.
use std::fmt;
use std::io;
fn fonksiyon1() -> fmt::Result {
// --snip--
Ok(())
}
fn fonksiyon2() -> io::Result<()> {
// --snip--
Ok(())
}
Gördüğünüz gibi, üst modülleri kullanmak iki Result türünü birbirinden ayırt eder. Bunun yerine use std::fmt::Result ve use std::io::Result yazsaydık aynı kapsamda iki ayrı Result türümüz olurdu ve Result yazdığımızda Rust hangisini kastettiğimizi bilemezdi.
as Anahtar Kelimesiyle Yeni İsimler Verme
Aynı ada sahip iki türü use ile aynı kapsama dahil etme sorununun bir çözümü daha vardır: Yoldan sonra as anahtar kelimesini kullanarak türe yeni bir yerel ad, yani takma ad (alias) verebiliriz. Liste 7-16, iki Result türünden birini as ile yeniden adlandırarak Liste 7-15’teki kodu yazmanın alternatif yolunu gösterir.
use std::fmt::Result;
use std::io::Result as IoResult;
fn fonksiyon1() -> Result {
// --snip--
Ok(())
}
fn fonksiyon2() -> IoResult<()> {
// --snip--
Ok(())
}
as anahtar kelimesiyle yeniden adlandırmaİkinci use deyiminde std::io::Result türü için IoResult yeni adını seçtik; bu ad, aynı kapsama dahil ettiğimiz std::fmt içindeki Result ile çakışmayacaktır. Hem Liste 7-15 hem de Liste 7-16 deyimsel kabul edilir; dolayısıyla hangisini tercih edeceğiniz size kalmıştır!
pub use ile İsimleri Yeniden Dışa Aktarma
Bir ismi use anahtar kelimesiyle kapsama dahil ettiğimizde bu isim, içe aktarıldığı kapsam açısından gizli kalır. O kapsamın dışındaki kodların da bu isme sanki doğrudan o kapsamda tanımlanmış gibi başvurabilmesini sağlamak için pub ve use anahtar kelimelerini birleştirebiliriz. Bu tekniğe yeniden dışa aktarma (re-exporting) denir; çünkü bir öğeyi kendi kapsamımıza dahil ederken aynı zamanda başkalarının da kendi kapsamlarına dahil edebileceği şekilde dışarıya açmış oluruz.
Liste 7-17, Liste 7-11’deki kodda kök modülde yer alan use deyiminin pub use olarak değiştirilmiş hâlini gösterir.
mod restoran_on_kisim {
pub mod karsilama {
pub fn bekleme_listesine_ekle() {}
}
}
pub use crate::restoran_on_kisim::karsilama;
pub fn restoranda_yemek_ye() {
karsilama::bekleme_listesine_ekle();
}
pub use ile bir ismi yeni kapsam üzerinden dışarıdaki tüm kodların kullanımına açmaBu değişiklikten önce harici kodların bekleme_listesine_ekle fonksiyonunu restoran::restoran_on_kisim::karsilama::bekleme_listesine_ekle() yolunu kullanarak çağırması gerekirdi; üstelik bunun çalışabilmesi için restoran_on_kisim modülünün de pub olarak işaretlenmesi şarttı. Kök modüldeki pub use deyimi karsilama modülünü yeniden dışa aktardığından, harici kodlar artık doğrudan restoran::karsilama::bekleme_listesine_ekle() yolunu kullanabilir.
Yeniden dışa aktarma, kodunuzun iç yapısı ile kodunuzu çağıran geliştiricilerin problem alanını (domain) düşünme biçimi birbirinden farklı olduğunda son derece yararlıdır. Örneğin bu restoran benzetmesinde işletmeciler işleri “ön kısım” ve “arka kısım” ayrımıyla düşünür; oysa müşteriler restoranın bölümlerini büyük olasılıkla bu kavramlarla düşünmez. pub use sayesinde kodumuzu içeride bir yapıyla geliştirebilir, dışarıya ise daha yalın ve farklı bir yapıyla sunabiliriz. Böylece kütüphanemiz hem üzerinde çalışan geliştiriciler hem de onu kullanan programcılar için iyi düzenlenmiş olur. Bölüm 14’teki “Kullanışlı Bir Açık API Dışa Aktarmak” başlığında bir başka pub use örneğini ve bunun crate dokümantasyonunu nasıl etkilediğini inceleyeceğiz.
Not: Gerçek dünya Rust kütüphanelerinde yaygın bir mimari pratik, iç modülleri gizli (mod restoran_on_kisim; gibi pub olmadan) tutarak dosyaları mantıksal alt dizinlere ayırmak, dış kullanıcıların ihtiyaç duyacağı ana tür ve modülleri ise src/lib.rs içinde pub use ile düz ve ergonomik bir hiyerarşiyle dışa açmaktır. Bu yaklaşım geriye dönük uyumluluğu da kolaylaştırır: İleride iç modülleri yeniden adlandırdığınızda veya farklı dosyalara taşıdığınızda, src/lib.rs içindeki pub use satırlarını koruduğunuz sürece kütüphanenizi kullanan dış kodlar bu iç yeniden düzenlemeden hiç etkilenmez.
Harici Paketleri Kullanma
Bölüm 2’de rastgele sayılar üretmek için rand adlı harici bir paketi kullanan bir tahmin oyunu projesi geliştirdik. rand paketini projemizde kullanmak için Cargo.toml dosyamıza şu satırı eklemiştik:
rand = "0.10.1"
Cargo.toml dosyasına rand paketini bir bağımlılık olarak eklemek, Cargo’ya rand paketini ve onun ihtiyaç duyduğu bağımlılıkları crates.io adresinden indirip projemiz için kullanılabilir hâle getirmesini söyler.
Ardından rand tanımlarını paketimizin kapsamına dahil etmek için crate’in adı olan rand ile başlayan bir use satırı ekledik ve kapsama dahil etmek istediğimiz öğeleri belirttik. Bölüm 2’deki “Rastgele Bir Sayı Üretmek” kısmında rand::prelude modülündeki öğeleri kapsama dahil ettiğimizi ve rand::rng fonksiyonunu çağırdığımızı hatırlayın:
use std::io;
use rand::prelude::*;
fn main() {
println!("Sayıyı tahmin et!");
let gizli_sayi = rand::rng().random_range(1..=100);
println!("Gizli sayı: {gizli_sayi}");
println!("Lütfen tahmininizi girin.");
let mut tahmin = String::new();
io::stdin()
.read_line(&mut tahmin)
.expect("Satır okunamadı");
println!("Tahmininiz: {tahmin}");
}
Rust topluluğunun üyeleri crates.io adresinde pek çok paketi kullanıma sunmuştur ve bunlardan herhangi birini kendi paketinize dahil etmek aynı adımları gerektirir: Paketi Cargo.toml dosyanızda listelemek ve crate’indeki öğeleri kapsamınıza dahil etmek için use kullanmak.
Standart kütüphane olan std’nin de paketimiz açısından harici bir crate olduğuna dikkat edin. Standart kütüphane Rust diliyle birlikte geldiğinden std’yi dahil etmek için Cargo.toml dosyamızı değiştirmemize gerek yoktur. Ancak oradaki öğeleri paketimizin kapsamına dahil etmek için yine de use ile başvurmamız gerekir. Örneğin HashMap için şu satırı kullanırız:
#![allow(unused)]
fn main() {
use std::collections::HashMap;
}
Bu, standart kütüphane crate’inin adı olan std ile başlayan mutlak bir yoldur.
use Listelerini Sadeleştirmek İçin İç İçe Yollar Kullanma
Aynı crate’te ya da aynı modülde tanımlanmış birden çok öğeyi kullanıyorsak her bir öğeyi ayrı satırda listelemek dosyalarımızda dikey olarak çok yer kaplayabilir. Örneğin Liste 2-4’teki tahmin oyununda yer alan şu iki use deyimi std içinden öğeleri kapsama dahil eder:
// --snip--
use std::cmp::Ordering;
use std::io;
// --snip--
use rand::prelude::*;
fn main() {
println!("Sayıyı tahmin et!");
let gizli_sayi = rand::rng().random_range(1..=100);
println!("Gizli sayı: {gizli_sayi}");
println!("Lütfen tahmininizi girin.");
let mut tahmin = String::new();
io::stdin()
.read_line(&mut tahmin)
.expect("Satır okunamadı");
println!("Tahmininiz: {tahmin}");
let tahmin: u32 = tahmin.trim().parse().expect("Lütfen bir sayı girin!");
match tahmin.cmp(&gizli_sayi) {
Ordering::Less => println!("Çok küçük!"),
Ordering::Greater => println!("Çok büyük!"),
Ordering::Equal => println!("Kazandınız!"),
}
}
Bunun yerine aynı öğeleri tek satırda kapsama dahil etmek için iç içe yollar (nested paths) kullanabiliriz. Bunu Liste 7-18’de gösterildiği gibi yolun ortak kısmını, ardından çift iki nokta üst üste işaretini (::) ve yolların farklılaşan kısımlarını süslü parantezler içinde listeleyerek yaparız.
use rand::prelude::*;
// --snip--
use std::{cmp::Ordering, io};
// --snip--
fn main() {
println!("Sayıyı tahmin et!");
let gizli_sayi = rand::rng().random_range(1..=100);
println!("Gizli sayı: {gizli_sayi}");
println!("Lütfen tahmininizi girin.");
let mut tahmin = String::new();
io::stdin()
.read_line(&mut tahmin)
.expect("Satır okunamadı");
let tahmin: u32 = tahmin.trim().parse().expect("Lütfen bir sayı yazın!");
println!("Tahmininiz: {tahmin}");
match tahmin.cmp(&gizli_sayi) {
Ordering::Less => println!("Çok küçük!"),
Ordering::Greater => println!("Çok büyük!"),
Ordering::Equal => println!("Kazandınız!"),
}
}
Daha büyük programlarda aynı crate’ten ya da modülden pek çok öğeyi iç içe yollarla kapsama dahil etmek, gereken ayrı use deyimlerinin sayısını önemli ölçüde azaltabilir!
İç içe yolu bir yolun herhangi bir seviyesinde kullanabiliriz; bu, alt yollardan birini paylaşan iki use deyimini birleştirirken işe yarar. Örneğin Liste 7-19 iki use deyimi gösterir: Biri std::io modülünü, diğeri ise std::io::Write öğesini kapsama dahil eder.
use std::io;
use std::io::Write;
use deyimiBu iki yolun ortak kısmı std::io’dur ve bu aynı zamanda birinci yolun tamamıdır. Bu iki yolu tek bir use deyiminde birleştirmek için Liste 7-20’de gösterildiği gibi iç içe yol içinde self anahtar kelimesini kullanabiliriz.
use std::io::{self, Write};
use deyiminde birleştirmeBu satır hem std::io modülünü hem de std::io::Write öğesini kapsama dahil eder.
Glob Operatörü ile Öğeleri İçe Aktarma
Bir yolda tanımlı tüm açık öğeleri kapsama dahil etmek istiyorsak o yolun ardından * glob operatörünü yazabiliriz:
#![allow(unused)]
fn main() {
use std::collections::*;
}
Bu use deyimi, std::collections içinde tanımlı tüm açık öğeleri geçerli kapsama dahil eder. Glob operatörünü kullanırken dikkatli olun! Glob kullanımı, hangi isimlerin kapsamda bulunduğunu ve programınızda geçen bir ismin nerede tanımlandığını anlamanızı zorlaştırabilir. Ayrıca kullandığınız bağımlılık kendi tanımlarını değiştirirse içe aktardığınız öğeler de değişir; örneğin bağımlılık, aynı kapsamda sizin tanımladığınız bir öğeyle aynı adı taşıyan yeni bir tanım eklerse bağımlılığı güncellediğinizde derleyici hatalarıyla karşılaşabilirsiniz.
Glob operatörü, test yazarken test edilecek her şeyi tests modülünün içine almak için sıkça kullanılır; bunu Bölüm 11’deki “Nasıl Test Yazılır” kısmında ele alacağız. Glob operatörü bazen prelude (ön tanımlı kapsam) deseninin bir parçası olarak da kullanılır: Bu desen hakkında daha fazla bilgi için standart kütüphane dokümantasyonuna bakabilirsiniz.
Not: Sıradan uygulama kodunda glob (*) operatöründen kaçınılmasının temel nedeni ad alanı kirliliği ve sürüm yükseltmelerinde yaşanabilecek sürpriz isim çakışmalarıdır; buna karşılık prelude ve tests modüllerinde durum farklıdır:
preludedeseni: Bir kütüphaneninpreludemodülü (örneğin yukarıda kullandığımızrand::prelude), kütüphanedeki öğelerin tamamını değil, yazarların glob ile güvenle içe aktarılmak üzere özenle seçip yeniden dışa aktardığı (pub use) az sayıdaki temel trait ve türü barındırır. Özellikle metot çağrılarının çalışabilmesi için kapsama alınması gereken trait’leri (RngExtgibi) kullanıcıya tek satırda (use rand::prelude::*;) sunmak amacıyla tasarlanmıştır.testsmodülü: Birim testleri genellikle test ettikleri kodla aynı dosyanın içindekimod testsalt modülünde yazılır veuse super::*;ile üst modüldeki tüm öğeleri (gizli olanlar dahil) tek seferde kapsama alır. Üst modül zaten sizin yazdığınız ve kontrol ettiğiniz kod olduğundan burada harici bağımlılık kaynaklı beklenmedik çakışma riski yoktur.
Modülleri Farklı Dosyalara Ayırma
Modülleri Farklı Dosyalara Ayırma
Bu bölümdeki tüm örneklerde şimdiye kadar birden fazla modülü tek bir dosyada tanımladık. Modüller büyüdüğünde, kod içinde gezinmeyi kolaylaştırmak amacıyla modül tanımlarını ayrı dosyalara taşımak isteyebilirsiniz.
Örneğin, birden fazla restoran modülü barındıran Liste 7-17’deki koddan başlayalım. Tüm modülleri crate kök dosyasında tanımlamak yerine her birini ayrı dosyalara ayıracağız. Bu örnekte crate kök dosyası src/lib.rs dosyasıdır; ancak aynı yöntem, crate kök dosyası src/main.rs olan ikili crate’lerde de işe yarar.
İlk olarak restoran_on_kisim modülünü kendi dosyasına çıkaracağız. restoran_on_kisim modülünün süslü parantezleri arasındaki kodu kaldırıp geriye yalnızca mod restoran_on_kisim; bildirimini bırakın; böylece src/lib.rs dosyası Liste 7-21’deki hâlini alacaktır. Liste 7-22’deki src/restoran_on_kisim.rs dosyasını oluşturana kadar bu kodun derlenmeyeceğini unutmayın.
mod restoran_on_kisim;
pub use crate::restoran_on_kisim::karsilama;
pub fn restoranda_yemek_ye() {
karsilama::bekleme_listesine_ekle();
}
restoran_on_kisim modülünü bildirmeArdından, daha önce süslü parantezlerin içinde yer alan kodu Liste 7-22’de gösterildiği gibi src/restoran_on_kisim.rs adlı yeni bir dosyaya yerleştirin. Derleyici, crate kökünde restoran_on_kisim adında gövdesiz bir modül bildirimiyle (mod restoran_on_kisim;) karşılaştığı için modülün kodunu bu dosyada araması gerektiğini bilir.
pub mod karsilama {
pub fn bekleme_listesine_ekle() {}
}
restoran_on_kisim modülü içindeki tanımlarModül ağacınızda bir dosyayı yüklemek için mod bildirimini yalnızca bir kez kullanmanız gerektiğine dikkat edin. Derleyici, dosyanın projenin bir parçası olduğunu ve mod deyimini koyduğunuz yere göre kodun modül ağacının neresinde konumlandığını bu tek bildirimle öğrenir. Bundan sonra projenizdeki diğer dosyalar, yüklenen bu dosyanın koduna “Modül Ağacındaki Bir Öğeye Başvurmak İçin Yollar” kısmında ele aldığımız gibi modülün bildirildiği konuma giden bir yol üzerinden başvurmalıdır. Başka bir deyişle mod, diğer programlama dillerinde görmüş olabileceğiniz C/C++ #include veya Python/JavaScript import benzeri bir dosya içe aktarma işlemi değildir.
Not: Rust’a yeni başlayanların en sık yaptığı hatalardan biri mod ile use anahtar kelimelerini birbirine karıştırmaktır:
mod restoran_on_kisim;derleyiciye “Bu noktada yeni bir alt modül oluştur ve gövdesini ilgili.rsdosyasından derle” der. Örneğin src/lib.rs içinde zaten bildirdiğinizrestoran_on_kisimmodülünü src/musteri.rs adlı başka bir dosyada kullanmak isterken yanlışlıkla tekrarmod restoran_on_kisim;yazarsanız derleyici src/musteri/restoran_on_kisim.rs adında yeni bir alt modül dosyası arar veerror[E0583]: file not found for module `restoran_on_kisim`hatası verir (hatta hata mesajının altında modülüuse crate::...ile içe aktarmanız gerektiğini hatırlatır).use crate::restoran_on_kisim;ise yeni bir dosya derlemez; modül ağacına daha öncemodile eklenmiş mevcut bir modüle geçerli kapsamda kısa yoldan erişmenizi sağlar. Benzer şekilde hem src/lib.rs hem src/main.rs içeren bir pakette aynı dosyayı her iki kökte demodile bildirmek yerine modülü yalnızca src/lib.rs içinde bildirmeli, src/main.rs içinden ise kütüphane crate’inin adıyla erişmelisiniz.
Şimdi de karsilama modülünü kendi dosyasına çıkaracağız. Bu süreç biraz farklıdır; çünkü karsilama, kök modülün değil restoran_on_kisim modülünün bir alt modülüdür. karsilama modülünün dosyasını, modül ağacındaki ata modüllerinin adını taşıyacak yeni bir dizine — bu örnekte src/restoran_on_kisim dizinine — yerleştireceğiz.
karsilama modülünü taşımaya başlamak için src/restoran_on_kisim.rs dosyasını yalnızca karsilama modülünün bildirimini içerecek şekilde değiştiriyoruz:
pub mod karsilama;
Ardından src/restoran_on_kisim adında bir dizin ve bu dizinin içinde karsilama modülündeki tanımları barındıracak karsilama.rs dosyasını oluşturuyoruz:
pub fn bekleme_listesine_ekle() {}
Eğer bunun yerine karsilama.rs dosyasını doğrudan src dizinine koysaydık derleyici, karsilama.rs içindeki kodun restoran_on_kisim modülünün bir alt modülü olarak değil, doğrudan crate kökünde bildirilen bir karsilama modülünde yer almasını beklerdi. Derleyicinin hangi modülün kodu için hangi dosyaya bakacağını belirleyen kuralları sayesinde, dizin ve dosya yapısı modül ağacıyla birebir örtüşür.
Alternatif Dosya Yolları
Şimdiye kadar Rust derleyicisinin kullandığı en deyimsel dosya yollarını ele aldık; ancak Rust daha eski bir dosya yolu stilini de destekler. Crate kökünde bildirilen restoran_on_kisim adlı bir modül için derleyici, modülün kodunu şu konumlardan birinde arayacaktır:
- src/restoran_on_kisim.rs (ele aldığımız modern düzen)
- src/restoran_on_kisim/mod.rs (daha eski stil, hâlâ desteklenen yol)
restoran_on_kisim modülünün alt modülü olan karsilama adlı bir modül içinse derleyici, modülün kodunu şu konumlardan birinde arayacaktır:
- src/restoran_on_kisim/karsilama.rs (ele aldığımız modern düzen)
- src/restoran_on_kisim/karsilama/mod.rs (daha eski stil, hâlâ desteklenen yol)
Aynı modül için her iki stili birden kullanırsanız derleyici belirsizlik nedeniyle hata verir (E0761). Aynı projedeki farklı modüller için iki stili karışık kullanmaya izin verilir; ancak bu durum projenizde gezinen kişiler için kafa karıştırıcı olabilir.
mod.rs adlı dosyaları kullanan stilin en büyük dezavantajı, projenizde mod.rs adında pek çok dosyanın birikmesi ve bu dosyalar kod editörünüzde aynı anda açık olduğunda sekmeleri birbirinden ayırt etmenin zorlaşmasıdır.
Her modülün kodunu ayrı bir dosyaya taşıdık ve modül ağacı aynı kaldı. restoranda_yemek_ye içindeki fonksiyon çağrıları, tanımlar artık farklı dosyalarda bulunsa bile hiçbir değişiklik gerektirmeden çalışmaya devam edecektir. Bu teknik, modüller büyüdükçe onları yeni dosyalara taşımanıza olanak tanır.
src/lib.rs içindeki pub use crate::restoran_on_kisim::karsilama deyiminin de değişmediğine ve use anahtar kelimesinin crate’in bir parçası olarak hangi dosyaların derlendiği üzerinde hiçbir etkisi olmadığına dikkat edin. mod anahtar kelimesi modülleri bildirir; Rust da o modülün içeriğini oluşturacak kod için modülle aynı adı taşıyan dosyaya bakar.
Not: Modern dosya düzeninin (src/restoran_on_kisim.rs + src/restoran_on_kisim/karsilama.rs) editör sekmelerinde mod.rs karmaşasını önlemesinin yanı sıra pratik bir geliştirme avantajı daha vardır: Başlangıçta alt modülü olmayan src/restoran_on_kisim.rs dosyasına ileride bir alt modül eklemek istediğinizde mevcut dosyayı taşımanıza veya yeniden adlandırmanıza gerek kalmaz; yanına src/restoran_on_kisim/ dizinini açmanız yeterlidir. Eski mod.rs düzeni ise günümüzde özellikle Bölüm 11’de göreceğimiz tests/ dizinindeki ortak entegrasyon testi yardımcı modüllerinde (tests/ortak/mod.rs) özel bir amaca hizmet ettiği için hâlâ bilmeye değerdir.
Özet
Rust, bir paketi birden fazla crate’e ve bir crate’i birden fazla modüle bölmenize, aynı zamanda bir modülde tanımlanan öğelere başka bir modülden başvurabilmenize olanak tanır. Bunu mutlak veya göreli yollar belirterek yapabilirsiniz. Bu yollar, aynı kapsamda bir öğeyi birden fazla kez kullanırken daha kısa bir isim yazabilmeniz için use deyimiyle kapsama dahil edilebilir. Modül kodu varsayılan olarak gizlidir; ancak pub anahtar kelimesini ekleyerek tanımları dışarıya açık hâle getirebilirsiniz.
Bir sonraki bölümde, standart kütüphanede yer alan ve düzenli biçimde yapılandırdığınız kodunuzda kullanabileceğiniz bazı koleksiyon veri yapılarını inceleyeceğiz.
Yaygın Koleksiyonlar
Rust’ın standart kütüphanesi, koleksiyonlar (collections) adı verilen oldukça kullanışlı pek çok veri yapısı içerir. Diğer veri türlerinin çoğu belirli tek bir değeri temsil ederken, koleksiyonlar birden fazla değeri barındırabilir. Yerleşik dizi ve demet türlerinin aksine, bu koleksiyonların işaret ettiği veriler öbekte (heap) saklanır; bu da veri miktarının derleme zamanında bilinmesi gerekmediği ve program çalışırken büyüyüp küçülebileceği anlamına gelir. Her koleksiyon türünün farklı yetenekleri ve maliyetleri vardır; mevcut durumunuza en uygun olanı seçmek zamanla geliştireceğiniz bir beceridir. Bu bölümde, Rust programlarında çok sık kullanılan üç koleksiyonu ele alacağız:
- Bir vektör (
Vec<T>), değişken sayıda değeri bellekte yan yana saklamanızı sağlar. - Bir string (
String), karakterlerden oluşan bir koleksiyondur. Daha önceStringtüründen bahsetmiştik; ancak bu bölümde onu derinlemesine inceleyeceğiz. - Bir hash map (
HashMap<K, V>), bir değeri belirli bir anahtarla ilişkilendirmenizi sağlar. Bu tür, eşlem (map) adı verilen daha genel bir veri yapısının özel bir uygulamasıdır.
Standart kütüphane tarafından sağlanan diğer koleksiyon türleri hakkında bilgi edinmek için standart kütüphane belgelendirmesine göz atabilirsiniz.
Not: Koleksiyonların asıl elemanları dinamik olarak büyüyüp küçülebilmek için
öbekte tutulurken, Vec<T> veya String değerinin işaretçi, uzunluk ve
kapasite bilgileri sabit boyutlu bir yapı oluşturur. Örneklerimizde yerel
değişken olarak gösterilen bu yapı yığında (stack) düşünülebilir; ancak
başka bir struct’ın alanı olduğunda veya Box içinde tutulduğunda kendisi de
öbekte bulunabilir. Önemli ayrım, koleksiyonun yönetim bilgileri ile
elemanları için ayrılan belleğin farklı olmasıdır. Değişken kapsam dışına
çıktığında ise öbekteki bellek, sahiplik kuralları uyarınca otomatik olarak
temizlenir. Ayrıca çok sık kullanıldıkları için Vec<T> ve String ön
tanımlı kapsamda (prelude) doğrudan hazır gelirken, HashMap ve diğer özel
amaçlı koleksiyonlar std::collections modülünden use ile kapsama dahil
edilir. İhtiyacınıza göre başvurabileceğiniz başlıca diğer standart
koleksiyonlar şunlardır:
VecDeque<T>: Yalnızca sondan değil, hem baştan hem sondan hızlı ekleme ve çıkarma gerektiren çift uçlu kuyruk (double-ended queue) senaryoları için,BTreeMap<K, V>: Anahtarların her zaman sıralı tutulması ve belirli bir aralıktaki anahtarların verimli biçimde sorgulanması gerektiğindeHashMap<K, V>yerine,HashSet<T>veBTreeSet<T>: Yinelenen elemanlara izin vermeyen benzersiz değer kümelerini sırasız (HashSet) veya sıralı (BTreeSet) olarak tutmak ve hızlı üyelik kontrolü yapmak için,BinaryHeap<T>: Elemanlar arasından her zaman en büyük (en öncelikli) değere hızlıca ulaşmak istediğiniz öncelik kuyruğu (priority queue) yapıları için tercih edilir.
Şimdi vektörlerin, string’lerin ve hash map’lerin nasıl oluşturulup güncelleneceğini ve her birini neyin özel kıldığını inceleyeceğiz.
Vektörlerle Değer Listeleri Saklama
Vektörlerle Değer Listeleri Saklama
İnceleyeceğimiz ilk koleksiyon türü, vektör (vector) olarak da bilinen Vec<T> türüdür. Vektörler, tüm değerleri bellekte yan yana yerleştiren tek bir veri yapısı içinde birden fazla değer saklamanızı sağlar. Vektörler yalnızca aynı türdeki değerleri tutabilir. Bir dosyadaki metin satırları veya bir alışveriş sepetindeki ürünlerin fiyatları gibi bir öğe listeniz olduğunda oldukça kullanışlıdırlar.
Yeni Bir Vektör Oluşturma
Yeni ve boş bir vektör oluşturmak için Liste 8-1’de gösterildiği gibi Vec::new fonksiyonunu çağırırız.
fn main() {
let v: Vec<i32> = Vec::new();
}
i32 türünden değerleri tutacak yeni ve boş bir vektör oluşturmakBurada açık bir tür bildirimi eklediğimize dikkat edin. Bu vektöre herhangi bir değer eklemediğimiz için Rust, ne tür elemanlar saklamayı planladığımızı bilemez. Bu önemli bir noktadır. Vektörler jenerikler kullanılarak uygulanmıştır; kendi türlerinizle jenerikleri nasıl kullanacağınızı Bölüm 10’da ele alacağız. Şimdilik standart kütüphanenin sunduğu Vec<T> türünün herhangi bir türü barındırabileceğini bilmeniz yeterlidir. Belirli bir türü tutacak bir vektör oluşturduğumuzda, bu türü açılı parantezler (< >) içinde belirtebiliriz. Liste 8-1’de Rust’a, v değişkenindeki Vec<T> yapısının i32 türünden elemanlar tutacağını söyledik.
Çoğu zaman başlangıç değerlerine sahip bir Vec<T> oluşturursunuz ve Rust saklamak istediğiniz değerin türünü kendiliğinden çıkarır; bu nedenle açık tür bildirimine nadiren ihtiyaç duyarsınız. Rust bu amaçla, verdiğiniz değerleri tutan yeni bir vektör oluşturan kullanışlı vec! makrosunu sunar. Liste 8-2; 1, 2 ve 3 değerlerini tutan yeni bir Vec<i32> oluşturur. Tam sayı türünün i32 olmasının nedeni, Bölüm 3’teki “Veri Türleri” kısmında ele aldığımız üzere varsayılan tam sayı türünün i32 olmasıdır.
fn main() {
let v = vec![1, 2, 3];
}
Başlangıçta i32 değerleri verdiğimiz için Rust, v’nin türünün Vec<i32> olduğunu çıkarabilir ve tür bildirimine gerek kalmaz. Şimdi bir vektörün nasıl değiştirileceğine bakalım.
Bir Vektörü Güncelleme
Bir vektör oluşturup ardından ona elemanlar eklemek için Liste 8-3’te gösterildiği gibi push metodunu kullanabiliriz.
fn main() {
let mut v = Vec::new();
v.push(5);
v.push(6);
v.push(7);
v.push(8);
}
push metodunu kullanmakBölüm 3’te ele aldığımız üzere, her değişkende olduğu gibi değerini değiştirebilmek istiyorsak onu mut anahtar kelimesiyle değiştirilebilir yapmamız gerekir. İçine yerleştirdiğimiz sayıların tamamı i32 türündedir ve Rust bunu veriden çıkarabildiği için Vec<i32> tür bildirimine ihtiyacımız yoktur.
Vektörlerin Elemanlarını Okuma
Bir vektörde saklanan bir değere başvurmanın iki yolu vardır: indeksleme yoluyla veya get metodunu kullanarak. Aşağıdaki örneklerde, daha net anlaşılması adına bu işlemlerden döndürülen değerlerin türlerini açıkça belirttik.
Liste 8-4, indeksleme sözdizimi ve get metodu ile bir vektördeki değere erişmenin her iki yolunu da gösterir.
fn main() {
let v = vec![1, 2, 3, 4, 5];
let ucuncu: &i32 = &v[2];
println!("Üçüncü eleman: {ucuncu}");
let ucuncu: Option<&i32> = v.get(2);
match ucuncu {
Some(ucuncu) => println!("Üçüncü eleman: {ucuncu}"),
None => println!("Üçüncü bir eleman yok."),
}
}
get metodunu kullanmakBuradaki birkaç ayrıntıya dikkat edin. Vektörler sıfırdan başlayarak sayılarla indekslendiği için üçüncü elemanı almak üzere 2 indeks değerini kullanırız. & ve [] kullanmak bize ilgili indeksteki elemana bir referans (&i32) verir. İndeksi argüman olarak geçirerek get metodunu kullandığımızda ise match ile işleyebileceğimiz bir Option<&T> elde ederiz.
Rust, mevcut eleman aralığının dışındaki bir indeks değerini kullanmaya çalıştığınızda programın nasıl davranacağını seçebilmeniz için elemana erişmenin bu iki yolunu sunar. Örnek olarak, beş elemanlı bir vektörümüz olduğunda ve Liste 8-5’te gösterildiği gibi her iki yöntemle indeksi 100 olan elemana erişmeye çalıştığımızda ne olacağına bakalım.
fn main() {
let v = vec![1, 2, 3, 4, 5];
let olmayan_eleman = &v[100];
let olmayan_eleman = v.get(100);
}
Bu kodu çalıştırdığımızda, ilk satırdaki [] yöntemi var olmayan bir elemana başvurduğu için programın panik yapmasına neden olur. Bu yöntem, vektörün sonunu aşan bir elemana erişilmeye çalışıldığında programınızın anında durmasını istiyorsanız en uygun seçenektir.
get metoduna vektör sınırlarının dışında bir indeks geçirildiğinde ise metot panik yapmadan None döndürür. Normal çalışma koşullarında vektör aralığının dışındaki bir elemana erişme ihtimali varsa bu yöntemi tercih edersiniz. Bu durumda kodunuz, Bölüm 6’da ele aldığımız gibi Some(&eleman) veya None olasılıklarının her ikisini de yönetecek mantığa sahip olur. Örneğin indeks değeri kullanıcının klavyeden girdiği bir sayıdan geliyor olabilir. Kullanıcı yanlışlıkla çok büyük bir sayı girerse ve program None değeri alırsa, kullanıcıya mevcut vektörde kaç öğe bulunduğunu bildirip geçerli bir değer girmesi için yeni bir şans tanıyabilirsiniz. Bu yaklaşım, basit bir yazım hatası yüzünden programı çökertmekten çok daha kullanıcı dostudur!
Not: Liste 8-5 çalıştırıldığında &v[100] ifadesi çalışma zamanında sınır kontrolü (bounds check) yapar ve C gibi dillerde görülebilen bellek taşması / tanımsız davranışa izin vermek yerine index out of bounds: the len is 5 but the index is 100 (indeks sınırların dışında: uzunluk 5 ama indeks 100) panik mesajıyla programı güvenli biçimde sonlandırır. Pratik bir seçim ölçütü olarak: indeksin geçerli olması algoritmanızın temel bir ön koşuluysa ve sınır aşımı kodunuzda bir mantık hatası anlamına geliyorsa hatayı anında yakalamak için &v[i] sözdizimini; indeks kullanıcı girdisi, dış veri veya dinamik bir hesaplamadan geliyorsa ve sınır dışında kalması olağan bir durumsa çökme olmadan Option<&T> üzerinden ele almak için v.get(i) metodunu tercih edin.
Program geçerli bir referans elde ettiğinde, ödünç alma denetleyicisi (borrow checker) bu referansın ve vektör içeriğine yönelik diğer tüm referansların geçerli kalmasını güvence altına almak için (Bölüm 4’te incelenen) sahiplik ve ödünç alma kurallarını uygular. Aynı kapsamda hem değiştirilebilir hem de değiştirilemez referanslara sahip olamayacağınızı söyleyen kuralı hatırlayın. Bu kural, bir vektördeki ilk elemana değiştirilemez bir referans (ilk) tutarken vektörün sonuna yeni bir eleman eklemeye çalıştığımız Liste 8-6’da devreye girer. Fonksiyonun devamında bu referansı tekrar kullanmaya çalıştığımız için program derlenmeyecektir.
fn main() {
let mut v = vec![1, 2, 3, 4, 5];
let ilk = &v[0];
v.push(6);
println!("İlk eleman: {ilk}");
}
Bu kodu derlemek aşağıdaki hatayla sonuçlanır:
$ cargo run
Compiling koleksiyonlar v0.1.0 (file:///projects/koleksiyonlar)
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
--> src/main.rs:6:5
|
4 | let ilk = &v[0];
| - immutable borrow occurs here
5 |
6 | v.push(6);
| ^^^^^^^^^ mutable borrow occurs here
7 |
8 | println!("İlk eleman: {ilk}");
| --- immutable borrow later used here
For more information about this error, try `rustc --explain E0502`.
error: could not compile `koleksiyonlar` (bin "koleksiyonlar") due to 1 previous error
Liste 8-6’daki kod ilk bakışta çalışması gerekiyormuş gibi görünebilir: İlk elemana tutulan bir referans, vektörün sonuna yapılan eklemeyi neden umursasın ki? Bu hatanın nedeni vektörlerin bellekte çalışma biçimidir: Vektörler tüm değerleri öbekte (heap) yan yana sakladığından, vektörün sonuna yeni bir eleman eklemek — eğer vektörün şu anda tutulduğu bellek bölgesinde tüm elemanları yan yana sığdıracak yeterli boş kapasite kalmadıysa — öbekte daha büyük yeni bir bellek alanı tahsis etmeyi, eski elemanları bu yeni alana kopyalamayı ve eski bellek alanını serbest bırakmayı gerektirebilir. Böyle bir yeniden tahsis (reallocation) gerçekleştiğinde, ilk elemana tutulan ilk referansı artık serbest bırakılmış geçersiz bir bellek adresine (sarkan referans) işaret ediyor olurdu. Ödünç alma kuralları, programların bu tehlikeli duruma düşmesini derleme zamanında engeller.
Not: Bir Vec<T> değişkeni yığında (stack) işaretçi, uzunluk (len) ve kapasite (capacity) bilgisini tutarken asıl elemanlarını öbekte saklar. Vektöre kaç eleman ekleneceğini önceden biliyor veya yaklaşık olarak tahmin edebiliyorsanız, Vec::new() yerine Vec::with_capacity(n) kullanarak başlangıçta en az n elemanlık öbek alanını tek seferde ayırabilirsiniz. Böylece push çağrıları sırasında kapasite doldukça tekrarlayan öbek yeniden tahsislerini ve eleman kopyalama maliyetini önlemiş olursunuz (mevcut ayrılmış kapasiteyi v.capacity() ile sorgulayabilirsiniz). Vec<T> türünün alt seviye uygulama ayrıntıları hakkında daha fazla bilgi için “The Rustonomicon” kitabına göz atabilirsiniz.
Bir Vektördeki Değerler Üzerinde Yineleme Yapma
Bir vektördeki her bir elemana sırayla erişmek istediğimizde, indeks kullanarak tek tek erişmek yerine tüm elemanlar üzerinde yineleme (iteration) yaparız. Liste 8-7, i32 değerlerinden oluşan bir vektörün her bir elemanına değiştirilemez referanslar almak ve bunları yazdırmak için for döngüsünün nasıl kullanılacağını gösterir.
fn main() {
let v = vec![100, 32, 57];
for i in &v {
println!("{i}");
}
}
for döngüsüyle elemanlar üzerinde yineleme yaparak vektördeki her elemanı yazdırmakTüm elemanlarda değişiklik yapmak amacıyla değiştirilebilir bir vektördeki her elemanın değiştirilebilir referansı üzerinde de yineleme yapabiliriz. Liste 8-8’deki for döngüsü her bir elemana 50 ekleyecektir.
fn main() {
let mut v = vec![100, 32, 57];
for i in &mut v {
*i += 50;
}
}
Değiştirilebilir referansın işaret ettiği değeri değiştirmek için, += operatörünü kullanmadan önce i içindeki değere ulaşmak amacıyla * başvuru çözme operatörünü (dereference operator) kullanmamız gerekir (i değişkeninin türü &mut i32 olduğundan, *i ifadesi referansı takip ederek asıl i32 değerine erişir). Başvuru çözme operatörü hakkında daha ayrıntılı bilgiyi Bölüm 15’teki “Referansı İzleyerek Değere Ulaşmak” kısmında ele alacağız.
Bir vektör üzerinde ister değiştirilemez ister değiştirilebilir şekilde yineleme yapmak, ödünç alma denetleyicisinin kuralları sayesinde tamamen güvenlidir. Liste 8-7 ve Liste 8-8’deki for döngüsü gövdelerinin içinde vektöre öğe eklemeye veya vektörden öğe çıkarmaya çalışsaydık, Liste 8-6’daki kodda aldığımıza benzer bir derleyici hatası alırdık. for döngüsünün vektöre tuttuğu referans, döngü devam ederken vektörün bütününün eşzamanlı olarak değiştirilmesini engeller.
Birden Fazla Türü Saklamak İçin Enum Kullanma
Vektörler yalnızca aynı türdeki değerleri saklayabilir. Bu kısıtlama kimi zaman zahmetli görünebilir; farklı türlerdeki öğelerden oluşan bir listeyi saklamaya ihtiyaç duyduğumuz kullanım senaryoları kesinlikle vardır. Neyse ki bir enum’ın tüm varyantları aynı enum türü altında tanımlanır; dolayısıyla farklı türlerdeki elemanları temsil edecek tek bir türe ihtiyaç duyduğumuzda bir enum tanımlayıp kullanabiliriz!
Örneğin bir elektronik tablodaki bir satırdan değerler almak istediğimizi ve bu satırdaki bazı sütunların tam sayılar, bazılarının kayan noktalı sayılar, bazılarının ise metinler (String) içerdiğini varsayalım. Varyantları bu farklı değer türlerini barındıracak bir enum tanımlayabiliriz; böylece tüm varyantlar aynı tür — yani tanımladığımız enum türü — olarak kabul edilir. Ardından bu enum türünü tutacak bir vektör oluşturarak dolaylı yoldan farklı türlerdeki verileri bir arada saklayabiliriz. Bunu Liste 8-9’da gösterdik.
fn main() {
enum ElektronikTabloHucresi {
Tamsayi(i32),
Ondalikli(f64),
Metin(String),
}
let satir = vec![
ElektronikTabloHucresi::Tamsayi(3),
ElektronikTabloHucresi::Metin(String::from("mavi")),
ElektronikTabloHucresi::Ondalikli(10.12),
];
}
Rust’ın her bir elemanı öbekte saklamak için tam olarak ne kadar bellek gerekeceğini belirleyebilmesi için, vektörde hangi türlerin yer alacağını derleme zamanında bilmesi gerekir. Ayrıca bu vektörde hangi türlere izin verildiğini açıkça belirtmemiz gerekir. Eğer Rust bir vektörün herhangi bir türü barındırmasına izin verseydi, elemanlar üzerinde gerçekleştirilen işlemlerde bu türlerden bir veya birkaçının hataya yol açma riski doğardı. Bir enum ile birlikte match ifadesi kullanmak, Bölüm 6’da ele aldığımız gibi Rust’ın olası her durumun işlendiğini derleme zamanında garanti altına almasını sağlar.
Programınızın çalışma zamanında vektörde saklamak üzere karşılaşacağı tüm türlerin kümesini önceden bilmiyorsanız enum tekniği işinize yaramayacaktır. Bunun yerine, Bölüm 18’de inceleyeceğimiz bir trait nesnesi (trait object) kullanabilirsiniz.
Vektörleri kullanmanın en yaygın yollarından bazılarını ele aldığımıza göre, standart kütüphanenin Vec<T> üzerinde tanımladığı diğer pek çok kullanışlı metodu görmek için API belgelendirmesini mutlaka inceleyin. Örneğin push metoduna ek olarak pop metodu, vektörün son elemanını çıkarıp (Option<T> olarak) döndürür.
Not: Günlük kullanımda en sık başvuracağınız Vec<T> metotlarından bazıları şunlardır: len eleman sayısını, is_empty vektörün boş olup olmadığını döndürür. insert(i, deger) belirtilen indekse eleman ekler, remove(i) ise o indeksteki elemanı çıkarıp döndürür; her ikisi de sonraki elemanları kaydırmak zorunda olduğundan büyük vektörlerde maliyetli olabilir. contains(°er) bir değerin vektörde bulunup bulunmadığını söyler; first ve last sırasıyla ilk ve son elemana Option<&T> olarak erişir; extend başka bir koleksiyonun elemanlarını sona ekler; sort elemanları sıralar. iter, iter_mut ve into_iter metotları ise Bölüm 13’te ayrıntılı inceleyeceğimiz iteratörleri üretir. Ayrıca bir vektörü, string dilimlerinde olduğu gibi &v[1..3] sözdizimiyle dilimleyebilir ve &[T] türünde bir dilim elde edebilirsiniz. Bölüm 4’teki “Dilim (Slice) Türü” başlığında gördüğümüz gibi, parametre olarak &Vec<T> yerine &[T] kabul eden bir fonksiyon hem vektörlerle hem de dizilerle çalışabildiğinden daha esnektir.
Bir Vektörü Düşürmek Elemanlarını da Düşürür
Diğer herhangi bir struct gibi bir vektör de Liste 8-10’da gösterildiği üzere kapsam dışına çıktığında bellekten serbest bırakılır.
fn main() {
{
let v = vec![1, 2, 3, 4];
// v ile bir şeyler yapın
} // <- v kapsam dışına çıkar ve burada serbest bırakılır
}
Vektör düşürüldüğünde tüm içeriği de düşürülür; yani barındırdığı tam sayılar (veya String gibi kendisi de öbek belleğine sahip karmaşık öğeler varsa bunların öbek verileri) bellekten temizlenir. Ödünç alma denetleyicisi, bir vektörün içeriğine yapılan herhangi bir referansın yalnızca vektörün kendisi geçerliyken kullanılmasını güvence altına alır.
Şimdi bir sonraki koleksiyon türüne geçelim: String!
String'lerle UTF-8 Kodlanmış Metin Saklama
String’lerle UTF-8 Kodlanmış Metin Saklama
Bölüm 4’te string’lerden bahsetmiştik; şimdi onları daha derinlemesine inceleyeceğiz. Rust’a yeni başlayanlar genellikle üç nedenin bir araya gelmesi yüzünden string’lerde takılır: Rust’ın olası hataları erkenden açığa çıkarma eğilimi, string’lerin pek çok programcının sandığından çok daha karmaşık bir veri yapısı olması ve UTF-8 kodlaması. Başka programlama dillerinden geliyorsanız bu etkenlerin birleşimi ilk başta zor görünebilir.
String’leri koleksiyonlar bağlamında ele alıyoruz; çünkü string’ler bir bayt koleksiyonu ve bu baytlar metin olarak yorumlandığında faydalı işlevler sunan metotlar bütünü olarak uygulanmıştır. Bu bölümde, her koleksiyon türünde bulunan oluşturma, güncelleme ve okuma gibi String üzerindeki temel işlemleri inceleyeceğiz. Ayrıca String’in diğer koleksiyonlardan ayrıldığı yönleri, özellikle de insanların ve bilgisayarların String verisini yorumlama biçimleri arasındaki farklar yüzünden bir String içine indeksleme yapmanın neden karmaşık olduğunu ele alacağız.
String’leri Tanımlama
Öncelikle string terimiyle ne kastettiğimizi tanımlayalım. Rust’ın çekirdek dilinde yalnızca tek bir string türü vardır: genellikle ödünç alınmış biçimi olan &str şeklinde gördüğümüz str string dilimi. Bölüm 4’te, başka bir yerde saklanan UTF-8 kodlu metin verilerine referans veren string dilimlerinden bahsetmiştik. Örneğin string sabitleri (string literals) programın ikili dosyasında saklanır ve bu nedenle birer string dilimidir.
Çekirdek dile yerleşik olmak yerine Rust’ın standart kütüphanesi tarafından sağlanan String türü ise büyüyebilen, değiştirilebilir, sahipli (owned) ve UTF-8 kodlu bir string türüdür (verisini öbekte, yani heap üzerinde tutar). Rust geliştiricileri Rust’taki “string’lerden” söz ettiğinde, bu türlerden yalnızca birini değil, hem String hem de string dilimi &str türlerini kastediyor olabilir. Bu bölüm büyük ölçüde String üzerine odaklanacak olsa da her iki tür de Rust’ın standart kütüphanesinde yoğun biçimde kullanılır ve hem String hem de string dilimleri UTF-8 kodludur.
Yeni Bir String Oluşturma
Vec<T> ile kullanılabilen işlemlerin birçoğu String için de geçerlidir; çünkü String, ek birtakım güvenceler, kısıtlamalar ve yeteneklerle donatılmış bir bayt vektörü sarmalayıcısı olarak uygulanmıştır. Vec<T> ve String ile aynı şekilde çalışan fonksiyonlara güzel bir örnek, Liste 8-11’de gösterildiği gibi yeni bir örnek oluşturmak için kullanılan new fonksiyonudur.
fn main() {
let mut s = String::new();
}
String oluşturmaBu satır, daha sonra içine veri yükleyebileceğimiz s adında yeni ve boş bir string oluşturur. Çoğu zaman string’i başlatmak istediğimiz bir başlangıç verimiz olur. Bunun için, string sabitlerinin de uyguladığı Display trait’ini uygulayan her tür üzerinde kullanılabilen to_string metodundan yararlanırız. Liste 8-12 iki örnek göstermektedir.
fn main() {
let veri = "başlangıç içeriği";
let s = veri.to_string();
// Bu metot doğrudan bir string sabiti üzerinde de çalışır:
let s = "başlangıç içeriği".to_string();
}
String oluşturmak için to_string metodunu kullanmaBu kod, başlangıç içeriği metnini barındıran bir string oluşturur.
Bir string sabitinden String oluşturmak için String::from fonksiyonunu da kullanabiliriz. Liste 8-13’teki kod, to_string kullanan Liste 8-12’deki kodla tamamen eşdeğerdir.
fn main() {
let s = String::from("başlangıç içeriği");
}
String oluşturmak için String::from fonksiyonunu kullanmaString’ler çok çeşitli amaçlarla kullanıldığından, string’lerle çalışmak için bize pek çok seçenek sunan farklı jenerik API’ler mevcuttur. Bunların bazıları ilk bakışta fazlalık gibi görünebilir, ancak hepsinin kendine özgü bir kullanım yeri vardır! Bu örnekte String::from ve to_string birebir aynı işi yapar; hangisini seçeceğiniz tamamen stil ve okunabilirlik tercihinize bağlıdır.
Not: String ile &str arasında gidip gelmek Rust’ta çok sık yapılan bir iştir; aşağıdaki dönüşümleri bilmek pek çok derleyici hatasını hızla çözmenizi sağlar. Bir string sabitinden ya da &str değerinden sahipli bir String elde etmek için String::from("merhaba"), "merhaba".to_string() ve "merhaba".to_owned() çağrılarının hepsi aynı sonucu verir. Ters yönde, elinizdeki bir String değerini &str bekleyen bir fonksiyona vermek için yeni bir kopya oluşturmanıza gerek yoktur: &s (Bölüm 15’te ele alacağımız deref zorlaması sayesinde), s.as_str() ve &s[..] ifadelerinin her biri String içindeki veriye ödünç alınmış bir &str görünümü sunar. Bu nedenle kendi fonksiyonlarınızda parametre türü olarak &String yerine &str kullanmak iyi bir alışkanlıktır: Bölüm 4’te gördüğümüz gibi böyle bir fonksiyon hem String değerleriyle hem de string sabitleriyle çağrılabilir.
String’lerin UTF-8 kodlu olduğunu unutmayın; dolayısıyla Liste 8-14’te gösterildiği gibi uygun biçimde kodlanmış her türlü metin verisini içlerinde saklayabiliriz.
fn main() {
let merhaba = String::from("السلام عليكم");
let merhaba = String::from("Dobrý den");
let merhaba = String::from("Hello");
let merhaba = String::from("שלום");
let merhaba = String::from("नमस्ते");
let merhaba = String::from("こんにちは");
let merhaba = String::from("안녕하세요");
let merhaba = String::from("你好");
let merhaba = String::from("Olá");
let merhaba = String::from("Здравствуйте");
let merhaba = String::from("Hola");
}
Bunların tamamı geçerli String değerleridir.
Bir String’i Güncelleme
Bir String’in boyutu büyüyebilir ve tıpkı Vec<T> türünde olduğu gibi, içine daha fazla veri ekledikçe içeriği değişebilir. Ek olarak, String değerlerini birleştirmek için + operatörünü veya format! makrosunu rahatlıkla kullanabilirsiniz.
push_str veya push ile Ekleme Yapma
Liste 8-15’te gösterildiği gibi, bir string dilimi eklemek üzere push_str metodunu kullanarak bir String’i büyütebiliriz.
fn main() {
let mut s = String::from("foo");
s.push_str("bar");
}
push_str metoduyla bir String’e string dilimi eklemeBu iki satırdan sonra s değişkeni foobar değerini barındıracaktır. push_str metodu bir string dilimi alır; çünkü eklediğimiz parametrenin sahipliğini almak istemeyebiliriz. Örneğin Liste 8-16’daki kodda, s2’nin içeriğini s1’e ekledikten sonra da s2’yi kullanabilmek istiyoruz.
fn main() {
let mut s1 = String::from("foo");
let s2 = "bar";
s1.push_str(s2);
println!("s2 değeri: {s2}");
}
String’e ekledikten sonra string dilimini kullanmaya devam etmeEğer push_str metodu s2’nin sahipliğini alsaydı, son satırda değerini yazdıramazdık. Oysa bu kod tam beklediğimiz gibi çalışır!
push metodu ise parametre olarak tek bir karakter (char) alır ve onu String’in sonuna ekler. Liste 8-17, push metodunu kullanarak bir String’e l harfini eklemektedir.
fn main() {
let mut s = String::from("lo");
s.push('l');
}
push kullanarak bir String değerine tek bir karakter eklemeBunun sonucunda s değişkeni lol değerini içerecektir.
+ veya format! ile Birleştirme
Çoğu zaman mevcut iki string’i birleştirmek istersiniz. Bunu yapmanın yollarından biri, Liste 8-18’de gösterildiği gibi + operatörünü kullanmaktır.
fn main() {
let s1 = String::from("Merhaba, ");
let s2 = String::from("dünya!");
let s3 = s1 + &s2; // not: s1 buraya taşındı ve artık kullanılamaz
}
String değerini yeni bir String değerinde birleştirmek için + operatörünü kullanmas3 string’i Merhaba, dünya! metnini barındıracaktır. Birleştirme işleminden sonra s1’in neden artık geçerli olmadığını ve s2 için neden bir referans kullandığımızı, + operatörünü kullandığımızda çağrılan metodun imzası açıklar. + operatörü, imzası şuna benzeyen add metodunu çağırır:
fn add(self, s: &str) -> String {
Standart kütüphanede add metodunun jenerikler ve ilişkili türler kullanılarak tanımlandığını görürsünüz. Burada jeneriklerin yerine, bu metodu String değerleriyle çağırdığımızda ortaya çıkan somut türleri yazdık. Jenerikleri Bölüm 10’da ele alacağız. Bu imza, + operatörünün incelikli kısımlarını anlamamız için gereken ipuçlarını verir.
İlk olarak, s2’nin başında & vardır; yani ilk string’e ikinci string’in bir referansını ekliyoruz. Bunun nedeni add fonksiyonundaki s parametresidir: Bir String’e yalnızca bir string dilimi (&str) ekleyebiliriz; iki String değerini doğrudan birbirine ekleyemeyiz. Ama bir dakika — &s2 ifadesinin türü, add’in ikinci parametresinde belirtildiği gibi &str değil, &String türüdür. Öyleyse Liste 8-18 neden derleniyor?
add çağrısında &s2’yi kullanabilmemizin nedeni, derleyicinin &String argümanını &str türüne zorlayabilmesidir. add metodunu çağırdığımızda Rust, burada &s2 ifadesini &s2[..] dilimine dönüştüren bir deref zorlaması (deref coercion) uygular. Deref zorlamasını Bölüm 15’te daha derinlemesine inceleyeceğiz. add metodu s parametresinin sahipliğini almadığı için s2, bu işlemden sonra da geçerli bir String olarak kalır.
İkinci olarak, imzada self parametresinin başında & bulunmadığı için add metodunun self’in sahipliğini aldığını görebiliriz. Bu durum, Liste 8-18’deki s1 değişkeninin add çağrısının içine taşınacağı ve sonrasında artık geçerli olmayacağı anlamına gelir. Dolayısıyla let s3 = s1 + &s2; deyimi her iki string’i kopyalayıp yeni bir string oluşturacakmış gibi görünse de aslında s1’in sahipliğini alır, s2’nin içeriğinin bir kopyasını onun sonuna ekler ve ardından sonuç değerinin sahipliğini döndürür. Başka bir deyişle, çok sayıda kopyalama yapıyormuş gibi görünse de yapmaz; bu uygulama kopyalamaktan çok daha verimlidir.
Birden fazla string’i birleştirmemiz gerektiğinde + operatörünün kullanımı hantal bir hâl alır:
fn main() {
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let s = s1 + "-" + &s2 + "-" + &s3;
}
Bu noktada s değişkeninin değeri tic-tac-toe olur. Ancak bunca + ve " karakteri arasında ne olup bittiğini anlamak güçtür. String’leri daha karmaşık biçimlerde birleştirmek için bunun yerine format! makrosunu kullanabiliriz:
fn main() {
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let s = format!("{s1}-{s2}-{s3}");
}
Bu kod da s değişkenini tic-tac-toe olarak ayarlar. format! makrosu tıpkı println! gibi çalışır; ancak çıktıyı ekrana yazdırmak yerine içeriği barındıran yeni bir String döndürür. format! kullanan sürüm çok daha kolay okunur; üstelik format! makrosunun ürettiği kod referansları kullandığından parametrelerinin hiçbirinin sahipliğini almaz.
Not: + operatörü (fn add(self, s: &str) -> String) ile format! makrosunun bellek ve sahiplik davranışını şöyle özetleyebiliriz: let s3 = s1 + &s2; deyiminde sol taraftaki s1 (self) sahipliğiyle birlikte add metoduna taşınır; yığındaki (stack) işaretçi, uzunluk ve kapasite bilgisi devralınarak öbekteki mevcut bellek tamponu yeniden kullanılır (kapasite yetmezse tampon büyütülür) ve s1 işlemden sonra geçersiz hâle gelir. Sağ taraftaki &s2 ise deref zorlaması sayesinde &str dilimine (&s2[..]) dönüştürülerek yalnızca ödünç alınır; böylece s2 geçerliliğini korur. Buna karşılık format! makrosu argümanların tamamını referansla okur, hiçbirinin sahipliğini tüketmez ve sonucu öbekte ayrılmış yeni bir String olarak döndürür.
String’leri İndeksleme
Diğer pek çok programlama dilinde, bir string içindeki tekil karakterlere indeks numarasıyla başvurarak erişmek geçerli ve yaygın bir işlemdir. Ancak Rust’ta indeksleme sözdizimini kullanarak bir String’in parçalarına erişmeye çalışırsanız derleme hatası alırsınız. Liste 8-19’daki geçersiz kodu inceleyelim.
fn main() {
let s1 = String::from("merhaba");
let m = s1[0];
}
String üzerinde indeksleme sözdizimini kullanmaya çalışmaBu kod aşağıdaki hatayla sonuçlanacaktır:
$ cargo run
Compiling koleksiyonlar v0.1.0 (file:///projects/koleksiyonlar)
error[E0277]: the type `str` cannot be indexed by `{integer}`
--> src/main.rs:3:16
|
3 | let m = s1[0];
| ^ string indices are ranges of `usize`
|
= help: the trait `SliceIndex<str>` is not implemented for `{integer}`
= note: you can use `.chars().nth()` or `.bytes().nth()`
for more information, see chapter 8 in The Book: <https://doc.rust-lang.org/book/ch08-02-strings.html#indexing-into-strings>
help: `usize` implements trait `SliceIndex<T>`
--> /rustc/58cf8f9c35b355199727627d07ae34a4e2846d67/library/core/src/slice/index.rs:179:0
|
= note: `SliceIndex<[T]>`
--> /rustc/58cf8f9c35b355199727627d07ae34a4e2846d67/library/core/src/bstr/traits.rs:197:0
|
= note: `SliceIndex<ByteStr>`
= note: required for `String` to implement `Index<{integer}>`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `koleksiyonlar` (bin "koleksiyonlar") due to 1 previous error
Hata mesajı durumu açıkça özetliyor: Rust string’leri tam sayıyla indekslemeyi desteklemez (the type `str` cannot be indexed by `{integer}` — derleyici bunun yerine .chars().nth() veya .bytes().nth() kullanılabileceğini belirtir). Peki ama neden? Bu soruyu yanıtlamak için Rust’ın string’leri bellekte nasıl sakladığını incelememiz gerekir.
İç Temsil
Bir String, bir Vec<u8> üzerindeki sarmalayıcıdır. Liste 8-14’teki uygun biçimde kodlanmış UTF-8 örnek string’lerimizden bazılarına bakalım. İlk olarak şunu ele alalım:
fn main() {
let merhaba = String::from("السلام عليكم");
let merhaba = String::from("Dobrý den");
let merhaba = String::from("Hello");
let merhaba = String::from("שלום");
let merhaba = String::from("नमस्ते");
let merhaba = String::from("こんにちは");
let merhaba = String::from("안녕하세요");
let merhaba = String::from("你好");
let merhaba = String::from("Olá");
let merhaba = String::from("Здравствуйте");
let merhaba = String::from("Hola");
}
Bu durumda uzunluk (merhaba.len()) 4 olacaktır; yani "Hola" string’ini saklayan vektör 4 bayt uzunluğundadır. UTF-8 ile kodlandığında bu harflerin her biri 1 bayt yer kaplar. Ancak aşağıdaki satır sizi şaşırtabilir (bu string’in 3 rakamıyla değil, büyük Kiril harfi Ze (З) ile başladığına dikkat edin):
fn main() {
let merhaba = String::from("السلام عليكم");
let merhaba = String::from("Dobrý den");
let merhaba = String::from("Hello");
let merhaba = String::from("שלום");
let merhaba = String::from("नमस्ते");
let merhaba = String::from("こんにちは");
let merhaba = String::from("안녕하세요");
let merhaba = String::from("你好");
let merhaba = String::from("Olá");
let merhaba = String::from("Здравствуйте");
let merhaba = String::from("Hola");
}
Bu string’in ne kadar uzun olduğu sorulsaydı 12 diyebilirdiniz. Oysa Rust’ın yanıtı 24’tür: "Здравствуйте" metnini UTF-8 ile kodlamak için gereken bayt sayısı budur; çünkü bu string’deki her bir Unicode skaler değeri bellekte 2 bayt yer kaplar. Dolayısıyla string’in baytları üzerindeki bir indeks her zaman geçerli bir Unicode skaler değerine karşılık gelmez. Bunu görmek için şu geçersiz Rust kodunu düşünün:
let merhaba = "Здравствуйте";
let cevap = &merhaba[0];
cevap değişkeninin ilk harf olan З olmayacağını artık biliyorsunuz. UTF-8 ile kodlandığında З harfinin ilk baytı 208, ikincisi ise 151’dir; dolayısıyla cevap değerinin 208 olması gerekirmiş gibi görünür, ancak 208 tek başına geçerli bir karakter değildir. Bu string’in ilk harfini isteyen bir kullanıcı büyük olasılıkla 208 sayısının dönmesini istemeyecektir; fakat Rust’ın bayt indeksi 0’da sahip olduğu tek veri budur. String yalnızca Latin harfleri içerse bile kullanıcılar genellikle ham bayt değerinin dönmesini istemez: &"hi"[0] bayt değerini döndüren geçerli bir kod olsaydı 'h' değil, 104 döndürürdü.
Sonuç olarak Rust, beklenmedik bir değer döndürmekten ve hemen fark edilemeyecek hatalara yol açmaktan kaçınmak için bu kodu hiç derlemez ve olası yanlış anlaşılmaları geliştirme sürecinin henüz başında engeller.
Baytlar, Skaler Değerler ve Grafem Kümeleri
UTF-8 ile ilgili bir diğer önemli nokta da Rust açısından string’leri ele almanın aslında üç farklı yolu olmasıdır: baytlar (bytes), skaler değerler (scalar values) ve grafem kümeleri (grapheme clusters) — yani bizim günlük dilde harf dediğimiz şeye en yakın birimler.
Devanagari alfabesiyle yazılmış Hintçe “नमस्ते” kelimesine bakarsak, bu kelime bellekte şu şekilde bir u8 değerleri vektörü olarak saklanır:
[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]
Bu 18 bayttır ve bilgisayarların bu veriyi nihayetinde bellekte saklama biçimidir. Aynı veriye Rust’ın char türünün karşılığı olan Unicode skaler değerleri olarak bakarsak veri şöyle görünür:
['न', 'म', 'स', '्', 'त', 'े']
Burada altı adet char değeri vardır; ancak dördüncü ve altıncı öğeler tek başına harf değildir: Bunlar kendi başlarına anlam taşımayan birleştirici ses/aksan işaretleridir (diacritics). Son olarak, aynı veriye grafem kümeleri olarak bakarsak bir insanın bu Hintçe kelimeyi oluşturan dört harf diye adlandıracağı birimleri elde ederiz:
["न", "म", "स्", "ते"]
Rust, metin hangi insan dilinde olursa olsun her programın kendi ihtiyacına uygun yorumlama biçimini seçebilmesi için bilgisayarların sakladığı ham string verisini yorumlamanın farklı yollarını sunar.
Rust’ın bir karakter elde etmek amacıyla String içine indeksleme yapmamıza izin vermemesinin son bir nedeni daha vardır: İndeksleme işlemlerinin her zaman sabit zaman (O(1)) alması beklenir. Ancak UTF-8 kodlu bir String ile bu performansı garanti etmek mümkün değildir; çünkü Rust’ın kaç adet geçerli karakter bulunduğunu belirlemek için metnin başından ilgili indekse kadar tüm baytları taraması gerekirdi.
String’leri Dilimleme
String indeksleme işleminin dönüş türünün ne olması gerektiği — bir bayt değeri mi, bir karakter mi, bir grafem kümesi mi yoksa bir string dilimi mi — net olmadığından bir string içine doğrudan indeksleme yapmak genellikle kötü bir fikirdir. Bu nedenle, string dilimleri oluşturmak için gerçekten indeks kullanmanız gerekiyorsa Rust sizden daha açık olmanızı ister.
[] operatörünü tek bir sayıyla kullanmak yerine, belirli baytları içeren bir string dilimi oluşturmak için [] operatörünü bir aralık ile kullanabilirsiniz:
#![allow(unused)]
fn main() {
let merhaba = "Здравствуйте";
let s = &merhaba[0..4];
}
Burada s, string’in ilk 4 baytını barındıran bir &str olacaktır. Bu karakterlerin her birinin 2 bayt olduğundan daha önce bahsetmiştik; dolayısıyla s diliminin değeri Зд olur.
Bir karakterin baytlarının yalnızca bir kısmını &merhaba[0..1] gibi bir ifadeyle dilimlemeye çalışsaydık, tıpkı bir vektörde geçersiz bir indekse erişildiğinde olduğu gibi Rust çalışma zamanında panik üretirdi:
$ cargo run
Compiling koleksiyonlar v0.1.0 (file:///projects/koleksiyonlar)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
Running `target/debug/koleksiyonlar`
thread 'main' (6017738) panicked at src/main.rs:4:21:
end byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2 of string)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Panik mesajında da görüldüğü üzere, bitiş bayt indeksi 1 geçerli bir karakter sınırı değildir ve 'З' karakterinin baytlarının (0..2) ortasına denk gelir. Bu nedenle aralık kullanarak string dilimleri oluştururken dikkatli olmalısınız; aksi hâlde programınızın çökmesine yol açabilirsiniz. Vektörlerdeki get metodunda olduğu gibi, panik yapmayan bir alternatif olarak merhaba.get(0..1) çağrısını kullanabilirsiniz: Bu metot, aralık string sınırları içindeyse, başlangıcı bitişinden büyük değilse ve iki uç da UTF-8 karakter sınırına denk geliyorsa dilimi Some içinde döndürür; aksi hâlde None döndürür.
String’ler Üzerinde Yineleme Yapma
String parçaları üzerinde çalışmanın en doğru yolu, karakterleri mi yoksa baytları mı istediğinizi açıkça belirtmektir. Tekil Unicode skaler değerleri için chars metodunu kullanın. "Зд" üzerinde chars metodunu çağırmak metni ayırarak char türünde iki değer üretir; her bir elemana erişmek için sonuç üzerinde yineleme yapabilirsiniz:
#![allow(unused)]
fn main() {
for c in "Зд".chars() {
println!("{c}");
}
}
Bu kod ekrana şunu yazdıracaktır:
З
д
Alternatif olarak bytes metodu her bir ham baytı döndürür; üzerinde çalıştığınız alana göre bu yöntem daha uygun olabilir:
#![allow(unused)]
fn main() {
for b in "Зд".bytes() {
println!("{b}");
}
}
Bu kod, bu string’i oluşturan 4 baytı yazdıracaktır:
208
151
208
180
Ancak geçerli Unicode skaler değerlerinin 1 bayttan daha fazlasından oluşabileceğini her zaman aklınızda bulundurun.
Devanagari alfabesinde olduğu gibi string’lerden grafem kümeleri elde etmek karmaşık bir işlemdir; bu nedenle bu işlevsellik standart kütüphane tarafından sunulmaz. İhtiyacınız olan işlev buysa crates.io üzerinde bu amaca yönelik crate’ler (örneğin unicode-segmentation) bulabilirsiniz.
Not: UTF-8 kodlamasında ASCII harfler 1 bayt yer kaplarken Türkçeye özgü
'ç', 'ğ', 'ı', 'ö', 'ş', 'ü' ve 'İ' harflerinin her biri (büyük
harf biçimleri dahil) bellekte 2 bayt yer kaplar. Örneğin 3 harfli "çay"
metni için "çay".chars().count() çağrısı 3 ('ç', 'a', 'y')
döndürürken, "çay".len() çağrısı [195, 167, 97, 121] baytlarına karşılık
gelen 4 değerini döndürür. Benzer biçimde 5 harfli "göğüs" bellekte 8
bayt, 8 harfli "İstanbul" ise 'İ' harfi 2 bayt olduğu için 9 bayt
uzunluğundadır. Dolayısıyla &"İstanbul"[0..1] ifadesi 'İ' karakterinin iki
baytını ortadan böldüğü için çalışma zamanında panik üretir; ilk harfi güvenle
dilimlemek için geçerli karakter sınırı olan &"İstanbul"[0..2] aralığı
kullanılmalıdır.
Büyük-küçük harf dönüşümlerinde de dikkatli olun: Standart kütüphanedeki to_lowercase ve to_uppercase metotları dilden bağımsız Unicode kurallarını uygular. Örneğin 'İ'.to_lowercase() çağrısı tek bir char değil, 'i' ve birleştirici üst nokta '\u{307}' olmak üzere iki Unicode skaler değeri (tek bir grafem kümesi) üreten bir iteratör döndürür; "I".to_lowercase() ise Türkçede beklendiği gibi "ı" değil, "i" üretir. Türkçe dil kurallarına duyarlı ('İ' -> 'i', 'I' -> 'ı') büyük-küçük harf dönüşümü gerektiğinde icu_casemap gibi yerelleştirme destekli crate’leri tercih edebilirsiniz.
String’lerin Karmaşıklıklarıyla Başa Çıkma
Özetlemek gerekirse string’ler karmaşıktır. Farklı programlama dilleri, bu karmaşıklığı programcıya nasıl yansıtacakları konusunda farklı tercihler yapar. Rust, String verisinin doğru işlenmesini tüm Rust programları için varsayılan davranış hâline getirmeyi seçmiştir; bu da programcıların UTF-8 verisini işleme konusunu en baştan düşünmesi gerektiği anlamına gelir. Bu ödünleşim, string’lerin karmaşıklığını diğer programlama dillerinde görünenden daha fazla göz önüne serer; ancak geliştirme yaşam döngünüzün ilerleyen aşamalarında ASCII dışı karakterlerden kaynaklanan hatalarla uğraşmak zorunda kalmanızı önler.
İyi haber şu ki standart kütüphane, bu karmaşık durumları doğru biçimde ele almanıza yardımcı olmak üzere String ve &str türleri üzerine inşa edilmiş pek çok işlev sunar. Bir string içinde arama yapmak için contains ve bir string’in belirli kısımlarını başka bir string ile değiştirmek için replace gibi kullanışlı metotlar için standart kütüphane belgelerine göz atmayı unutmayın.
Şimdi biraz daha az karmaşık bir konuya geçelim: hash map’ler!
Hash Map'lerde Anahtarları ve İlişkili Değerleri Saklama
Hash Map’lerde Anahtarları ve İlişkili Değerleri Saklama
Yaygın koleksiyonlarımızın sonuncusu hash map’tir (karma eşlem). HashMap<K, V> türü, K türündeki anahtarların V türündeki değerlerle eşleşmesini bir karma (özetleme) fonksiyonu (hashing function) kullanarak saklar; bu fonksiyon, söz konusu anahtarların ve değerlerin belleğe nasıl yerleştirileceğini belirler. Pek çok programlama dili bu tür bir veri yapısını destekler ancak çoğu zaman hash, map (eşlem/harita), object (nesne), hash table (karma tablosu), dictionary (sözlük) veya associative array (ilişkisel dizi) gibi farklı adlar kullanır.
Hash map’ler, verilere vektörlerde olduğu gibi sayısal bir indeks kullanarak değil de herhangi bir türde olabilen bir anahtar üzerinden ulaşmak istediğinizde oldukça kullanışlıdır. Örneğin bir oyunda her takımın skorunu, anahtarların takım adları ve değerlerin ise her takımın skoru olduğu bir hash map’te tutabilirsiniz. Bir takımın adı verildiğinde, o takımın skoruna doğrudan ulaşabilirsiniz.
Bu bölümde hash map’lerin temel API’sini inceleyeceğiz; ancak standart kütüphanenin HashMap<K, V> üzerinde tanımladığı fonksiyonlar arasında çok daha fazla kullanışlı özellik gizlidir. Her zaman olduğu gibi, daha fazla bilgi için standart kütüphane belgelendirmesine göz atabilirsiniz.
Yeni Bir Hash Map Oluşturma
Boş bir hash map oluşturmanın bir yolu new fonksiyonunu kullanmak ve ardından insert metoduyla eleman eklemektir. Liste 8-20’de adları Mavi ve Sarı olan iki takımın skorlarını takip ediyoruz. Mavi takım 10 puanla, Sarı takım ise 50 puanla oyuna başlar.
fn main() {
use std::collections::HashMap;
let mut skorlar = HashMap::new();
skorlar.insert(String::from("Mavi"), 10);
skorlar.insert(String::from("Sarı"), 50);
}
Öncelikle standart kütüphanenin koleksiyonlar modülünden HashMap türünü use ile kapsama dahil etmemiz gerektiğine dikkat edin. Üç yaygın koleksiyonumuz arasında en az sıklıkla kullanılanı bu olduğu için, ön tanımlı kapsamda (prelude) otomatik olarak kapsama dahil edilen öğeler arasında yer almaz. Hash map’ler standart kütüphaneden daha az yerleşik destek alır; örneğin vektörlerdeki vec! gibi bunları oluşturmaya yarayan yerleşik bir makro yoktur.
Tıpkı vektörler gibi hash map’ler de asıl verilerini öbekte (heap) saklar (HashMap değerinin tabloyu yöneten sabit boyutlu kısmı ise örneklerimizdeki yerel değişkenin içinde bulunur). Bu örnekteki HashMap, String türünde anahtarlara ve i32 türünde değerlere sahiptir. Vektörler gibi hash map’ler de türdeştir (homojendir): tüm anahtarlar kendi içinde aynı türde ve tüm değerler de kendi içinde aynı türde olmalıdır.
Not: Bir türün hash map anahtarı olarak kullanılabilmesi için Eq ve Hash
trait’lerini uygulaması gerekir; String, &str, tam sayı türleri, bool,
char ve yalnızca bu türlerden oluşan demetler bu koşulu hazır olarak sağlar.
Kayan noktalı sayılar (f32, f64) ise NaN değeri kendisine bile eşit
olmadığından Eq trait’ini uygulamaz ve doğrudan anahtar olarak kullanılamaz.
Kendi struct veya enum türlerinizin bütün alanları gerekli trait’leri
uyguluyorsa, Ek C’de ele alınan #[derive(PartialEq, Eq, Hash)] özniteliğiyle
bu trait’leri türetebilirsiniz. Ayrıca anahtarları String olan bir hash
map’te arama yaparken yeni bir String oluşturmanıza gerek yoktur:
skorlar.get("Mavi") biçiminde doğrudan bir string sabiti (&str)
verebilirsiniz; get, remove ve contains_key gibi metotlar, Borrow
trait’i sayesinde &String yerine &str türünde argümanları da kabul eder.
Bir Hash Map’teki Değerlere Erişme
Liste 8-21’de gösterildiği gibi anahtarını get metoduna vererek hash map’ten bir değer alabiliriz.
fn main() {
use std::collections::HashMap;
let mut skorlar = HashMap::new();
skorlar.insert(String::from("Mavi"), 10);
skorlar.insert(String::from("Sarı"), 50);
let takim_adi = String::from("Mavi");
let skor = skorlar.get(&takim_adi).copied().unwrap_or(0);
}
Burada skor değişkeni Mavi takımla ilişkili değeri alacak ve sonuç 10 olacaktır. get metodu bir Option<&V> döndürür; hash map’te o anahtara karşılık gelen bir değer yoksa get metodu None döndürür. Bu program Option değerini işlemek için önce Option<&i32> yerine doğrudan Option<i32> elde etmek üzere copied metodunu, ardından skorlar içinde bu anahtara ait bir girdi bulunmuyorsa skor değişkenine 0 değerini atamak üzere unwrap_or metodunu çağırır.
Bir for döngüsü kullanarak, vektörlerde yaptığımıza benzer biçimde bir hash map’teki her anahtar-değer çifti üzerinde dönebiliriz:
fn main() {
use std::collections::HashMap;
let mut skorlar = HashMap::new();
skorlar.insert(String::from("Mavi"), 10);
skorlar.insert(String::from("Sarı"), 50);
for (anahtar, deger) in &skorlar {
println!("{anahtar}: {deger}");
}
}
Bu kod her bir çifti rastgele (belirsiz) bir sırayla yazdıracaktır:
Sarı: 50
Mavi: 10
Hash Map’lerde Sahipliği Yönetme
i32 gibi Copy trait’ini uygulayan türler için değerler hash map’in içine kopyalanır. String gibi sahipli (owned) değerlerde ise değerler taşınır ve Liste 8-22’de gösterildiği gibi hash map bu değerlerin yeni sahibi olur.
fn main() {
use std::collections::HashMap;
let alan_adi = String::from("Favori renk");
let alan_degeri = String::from("Mavi");
let mut map = HashMap::new();
map.insert(alan_adi, alan_degeri);
// alan_adi ve alan_degeri bu noktada geçersizdir, bunları kullanmayı deneyin ve
// hangi derleyici hatasını aldığınızı görün!
}
insert çağrısıyla hash map’in içine taşındıktan sonra alan_adi ve alan_degeri değişkenlerini artık kullanamayız.
Hash map’e değerlerin kendisi yerine referanslarını eklersek değerler hash map’e taşınmaz. Ancak referansların işaret ettiği değerlerin, en az hash map geçerli olduğu sürece geçerli kalması gerekir. Bu konuyu Bölüm 10’daki “Ömürlerle Referansları Doğrulama” başlığında daha ayrıntılı ele alacağız.
Bir Hash Map’i Güncelleme
Anahtar ve değer çiftlerinin sayısı dinamik olarak artabilse de her benzersiz anahtar aynı anda yalnızca tek bir değerle ilişkilendirilebilir (ancak tersi geçerli değildir: örneğin skorlar hash map’inde hem Mavi takım hem de Sarı takım 10 değerine sahip olabilir).
Bir hash map’teki veriyi değiştirmek istediğinizde, bir anahtara hâlihazırda bir değer atanmışsa bu durumu nasıl ele alacağınıza karar vermeniz gerekir. Eski değeri tamamen göz ardı ederek yerine yeni değeri yazabilirsiniz. Eski değeri koruyup yeni değeri yok sayabilir ve yeni değeri yalnızca anahtarın henüz bir değeri yoksa ekleyebilirsiniz. Ya da eski değer ile yeni değeri birleştirebilirsiniz. Gelin bu işlemlerin her birini nasıl yapacağımıza bakalım!
Bir Değerin Üzerine Yazma
Bir hash map’e bir anahtar ve değer ekledikten sonra aynı anahtarı farklı bir değerle tekrar eklerseniz, o anahtarla ilişkili değer yenisiyle değiştirilir. Liste 8-23’teki kod insert metodunu iki kez çağırsa da her iki seferde de Mavi takımın anahtarı için değer eklediğimizden hash map yalnızca tek bir anahtar-değer çifti barındıracaktır.
fn main() {
use std::collections::HashMap;
let mut skorlar = HashMap::new();
skorlar.insert(String::from("Mavi"), 10);
skorlar.insert(String::from("Mavi"), 25);
println!("{skorlar:?}");
}
Bu kod ekrana {"Mavi": 25} yazdıracaktır; başlangıçtaki 10 değerinin üzerine yazılmıştır.
Yalnızca Anahtar Mevcut Değilse Anahtar ve Değer Ekleme
Belirli bir anahtarın hash map’te bir değerle birlikte hâlihazırda var olup olmadığını kontrol edip şu adımları uygulamak oldukça yaygın bir ihtiyaçtır: Anahtar hash map’te zaten varsa mevcut değer olduğu gibi kalsın; anahtar yoksa anahtar ve onun için verilen değer eklensin.
Hash map’ler bu işlem için, kontrol etmek istediğiniz anahtarı parametre olarak alan entry adında özel bir API sunar. entry metodunun dönüş değeri, var olabilecek ya da olmayabilecek bir girdiyi temsil eden Entry adlı bir enum’dır. Diyelim ki Sarı takımın anahtarıyla ilişkili bir değer olup olmadığını kontrol etmek istiyoruz; eğer yoksa 50 değerini eklemek istiyoruz ve aynı işlemi Mavi takım için de uygulamak istiyoruz. entry API’sini kullandığımızda kod Liste 8-24’teki gibi görünür.
fn main() {
use std::collections::HashMap;
let mut skorlar = HashMap::new();
skorlar.insert(String::from("Mavi"), 10);
skorlar.entry(String::from("Sarı")).or_insert(50);
skorlar.entry(String::from("Mavi")).or_insert(50);
println!("{skorlar:?}");
}
entry metodunu kullanmaEntry üzerindeki or_insert metodu, ilgili Entry anahtarı mevcutsa o anahtarın değerine değiştirilebilir bir referans döndürecek; anahtar mevcut değilse verilen parametreyi bu anahtarın yeni değeri olarak ekleyip yeni değere değiştirilebilir bir referans döndürecek şekilde tanımlanmıştır. Bu teknik, aynı kontrol mantığını kendimiz yazmaktan çok daha temizdir ve ödünç alma denetleyicisiyle çok daha uyumlu çalışır.
Liste 8-24’teki kodu çalıştırdığımızda {"Sarı": 50, "Mavi": 10} çıktısını alırız. İlk entry çağrısı, Sarı takımın henüz bir değeri olmadığı için Sarı takım anahtarını 50 değeriyle birlikte ekler. İkinci entry çağrısı ise Mavi takımın zaten 10 değeri bulunduğu için hash map’te herhangi bir değişiklik yapmaz.
Not: entry API’si, if !skorlar.contains_key("Sarı") { skorlar.insert(String::from("Sarı"), 50); } şeklinde iki ayrı çağrı yazmaya kıyasla hem daha ergonomik hem de daha verimlidir. İki ayrı metot çağrısında anahtarın karma (hash) değeri iki kez hesaplanır ve tabloda iki kez arama yapılır; oysa entry metodu anahtarın tablodaki konumunu yalnızca bir kez bulur ve Entry enum’ı (Occupied veya Vacant varyantı) üzerinden ekleme ya da erişim işlemini doğrudan o konumda gerçekleştirir. Ayrıca tek bir değiştirilebilir ödünç alma üzerinden &mut V döndürdüğü için aynı kapsamda okuma ve yazma referanslarının çakışmasını önler. Eklenecek varsayılan değeri hesaplamak maliyetliyse, or_insert yerine kapanış alan or_insert_with(|| ...) (veya türün Default değerini kullanan or_default()) metodunu tercih ederek değerin yalnızca anahtar gerçekten yoksa üretilmesini sağlayabilirsiniz.
Eski Değere Göre Bir Değeri Güncelleme
Hash map’lerin bir diğer yaygın kullanım alanı, bir anahtarın değerini bulup onu eski değerine göre güncellemektir. Örneğin Liste 8-25, bir metin içinde her kelimenin kaç kez geçtiğini sayan bir kodu göstermektedir. Kelimeleri anahtar olarak tutan bir hash map kullanıyor ve o kelimeyle kaç kez karşılaştığımızı takip etmek için ilişkili değeri artırıyoruz. Bir kelimeyle ilk kez karşılaşıyorsak tabloya önce 0 değerini ekliyoruz.
fn main() {
use std::collections::HashMap;
let metin = "merhaba dünya harika dünya";
let mut map = HashMap::new();
for kelime in metin.split_whitespace() {
let sayac = map.entry(kelime).or_insert(0);
*sayac += 1;
}
println!("{map:?}");
}
Bu kod ekrana {"dünya": 2, "merhaba": 1, "harika": 1} yazdıracaktır. Aynı anahtar-değer çiftlerinin farklı bir sırada yazdırıldığını da görebilirsiniz: “Bir Hash Map’teki Değerlere Erişme” bölümünden, bir hash map üzerinde dönerken elemanların rastgele bir sırayla geldiğini hatırlayın.
split_whitespace metodu, metin içindeki değerin boşluk karakterleriyle ayrılmış alt dilimleri (&str) üzerinde dönen bir iteratör döndürür. or_insert metodu ise belirtilen anahtara karşılık gelen değere değiştirilebilir bir referans (&mut V) döndürür. Burada bu değiştirilebilir referansı sayac değişkeninde saklıyoruz; dolayısıyla doğrudan o değere atama yapabilmek için önce yıldız (*) başvuru çözme operatörünü kullanarak sayac referansını çözmemiz (*sayac += 1;) gerekir. Değiştirilebilir referans for döngüsünün her adımının sonunda kapsam dışına çıkar; bu nedenle yapılan tüm bu değişiklikler güvenlidir ve ödünç alma kurallarına uygundur.
Karma Fonksiyonları
Varsayılan olarak HashMap, karma tablolarını hedef alan hizmet reddi (denial-of-service, DoS) saldırılarına karşı direnç sağlayabilen SipHash adlı bir karma (özetleme) fonksiyonu kullanır1. Bu mevcut en hızlı özetleme algoritması değildir; ancak daha yüksek güvenlik karşılığında katlanılan bu küçük performans ödünü buna değerdir. Kodunuzun performans profilini çıkarır ve varsayılan özetleme fonksiyonunun amacınız için fazla yavaş kaldığını tespit ederseniz, farklı bir karma oluşturucu (hasher) belirterek başka bir fonksiyona geçiş yapabilirsiniz. Karma oluşturucu, BuildHasher trait’ini uygulayan bir türdür. Trait’lerden ve bunların nasıl uygulanacağından Bölüm 10’da bahsedeceğiz. Kendi karma oluşturucunuzu sıfırdan yazmanız da gerekmez; crates.io üzerinde diğer Rust kullanıcılarının paylaştığı ve pek çok yaygın özetleme algoritmasını uygulayan hazır karma oluşturucu kütüphaneleri bulunmaktadır.
Özet
Vektörler, string’ler ve hash map’ler; programlarınızda veri saklamanız, verilere erişmeniz ve onları değiştirmeniz gerektiğinde ihtiyaç duyacağınız işlevselliğin büyük bir bölümünü sağlar. Artık çözebilecek donanıma sahip olduğunuz birkaç alıştırma aşağıda verilmiştir:
- Bir tam sayı listesi verildiğinde, bir vektör kullanarak listenin medyanını (ortancasını; liste sıralandığında tam ortadaki konumda bulunan değeri) ve modunu (tepe değerini; en sık tekrar eden değeri — burada bir hash map işinize yarayacaktır) döndürün.
- String’leri Pig Latin (Domuz Latincesi) biçimine dönüştürün. Ünsüz harfle başlayan her kelimenin ilk ünsüz harfi kelimenin sonuna taşınır ve sonuna ay eklenir; böylece first kelimesi irst-fay (veya Türkçe bir örnekle kitap kelimesi itap-kay) olur. Ünlü harfle başlayan kelimelerin sonuna ise bunun yerine doğrudan hay eklenir (apple kelimesi apple-hay veya elma kelimesi elma-hay olur). UTF-8 kodlamasıyla ilgili ayrıntıları göz önünde bulundurmayı unutmayın!
- Bir hash map ve vektörler kullanarak, kullanıcının bir şirketteki bir departmana çalışan isimleri eklemesine olanak tanıyan metin tabanlı bir arayüz oluşturun; örneğin “Sally’yi Mühendislik bölümüne ekle” veya “Amir’i Satış bölümüne ekle” (
"Add Sally to Engineering"veya"Add Amir to Sales"). Ardından kullanıcının bir departmandaki tüm kişilerin listesini ya da şirketteki tüm kişilerin departmanlara göre gruplanmış ve alfabetik olarak sıralanmış listesini alabilmesini sağlayın.
Standart kütüphane API belgelendirmesi; vektörlerin, string’lerin ve hash map’lerin bu alıştırmalar için çok yararlı olacak metotlarını açıklar!
Not: Bu üç alıştırmayı çözerken işinizi kolaylaştıracak bazı pratik standart kütüphane ipuçları:
- Medyan ve Mod: Vektörü yerinde sıralamak için
sort()veya tam sayılarda ek bellek ayırmadan daha hızlı çalışansort_unstable()metodunu kullanabilirsiniz. Mod hesabında ise Liste 8-25’tekientry(sayi).or_insert(0)deseninden yararlanıp en yüksek sayaca sahip anahtarı bulabilirsiniz. - Pig Latin ve UTF-8: Türkçedeki
'ç','ğ','ı','ö','ş','ü'gibi karakterler UTF-8’de 2 bayt yer kapladığından&kelime[0..1]gibi bayt dilimlemeleri çalışma zamanında panik üretebilir. Bunun yerinelet mut karakterler = kelime.chars();oluşturup ilk karakterikarakterler.next()ile güvenle alabilir, kelimenin geri kalan dilimine isekarakterler.as_str()ile ulaşabilirsiniz. - Çalışan ve Departman Arayüzü: Şirket verisini
HashMap<String, Vec<String>>olarak modelleyip yeni çalışan eklerkendepartmanlar.entry(departman).or_default().push(isim)çağrısını kullanabilirsiniz. Komut satırından (std::io::stdin()) okuduğunuz metni kelimelere ayırmak içinsplit_whitespace(), çalışan isimlerini alfabetik sıralamak içinVec::sort(), tüm şirketi departman adına göre de sıralı listelemek için anahtarları bir vektöre toplayıp sıralamayı (veya anahtarları doğal olarak sıralı tutanBTreeMapyapısını) değerlendirebilirsiniz.
Artık işlemlerin başarısız olabileceği daha karmaşık programlara adım atıyoruz; dolayısıyla hata yönetimini ele almak için en doğru zamandayız. Sırada tam olarak bu var!
Hata Yönetimi
Hatalar yazılım geliştirmenin kaçınılmaz bir gerçeğidir; bu nedenle Rust, bir şeylerin ters gittiği durumları ele almak için çeşitli özellikler sunar. Pek çok durumda Rust, kodunuzun derlenebilmesi için olası bir hatayı kabul etmenizi ve buna karşı bir önlem almanızı zorunlu kılar. Bu zorunluluk, kodunuzu üretime almadan önce olası hataları fark etmenizi ve uygun biçimde ele almanızı sağlayarak programınızı daha sağlam hâle getirir!
Rust, hataları iki ana kategoriye ayırır: kurtarılabilir (recoverable) ve kurtarılamayan (unrecoverable) hatalar. Örneğin bir dosya bulunamadı hatası gibi kurtarılabilir bir hata durumunda, büyük olasılıkla yalnızca sorunu kullanıcıya bildirmek ve işlemi yeniden denemek isteriz. Kurtarılamayan hatalar ise bir dizinin sonunun ötesindeki bir konuma erişmeye çalışmak gibi her zaman koddaki kusurların (bug) belirtisidir; bu nedenle programı derhal durdurmak isteriz.
Çoğu programlama dili bu iki hata türü arasında ayrım yapmaz ve istisnalar (exceptions) gibi mekanizmalar kullanarak her ikisini de aynı biçimde ele alır. Rust’ta istisna mekanizması yoktur. Bunun yerine Rust, kurtarılabilir hatalar için Result<T, E> türüne ve program kurtarılamayan bir hatayla karşılaştığında yürütmeyi durduran panic! makrosuna sahiptir. Bu bölümde önce panic! makrosunu çağırmayı, ardından Result<T, E> değerleri döndürmeyi ele alacağız. Ek olarak, bir hatadan kurtulmaya çalışmak ile programın yürütülmesini durdurmak arasında karar verirken göz önünde bulundurulması gereken ölçütleri inceleyeceğiz.
Not: C++, Java, Python, C# veya JavaScript gibi try/catch tabanlı istisna
fırlatan dillerden geliyorsanız, Rust’ın hataları fırlatmak yerine sıradan
dönüş değerleri olarak modellemesi ilk başta farklı gelebilir. İstisna
kullanan pek çok dilde yalnızca bir fonksiyonun imzasına bakarak o fonksiyonun
hata fırlatıp fırlatmayacağını anlamak zordur; gözden kaçan bir istisna çağrı
yığını boyunca sessizce yükselip canlı ortamda beklenmedik çökmelere yol
açabilir. Rust’ta ise kurtarılabilir hatalar genellikle dönüş türündeki
Result<T, E> ile açıkça belirtilir ve döndürülen bir Result değerini
yanlışlıkla görmezden geldiğinizde derleyici (#[must_use] özniteliği
sayesinde) sizi uyarır. Bu, bir fonksiyonun panikleyemeyeceği anlamına gelmez:
Result, normal hata akışını modeller. Birazdan göreceğimiz ? operatörü de
bu hataları çağıran fonksiyona kısa ve okunaklı biçimde iletmemizi sağlar.
Standart kütüphanedeki bu sağlam temelin üzerine gerçek dünya projelerinde topluluğun geliştirdiği iki popüler crate de sıklıkla eklenir: kütüphane geliştirirken yapılandırılmış özel hata türleri tanımlamayı kolaylaştıran thiserror ve uygulama katmanında farklı hata türlerini bağlam (context) bilgisiyle ergonomik biçimde yönetmeyi sağlayan anyhow. Bu bölümde panic! ve Result<T, E> mekanizmalarını sağlam biçimde kavradığınızda, ekosistemdeki bu araçları da zahmetsizce kullanabilirsiniz.
panic! ile Kurtarılamayan Hatalar
panic! ile Kurtarılamayan Hatalar
Bazen kodunuzda işler ters gider ve bu konuda yapabileceğiniz hiçbir şey yoktur. Bu durumlar için Rust’ta panic! makrosu bulunur. Uygulamada paniğe yol açmanın iki yolu vardır: kodumuzun panik yapmasına neden olacak bir işlem yapmak (örneğin bir dizinin sonunun ötesine erişmeye çalışmak) ya da doğrudan panic! makrosunu çağırmak. Her iki durumda da programımızda bir panik tetikleriz. Varsayılan olarak bu panikler bir hata mesajı yazdırır, yığını (stack) geri sararak (unwinding) temizler ve programı sonlandırır. Ayrıca bir çevre değişkeni aracılığıyla, panik oluştuğunda hatanın kaynağını bulmayı kolaylaştırmak için Rust’ın çağrı yığınını (call stack) görüntülemesini de sağlayabilirsiniz.
Bir Paniğe Yanıt Olarak Yığını Geri Sarma veya İptal Etme
Varsayılan olarak bir panik oluştuğunda program yığını geri sarmaya (unwinding) başlar; bu, Rust’ın çağrı yığını boyunca geriye doğru ilerleyerek karşılaştığı her fonksiyona ait verileri tek tek temizlediği anlamına gelir. Ancak yığında geriye doğru ilerleyip temizlik yapmak epey iş yükü gerektirir. Bu nedenle Rust, alternatif olarak temizlik yapmadan programı derhal sonlandıran iptal etme (aborting) seçeneğini de sunar.
Bu durumda programın kullanmakta olduğu belleğin daha sonra işletim sistemi tarafından geri alınması gerekir. Projenizde üretilen ikili (binary) dosyayı olabildiğince küçük tutmanız gerekiyorsa, Cargo.toml dosyanızdaki ilgili [profile] bölümlerine panic = 'abort' ekleyerek panik anında geri sarma yerine iptal etme davranışına geçebilirsiniz. Örneğin yayın (release) modunda panik anında programı doğrudan iptal etmek istiyorsanız şunu ekleyebilirsiniz:
[profile.release]
panic = 'abort'
Not: panic = 'unwind' (varsayılan) ile panic = 'abort' arasındaki seçim
yalnızca ikili dosya boyutunu değil, kaynak temizleme davranışını da doğrudan
etkiler. Geri sarma modunda Rust, çağrı yığınındaki her çerçeveden çıkarken
kapsamdaki yerel değişkenler için, Bölüm 15’te ayrıntılı olarak
inceleyeceğimiz Drop trait’ine ait yıkıcı (destructor) kodları çalıştırır;
böylece öbekte (heap) ayrılmış bellek alanları serbest bırakılır, ilgili
kaynakların temizlik kodları çalışır ve tutulan kilitler serbest bırakılır.
Ancak tamponlanmış verinin başarıyla yazılması veya fiziksel diske
kaydedilmesi garanti değildir; yazma hatalarını ele almak için flush, kalıcı
saklamayı istemek için gerektiğinde sync_all gibi işlemler ayrıca
kullanılmalıdır. Geri sarma tabloları ve bu sırada gereken temizlik kodları
ikili dosyanın boyutunu artırabilir. panic = 'abort' seçildiğinde ise bu
temizlik kodları üretilmez (bu da özellikle gömülü sistemlerde ve WebAssembly
hedeflerinde önemli ölçüde boyut tasarrufu sağlar), fakat panik anında Drop
yıkıcıları çalıştırılmaz; süreç anında sonlandırılır ve yalnızca işletim
sisteminin süreç bazında yaptığı bellek ve dosya tanıtıcısı temizliği
gerçekleşir.
Basit bir programda panic! makrosunu çağırmayı deneyelim:
fn main() {
panic!("çök ve yan");
}
Programı çalıştırdığınızda şuna benzer bir çıktı görürsünüz:
$ cargo run
Compiling panik v0.1.0 (file:///projects/panik)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.25s
Running `target/debug/panik`
thread 'main' (6018279) panicked at src/main.rs:2:5:
çök ve yan
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
panic! çağrısı, çıktının son bölümünde yer alan hata mesajını üretir. Panik çıktısının ilk iki satırı kaynak kodumuzda paniğin gerçekleştiği konumu ve panik mesajımızı (çök ve yan) gösterir: src/main.rs:2:5 ifadesi, paniğin src/main.rs dosyamızın ikinci satırının beşinci karakterinde gerçekleştiğini belirtir (parantez içindeki sayı ise işletim sisteminin ana thread’e atadığı kimlik numarasıdır; bu sayı her çalıştırmada değişebilir).
Bu örnekte işaret edilen satır doğrudan bizim yazdığımız koda aittir ve o satıra baktığımızda panic! makro çağrısını görürüz. Başka durumlarda ise panic! çağrısı, kodumuzun çağırdığı harici bir kodun içinde yer alabilir; bu durumda hata mesajında bildirilen dosya adı ve satır numarası, en başta panic! çağrısına yol açan bizim kodumuzdaki satırı değil, panic! makrosunun bizzat çağrıldığı başka bir koddaki satırı gösterir.
Not: panic! makrosu tıpkı println! gibi biçimlendirme argümanları kabul eder; örneğin panic!("Geçersiz yapılandırma değeri: {deger}") yazarak hata mesajına değişkenlerin değerini ekleyebilirsiniz. Standart kütüphanede, perde arkasında panic! çağıran ve niyetinizi daha açık ifade eden birkaç yardımcı makro daha bulunur: unreachable!() mantık gereği asla ulaşılmaması gereken kod yollarını, todo!() ve unimplemented!() ise henüz yazılmamış kod parçalarını işaretler. Bölüm 11’de testlerde sıkça kullanacağımız assert!, assert_eq! ve assert_ne! makroları da verilen koşul sağlanmadığında panik yapar. Bölüm 3’te gördüğünüz gibi hata ayıklama (debug) derlemelerinde tam sayı taşması da bir paniğe yol açar.
Kodumuzun soruna yol açan kısmını bulmak için, panic! çağrısının gerçekleştiği noktaya gelene dek çağrılan fonksiyonların geri izlemesini (backtrace) kullanabiliriz. Bir panic! geri izlemesinin nasıl kullanıldığını anlamak için başka bir örneğe bakalım: Bu kez panic! çağrısını doğrudan biz yapmayacağız; çağrı, kodumuzdaki bir kusur nedeniyle bir kütüphanenin içinden gelecek. Böyle bir durumda neyle karşılaştığımızı görelim. Liste 9-1’de bir vektörün geçerli indeks aralığının ötesindeki bir indekse erişmeye çalışan bir kod yer almaktadır.
fn main() {
let v = vec![1, 2, 3];
v[99];
}
panic! çağrısına yol açarBurada vektörümüzün 100. elemanına (indeksleme sıfırdan başladığı için 99. indeksteki elemana) erişmeye çalışıyoruz; ancak vektörde yalnızca üç eleman var. Bu durumda Rust panik yapacaktır. [] operatörünün bir eleman döndürmesi beklenir; fakat geçersiz bir indeks verirseniz Rust’ın burada doğru olarak döndürebileceği hiçbir eleman yoktur.
C dilinde bir veri yapısının sonunun ötesini okumaya çalışmak tanımsız davranıştır (undefined behavior). Söz konusu bellek alanı o veri yapısına ait olmasa bile, bellekte o indekse karşılık gelen adreste ne bulunuyorsa onu okuyabilirsiniz. Buna tampon sınırı ötesini okuma (buffer overread) adı verilir ve bir saldırganın indeksi kurcalayarak veri yapısından hemen sonra bellekte tutulan, erişmemesi gereken hassas verileri okumasına imkân tanıdığında ciddi güvenlik açıklarına yol açabilir.
Programınızı bu tür güvenlik açıklarından korumak amacıyla Rust, var olmayan bir indeksteki elemanı okumaya çalıştığınızda yürütmeyi durdurur ve devam etmeyi reddeder. Gelin bunu deneyip görelim:
$ cargo run
Compiling panik v0.1.0 (file:///projects/panik)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.27s
Running `target/debug/panik`
thread 'main' (6017887) panicked at src/main.rs:4:6:
index out of bounds: the len is 3 but the index is 99
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Bu hata, v vektörünün 99. indeksine erişmeye çalıştığımız main.rs dosyamızın 4. satırına işaret eder.
Çıktıdaki note: satırı, hataya tam olarak neyin yol açtığını gösteren bir geri izleme elde etmek için RUST_BACKTRACE çevre değişkenini ayarlayabileceğimizi belirtir. Geri izleme, bu noktaya gelinene kadar çağrılmış tüm fonksiyonların listesidir. Rust’taki geri izlemeler diğer dillerdekiyle aynı mantıkla çalışır: geri izlemeyi okumanın püf noktası, en üstten başlayıp kendi yazdığınız dosyaları görene kadar aşağı doğru okumaktır. Sorunun kaynaklandığı yer tam olarak orasıdır. Bu noktanın üstündeki satırlar sizin kodunuzun çağırdığı kodları, altındaki satırlar ise sizin kodunuzu çağıran kodları gösterir. Öncesindeki ve sonrasındaki bu satırlar çekirdek Rust kodunu, standart kütüphane kodunu veya kullandığınız crate’leri içerebilir. Şimdi RUST_BACKTRACE çevre değişkenini 0 dışındaki herhangi bir değere ayarlayarak bir geri izleme almayı deneyelim. Liste 9-2, göreceğinize benzer bir çıktıyı göstermektedir.
$ RUST_BACKTRACE=1 cargo run
thread 'main' (6017887) panicked at src/main.rs:4:6:
index out of bounds: the len is 3 but the index is 99
stack backtrace:
0: __rustc::rust_begin_unwind
at /rustc/b940084d7eb6a299eb4bfeb8e34901bc051e7ac4/library/std/src/panicking.rs:679:5
1: core::panicking::panic_fmt
at /rustc/b940084d7eb6a299eb4bfeb8e34901bc051e7ac4/library/core/src/panicking.rs:80:14
2: core::panicking::panic_bounds_check
at /rustc/b940084d7eb6a299eb4bfeb8e34901bc051e7ac4/library/core/src/panicking.rs:271:5
3: <usize as core::slice::index::SliceIndex<[i32]>>::index
at file:///home/.rustup/toolchains/1.99/lib/rustlib/src/rust/library/core/src/slice/index.rs:238:10
4: <[i32] as core::ops::index::Index<usize>>::index
at file:///home/.rustup/toolchains/1.99/lib/rustlib/src/rust/library/core/src/slice/index.rs:19:15
5: <alloc::vec::Vec<i32> as core::ops::index::Index<usize>>::index
at file:///home/.rustup/toolchains/1.99/lib/rustlib/src/rust/library/alloc/src/vec/mod.rs:3934:9
6: panik::main
at ./src/main.rs:4:6
7: <fn() as core::ops::function::FnOnce<()>>::call_once
at file:///home/.rustup/toolchains/1.99/lib/rustlib/src/rust/library/core/src/ops/function.rs:250:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
RUST_BACKTRACE çevre değişkeni ayarlandığında bir panic! çağrısının ürettiği geri izleme çıktısıBu oldukça uzun bir çıktı! Göreceğiniz birebir çıktı, işletim sisteminize ve Rust sürümünüze bağlı olarak farklılık gösterebilir. Bu bilgileri içeren geri izlemeler elde edebilmek için hata ayıklama sembollerinin (debug symbols) etkinleştirilmiş olması gerekir. Hata ayıklama sembolleri, burada yaptığımız gibi --release bayrağı olmadan cargo build veya cargo run çalıştırıldığında varsayılan olarak etkindir.
Liste 9-2’deki çıktıda geri izlemenin 6 numaralı kaydı, projemizde soruna yol açan satıra işaret eder: src/main.rs dosyasının 4. satırı. Programımızın panik yapmasını istemiyorsak, incelememize kendi yazdığımız bir dosyadan bahseden ilk satırın gösterdiği konumdan başlamalıyız. Bilerek panik yapacak biçimde yazdığımız Liste 9-1’de paniği gidermenin yolu, vektörün geçerli indeks aralığının ötesinden bir eleman istememektir. Gelecekte kodunuz panik yaptığında, kodun hangi değerlerle hangi işlemi yaparak paniğe yol açtığını ve bunun yerine ne yapması gerektiğini belirlemeniz gerekecektir.
Not: Liste 9-2’nin sonundaki note: satırında görülen RUST_BACKTRACE=1 ile RUST_BACKTRACE=full arasındaki fark günlük hata ayıklamada işinizi çok kolaylaştırır:
RUST_BACKTRACE=1(önerilen başlangıç noktası): Programı başlatan Rust çalışma zamanı (runtime) başlangıç çerçevelerini ve panik sonrasındaki düşük seviyeli iç adımları gizleyerek yalnızca hataya giden asıl çağrı zincirini özetler. Örneğin Liste 9-2’yi aşağıdan yukarıya (6→0) takip ederseniz çağrı akışını adım adım görebilirsiniz:6: panik::mainiçindekiv[99]ifadesi önce5: Vec::index’i, o da dilim indekslemesini (4ve3) çağırmış; sınır denetimi başarısız olunca2: panic_bounds_checküzerinden panik başlatılmıştır. Hatayı ayıklarken ise her zaman yukarıdan aşağıya inip kendi kaynak dosyanızı (./src/main.rs) gördüğünüz ilk kayıttan (6) başlamalısınız (vektörlerde panik yapmak yerine güvenli erişim gerektiğinde Bölüm 8’de gördüğümüzv.get(99)metodunu tercih edebilirsiniz).RUST_BACKTRACE=full: Gizlenen tüm standart kütüphane ve çalışma zamanı başlatma çerçevelerini ham bellek adresleriyle birlikte eksiksiz listeler. Uygulama düzeyindeki hatalar içinRUST_BACKTRACE=1çok daha okunaklıdır;fullseçeneğine genellikle yalnızca düşük seviyeli kütüphane, FFI veya derleyici sorunlarını incelerken ihtiyaç duyulur. Ayrıca Windows PowerShell kullanıyorsanız bu çevre değişkenini$env:RUST_BACKTRACE=1; cargo runkomutuyla ayarlayabilirsiniz.
Bu bölümün ilerleyen kısımlarındaki “panic! Yapmalı mı Yapmamalı mı?” başlığında panic! makrosuna geri dönecek ve hata durumlarını ele alırken panic!’i ne zaman kullanıp ne zaman kullanmamamız gerektiğini inceleyeceğiz. Şimdi sırada Result kullanarak bir hatadan nasıl kurtulabileceğimize bakmak var.
Result ile Kurtarılabilir Hatalar
Result ile Kurtarılabilir Hatalar
Hataların çoğu, programın tamamen durmasını gerektirecek kadar ciddi değildir. Bazen bir fonksiyon başarısız olduğunda, bunun altında kolayca yorumlayıp karşılık verebileceğiniz bir neden yatar. Örneğin bir dosyayı açmaya çalıştığınızda işlem dosyanın henüz var olmaması yüzünden başarısız olursa, süreci sonlandırmak yerine dosyayı oluşturmak isteyebilirsiniz.
Bölüm 2’deki “Potansiyel Bir Hatayı Result ile Ele Almak” kısmından hatırlayacağınız üzere Result enum’ı, Ok ve Err olmak üzere iki varyanta sahip olacak şekilde şöyle tanımlanır:
#![allow(unused)]
fn main() {
enum Result<T, E> {
Ok(T),
Err(E),
}
}
T ve E jenerik tür parametreleridir: jenerikleri Bölüm 10’da daha ayrıntılı olarak ele alacağız. Şimdilik bilmeniz gereken, T’nin başarı durumunda Ok varyantı içinde döndürülecek değerin türünü, E’nin ise başarısızlık durumunda Err varyantı içinde döndürülecek hatanın türünü temsil ettiğidir. Result bu jenerik tür parametrelerine sahip olduğu için, döndürmek istediğimiz başarı ve hata değerlerinin farklılık gösterebildiği pek çok durumda Result türünü ve üzerinde tanımlı fonksiyonları kullanabiliriz.
Başarısız olma ihtimali nedeniyle Result değeri döndüren bir fonksiyon çağıralım. Liste 9-3’te bir dosyayı açmaya çalışıyoruz.
use std::fs::File;
fn main() {
let karsilama_dosyasi_sonucu = File::open("merhaba.txt");
}
File::open fonksiyonunun dönüş türü Result<T, E>’dir. Jenerik T parametresi, File::open uygulaması tarafından başarı değerinin türü olan ve bir dosya tanıtıcısını (file handle) temsil eden std::fs::File ile doldurulmuştur. Hata değerinde kullanılan E’nin türü ise std::io::Error’dır. Bu dönüş türü, File::open çağrısının başarılı olup okuma veya yazma yapabileceğimiz bir dosya tanıtıcısı döndürebileceği anlamına gelir. Fonksiyon çağrısı başarısız da olabilir: örneğin dosya mevcut olmayabilir veya dosyaya erişim iznimiz bulunmayabilir. File::open fonksiyonunun hem işlemin başarılı mı yoksa başarısız mı olduğunu bize bildirecek hem de duruma göre dosya tanıtıcısını ya da hata bilgisini iletecek bir yola ihtiyacı vardır. İşte Result enum’ının aktardığı bilgi tam olarak budur.
File::open başarılı olduğunda karsilama_dosyasi_sonucu değişkenindeki değer, dosya tanıtıcısını barındıran bir Ok örneği olacaktır. Başarısız olduğunda ise karsilama_dosyasi_sonucu içindeki değer, oluşan hatanın türü hakkında daha fazla bilgi taşıyan bir Err örneği olacaktır.
File::open çağrısının döndürdüğü değere göre farklı adımlar atabilmek için Liste 9-3’teki koda ekleme yapmamız gerekir. Liste 9-4, Bölüm 6’da incelediğimiz temel bir araç olan match ifadesini kullanarak Result değerini ele almanın bir yolunu gösterir.
use std::fs::File;
fn main() {
let karsilama_dosyasi_sonucu = File::open("merhaba.txt");
let karsilama_dosyasi = match karsilama_dosyasi_sonucu {
Ok(dosya) => dosya,
Err(hata) => panic!("Dosyayı açarken problem oluştu: {hata:?}"),
};
}
Result varyantlarını ele almak için match ifadesi kullanmaTıpkı Option enum’ı gibi Result enum’ı ve varyantları da ön tanımlı kapsam (prelude) aracılığıyla otomatik olarak kapsama dahil edilir; dolayısıyla match kollarında Ok ve Err varyantlarının önüne Result:: yazmamıza gerek yoktur.
Sonuç Ok olduğunda bu kod, Ok varyantının içindeki dosya değerini döndürür ve biz de bu dosya tanıtıcısını karsilama_dosyasi değişkenine atarız. match bloğundan sonra bu dosya tanıtıcısını okuma veya yazma işlemleri için kullanabiliriz.
match ifadesinin diğer kolu ise File::open çağrısından bir Err değeri aldığımız durumu ele alır. Bu örnekte panic! makrosunu çağırmayı tercih ettik. Çalışma dizinimizde merhaba.txt adında bir dosya yoksa ve bu kodu çalıştırırsak panic! makrosundan aşağıdaki çıktıyı görürüz:
$ cargo run
Compiling hata-yonetimi v0.1.0 (file:///projects/hata-yonetimi)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.73s
Running `target/debug/hata-yonetimi`
thread 'main' (6018048) panicked at src/main.rs:8:22:
Dosyayı açarken problem oluştu: Os { code: 2, kind: NotFound, message: "No such file or directory" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Her zamanki gibi bu çıktı bize neyin ters gittiğini açıkça bildirir (No such file or directory: böyle bir dosya ya da dizin yok).
Farklı Hatalar Üzerinde Eşleştirme Yapmak
Liste 9-4’teki kod, File::open hangi nedenle başarısız olursa olsun panic! çağıracaktır. Oysa biz farklı başarısızlık nedenlerine göre farklı adımlar atmak istiyoruz: File::open dosyanın mevcut olmaması yüzünden başarısız olduysa dosyayı oluşturup bu yeni dosyanın tanıtıcısını döndürmek; başka herhangi bir nedenle —örneğin dosyayı açma iznimiz olmadığı için— başarısız olduysa tıpkı Liste 9-4’teki gibi panic! çağırmak istiyoruz. Bunun için Liste 9-5’te gösterildiği gibi iç içe bir match ifadesi ekliyoruz.
use std::fs::File;
use std::io::ErrorKind;
fn main() {
let karsilama_dosyasi_sonucu = File::open("merhaba.txt");
let karsilama_dosyasi = match karsilama_dosyasi_sonucu {
Ok(dosya) => dosya,
Err(hata) => match hata.kind() {
ErrorKind::NotFound => match File::create("merhaba.txt") {
Ok(olusturulan_dosya) => olusturulan_dosya,
Err(e) => panic!("Dosyayı oluştururken problem oluştu: {e:?}"),
},
_ => {
panic!("Dosyayı açarken problem oluştu: {hata:?}");
}
},
};
}
File::open fonksiyonunun Err varyantı içinde döndürdüğü değerin türü, standart kütüphanenin sunduğu bir struct olan io::Error’dır. Bu struct, bir io::ErrorKind değeri elde etmek için çağırabileceğimiz kind adında bir metoda sahiptir. Standart kütüphanede yer alan io::ErrorKind enum’ı, bir girdi/çıktı (io) işleminden kaynaklanabilecek farklı hata çeşitlerini temsil eden varyantlar barındırır. Burada kullanmak istediğimiz varyant, açmaya çalıştığımız dosyanın henüz var olmadığını belirten ErrorKind::NotFound varyantıdır. Bu doğrultuda karsilama_dosyasi_sonucu üzerinde eşleştirme yaparken hata.kind() üzerinde de bir iç eşleştirme kuruyoruz.
İçteki eşleştirmede kontrol etmek istediğimiz koşul, hata.kind() çağrısının döndürdüğü değerin ErrorKind enum’ının NotFound varyantı olup olmadığıdır. Eğer öyleyse File::create ile dosyayı oluşturmayı deneriz. Ancak File::create işlemi de başarısız olabileceğinden en içteki match ifadesinde ikinci bir kola daha ihtiyaç duyarız. Dosya oluşturulamadığında farklı bir hata mesajı yazdırılır. hata.kind() üzerindeki match ifadesinin diğer kolu (_) ise Liste 9-4’tekiyle aynı biçimde panic! çağırır; böylece program, eksik dosya hatası dışındaki her türlü hatada panik yapar.
Result<T, E> ile match Kullanmanın Alternatifleri
Ortada epey match birikti! match ifadesi son derece kullanışlı olsa da oldukça temel düzeyde bir yapıdır. Bölüm 13’te, Result<T, E> üzerinde tanımlı pek çok metotla birlikte kullanılan kapanışları (closures) öğreneceksiniz. Kodunuzda Result<T, E> değerlerini ele alırken bu metotlar match kullanmaya kıyasla çok daha özlü bir yazım sunabilir.
Örneğin Liste 9-5’teki mantığın birebir aynısını bu kez kapanışlar ve unwrap_or_else metoduyla şöyle yazabiliriz:
use std::fs::File;
use std::io::ErrorKind;
fn main() {
let karsilama_dosyasi = File::open("merhaba.txt").unwrap_or_else(|hata| {
if hata.kind() == ErrorKind::NotFound {
File::create("merhaba.txt").unwrap_or_else(|hata| {
panic!("Dosyayı oluştururken problem oluştu: {hata:?}");
})
} else {
panic!("Dosyayı açarken problem oluştu: {hata:?}");
}
});
}
Bu kod Liste 9-5 ile aynı davranışa sahip olmasına rağmen hiçbir match ifadesi içermez ve okuması çok daha temizdir. Bölüm 13’ü okuduktan sonra bu örneğe geri dönün ve standart kütüphane belgelerinde unwrap_or_else metodunu inceleyin. Hatalarla çalışırken bu gibi pek çok metot, iç içe geçmiş devasa match ifadelerini sadeleştirmenizi sağlar.
Hata Durumunda Panik İçin Kısayollar
match kullanımı işimizi görse de kimi zaman biraz uzun kalabilir ve asıl niyeti her zaman net biçimde yansıtmayabilir. Result<T, E> türü üzerinde çeşitli ve daha belirli görevleri yerine getirmek üzere tanımlanmış pek çok yardımcı metot bulunur. unwrap metodu, tıpkı Liste 9-4’te yazdığımız match ifadesi gibi çalışan bir kısayol metodudur: Result değeri Ok varyantıysa Ok içindeki değeri döndürür; Err varyantıysa bizim yerimize panic! makrosunu çağırır. Aşağıda unwrap metodunun kullanımına bir örnek görüyorsunuz:
use std::fs::File;
fn main() {
let karsilama_dosyasi = File::open("merhaba.txt").unwrap();
}
Bu kodu merhaba.txt dosyası olmadan çalıştırırsak, unwrap metodunun yaptığı panic! çağrısından kaynaklanan şu hata mesajını görürüz:
thread 'main' (6018091) panicked at src/main.rs:4:55:
called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }
Benzer şekilde expect metodu, panic! hata mesajını kendimizin belirlemesine de olanak tanır. unwrap yerine expect kullanmak ve açıklayıcı hata mesajları vermek hem kodunuzdaki niyeti ortaya koyar hem de olası bir paniğin kaynağını bulmayı kolaylaştırır. expect sözdizimi şöyle görünür:
use std::fs::File;
fn main() {
let karsilama_dosyasi = File::open("merhaba.txt")
.expect("merhaba.txt bu projeye dahil edilmelidir");
}
expect metodunu da tıpkı unwrap gibi dosya tanıtıcısını döndürmek ya da panic! makrosunu çağırmak için kullanırız. Ancak expect, panic! çağrısında unwrap’in varsayılan mesajı yerine kendisine argüman olarak verdiğimiz metni kullanır. Çıktısı şöyle görünür:
thread 'main' (6018134) panicked at src/main.rs:5:10:
merhaba.txt bu projeye dahil edilmelidir: Os { code: 2, kind: NotFound, message: "No such file or directory" }
Üretim kalitesindeki kodlarda çoğu Rust geliştiricisi (Rustacean), unwrap yerine expect kullanmayı tercih eder ve mesajda ilgili işlemin neden her zaman başarılı olmasının beklendiğine dair bağlam sunar. Böylece varsayımlarınızın yanlış çıktığı bir durum yaşanırsa hata ayıklama sırasında elinizde çok daha yararlı bilgi bulunur.
Not: Ekip projelerinde unwrap yerine expect tercih edilmesinin temel nedeni, aylar sonra bir hata günlüğüne (log) bakıldığında tek başına called Result::unwrap() on an Err value mesajının “bu kodu yazarken neye güvenmiştik?” sorusuna yanıt vermemesidir. Rust topluluğunda yerleşmiş en iyi pratik, expect mesajını yalnızca neyin başarısız olduğunu tekrarlayacak şekilde ("Dosya açılamadı") değil, sağlanması beklenen ön koşulu belgeleyecek şekilde ("merhaba.txt kurulum aşamasında paketlenmiş olmalıdır" veya "Sabit yapılandırma adresi geçerli bir IP olmalıdır") yazmaktır. Böylece kodu okuyan ekip arkadaşlarınız bu satırın neden panik yapmayacağının varsayıldığını hemen görür; varsayım bozulduğunda da panik çıktısı doğrudan ihlal edilen ön koşula işaret eder. Hatta pek çok ekip, üretim kodundaki unwrap kullanımlarını otomatik olarak yakalamak için Clippy’nin clippy::unwrap_used denetimini (lint) etkinleştirir.
Hataları Yaymak
Bir fonksiyonun gövdesi başarısız olabilecek bir işlem çağırdığında, hatayı fonksiyonun kendi içinde ele almak yerine ne yapılacağına karar verebilmesi için çağıran koda döndürebilirsiniz. Bu yaklaşıma hatayı yayma (propagating) adı verilir. Böylece denetim çağıran koda geçer; çağıran kod, hatanın nasıl ele alınması gerektiğine dair sizin kodunuzun bağlamında bulunmayan ek bilgiye veya mantığa sahip olabilir.
Örneğin Liste 9-6, bir dosyadan kullanıcı adını okuyan bir fonksiyonu göstermektedir. Dosya mevcut değilse veya okunamıyorsa bu fonksiyon, oluşan hataları kendisini çağıran koda döndürür.
#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};
fn dosyadan_kullanici_adini_oku() -> Result<String, io::Error> {
let kullanici_adi_dosyasi_sonucu = File::open("merhaba.txt");
let mut kullanici_adi_dosyasi = match kullanici_adi_dosyasi_sonucu {
Ok(dosya) => dosya,
Err(e) => return Err(e),
};
let mut kullanici_adi = String::new();
match kullanici_adi_dosyasi.read_to_string(&mut kullanici_adi) {
Ok(_) => Ok(kullanici_adi),
Err(e) => Err(e),
}
}
}
match kullanarak hataları çağıran koda döndüren bir fonksiyonBu fonksiyon çok daha kısa bir biçimde yazılabilir; ancak hata yönetimini adım adım incelemek amacıyla işe her adımı elle yazarak başlayacağız, en sonda ise kısa yolu göstereceğiz. Öncelikle fonksiyonun dönüş türüne bakalım: Result<String, io::Error>. Bu imza, fonksiyonun Result<T, E> türünde bir değer döndürdüğünü; jenerik T parametresinin somut String türüyle, jenerik E parametresinin ise somut io::Error türüyle doldurulduğunu gösterir.
Bu fonksiyon herhangi bir sorunla karşılaşmadan tamamlanırsa, fonksiyonu çağıran kod dosyadan okunan kullanıcı adını —yani kullanici_adi içindeki String değerini— taşıyan bir Ok değeri alacaktır. Fonksiyon bir sorunla karşılaşırsa, çağıran kod sorunun ne olduğuna ilişkin ayrıntıları barındıran bir io::Error örneğine sahip Err değeri alacaktır. Fonksiyonun hata dönüş türü olarak io::Error seçmemizin nedeni, fonksiyon gövdesinde çağırdığımız ve başarısız olabilecek her iki işlemin de —File::open fonksiyonu ile read_to_string metodunun— hata durumunda io::Error döndürmesidir.
Fonksiyonun gövdesi File::open fonksiyonunu çağırarak başlar. Ardından dönen Result değerini Liste 9-4’tekine benzer bir match ile ele alırız. File::open başarılı olursa desen değişkeni dosya içindeki dosya tanıtıcısı, değiştirilebilir kullanici_adi_dosyasi değişkenine atanır ve fonksiyon çalışmaya devam eder. Err kolunda ise panic! çağırmak yerine return anahtar kelimesini kullanarak tüm fonksiyondan erken çıkar ve artık e desen değişkeninde tutulan File::open hata değerini bu fonksiyonun hatası olarak çağıran koda geri iletiriz.
kullanici_adi_dosyasi içinde geçerli bir dosya tanıtıcısı elde ettiğimizde fonksiyon, kullanici_adi değişkeninde yeni bir String oluşturur ve dosya içeriğini kullanici_adi içine okumak üzere kullanici_adi_dosyasi üzerinde read_to_string metodunu çağırır. File::open başarılı olmuş olsa bile read_to_string metodu da başarısız olabileceği için yine bir Result döndürür. Dolayısıyla bu Result değerini ele almak için ikinci bir match ifadesine ihtiyaç duyarız: read_to_string başarılı olursa fonksiyonumuz amacına ulaşmış demektir ve artık kullanici_adi içinde bulunan kullanıcı adını Ok içine sararak döndürürüz. read_to_string başarısız olursa hata değerini, tıpkı File::open dönüş değerini ele alan match ifadesinde yaptığımız gibi döndürürüz. Ancak bu ifade fonksiyonun son ifadesi olduğu için burada açıkça return yazmamıza gerek yoktur.
Bu fonksiyonu çağıran kod, sonuç olarak ya kullanıcı adını içeren bir Ok değeri ya da bir io::Error içeren Err değeri alacak ve bunlarla ne yapacağına kendisi karar verecektir. Çağıran kod bir Err değeri aldığında örneğin panic! çağırıp programı durdurabilir, varsayılan bir kullanıcı adı kullanabilir ya da kullanıcı adını dosya dışında başka bir kaynaktan arayabilir. Çağıran kodun aslında ne yapmaya çalıştığına dair yeterli bilgiye sahip olmadığımız için, başarı ya da hata bilgisinin tamamını, uygun biçimde ele alması amacıyla yukarıya, çağıran koda yayarız.
Hataları yayma deseni Rust’ta o kadar yaygındır ki Rust, bu işlemi çok daha kolay hâle getirmek için soru işareti operatörünü (?) sunar.
? Operatörü Kısayolu
Liste 9-7, Liste 9-6 ile birebir aynı işlevselliğe sahip olan ancak ? operatörünü kullanan bir dosyadan_kullanici_adini_oku uygulamasını göstermektedir.
#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};
fn dosyadan_kullanici_adini_oku() -> Result<String, io::Error> {
let mut kullanici_adi_dosyasi = File::open("merhaba.txt")?;
let mut kullanici_adi = String::new();
kullanici_adi_dosyasi.read_to_string(&mut kullanici_adi)?;
Ok(kullanici_adi)
}
}
? operatörünü kullanarak hataları çağıran koda döndüren bir fonksiyonBir Result değerinin hemen sonuna yerleştirilen ? operatörü, Liste 9-6’da Result değerlerini işlemek için yazdığımız match ifadeleriyle neredeyse aynı şekilde çalışacak biçimde tanımlanmıştır. Result değeri Ok ise, Ok içindeki değer bu ifadenin sonucu olarak elde edilir ve program akışı devam eder. Değer Err ise, sanki return anahtar kelimesini kullanmışız gibi tüm fonksiyondan Err döndürülür ve böylece hata değeri çağıran koda yayılır.
Liste 9-6’daki match ifadesinin yaptığı iş ile ? operatörünün yaptığı iş arasında önemli bir fark vardır: üzerinde ? operatörü kullanılan hata değerleri, standart kütüphanedeki From trait’inde tanımlanan ve değerleri bir türden başka bir türe dönüştürmeye yarayan from fonksiyonundan geçirilir. ? operatörü from fonksiyonunu çağırdığında, alınan hata türü içinde bulunulan fonksiyonun dönüş türünde tanımlanan hata türüne dönüştürülür. Bu özellik, bir fonksiyonun içindeki adımlar birbirinden farklı nedenlerle başarısız olabilse bile fonksiyonun tüm olası başarısızlık biçimlerini tek bir hata türü altında toplayarak döndürdüğü durumlarda çok kullanışlıdır.
Örneğin Liste 9-7’deki dosyadan_kullanici_adini_oku fonksiyonunu, kendi tanımladığımız BizimHatamiz adında özel bir hata türü döndürecek şekilde değiştirebiliriz. Bir io::Error değerinden BizimHatamiz örneği üretmek üzere impl From<io::Error> for BizimHatamiz uygulamasını da tanımlarsak, dosyadan_kullanici_adini_oku gövdesindeki ? operatörü çağrıları fonksiyona tek satır ek kod yazmamıza gerek kalmadan from fonksiyonunu çağırarak hata türünü otomatik olarak dönüştürür.
Not: ? operatörünün perde arkasında Err(e) => return Err(From::from(e)) dönüşümü yapması, gerçek dünya projelerinde farklı kütüphanelerden gelen hataları birleştirmenin temel anahtarıdır. Örneğin bir yapılandırma dosyasını okuyup içindeki port numarasını ayrıştıran bir fonksiyon hem io::Error (dosya okuma) hem de ParseIntError (sayıya çevirme) üretebilir. Bu iki hatayı kapsayan bir YapilandirmaHatasi enum’ı tanımlayıp her iki hata türü için From trait’ini uyguladığınızda, fonksiyon içinde hem fs::read_to_string(yol)? hem de icerik.trim().parse()? ifadelerini yan yana, hiçbir manuel dönüştürme kodu yazmadan kullanabilirsiniz:
use std::num::ParseIntError;
use std::{fs, io};
#[derive(Debug)]
enum YapilandirmaHatasi {
Okuma(io::Error),
Ayristirma(ParseIntError),
}
impl From<io::Error> for YapilandirmaHatasi {
fn from(hata: io::Error) -> Self {
YapilandirmaHatasi::Okuma(hata)
}
}
impl From<ParseIntError> for YapilandirmaHatasi {
fn from(hata: ParseIntError) -> Self {
YapilandirmaHatasi::Ayristirma(hata)
}
}
fn port_numarasini_oku(yol: &str) -> Result<u16, YapilandirmaHatasi> {
let icerik = fs::read_to_string(yol)?; // io::Error -> YapilandirmaHatasi
let port: u16 = icerik.trim().parse()?; // ParseIntError -> YapilandirmaHatasi
Ok(port)
}
? operatörünün her satırda hangi From uygulamasını çağıracağını derleyici, fonksiyonun dönüş türündeki hata türüne bakarak kendisi belirler.
Liste 9-7 bağlamında File::open çağrısının sonundaki ?, işlem başarılıysa Ok içindeki değeri çıkarıp kullanici_adi_dosyasi değişkenine atar. Bir hata oluşursa ? operatörü tüm fonksiyondan erken çıkar ve Err değerini çağıran koda döndürür. Aynı davranış read_to_string çağrısının sonundaki ? için de geçerlidir.
? operatörü çok sayıda basmakalıp (boilerplate) kodu ortadan kaldırır ve fonksiyonun uygulamasını belirgin biçimde sadeleştirir. Hatta Liste 9-8’de gösterildiği gibi metot çağrılarını doğrudan ? sonrasına zincirleyerek bu kodu daha da kısaltabiliriz.
#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};
fn dosyadan_kullanici_adini_oku() -> Result<String, io::Error> {
let mut kullanici_adi = String::new();
File::open("merhaba.txt")?.read_to_string(&mut kullanici_adi)?;
Ok(kullanici_adi)
}
}
? operatöründen sonra metot çağrılarını zincirlemekullanici_adi değişkeninde yeni String oluşturma adımını fonksiyonun başına taşıdık; bu adımın kendisinde bir değişiklik yok. Ardından ayrı bir kullanici_adi_dosyasi değişkeni tanımlamak yerine read_to_string çağrısını doğrudan File::open("merhaba.txt")? ifadesinin sonucuna zincirledik. read_to_string çağrısının sonunda yine ? bulunuyor ve hem File::open hem de read_to_string hata döndürmeyip başarılı olduğunda kullanici_adi değerini taşıyan bir Ok döndürüyoruz. İşlevsellik Liste 9-6 ve Liste 9-7 ile birebir aynıdır; bu yalnızca aynı mantığı yazmanın daha ergonomik bir yoludur.
Liste 9-9 ise fs::read_to_string kullanarak bu işlemi daha da kısaltmanın yolunu göstermektedir.
#![allow(unused)]
fn main() {
use std::fs;
use std::io;
fn dosyadan_kullanici_adini_oku() -> Result<String, io::Error> {
fs::read_to_string("merhaba.txt")
}
}
fs::read_to_string kullanmaBir dosyayı bir string içine okumak son derece yaygın bir işlem olduğundan standart kütüphane bu iş için kullanışlı fs::read_to_string fonksiyonunu sunar: Bu fonksiyon dosyayı açar, yeni bir String oluşturur, dosyanın içeriğini okuyup bu String içine yerleştirir ve sonucu döndürür. Elbette doğrudan fs::read_to_string kullanmak bize hata yönetiminin adımlarını ayrıntılarıyla açıklama fırsatı vermeyeceği için önce uzun yoldan gittik.
? Operatörü Nerelerde Kullanılabilir?
? operatörü yalnızca, dönüş türü ? operatörünün uygulandığı değerle uyumlu olan fonksiyonların içinde kullanılabilir. Bunun nedeni ? operatörünün, tıpkı Liste 9-6’da tanımladığımız match ifadesinde olduğu gibi fonksiyondan erken değer döndürme işlemi gerçekleştirmesidir. Liste 9-6’daki match bir Result değeri üzerinde çalışıyordu ve erken dönüş kolu bir Err(e) değeri döndürüyordu. Bu erken return ile uyumlu olabilmesi için fonksiyonun dönüş türünün de bir Result olması gerekir.
Liste 9-10’da, ? uyguladığımız değerin türüyle uyumsuz dönüş türüne sahip bir main fonksiyonunda ? operatörünü kullandığımızda alacağımız derleyici hatasını inceleyelim.
use std::fs::File;
fn main() {
let karsilama_dosyasi = File::open("merhaba.txt")?;
}
() döndüren bir main fonksiyonunda ? kullanma girişimi derlenmeyecektirBu kod, başarısız olabilecek bir dosya açma işlemi yapar. File::open çağrısının döndürdüğü Result değerini ? operatörü izler; ancak bu main fonksiyonunun dönüş türü Result değil, () birim türüdür. Kodu derlediğimizde aşağıdaki hata mesajını alırız:
$ cargo run
Compiling hata-yonetimi v0.1.0 (file:///projects/hata-yonetimi)
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
--> src/main.rs:4:54
|
3 | fn main() {
| --------- this function should return `Result` or `Option` to accept `?`
4 | let karsilama_dosyasi = File::open("merhaba.txt")?;
| ^ cannot use the `?` operator in a function that returns `()`
|
help: consider adding return type
|
3 ~ fn main() -> Result<(), Box<dyn std::error::Error>> {
4 | let karsilama_dosyasi = File::open("merhaba.txt")?;
5 + Ok(())
|
For more information about this error, try `rustc --explain E0277`.
error: could not compile `hata-yonetimi` (bin "hata-yonetimi") due to 1 previous error
Bu hata (E0277), ? operatörünü yalnızca Result, Option veya FromResidual trait’ini uygulayan başka bir tür döndüren fonksiyonlarda kullanabileceğimizi belirtir; hatalı satırın altındaki etiket de () döndüren bir fonksiyonda ? operatörünün kullanılamayacağını söyler.
Bu hatayı gidermek için iki seçeneğiniz vardır. Birinci seçenek, sizi engelleyen bir kısıtlama olmadığı sürece fonksiyonunuzun dönüş türünü ? operatörünü uyguladığınız değerle uyumlu olacak biçimde değiştirmektir (nitekim derleyicinin help: satırı da tam olarak bunu önerir). İkinci seçenek ise Result<T, E> değerini uygun gördüğünüz şekilde işlemek üzere bir match ifadesi ya da Result<T, E> metotlarından birini kullanmaktır.
Hata mesajı, ? operatörünün Option<T> değerleriyle de kullanılabileceğine dikkat çekmektedir. Tıpkı Result üzerinde ? kullanımında olduğu gibi, Option üzerinde de ? operatörünü yalnızca Option döndüren bir fonksiyonun içinde kullanabilirsiniz. Bir Option<T> üzerinde çağrıldığında ? operatörünün davranışı, Result<T, E> üzerindeki davranışına çok benzer: değer None ise o noktada fonksiyondan erken olarak None döndürülür; değer Some ise Some içindeki değer ifadenin sonuç değeri olur ve fonksiyon çalışmaya devam eder. Liste 9-11’de verilen metindeki ilk satırın son karakterini bulan bir fonksiyon örneği yer almaktadır.
fn ilk_satirin_son_karakteri(metin: &str) -> Option<char> {
metin.lines().next()?.chars().last()
}
fn main() {
assert_eq!(
ilk_satirin_son_karakteri("Merhaba, dünya\nBugün nasılsın?"),
Some('a')
);
assert_eq!(ilk_satirin_son_karakteri(""), None);
assert_eq!(ilk_satirin_son_karakteri("\nmerhaba"), None);
}
Option<T> değeri üzerinde ? operatörünü kullanmaBu fonksiyon Option<char> döndürür; çünkü orada bir karakter bulunması da bulunmaması da mümkündür. Kod, metin string dilimi argümanını alır ve üzerinde string içindeki satırlar boyunca ilerleyen bir iteratör (iterator) döndüren lines metodunu çağırır. Fonksiyon ilk satırı incelemek istediğinden, iteratörden ilk değeri almak üzere next metodunu çağırır. Eğer metin boş bir string ise bu next çağrısı None döndürür; bu durumda ? operatörü işlemi durdurur ve ilk_satirin_son_karakteri fonksiyonundan None döndürür. metin boş değilse next, metin içindeki ilk satırın string dilimini tutan bir Some değeri döndürür.
? operatörü bu string dilimini dışarı çıkarır ve böylece karakterlerine ait bir iteratör elde etmek için bu dilim üzerinde doğrudan chars metodunu çağırabiliriz. İlk satırdaki son karakterle ilgilendiğimiz için iteratördeki son öğeyi almak üzere last metodunu çağırırız. İlk satırın boş bir string olma ihtimali bulunduğundan —örneğin "\nmerhaba" metninde olduğu gibi metin boş bir satırla başlayıp sonraki satırlarda karakterler içeriyorsa— last çağrısının dönüş türü de bir Option’dır. İlk satırda bir son karakter varsa bu karakter Some varyantı içinde döndürülür. Metot zincirinin ortasındaki ? operatörü bu mantığı son derece özlü biçimde ifade etmemize olanak tanıyarak fonksiyonu tek satırda yazmamızı sağlar. Option üzerinde ? operatörünü kullanamasaydık bu mantığı daha fazla metot çağrısı veya bir match ifadesiyle kurmamız gerekirdi.
Result döndüren bir fonksiyonda Result üzerinde, Option döndüren bir fonksiyonda ise Option üzerinde ? operatörünü kullanabileceğinizi; ancak bu ikisini doğrudan birbirine karıştıramayacağınızı unutmayın. ? operatörü bir Result değerini otomatik olarak Option’a ya da Option’ı Result’a dönüştürmez; böyle durumlarda dönüşümü açıkça gerçekleştirmek için Result üzerindeki ok metodu veya Option üzerindeki ok_or metodu gibi yardımcı metotları kullanabilirsiniz.
Not: Aynı fonksiyon içinde hem Option hem de Result döndüren çağrılarla çalışırken ? zincirini kesintisiz sürdürmek için şu üç dönüşüm metodu çok işinize yarar:
Option<T>→Result<T, E>(.ok_or/.ok_or_else):Resultdöndüren bir fonksiyondaNonedurumunu anlamlı bir hataya çevirip?ile yaymak içinsecenek.ok_or("Değer bulunamadı")?(sabit hata değeri için) veyasecenek.ok_or_else(|| PahaliHata::olustur())?(hatayı yalnızcaNonegeldiğinde tembel olarak üretmek için) kullanabilirsiniz.Result<T, E>→Option<T>(.ok):Optiondöndüren bir fonksiyonda hatanın ayrıntısını göz ardı edipErr(_)durumunuNoneolarak erken döndürmek içinsonuc.ok()?yazabilirsiniz.- İç içe
Option<Result<T, E>>↔Result<Option<T>, E>(.transpose): Örneğin isteğe bağlı bir metin girdisini sayıya çevirirkengirdi.map(|s| s.parse::<u32>())ifadesiOption<Result<u32, ParseIntError>>üretir; sonuna.transpose()?eklediğinizde katmanlar yer değiştirir, olası ayrıştırma hatası?ile dışarı yayılır ve elinizde temiz birOption<u32>kalır.
Şimdiye kadar yazdığımız tüm main fonksiyonları () döndürdü. main fonksiyonu, çalıştırılabilir (executable) bir programın giriş ve çıkış noktası olduğu için özeldir ve programın beklendiği gibi çalışabilmesi açısından dönüş türünün ne olabileceği konusunda belirli kısıtlamalar vardır.
Neyse ki main fonksiyonu bir Result<(), E> de döndürebilir. Liste 9-12, Liste 9-10’daki kodu içerir; ancak burada main fonksiyonunun dönüş türünü Result<(), Box<dyn Error>> olarak değiştirdik ve fonksiyonun sonuna bir Ok(()) dönüş değeri ekledik. Bu kod artık sorunsuzca derlenecektir.
use std::error::Error;
use std::fs::File;
fn main() -> Result<(), Box<dyn Error>> {
let karsilama_dosyasi = File::open("merhaba.txt")?;
Ok(())
}
main fonksiyonunu Result<(), E> döndürecek şekilde değiştirmek, Result değerleri üzerinde ? operatörünün kullanılmasına olanak tanırBox<dyn Error> türü bir trait nesnesidir; bu konuyu Bölüm 18’deki “Ortak Davranışı Soyutlamak İçin Trait Nesnelerini Kullanma” başlığında ele alacağız. Şimdilik Box<dyn Error> ifadesini “herhangi bir hata türü” şeklinde okuyabilirsiniz. Hata türü Box<dyn Error> olan bir main fonksiyonunda bir Result değeri üzerinde ? kullanılmasına izin verilir; çünkü bu tür, herhangi bir Err değerinin fonksiyondan erken döndürülmesini mümkün kılar. Bu main fonksiyonunun gövdesi şu hâliyle yalnızca std::io::Error türünde hatalar üretecek olsa bile, imzada Box<dyn Error> belirtmemiz sayesinde ileride main gövdesine farklı hata türleri döndüren yeni kodlar eklendiğinde de bu imza geçerliliğini koruyacaktır.
Bir main fonksiyonu Result<(), E> döndürdüğünde çalıştırılabilir program, main Ok(()) döndürürse 0 çıkış koduyla, bir Err değeri döndürürse sıfırdan farklı bir çıkış koduyla sonlanır. C dilinde yazılmış çalıştırılabilir programlar sonlanırken tam sayı döndürür: başarıyla tamamlanan programlar 0 tam sayısını, hata veren programlar ise 0’dan farklı bir tam sayıyı döndürür. Rust da bu yerleşik kuralla uyumlu olmak adına çalıştırılabilir dosyalardan tam sayı çıkış kodları döndürür.
Not: Box<dyn Error> türünün “herhangi bir hata türü” olarak çalışmasının ardında yine ? operatörünün From dönüşümü vardır: Standart kütüphane, Error trait’ini uygulayan her E türü için From<E> for Box<dyn Error> uygulamasını hazır sunar; dolayısıyla ? operatörü io::Error, ParseIntError ya da sizin tanımladığınız bir hata türünü otomatik olarak Box<dyn Error> içine sarabilir. main bir Err değeriyle sonlandığında Rust çalışma zamanı bu değeri Debug biçimiyle standart hata çıktısına yazar; örneğin Liste 9-12’yi merhaba.txt dosyası olmadan çalıştırırsanız Error: Os { code: 2, kind: NotFound, message: "No such file or directory" } satırını görür ve programın 1 çıkış koduyla sonlandığını gözlemlersiniz. Kullanıcıya daha okunaklı bir mesaj göstermek istediğinizde hatayı main içinde kendiniz yakalayıp eprintln! ile yazdırabilirsiniz; Bölüm 12’de tam olarak bunu yapacağız.
main fonksiyonu, bir ExitCode döndüren report fonksiyonunu barındıran std::process::Termination trait’ini uygulayan herhangi bir türü döndürebilir. Kendi türleriniz için Termination trait’ini uygulama konusunda daha fazla bilgi edinmek üzere standart kütüphane belgelerine başvurabilirsiniz.
Artık panic! çağırmanın ve Result döndürmenin ayrıntılarını incelediğimize göre, hangi durumda hangisinin uygun olduğuna nasıl karar vereceğimiz konusuna geçelim.
panic! Yapmalı mı Yapmamalı mı?
panic! Yapmalı mı Yapmamalı mı?
Peki ne zaman panic! çağırmanız, ne zaman Result döndürmeniz gerektiğine nasıl karar verirsiniz? Kod paniklediğinde normal yürütme akışı kesilir; çağıran kod, bir Err değeri alıp işlemi olağan hata akışı içinde sürdürme fırsatı bulamaz. Geri sarma kullanan programlarda bazı panikler catch_unwind ile yakalanabilse de bu mekanizma normal hata yönetiminde Result yerine kullanılmaz. Ortada bir kurtarma olasılığı bulunsun ya da bulunmasın her türlü hata durumu için panic! çağırabilirsiniz; ancak bunu yaptığınızda, durumun kurtarılamaz olduğuna çağıran kod adına siz karar vermiş olursunuz. Bir Result değeri döndürmeyi tercih ettiğinizde ise çağıran koda seçenek sunarsınız. Çağıran kod kendi bağlamına uygun bir biçimde hatayı telafi etmeyi deneyebilir ya da eldeki Err değerinin bu senaryoda kurtarılamaz olduğuna hükmedip panic! çağırarak sizin kurtarılabilir hatanızı kurtarılamaz bir hataya dönüştürebilir. Dolayısıyla, başarısız olma ihtimali bulunan bir fonksiyon tanımlarken Result döndürmek iyi bir varsayılan tercihtir.
Örnekler, prototip kodları ve testler gibi durumlarda ise Result döndürmek yerine panik yapan kod yazmak daha uygundur. Gelin önce bunun nedenini inceleyelim, ardından derleyicinin başarısızlığın imkânsız olduğunu anlayamadığı ancak bir insan olarak sizin görebildiğiniz durumları ele alalım. Bölümün sonunda da kütüphane kodlarında panik yapıp yapmamaya nasıl karar verileceğine ilişkin genel yönergelerle konuyu tamamlayacağız.
Örnekler, Prototip Kodu ve Testler
Bir kavramı açıklamak amacıyla örnek kod yazarken işin içine kapsamlı hata yönetimi kodlarını da katmak, asıl anlatılmak istenen örneği bulanıklaştırabilir. Örneklerde unwrap gibi panik yapabilecek bir metoda yapılan çağrının, uygulamanızın hataları gerçekte ele alacağı yöntem için yalnızca bir yer tutucu olduğu kabul edilir; zira bu yöntem kodunuzun geri kalanının ne yaptığına göre değişiklik gösterebilir.
Benzer şekilde unwrap ve expect metotları, prototip geliştirirken ve hataları nasıl ele alacağınıza henüz karar vermeye hazır olmadığınızda son derece pratiktir. Programınızı daha dayanıklı hâle getirmeye sıra geldiğinde kolayca bulup değiştirebileceğiniz net işaretler bırakırlar.
Bir test içinde yapılan metot çağrısı başarısız olursa, o metot doğrudan test edilen işlev olmasa bile testin tamamının başarısız sayılmasını istersiniz. Testlerin başarısız olarak işaretlenme mekanizması panic! olduğundan, testlerde unwrap veya expect çağırmak tam olarak yapılması gereken şeydir.
Derleyiciden Daha Fazla Bilgiye Sahip Olduğunuz Durumlar
Result değerinin kesinlikle Ok olmasını güvence altına alan başka bir mantığınız bulunduğunda, ancak derleyici bu mantığı kendi başına kavrayamadığında da expect çağırmak yerinde olur. Yine de ele almanız gereken bir Result değeriyle karşılaşırsınız; çünkü çağırdığınız işlem, sizin özel senaryonuzda mantıksal açıdan imkânsız olsa bile genel anlamda başarısız olma ihtimalini taşır. Kodu bizzat inceleyerek hiçbir zaman Err varyantıyla karşılaşmayacağınızdan emin olabiliyorsanız expect çağırmak ve hiçbir zaman Err oluşmayacağını düşünme gerekçenizi argüman metninde belgelemek tamamen kabul edilebilirdir. İşte bir örnek:
fn main() {
use std::net::IpAddr;
let ev: IpAddr = "127.0.0.1"
.parse()
.expect("Sabit kodlanmış IP adresi geçerli olmalıdır");
}
Burada sabit kodlanmış (hardcoded) bir string dilimini ayrıştırarak bir IpAddr örneği oluşturuyoruz. 127.0.0.1 ifadesinin geçerli bir IP adresi olduğunu gözümüzle görebildiğimiz için burada expect kullanmak uygundur. Ancak elimizde sabit kodlanmış ve geçerli bir string bulunması parse metodunun dönüş türünü değiştirmez: Yine bir Result değeri elde ederiz ve derleyici bu string’in her zaman geçerli bir IP adresi olduğunu anlayacak kadar akıllı olmadığından, Err varyantı hâlâ bir olasılıkmış gibi bizi Result değerini ele almaya zorlar. Eğer IP adresi string’i programa sabit kodlanmak yerine bir kullanıcıdan alınsaydı ve dolayısıyla gerçekten başarısız olma ihtimali taşıyor olsaydı, Result değerini kesinlikle daha dayanıklı bir yöntemle ele almak isterdik. expect mesajında bu IP adresinin sabit kodlanmış olduğu varsayımını açıkça belirtmek, gelecekte IP adresini başka bir kaynaktan almamız gerektiğinde expect çağrısını daha sağlam bir hata yönetimi koduyla değiştirmemiz gerektiğini bize hatırlatacaktır.
Hata Yönetimi Yönergeleri
Kodunuzun kötü bir duruma sürüklenme ihtimali varsa panik yapmasını sağlamak yerinde bir yaklaşımdır. Bu bağlamda kötü durum (bad state); kodunuza geçersiz, birbiriyle çelişen veya eksik değerler iletilmesi gibi nedenlerle bir varsayımın, güvencenin, sözleşmenin ya da değişmez koşulun (invariant) ihlal edilmesi ve buna ek olarak aşağıdaki koşullardan birinin veya birkaçının geçerli olmasıdır:
- Kötü durum, kullanıcının veriyi yanlış biçimde girmesi gibi ara sıra yaşanması doğal karşılanan durumların aksine beklenmedik bir olaydır.
- Bu noktadan sonraki kodunuz, sorunu her adımda yeniden kontrol etmek yerine söz konusu kötü durumda bulunmadığına güvenebilmelidir.
- Bu kısıtı kullandığınız türlerin içine kodlamanın uygun bir yolu yoktur. Bununla ne kastettiğimizi Bölüm 18’deki “Durumları ve Davranışı Türlerin Kendisi Olarak Kodlamak” başlığında bir örnek üzerinden inceleyeceğiz.
Birisi kodunuzu çağırıp mantıksız değerler ilettiğinde, mümkünse bir hata döndürmek en doğrusudur; böylece kütüphanenizi kullanan geliştirici o durumda ne yapacağına kendi karar verebilir. Ancak çalışmaya devam etmenin güvenlik açığı doğurabileceği veya zararlı olabileceği durumlarda en doğru tercih panic! çağırmak ve kütüphanenizi kullanan kişiyi kendi kodundaki kusur (bug) konusunda uyararak hatayı geliştirme aşamasında düzeltmesini sağlamaktır. Benzer biçimde, denetiminiz dışındaki harici bir kodu çağırıyorsanız ve bu kod sizin düzeltemeyeceğiniz geçersiz bir durum döndürüyorsa panic! kullanmak çoğu zaman uygundur.
Buna karşılık başarısızlık beklenen bir olasılıksa, panic! çağırmak yerine Result döndürmek çok daha doğrudur. Bir ayrıştırıcıya (parser) hatalı biçimlendirilmiş veri verilmesi veya bir HTTP isteğinin istek sınırına (rate limit) ulaşıldığını belirten bir durum kodu döndürmesi buna örnektir. Bu tür durumlarda Result döndürmek, başarısızlığın olağan bir ihtimal olduğunu ve çağıran kodun bu durumu nasıl ele alacağına karar vermesi gerektiğini açıkça ifade eder.
Kodunuz geçersiz değerlerle çağrıldığında kullanıcıyı riske atabilecek bir işlem gerçekleştiriyorsa, önce değerlerin geçerliliğini doğrulamalı ve değerler geçersizse panik yapmalıdır. Bunun temel nedeni güvenliktir: Geçersiz veriler üzerinde işlem yapmaya çalışmak kodunuzu güvenlik açıklarına maruz bırakabilir. Sınır dışı bellek erişimi yapmaya kalkıştığınızda standart kütüphanenin panic! çağırmasının başlıca sebebi de budur: Mevcut veri yapısına ait olmayan bir bellek bölgesine erişmeye çalışmak yaygın bir güvenlik sorunudur. Fonksiyonların çoğu zaman sözleşmeleri (contracts) vardır; yani davranışları yalnızca girdiler belirli gereksinimleri karşıladığında garanti edilir. Sözleşme ihlal edildiğinde panik yapmak mantıklıdır, çünkü bir sözleşme ihlali her zaman çağıran taraftaki bir kusura işaret eder ve çağıran kodun çalışma zamanında açıkça ele almasını isteyeceğiniz türden bir hata değildir. Üstelik çağıran kodun bu durumdan kurtulmasının makul bir yolu da yoktur; kodu çağıran programcıların hatayı kaynak kodda düzeltmesi gerekir. Bir fonksiyonun sözleşmesi —özellikle de ihlali paniğe yol açacaksa— fonksiyonun API dokümantasyonunda mutlaka açıklanmalıdır.
Öte yandan tüm fonksiyonlarınıza çok sayıda hata denetimi eklemek kodu gereksiz yere uzatacak ve yorucu hâle getirecektir. Neyse ki bu denetimlerin büyük bölümünü sizin yerinize yapması için Rust’ın tür sisteminden —ve dolayısıyla derleyicinin yürüttüğü tür denetiminden— yararlanabilirsiniz. Fonksiyonunuz parametre olarak belirli bir tür kabul ediyorsa, derleyicinin geçerli bir değere sahip olduğunuzu zaten doğruladığını bilerek iş mantığınızı güvenle sürdürebilirsiniz. Örneğin parametre olarak Option yerine doğrudan somut bir tür tanımladığınızda, programınız hiçbir şey yerine kesinlikle bir değer bekler. Böylece kodunuzun Some ve None varyantları için iki ayrı durumu ele alması gerekmez; ortada kesinlikle bir değer olduğu tek bir durum vardır. Fonksiyonunuza “hiçbir şey” göndermeye çalışan bir kod daha derleme aşamasında reddedileceğinden, fonksiyonunuzun çalışma zamanında bunu kontrol etmesine gerek kalmaz. Bir diğer örnek de parametrenin hiçbir zaman negatif olmamasını garanti etmek için u32 gibi işaretsiz bir tam sayı türü kullanmaktır.
Doğrulama İçin Özel Türler
Geçerli bir değere sahip olduğumuzdan emin olmak için Rust’ın tür sistemini kullanma fikrini bir adım ileri taşıyalım ve doğrulama amacıyla özel bir tür oluşturmayı inceleyelim. Bölüm 2’de kullanıcıdan 1 ile 100 arasında bir sayı tahmin etmesini istediğimiz tahmin oyununu hatırlayın. Kullanıcının tahminini gizli sayımızla karşılaştırmadan önce bu aralıkta olup olmadığını hiç doğrulamamış, u32 türüne ayrıştırma yaparak yalnızca negatif olmadığından ve bu türün aralığına sığdığından emin olmuştuk. Sıfır da geçerli bir u32 değeridir. O örnekte bunun sonuçları çok ağır değildi; “Çok büyük!” veya “Çok küçük!” çıktılarımız yine doğru kalıyordu. Yine de kullanıcıyı geçerli tahminlere yönlendirmek ve aralık dışında bir sayı girilmesi ile örneğin harf yazılması durumlarında farklı davranışlar sergilemek faydalı bir iyileştirme olurdu.
Bunu yapmanın bir yolu, negatif sayıları da yakalayabilmek için tahmini u32 yerine i32 olarak ayrıştırmak ve ardından sayının geçerli aralıkta olup olmadığını denetleyen şöyle bir kontrol eklemektir:
use std::cmp::Ordering;
use std::io;
use rand::prelude::*;
fn main() {
println!("Sayıyı tahmin et!");
let gizli_sayi = rand::rng().random_range(1..=100);
loop {
// --snip--
println!("Lütfen tahmininizi girin.");
let mut tahmin = String::new();
io::stdin()
.read_line(&mut tahmin)
.expect("Satır okunamadı");
let tahmin: i32 = match tahmin.trim().parse() {
Ok(sayi) => sayi,
Err(_) => continue,
};
if tahmin < 1 || tahmin > 100 {
println!("Gizli sayı 1 ile 100 arasında olacak.");
continue;
}
match tahmin.cmp(&gizli_sayi) {
// --snip--
Ordering::Less => println!("Çok küçük!"),
Ordering::Greater => println!("Çok büyük!"),
Ordering::Equal => {
println!("Kazandınız!");
break;
}
}
}
}
Buradaki if ifadesi değerimizin geçerli aralığın dışında olup olmadığını kontrol eder; aralık dışındaysa kullanıcıyı bilgilendirir ve döngünün bir sonraki adımına geçip yeni bir tahmin istemek üzere continue çağırır. if ifadesinden sonra ise tahmin değerinin 1 ile 100 arasında olduğundan emin bir şekilde tahmin ile gizli_sayi arasındaki karşılaştırmalara devam edebiliriz.
Ne var ki bu ideal bir çözüm değildir: Programın yalnızca 1 ile 100 arasındaki değerler üzerinde çalışması kritik bir gereklilik olsaydı ve bu koşulu şart koşan pek çok fonksiyon bulunsaydı, her fonksiyonda aynı kontrolü tekrarlamak hem zahmetli olurdu hem de performansı olumsuz etkileyebilirdi.
Doğrulamaları her yerde tekrarlamak yerine, ayrı bir modülde yeni bir tür tanımlayabilir ve tüm doğrulama mantığını o türün örneğini oluşturan tek bir fonksiyonda toplayabiliriz. Böylece diğer fonksiyonlar imzalarında bu yeni türü güvenle kullanabilir ve aldıkları değerleri yeniden denetlemeye gerek duymadan işleyebilir. Liste 9-13, yalnızca new fonksiyonuna 1 ile 100 arasında bir değer verildiğinde örnek oluşturan bir Tahmin türünü tanımlamanın bir yolunu göstermektedir.
#![allow(unused)]
fn main() {
pub struct Tahmin {
deger: i32,
}
impl Tahmin {
pub fn new(deger: i32) -> Tahmin {
if deger < 1 || deger > 100 {
panic!(
"Tahmin değeri 1 ile 100 arasında olmalıdır, {deger} alındı."
);
}
Tahmin { deger }
}
pub fn deger(&self) -> i32 {
self.deger
}
}
}
Tahmin türüsrc/tahmin_oyunu.rs dosyasındaki bu kodun, src/main.rs dosyasına —burada göstermediğimiz— mod tahmin_oyunu; modül bildiriminin eklenmesine bağlı olduğunu unutmayın (modülleri ayrı dosyalara bölmeyi Bölüm 7’de ele almıştık). Bu yeni modül dosyasında, içinde i32 tutan deger adında bir alana sahip Tahmin struct’ını tanımlıyoruz. Sayının saklanacağı yer burasıdır.
Ardından Tahmin üzerinde, Tahmin örnekleri oluşturan new adında bir ilişkili fonksiyon tanımlıyoruz. new fonksiyonu i32 türünde deger adlı tek bir parametre alacak ve bir Tahmin döndürecek biçimde tanımlanmıştır. Fonksiyon gövdesindeki kod, deger parametresinin 1 ile 100 arasında olup olmadığını sınar. deger bu sınamayı geçemezse panic! çağrısı yaparız; bu da çağıran kodu yazan programcıyı düzeltmesi gereken bir kusur bulunduğu konusunda uyarır, çünkü bu aralığın dışındaki bir deger ile Tahmin oluşturmaya çalışmak Tahmin::new fonksiyonunun dayandığı sözleşmeyi ihlal eder. Tahmin::new fonksiyonunun hangi koşullarda panik yapabileceği dışa açık API dokümantasyonunda belirtilmelidir; kendi oluşturacağınız API dokümantasyonunda panic! olasılığını bildirme kurallarını Bölüm 14’te ele alacağız. Eğer deger sınamayı geçerse, deger alanı bu parametreye ayarlanmış yeni bir Tahmin oluşturup döndürürüz.
Sonrasında self’i ödünç alan, başka hiçbir parametre kabul etmeyen ve bir i32 döndüren deger adında bir metot tanımlıyoruz. Bu tür metotlara, amaçları struct alanlarındaki veriyi alıp dışarıya döndürmek olduğu için kimi zaman alıcı metot (getter) denir. Tahmin struct’ının deger alanı gizli (private) olduğundan bu açık (pub) metoda ihtiyaç vardır. deger alanının gizli kalması kritik önem taşır; böylece Tahmin struct’ını kullanan kodların deger alanını doğrudan atamasına izin verilmez. tahmin_oyunu modülü dışındaki kodlar yeni bir Tahmin örneği oluşturmak için Tahmin::new fonksiyonunu kullanmak zorundadır; bu da Tahmin::new içindeki koşullardan geçmemiş bir deger barındıran hiçbir Tahmin örneğinin var olamayacağını garanti eder.
Böylelikle yalnızca 1 ile 100 arasındaki sayıları parametre olarak kabul eden veya döndüren bir fonksiyon, imzasında i32 yerine Tahmin aldığını ya da döndürdüğünü bildirebilir ve gövdesinde ek bir aralık denetimi yapmasına gerek kalmaz.
Not: Liste 9-13’teki yaklaşım, tür odaklı tasarımda “Ayrıştır, doğrulama yapma” (Parse, don’t validate) olarak bilinen ilkenin Rust’taki doğrudan karşılığıdır. Sıradan bir doğrulama kontrolü (if tahmin < 1 || tahmin > 100) geçildikten sonra değişkeniniz hâlâ düz bir i32 olarak kalır; yani derleyici bu değerin doğrulandığı bilgisini sonraki fonksiyonlara taşıyamaz. Oysa veriyi doğrulanmış bir Tahmin türüne dönüştürdüğünüzde bu güvence bizzat türün kendisine kazınır.
Rust’ın modül gizliliği kuralları bu güvenceyi sızdırmaz hâle getirir: pub struct Tahmin dışarıya açık olsa bile deger alanı gizli olduğundan, tahmin_oyunu modülü dışındaki hiçbir kod Tahmin { deger: 500 } struct ifadesiyle doğrulamayı atlayarak örnek oluşturamaz veya mevcut bir örneğin alanını sonradan değiştiremez. Üstelik bu deseni hem sözleşme ihlallerinde panik yapan kurucularla (fn new(deger: i32) -> Tahmin) hem de doğrudan kullanıcı girdisi işlerken kurtarılabilir hata döndüren kurucularla (fn new(deger: i32) -> Result<Tahmin, String>) birleştirerek geçersiz durumları programınızda derleme zamanında temsil edilemez kılabilirsiniz.
Özet
Rust’ın hata yönetimi özellikleri daha sağlam kodlar yazmanıza yardımcı olmak üzere tasarlanmıştır. panic! makrosu, programınızın başa çıkamayacağı bir durumda olduğunu bildirir ve geçersiz ya da hatalı değerlerle ilerlemeye çalışmak yerine süreci durdurmanıza olanak tanır. Result enum’ı ise işlemlerin kodunuzun telafi edebileceği bir biçimde başarısız olabileceğini ifade etmek için Rust’ın tür sisteminden yararlanır. Kodunuzu çağıran koda olası başarı ve başarısızlık durumlarını ele alması gerektiğini bildirmek için Result kullanabilirsiniz. Uygun senaryolarda panic! ve Result kullanmak, kodunuzu kaçınılmaz sorunlar karşısında çok daha güvenilir kılacaktır.
Standart kütüphanenin Option ve Result enum’larında jenerikleri ne kadar işlevsel biçimde kullandığını gördüğünüze göre, artık jeneriklerin nasıl çalıştığını ve onları kendi kodunuzda nasıl kullanabileceğinizi incelemeye geçebiliriz.
Jenerik Türler, Trait’ler ve Ömürler
Her programlama dili, kavram tekrarını etkili biçimde ele almak için çeşitli araçlar sunar. Rust’ta bu araçlardan biri jeneriklerdir (generics); yani somut türlerin veya diğer özelliklerin yerine geçen soyut yer tutuculardır. Kod derlenip çalıştırıldığında yerlerine ne geleceğini önceden bilmeden, jeneriklerin davranışını veya diğer jeneriklerle nasıl bir ilişki kurduğunu ifade edebiliriz.
Tıpkı fonksiyonların aynı kodu birden fazla somut değer üzerinde çalıştırabilmek için değeri önceden bilinmeyen parametreler alması gibi, fonksiyonlar i32 veya String gibi somut bir tür yerine jenerik bir türde parametreler de alabilir. Aslında jenerikleri Bölüm 6’da Option<T>, Bölüm 8’de Vec<T> ve HashMap<K, V>, Bölüm 9’da ise Result<T, E> ile zaten kullandık. Bu bölümde kendi türlerinizi, fonksiyonlarınızı ve metotlarınızı jeneriklerle nasıl tanımlayacağınızı keşfedeceksiniz!
İlk olarak, kod tekrarını azaltmak için tekrarlayan mantığı ayrı bir fonksiyona çıkarmayı gözden geçireceğiz. Ardından aynı tekniği kullanarak, yalnızca parametre türleri bakımından farklılaşan iki fonksiyondan jenerik bir fonksiyon elde edeceğiz. Ayrıca struct ve enum tanımlarında jenerik türlerin nasıl kullanıldığını da inceleyeceğiz.
Daha sonra, davranışı jenerik bir biçimde tanımlamak için trait’leri nasıl kullanacağınızı öğreneceksiniz. Bir jenerik türü rastgele herhangi bir türü kabul etmek yerine yalnızca belirli bir davranışa sahip türleri kabul edecek şekilde sınırlandırmak için trait’leri jenerik türlerle birleştirebilirsiniz.
Son olarak, referansların birbiriyle nasıl ilişkili olduğu konusunda derleyiciye bilgi veren özel bir jenerik çeşidi olan ömürleri (lifetimes) ele alacağız. Ömürler, ödünç alınan değerler hakkında derleyiciye yeterli bilgi sunmamızı sağlar; böylece derleyici, bizim yardımımız olmadan doğrulayabileceğinden çok daha fazla senaryoda referansların geçerli kalacağını garanti altına alabilir.
Not: Rust’ın tür sisteminde jenerikler, trait’ler ve ömürler birbirini tamamlayan üçlü bir yapı oluşturur:
- Jenerik türler (
<T>), bir fonksiyonun veya veri yapısının hangi türlerle çalışabileceğini soyutlar (“Ne tür bir veri?”). - Trait’ler (
T: PartialOrdgibi sınırlarla), bu soyut türlerin sahip olması gereken davranışları tanımlar ve kısıtlar (“Bu tür neler yapabilir?”). - Ömürler (
'a), ödünç alınan referansların geçerlilik kapsamlarını birbiriyle ilişkilendirir (“Bu referans hangi kapsam boyunca geçerlidir?”).
En önemli nokta ise bu üç mekanizmanın tamamının derleme zamanında çözümlenmesidir. Derleyici, jenerik tanımları programda kullanılan her somut tür için özelleştirilmiş makine koduna dönüştürür, trait sınırlarını doğrular ve ömür kurallarını denetledikten sonra ömür bildirimlerini ikili dosyadan tamamen siler. Böylece çalışma zamanında hiçbir ek bellek veya hız maliyeti ödemeden (sıfır maliyetli soyutlama) hem esnek hem de tam tür ve bellek güvenliğine sahip kod yazabilirsiniz.
Fonksiyon Çıkararak Kod Tekrarını Kaldırma
Jenerikler, kod tekrarını ortadan kaldırmak amacıyla belirli türlerin yerine birden fazla türü temsil eden bir yer tutucu koymamızı sağlar. Jenerik sözdizimine geçmeden önce, jenerik türler kullanmadan kod tekrarını nasıl giderebileceğimize bakalım: belirli değerlerin yerine birden fazla değeri temsil eden bir parametre koyacak şekilde ayrı bir fonksiyon çıkaracağız. Ardından aynı tekniği jenerik bir fonksiyon çıkarmak için uygulayacağız! Bir fonksiyona aktarabileceğiniz tekrarlayan kodları nasıl tespit edeceğinizi gördükçe, jeneriklerden yararlanabilecek tekrarlayan kodları da kolayca fark etmeye başlayacaksınız.
Bir listedeki en büyük sayıyı bulan Liste 10-1’deki kısa programla başlayalım.
fn main() {
let sayi_listesi = vec![34, 50, 25, 100, 65];
let mut en_buyuk = &sayi_listesi[0];
for sayi in &sayi_listesi {
if sayi > en_buyuk {
en_buyuk = sayi;
}
}
println!("En büyük sayı: {en_buyuk}");
assert_eq!(*en_buyuk, 100);
}
sayi_listesi değişkeninde bir tam sayı listesi saklar ve listedeki ilk sayının referansını en_buyuk adlı değişkene yerleştiririz. Ardından listedeki tüm sayılar üzerinde sırayla dolaşırız; o anki sayi değeri en_buyuk değişkeninin işaret ettiği sayıdan büyükse, en_buyuk içindeki referansı bu yeni sayıyı gösterecek şekilde güncelleriz. Buna karşılık, o anki sayı şimdiye dek görülen en büyük sayıdan küçük veya ona eşitse değişken değişmez ve kod listedeki bir sonraki sayıya geçer. Listedeki tüm sayılar incelendikten sonra en_buyuk değişkeni listedeki en büyük sayıyı işaret eder; bu örnekte söz konusu değer 100’dür.
Şimdi iki farklı sayı listesindeki en büyük sayıyı bulmamız istendiğini düşünelim. Bunu yapmak için Liste 10-2’de gösterildiği gibi Liste 10-1’deki kodu kopyalayıp aynı mantığı programın iki farklı yerinde kullanmayı seçebiliriz.
fn main() {
let sayi_listesi = vec![34, 50, 25, 100, 65];
let mut en_buyuk = &sayi_listesi[0];
for sayi in &sayi_listesi {
if sayi > en_buyuk {
en_buyuk = sayi;
}
}
println!("En büyük sayı: {en_buyuk}");
let sayi_listesi = vec![102, 34, 6000, 89, 54, 2, 43, 8];
let mut en_buyuk = &sayi_listesi[0];
for sayi in &sayi_listesi {
if sayi > en_buyuk {
en_buyuk = sayi;
}
}
println!("En büyük sayı: {en_buyuk}");
}
Bu kod çalışsa da kodu bu şekilde kopyalamak hem zahmetli hem de hataya açıktır. Üstelik mantıkta bir değişiklik yapmak istediğimizde kodu birden fazla yerde güncellemeyi unutmamamız gerekir.
Bu tekrarı ortadan kaldırmak için, parametre olarak geçirilen herhangi bir tam sayı listesi üzerinde çalışan bir fonksiyon tanımlayarak bir soyutlama oluşturacağız. Bu çözüm kodumuzu daha anlaşılır hâle getirir ve bir listedeki en büyük sayıyı bulma kavramını soyut bir biçimde ifade etmemizi sağlar.
Liste 10-3’te en büyük sayıyı bulan kodu en_buyuk_bul adlı bir fonksiyona çıkarıyoruz. Ardından Liste 10-2’deki iki listede yer alan en büyük sayıları bulmak için bu fonksiyonu çağırıyoruz. Gelecekte elimizde olabilecek başka herhangi bir i32 değer listesi için de aynı fonksiyonu kullanabiliriz.
fn en_buyuk_bul(liste: &[i32]) -> &i32 {
let mut en_buyuk = &liste[0];
for oge in liste {
if oge > en_buyuk {
en_buyuk = oge;
}
}
en_buyuk
}
fn main() {
let sayi_listesi = vec![34, 50, 25, 100, 65];
let sonuc = en_buyuk_bul(&sayi_listesi);
println!("En büyük sayı: {sonuc}");
assert_eq!(*sonuc, 100);
let sayi_listesi = vec![102, 34, 6000, 89, 54, 2, 43, 8];
let sonuc = en_buyuk_bul(&sayi_listesi);
println!("En büyük sayı: {sonuc}");
assert_eq!(*sonuc, 6000);
}
en_buyuk_bul fonksiyonu, fonksiyona geçirebileceğimiz herhangi bir somut i32 dilimini (&[i32]) temsil eden liste adında bir parametreye sahiptir. Sonuç olarak fonksiyonu çağırdığımızda kod, o çağrıda geçirdiğimiz belirli değerler üzerinde çalışır.
Özetle, Liste 10-2’deki kodu Liste 10-3’teki hâline dönüştürürken izlediğimiz adımlar şunlardır:
- Tekrarlanan kodu belirleyin.
- Tekrarlanan kodu bir fonksiyonun gövdesine çıkarın; bu kodun girdilerini ve dönüş değerini fonksiyon imzasında belirtin.
- Tekrarlanan kodun bulunduğu iki yeri, aynı kodu tekrar yazmak yerine bu fonksiyonu çağıracak şekilde güncelleyin.
Bir sonraki adımda, kod tekrarını azaltmak için aynı adımları jeneriklerle uygulayacağız. Tıpkı fonksiyon gövdesinin belirli değerler yerine soyut bir liste parametresi üzerinde çalışabilmesi gibi, jenerikler de kodun soyut türler üzerinde çalışmasına olanak tanır.
Örneğin elimizde iki fonksiyon olduğunu varsayalım: biri i32 değerlerinden oluşan bir dilimdeki en büyük öğeyi, diğeri ise char değerlerinden oluşan bir dilimdeki en büyük öğeyi bulsun. Bu tekrarı nasıl ortadan kaldırırız? Gelin birlikte öğrenelim!
Jenerik Veri Türleri
Jenerik Veri Türleri
Fonksiyon imzaları veya struct’lar gibi öğeler için daha sonra pek çok farklı somut veri türüyle kullanabileceğimiz tanımlar oluşturmak amacıyla jeneriklerden yararlanırız. Önce fonksiyonları, struct’ları, enum’ları ve metotları jenerikler kullanarak nasıl tanımlayacağımıza bakalım. Ardından jeneriklerin kod performansını nasıl etkilediğini ele alacağız.
Fonksiyon Tanımlarında
Jenerik kullanan bir fonksiyon tanımlarken, jenerikleri fonksiyon imzasında normalde parametrelerin ve dönüş değerinin veri türlerini belirttiğimiz yerlere yazarız. Bu yaklaşım kod tekrarını önlerken kodumuzu daha esnek hâle getirir ve fonksiyonumuzu çağıranlara daha geniş bir işlevsellik sunar.
en_buyuk_bul fonksiyonumuzdan devam edelim: Liste 10-4, her ikisi de bir dilimdeki en büyük değeri bulan iki fonksiyon göstermektedir. Ardından bunları jenerik kullanan tek bir fonksiyonda birleştireceğiz.
fn en_buyuk_i32(liste: &[i32]) -> &i32 {
let mut en_buyuk = &liste[0];
for oge in liste {
if oge > en_buyuk {
en_buyuk = oge;
}
}
en_buyuk
}
fn en_buyuk_char(liste: &[char]) -> &char {
let mut en_buyuk = &liste[0];
for oge in liste {
if oge > en_buyuk {
en_buyuk = oge;
}
}
en_buyuk
}
fn main() {
let sayi_listesi = vec![34, 50, 25, 100, 65];
let sonuc = en_buyuk_i32(&sayi_listesi);
println!("En büyük sayı: {sonuc}");
assert_eq!(*sonuc, 100);
let karakter_listesi = vec!['y', 'm', 'a', 'q'];
let sonuc = en_buyuk_char(&karakter_listesi);
println!("En büyük karakter: {sonuc}");
assert_eq!(*sonuc, 'y');
}
en_buyuk_i32 fonksiyonu, Liste 10-3’te ayrı bir fonksiyona çıkardığımız ve bir dilimdeki en büyük i32 değerini bulan fonksiyondur. en_buyuk_char fonksiyonu ise bir dilimdeki en büyük char değerini bulur. Her iki fonksiyonun gövdesi birebir aynı koda sahip olduğundan, tek bir fonksiyonda jenerik bir tür parametresi tanımlayarak bu tekrarı ortadan kaldıralım.
Yeni ve tek bir fonksiyonda türleri parametreleştirmek için, tıpkı bir fonksiyonun değer parametrelerine ad verdiğimiz gibi tür parametresine de bir ad vermemiz gerekir. Tür parametresi adı olarak geçerli herhangi bir tanımlayıcıyı kullanabilirsiniz. Ancak biz T kullanacağız; çünkü Rust’ta geleneksel olarak tür parametresi adları kısadır —çoğu zaman tek bir harften oluşur— ve Rust’ın tür adlandırma kuralı UpperCamelCase biçimindedir. İngilizce type (tür) kelimesinin kısaltması olan T, çoğu Rust programcısının varsayılan tercihidir.
Fonksiyon gövdesinde bir parametre kullandığımızda, derleyicinin bu adın ne anlama geldiğini bilmesi için parametre adını imzada bildirmemiz gerekir. Benzer şekilde, bir fonksiyon imzasında bir tür parametresi adı kullanacağımızda da bu adı kullanmadan önce bildirmeliyiz. Jenerik en_buyuk_bul fonksiyonunu tanımlamak için tür adı bildirimlerini fonksiyon adı ile parametre listesi arasına, açılı ayraçlar (<>) içine yerleştiririz:
fn en_buyuk_bul<T>(liste: &[T]) -> &T {
Bu tanımı, “en_buyuk_bul fonksiyonu bir T türü üzerinde jeneriktir” şeklinde okuruz. Bu fonksiyon, T türündeki değerlerden oluşan bir dilim olan liste adında tek bir parametre alır. en_buyuk_bul fonksiyonu yine aynı T türündeki bir değere referans döndürecektir.
Liste 10-5, imzasında jenerik veri türünü kullanan birleştirilmiş en_buyuk_bul fonksiyon tanımını gösterir. Liste ayrıca fonksiyonu ister i32 değerlerinden ister char değerlerinden oluşan bir dilimle nasıl çağırabileceğimizi de gösterir. Bu kodun henüz derlenmeyeceğine dikkat edin.
fn en_buyuk_bul<T>(liste: &[T]) -> &T {
let mut en_buyuk = &liste[0];
for oge in liste {
if oge > en_buyuk {
en_buyuk = oge;
}
}
en_buyuk
}
fn main() {
let sayi_listesi = vec![34, 50, 25, 100, 65];
let sonuc = en_buyuk_bul(&sayi_listesi);
println!("En büyük sayı: {sonuc}");
let karakter_listesi = vec!['y', 'm', 'a', 'q'];
let sonuc = en_buyuk_bul(&karakter_listesi);
println!("En büyük karakter: {sonuc}");
}
en_buyuk_bul fonksiyonu; bu kod henüz derlenmezBu kodu mevcut hâliyle derlersek şu hatayı alırız:
$ cargo run
Compiling bolum10 v0.1.0 (file:///projects/bolum10)
error[E0369]: binary operation `>` cannot be applied to type `&T`
--> src/main.rs:5:16
|
5 | if oge > en_buyuk {
| --- ^ -------- &T
| |
| &T
|
help: consider restricting type parameter `T` with trait `PartialOrd`
|
1 | fn en_buyuk_bul<T: std::cmp::PartialOrd>(liste: &[T]) -> &T {
| ++++++++++++++++++++++
For more information about this error, try `rustc --explain E0369`.
error: could not compile `bolum10` (bin "bolum10") due to 1 previous error
Derleyici, binary operation '>' cannot be applied to type '&T' (> ikili işlemi &T türüne uygulanamaz) hatasını verir ve yardım (help) satırında bir trait olan std::cmp::PartialOrd’dan söz eder; bir sonraki bölümde trait’leri ele alacağız. Şimdilik bu hatanın, en_buyuk_bul gövdesinin T’nin alabileceği olası tüm türler için çalışmayacağını belirttiğini bilmeniz yeterlidir. Gövde içinde T türündeki değerleri karşılaştırmak istediğimizden, yalnızca değerleri sıralanabilen türleri kullanabiliriz. Karşılaştırma işlemlerini mümkün kılmak üzere standart kütüphanede, türler üzerinde uygulayabileceğiniz std::cmp::PartialOrd trait’i bulunur (bu trait hakkında daha fazla bilgi için Ek C’ye bakabilirsiniz). Liste 10-5’i düzeltmek için yardım metninin önerisini izleyerek T için geçerli olan türleri yalnızca PartialOrd uygulayanlarla sınırlandırabiliriz. Standart kütüphane hem i32 hem de char için PartialOrd trait’ini uyguladığından kod bu değişiklikle sorunsuz derlenecektir.
Not: Jenerik bir fonksiyonu çağırırken T yerine hangi somut türün geçeceğini genellikle açıkça yazmazsınız; derleyici bunu argümanlardan çıkarır. Liste 10-5’teki en_buyuk_bul(&sayi_listesi) çağrısında sayi_listesi bir Vec<i32> olduğu için T kendiliğinden i32 olur; en_buyuk_bul(&karakter_listesi) çağrısında ise char olur. Derleyicinin türü argümanlardan çıkaramadığı ya da birden fazla türün mümkün olduğu durumlarda türü, turbofish adı verilen ::<> sözdizimiyle doğrudan çağrıda belirtebilirsiniz: en_buyuk_bul::<i32>(&sayi_listesi). Her iki durumda da derleyici, bu bölümün sonunda göreceğiniz gibi kullanılan her somut tür için fonksiyonun ayrı bir kopyasını üretir.
Struct Tanımlarında
<> sözdizimini kullanarak bir veya daha fazla alanında jenerik tür parametresi barındıran struct’lar da tanımlayabiliriz. Liste 10-6, herhangi bir türden x ve y koordinat değerlerini tutacak bir Nokta<T> struct’ı tanımlar.
struct Nokta<T> {
x: T,
y: T,
}
fn main() {
let tamsayi = Nokta { x: 5, y: 10 };
let ondalikli = Nokta { x: 1.0, y: 4.0 };
}
T türünde x ve y değerlerini tutan bir Nokta<T> struct’ıStruct tanımlarında jenerik kullanma sözdizimi, fonksiyon tanımlarındakine benzer. Önce struct adının hemen ardından açılı ayraçlar içinde tür parametresinin adını bildiririz. Ardından struct tanımında normalde somut veri türlerini yazacağımız yerlerde bu jenerik türü kullanırız.
Nokta<T>’yi tanımlarken yalnızca tek bir jenerik tür kullandığımız için bu tanım, Nokta<T> struct’ının bir T türü üzerinde jenerik olduğunu ve bu tür hangisi olursa olsun x ile y alanlarının her ikisinin de aynı türde olduğunu söyler. Liste 10-7’deki gibi farklı türlerde değerlere sahip bir Nokta<T> örneği oluşturmaya çalışırsak kodumuz derlenmez.
struct Nokta<T> {
x: T,
y: T,
}
fn main() {
let calismayacak = Nokta { x: 5, y: 4.0 };
}
T jenerik veri türüne sahip olduğu için x ve y alanları aynı türde olmalıdır.Bu örnekte x alanına tam sayı olan 5 değerini atadığımızda, derleyiciye bu Nokta<T> örneği için T jenerik türünün bir tam sayı olacağını bildirmiş oluruz. Ardından x ile aynı türde olmasını şart koştuğumuz y alanı için 4.0 değerini verdiğimizde, expected integer, found floating-point number (tam sayı bekleniyordu, kayan noktalı sayı bulundu) açıklamasıyla şu tür uyuşmazlığı hatasını alırız:
$ cargo run
Compiling bolum10 v0.1.0 (file:///projects/bolum10)
error[E0308]: mismatched types
--> src/main.rs:7:41
|
7 | let calismayacak = Nokta { x: 5, y: 4.0 };
| ^^^ expected integer, found floating-point number
For more information about this error, try `rustc --explain E0308`.
error: could not compile `bolum10` (bin "bolum10") due to 1 previous error
Hem x hem de y alanının jenerik olduğu fakat birbirinden farklı türler alabildiği bir Nokta struct’ı tanımlamak için birden fazla jenerik tür parametresi kullanabiliriz. Örneğin Liste 10-8’de Nokta tanımını T ve U türleri üzerinde jenerik olacak şekilde değiştiriyoruz; böylece x alanı T türünde, y alanı ise U türünde olabiliyor.
struct Nokta<T, U> {
x: T,
y: U,
}
fn main() {
let ikisi_de_tamsayi = Nokta { x: 5, y: 10 };
let ikisi_de_ondalikli = Nokta { x: 1.0, y: 4.0 };
let tamsayi_ve_ondalikli = Nokta { x: 5, y: 4.0 };
}
x ile y’nin farklı türlerde değerler alabilmesini sağlayan bir Nokta<T, U>Artık gösterilen tüm Nokta örneklerine izin verilir! Bir tanımda istediğiniz kadar jenerik tür parametresi kullanabilirsiniz; ancak birkaç taneden fazlasını kullanmak kodunuzun okunmasını güçleştirir. Kodunuzda çok sayıda jenerik türe ihtiyaç duyduğunuzu fark ederseniz, bu durum kodunuzun daha küçük parçalara ayrılarak yeniden yapılandırılması gerektiğinin bir işareti olabilir.
Enum Tanımlarında
Struct’larda yaptığımız gibi enum’ları da varyantlarında jenerik veri türleri tutacak şekilde tanımlayabiliriz. Standart kütüphanenin sunduğu ve Bölüm 6’da kullandığımız Option<T> enum’ına yeniden göz atalım:
#![allow(unused)]
fn main() {
enum Option<T> {
Some(T),
None,
}
}
Bu tanım artık size çok daha anlamlı gelmelidir. Gördüğünüz gibi Option<T> enum’ı T türü üzerinde jeneriktir ve iki varyanta sahiptir: T türünden bir değer tutan Some ile hiçbir değer tutmayan None varyantı. Option<T> enum’ını kullanarak isteğe bağlı bir değer kavramını soyut biçimde ifade edebiliriz; üstelik Option<T> jenerik olduğu için söz konusu değerin türü ne olursa olsun bu soyutlamayı kullanabiliriz.
Enum’lar birden fazla jenerik tür de kullanabilir. Bölüm 9’da kullandığımız Result enum’ının tanımı buna bir örnektir:
#![allow(unused)]
fn main() {
enum Result<T, E> {
Ok(T),
Err(E),
}
}
Result enum’ı, T ve E olmak üzere iki tür üzerinde jeneriktir ve iki varyantı vardır: T türünden bir değer tutan Ok ve E türünden bir değer tutan Err. Bu tanım, başarılı olabilecek (T türünde bir değer döndüren) veya başarısız olabilecek (E türünde bir hata döndüren) bir işlemimizin bulunduğu her yerde Result enum’ını kullanmayı son derece elverişli kılar. Nitekim Liste 9-3’te bir dosyayı açarken kullandığımız yapı tam olarak buydu: Dosya başarıyla açıldığında T yerine std::fs::File türü, dosya açılırken sorun yaşandığında ise E yerine std::io::Error türü geçmişti.
Kodunuzda yalnızca tuttukları değerlerin türleri bakımından farklılaşan birden fazla struct veya enum tanımı bulunduğunu fark ettiğinizde, jenerik türler kullanarak bu tekrarı ortadan kaldırabilirsiniz.
Metot Tanımlarında
Bölüm 5’te yaptığımız gibi struct ve enum’lar üzerinde metotlar uygulayabilir, bu metotların tanımlarında da jenerik türler kullanabiliriz. Liste 10-9, Liste 10-6’da tanımladığımız Nokta<T> struct’ını ve üzerinde tanımlanmış x adlı metodu gösterir.
struct Nokta<T> {
x: T,
y: T,
}
impl<T> Nokta<T> {
fn x(&self) -> &T {
&self.x
}
}
fn main() {
let p = Nokta { x: 5, y: 10 };
println!("p.x = {}", p.x());
}
Nokta<T> struct’ı üzerinde, T türündeki x alanına bir referans döndüren x adlı metodun uygulanmasıBurada Nokta<T> üzerinde, x alanındaki veriye bir referans döndüren x adında bir metot tanımladık.
Metotları Nokta<T> türü üzerinde uyguladığımızı belirtebilmek için T’yi impl anahtar kelimesinin hemen ardından bildirmemiz gerektiğine dikkat edin. T’yi impl sonrasında jenerik bir tür olarak bildirdiğimizde Rust, Nokta içindeki açılı ayraçlarda yer alan türün somut bir tür değil, jenerik bir tür olduğunu anlar. Bu jenerik parametre için struct tanımında bildirilenden farklı bir ad da seçebilirdik; ancak aynı adı kullanmak yerleşik kuraldır. Jenerik bir tür bildiren bir impl bloğu içinde yazdığınız metot, jenerik türün yerine hangi somut tür geçerse geçsin bu türün her örneği üzerinde tanımlı olur.
Bir tür üzerinde metot tanımlarken jenerik türlere kısıtlamalar da getirebiliriz. Örneğin metotları herhangi bir jenerik türdeki Nokta<T> örnekleri yerine yalnızca Nokta<f32> örnekleri üzerinde uygulayabiliriz. Liste 10-10’da somut f32 türünü kullanıyoruz; yani impl sonrasında herhangi bir tür parametresi bildirmiyoruz.
struct Nokta<T> {
x: T,
y: T,
}
impl<T> Nokta<T> {
fn x(&self) -> &T {
&self.x
}
}
impl Nokta<f32> {
fn orijinden_uzaklik(&self) -> f32 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
}
fn main() {
let p = Nokta { x: 5, y: 10 };
println!("p.x = {}", p.x());
}
T jenerik tür parametresi için belirli bir somut türe sahip bir struct’a uygulanan impl bloğuBu kod, Nokta<f32> türünün orijinden_uzaklik adlı bir metoda sahip olacağı, T’nin f32 olmadığı diğer Nokta<T> örneklerinde ise bu metodun tanımlı olmayacağı anlamına gelir. Metot, noktamızın (0.0, 0.0) koordinatlarındaki başlangıç noktasına olan uzaklığını hesaplar ve yalnızca kayan noktalı sayı türlerinde bulunan matematiksel işlemleri (powi ve sqrt) kullanır.
Not: impl<T> Nokta<T> ile impl Nokta<f32> sözdizimi arasındaki fark, açılı
ayraçların iki farklı görev üstlenmesinden kaynaklanır: impl anahtar
kelimesinin hemen ardından gelen <T>, yeni bir jenerik tür parametresi
bildirerek onu kapsama dahil eder; Nokta<T> içindeki <T> ise bu
bildirilen parametreyi kullanır. İlk <T>’yi unutup impl Nokta<T>
yazarsanız derleyici T’yi tıpkı f32 gibi somut bir tür sanır ve kapsamda
T adında bir tür bulamadığı için (E0412) hata verir. Buna karşılık impl Nokta<f32> yazdığımızda f32 zaten bilinen somut bir tür olduğundan
impl’den sonra yeni bir tür parametresi bildirmeyiz.
Bir struct tanımındaki jenerik tür parametreleri, aynı struct’ın metot imzalarında kullandığınız jenerik tür parametreleriyle her zaman aynı olmak zorunda değildir. Liste 10-11, örneği daha anlaşılır kılmak amacıyla Nokta struct’ı için X1 ve Y1, karistir metodunun imzası içinse X2 ve Y2 jenerik türlerini kullanır. Bu metot, self olan Nokta değerinden (X1 türündeki) x alanını ve parametre olarak geçirilen Nokta değerinden (Y2 türündeki) y alanını alarak yeni bir Nokta örneği oluşturur.
struct Nokta<X1, Y1> {
x: X1,
y: Y1,
}
impl<X1, Y1> Nokta<X1, Y1> {
fn karistir<X2, Y2>(self, diger: Nokta<X2, Y2>) -> Nokta<X1, Y2> {
Nokta {
x: self.x,
y: diger.y,
}
}
}
fn main() {
let p1 = Nokta { x: 5, y: 10.4 };
let p2 = Nokta {
x: "Merhaba",
y: 'c',
};
let p3 = p1.karistir(p2);
println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}
main fonksiyonunda, x için bir i32 (5 değeriyle) ve y için bir f64 (10.4 değeriyle) tutan p1 adında bir Nokta tanımladık. p2 değişkeni ise x için bir string dilimine ("Merhaba" değeriyle) ve y için bir char değerine ('c' değeriyle) sahip bir Nokta struct’ıdır. p1 üzerinde p2 argümanıyla karistir metodunu çağırmak bize p3 değerini verir: x alanı p1’den geldiği için p3’ün x alanı i32 türünde, y alanı ise p2’den geldiği için char türünde olur. println! makrosu çağrısı ekrana p3.x = 5, p3.y = c yazdırır.
Bu örneğin amacı, bazı jenerik parametrelerin impl ile, bazılarının ise metot tanımıyla bildirildiği bir durumu göstermektir. Burada X1 ve Y1 jenerik parametreleri struct tanımına ait oldukları için impl sonrasında bildirilir. X2 ve Y2 jenerik parametreleri ise yalnızca bu metotla ilgili olduklarından fn karistir sonrasında bildirilir.
Jenerik Kullanan Kodların Performansı
Jenerik tür parametreleri kullanmanın çalışma zamanında bir maliyeti olup olmadığını merak ediyor olabilirsiniz. İyi haber şu ki jenerik türler kullanmak, programınızın somut türler kullanan eşdeğer bir koda göre daha yavaş çalışmasına yol açmaz.
Rust bunu, derleme zamanında jenerik kullanan kod üzerinde tek biçimlileştirme (monomorphization) —diğer adıyla monomorfizasyon— uygulayarak başarır. Tek biçimlileştirme, derleme sırasında kullanılan somut türleri yerlerine koyarak jenerik kodu türe özgü koda dönüştürme sürecidir. Bu süreçte derleyici, Liste 10-5’te jenerik fonksiyonu oluştururken izlediğimiz adımların tam tersini yapar: jenerik kodun çağrıldığı tüm yerlere bakar ve jenerik kodun birlikte çağrıldığı somut türler için özelleştirilmiş kod üretir.
Standart kütüphanenin jenerik Option<T> enum’ını kullanarak bunun nasıl çalıştığına bakalım:
#![allow(unused)]
fn main() {
let tamsayi = Some(5);
let ondalikli = Some(5.0);
}
Rust bu kodu derlediğinde tek biçimlileştirme uygular. Bu işlem sırasında derleyici, Option<T> örneklerinde kullanılan değerleri okur ve iki farklı Option<T> kullanımı tespit eder: biri i32, diğeri ise f64 türündedir. Bunun sonucunda Option<T>’nin jenerik tanımını i32 ve f64 için özelleştirilmiş iki ayrı tanıma açar ve jenerik tanımı bu türe özgü tanımlarla değiştirir.
Kodun tek biçimlileştirilmiş hâli aşağıdakine benzer (derleyici burada açıklama amacıyla kullandıklarımızdan farklı iç adlar üretir):
enum Option_i32 {
Some(i32),
None,
}
enum Option_f64 {
Some(f64),
None,
}
fn main() {
let tamsayi = Option_i32::Some(5);
let ondalikli = Option_f64::Some(5.0);
}
Jenerik Option<T>, derleyicinin oluşturduğu türe özgü tanımlarla değiştirilir. Rust jenerik kodu her bir kullanımda somut türü belirten koda derlediği için jenerikleri kullanmanın çalışma zamanında hiçbir ek maliyeti yoktur. Kod çalıştığında, her bir tanımı elle tek tek çoğaltmışız gibi performans gösterir. Tek biçimlileştirme süreci, Rust’ın jeneriklerini çalışma zamanında son derece verimli kılar.
Not: Tek biçimlileştirme sayesinde derleyici hangi somut fonksiyon veya
metodun çağrılacağını derleme zamanında kesin olarak bilir; buna statik
gönderim (static dispatch) denir. Bu bilgi, doğrudan çağrılar ve satır içi
açma (inlining) gibi optimizasyonlara olanak tanır; ancak belirli bir
optimizasyonun uygulanması veya kodun her durumda daha hızlı olması garanti
edilmez. Başlıca ödünleşimlerden biri, bir jenerik tanım çok sayıda farklı
somut türle kullanıldığında her tür için ayrı makine kodu üretilmesi nedeniyle
derleme süresinin ve üretilen ikili dosya (binary) boyutunun bir miktar
artabilmesidir. Homojen olmayan koleksiyonlara veya daha küçük ikili dosya
boyutuna ihtiyaç duyduğunuz durumlarda Rust, Bölüm 18’de inceleyeceğimiz
trait nesneleri (dyn Trait) aracılığıyla çalışma zamanında çözümlenen
dinamik gönderim (dynamic dispatch) seçeneğini de sunar.
Trait'lerle Ortak Davranış Tanımlama
Trait’lerle Ortak Davranış Tanımlama
Bir trait, belirli bir türün sahip olduğu ve diğer türlerle paylaşabileceği işlevselliği tanımlar. Ortak davranışı soyut bir biçimde tanımlamak için trait’leri kullanabiliriz. Jenerik bir türün, belirli bir davranışa sahip herhangi bir tür olabileceğini belirtmek için ise trait sınırlarını (trait bounds) kullanabiliriz.
Not: Trait’ler, aralarında bazı farklar bulunsa da diğer dillerde genellikle arayüz (interface) olarak adlandırılan özelliğe benzer.
Bir Trait Tanımlama
Bir türün davranışı, o tür üzerinde çağırabileceğimiz metotlardan oluşur. Farklı türlerin hepsi üzerinde aynı metotları çağırabiliyorsak bu türler aynı davranışı paylaşıyor demektir. Trait tanımları, belirli bir amacı gerçekleştirmek için gereken davranış kümesini tanımlamak üzere metot imzalarını bir araya getirmenin yoludur.
Örneğin, farklı tür ve uzunluklarda metinler tutan birden fazla struct’ımız olduğunu varsayalım: Belirli bir yerden bildirilen bir haber metnini tutan HaberMakalesi struct’ı ve en fazla 280 karakterlik bir metnin yanı sıra bu metnin yeni bir gönderi mi, yeniden paylaşım mı yoksa başka bir gönderiye yanıt mı olduğunu gösteren üst verileri barındıran SosyalGonderi struct’ı.
Bir HaberMakalesi veya SosyalGonderi örneğinde saklanabilecek verilerin özetlerini görüntüleyebilen toplayici adında bir medya toplayıcı (media aggregator) kütüphane crate’i geliştirmek istiyoruz. Bunun için her türden bir özet almamız gerekiyor ve bu özeti bir örnek üzerinde ozetle metodunu çağırarak isteyeceğiz. Liste 10-12, bu davranışı ifade eden dışarıya açık (pub) bir Ozet trait’inin tanımını göstermektedir.
pub trait Ozet {
fn ozetle(&self) -> String;
}
ozetle metodunun sağladığı davranıştan oluşan bir Ozet trait’iBurada trait anahtar kelimesini ve ardından trait’in adını —bu örnekte Ozet— yazarak bir trait bildiriyoruz. Ayrıca birkaç örnek sonra göreceğimiz üzere, bu crate’e bağımlı olan diğer crate’lerin de bu trait’ten yararlanabilmesi için trait’i pub olarak işaretliyoruz. Süslü parantezlerin içinde ise bu trait’i uygulayan türlerin sahip olacağı davranışları betimleyen metot imzalarını —bu örnekte fn ozetle(&self) -> String— bildiriyoruz.
Metot imzasından sonra süslü parantezler içinde bir gövde tanımlamak yerine noktalı virgül koyuyoruz. Bu trait’i uygulayan her tür, metodun gövdesi için kendi özel davranışını sağlamak zorundadır. Derleyici, Ozet trait’ine sahip her türün tam olarak bu imzayla tanımlanmış bir ozetle metoduna sahip olmasını zorunlu kılacaktır.
Bir trait, gövdesinde birden fazla metot barındırabilir: Metot imzaları her satıra bir tane gelecek şekilde alt alta yazılır ve her satır noktalı virgülle biter.
Bir Tür Üzerinde Trait Uygulama
Ozet trait’inin metotları için istediğimiz imzaları tanımladığımıza göre, artık bu trait’i medya toplayıcımızdaki türler üzerinde uygulayabiliriz. Liste 10-13, ozetle metodunun dönüş değerini oluşturmak için manşeti, yazarı ve konumu kullanan HaberMakalesi struct’ı üzerindeki Ozet trait uygulamasını gösterir. SosyalGonderi struct’ı için ise gönderi içeriğinin zaten 280 karakterle sınırlandırıldığını varsayarak ozetle metodunu kullanıcı adı ve ardından gönderinin tüm metnini döndürecek şekilde tanımlıyoruz.
pub trait Ozet {
fn ozetle(&self) -> String;
}
pub struct HaberMakalesi {
pub manset: String,
pub konum: String,
pub yazar: String,
pub icerik: String,
}
impl Ozet for HaberMakalesi {
fn ozetle(&self) -> String {
format!("{}, {} ({})", self.manset, self.yazar, self.konum)
}
}
pub struct SosyalGonderi {
pub kullanici_adi: String,
pub icerik: String,
pub yanit: bool,
pub yeniden_paylasim: bool,
}
impl Ozet for SosyalGonderi {
fn ozetle(&self) -> String {
format!("{}: {}", self.kullanici_adi, self.icerik)
}
}
HaberMakalesi ve SosyalGonderi türleri üzerinde Ozet trait’inin uygulanmasıBir tür üzerinde trait uygulamak, normal metotları tanımlamaya çok benzer. Aradaki fark, impl anahtar kelimesinden sonra uygulamak istediğimiz trait’in adını yazmamız, ardından for anahtar kelimesini kullanmamız ve sonrasında trait’i hangi tür için uygulayacağımızı belirtmemizdir. impl bloğunun içine trait tanımında yer alan metot imzalarını yazarız. Ancak her imzanın sonuna noktalı virgül koymak yerine süslü parantez açar ve metot gövdesini, trait metotlarının o türe özgü olarak sergilemesini istediğimiz davranışla doldururuz.
Kütüphanemiz HaberMakalesi ve SosyalGonderi üzerinde Ozet trait’ini uyguladığına göre, crate kullanıcıları bu türlerin örnekleri üzerinde trait metotlarını tıpkı normal metotları çağırır gibi çağırabilir. Tek fark, kullanıcının türlerin yanı sıra trait’i de kapsama dahil etmesinin gerekmesidir. Bir ikili crate’in toplayici kütüphane crate’imizi nasıl kullanabileceğine dair bir örnek aşağıda verilmiştir:
use toplayici::{Ozet, SosyalGonderi};
fn main() {
let gonderi = SosyalGonderi {
kullanici_adi: String::from("horse_ebooks"),
icerik: String::from(
"elbette, muhtemelen zaten bildiğiniz gibi, insanlar",
),
yanit: false,
yeniden_paylasim: false,
};
println!("1 yeni gönderi: {}", gonderi.ozetle());
}
Bu kod ekrana 1 yeni gönderi: horse_ebooks: elbette, muhtemelen zaten bildiğiniz gibi, insanlar yazdırır.
Not: Yukarıdaki use toplayici::{Ozet, SosyalGonderi}; satırındaki Ozet
kısmını silerseniz, SosyalGonderi türü kapsamda olduğu hâlde
gonderi.ozetle() çağrısı derlenmez. Derleyici error[E0599]: no method named `ozetle` found hatasıyla birlikte items from traits can only be used if the trait is in scope (trait’lerden gelen öğeler yalnızca trait
kapsamdaysa kullanılabilir) açıklamasını ve eklemeniz gereken use satırını
önerir. Trait’i kapsama almak, derleyicinin metot aramasına o trait’in
metotlarını da dahil eder. Aynı adlı metot sunan iki trait de kapsamdaysa
çağrı yine belirsiz olabilir; böyle durumlarda Bölüm 20’de göreceğimiz tam
nitelikli sözdizimiyle hangi trait’in kastedildiğini belirtiriz. Yalnızca
standart kütüphanenin prelude’ünde yer alan Clone, ToString veya
Iterator gibi trait’ler her kapsamda hazır olduğundan onlar için ek bir
use satırına gerek yoktur.
toplayici crate’ine bağımlı olan diğer crate’ler de Ozet trait’ini kendi türleri üzerinde uygulamak üzere kapsama dahil edebilir. Burada dikkat edilmesi gereken önemli bir kısıtlama vardır: Bir trait’i bir tür üzerinde yalnızca trait’in kendisi, türün kendisi veya her ikisi birden kendi crate’imize yerel (local) ise uygulayabiliriz. Örneğin, SosyalGonderi türü toplayici crate’imize yerel olduğu için toplayici crate’imizin bir parçası olarak SosyalGonderi gibi özel bir tür üzerinde Display gibi standart kütüphane trait’lerini uygulayabiliriz. Benzer şekilde, Ozet trait’i toplayici crate’imize yerel olduğu için toplayici crate’i içinde Vec<T> üzerinde de Ozet trait’ini uygulayabiliriz.
Ancak harici trait’leri harici türler üzerinde uygulayamayız. Örneğin, toplayici crate’imiz içinde Vec<T> üzerinde Display trait’ini uygulayamayız; çünkü hem Display hem de Vec<T> standart kütüphanede tanımlanmıştır ve toplayici crate’imize yerel değildir. Bu kısıtlama, tutarlılık (coherence) adı verilen özelliğin ve daha özelinde —ebeveyn tür mevcut olmadığı için bu adı alan— yetim kuralının (orphan rule) bir parçasıdır. Bu kural, başkalarının kodunun sizin kodunuzu, sizin kodunuzun da başkalarının kodunu bozamamasını güvence altına alır. Bu kural olmasaydı iki farklı crate aynı tür için aynı trait’i uygulayabilir ve Rust hangi uygulamayı kullanacağını bilemezdi.
Not: Yetim kuralının bağımlılık çakışmalarını nasıl önlediğini somut bir
senaryoyla düşünelim: Projenize eklediğiniz iki harici bağımlılığın ikisi de
standart kütüphanedeki Vec<String> türü için Display trait’ini kendince
tanımlayabilseydi, bir Vec<String> değerini {} ile yazdırmak istediğinizde
derleyici hangi crate’in uygulamasını çağıracağına karar veremezdi; üstelik
bir bağımlılığın yeni sürümüne böyle bir impl eklendiğinde çalışan kodunuz
bir anda derlenemez hâle gelirdi. Yine de Vec<String> gibi harici bir türe
Display gibi harici bir trait kazandırmanız gereken durumlarda, bu türü
struct Sarmalayici(Vec<String>); şeklinde tek alanlı yerel bir demet
struct’ı içine alarak kendi yerel türünüzü oluşturabilirsiniz. Ek bir öbek
tahsisi gerektirmeyen ve genellikle derleyicinin ek çağrı maliyetini ortadan
kaldırabildiği bu tekniğe newtype deseni denir; ayrıntılarını Bölüm 20’deki
“Dış Trait’leri Newtype Deseniyle Uygulamak”
bölümünde inceleyeceğiz.
Varsayılan Uygulamaları Kullanma
Bazen her tür üzerindeki tüm metotların tek tek yazılmasını zorunlu kılmak yerine, bir trait içindeki metotların bazıları veya tamamı için varsayılan uygulamalar (default implementations) tanımlamak faydalıdır. Böylece trait’i belirli bir tür üzerinde uygularken her bir metodun varsayılan davranışını olduğu gibi koruyabilir ya da geçersiz kılabiliriz (override).
Liste 10-14’te, Liste 10-12’de yaptığımız gibi yalnızca metot imzasını tanımlamak yerine Ozet trait’inin ozetle metodu için varsayılan bir string belirliyoruz.
pub trait Ozet {
fn ozetle(&self) -> String {
String::from("(Devamını oku...)")
}
}
pub struct HaberMakalesi {
pub manset: String,
pub konum: String,
pub yazar: String,
pub icerik: String,
}
impl Ozet for HaberMakalesi {}
pub struct SosyalGonderi {
pub kullanici_adi: String,
pub icerik: String,
pub yanit: bool,
pub yeniden_paylasim: bool,
}
impl Ozet for SosyalGonderi {
fn ozetle(&self) -> String {
format!("{}: {}", self.kullanici_adi, self.icerik)
}
}
ozetle metodunun varsayılan bir uygulamasıyla birlikte Ozet trait’ini tanımlamaHaberMakalesi örneklerini özetlerken bu varsayılan uygulamayı kullanmak için impl Ozet for HaberMakalesi {} şeklinde boş bir impl bloğu yazarız.
Artık ozetle metodunu doğrudan HaberMakalesi üzerinde tanımlamıyor olsak da, trait içinde varsayılan bir uygulama sunduk ve HaberMakalesi türünün Ozet trait’ini uyguladığını belirttik. Sonuç olarak, bir HaberMakalesi örneği üzerinde ozetle metodunu yine şu şekilde çağırabiliriz:
use toplayici::{self, HaberMakalesi, Ozet};
fn main() {
let makale = HaberMakalesi {
manset: String::from("Penguenler Stanley Cup Şampiyonluğunu kazandı!"),
konum: String::from("Pittsburgh, PA, ABD"),
yazar: String::from("Iceburgh"),
icerik: String::from(
"Pittsburgh Penguenleri bir kez daha NHL'deki en iyi \
hokey takımı.",
),
};
println!("Yeni makale mevcut! {}", makale.ozetle());
}
Bu kod ekrana Yeni makale mevcut! (Devamını oku...) yazdırır.
Varsayılan bir uygulama oluşturmak, Liste 10-13’teki SosyalGonderi üzerindeki Ozet uygulamasında herhangi bir değişiklik yapmamızı gerektirmez. Bunun nedeni, varsayılan bir uygulamayı geçersiz kılma sözdiziminin, varsayılan uygulaması olmayan bir trait metodunu uygulama sözdizimiyle birebir aynı olmasıdır.
Varsayılan uygulamalar, aynı trait içindeki diğer metotları —o metotların varsayılan bir uygulaması olmasa bile— çağırabilir. Bu sayede bir trait pek çok kullanışlı işlevselliği hazır olarak sunarken, trait’i uygulayan türlerden bunun yalnızca küçük bir parçasını tanımlamalarını isteyebilir. Örneğin Ozet trait’ini, uygulanması zorunlu bir yazari_ozetle metoduna ve bu yazari_ozetle metodunu çağıran varsayılan uygulamalı bir ozetle metoduna sahip olacak şekilde tanımlayabiliriz:
pub trait Ozet {
fn yazari_ozetle(&self) -> String;
fn ozetle(&self) -> String {
format!(
"({} yazarından daha fazlasını okuyun...)",
self.yazari_ozetle()
)
}
}
pub struct SosyalGonderi {
pub kullanici_adi: String,
pub icerik: String,
pub yanit: bool,
pub yeniden_paylasim: bool,
}
impl Ozet for SosyalGonderi {
fn yazari_ozetle(&self) -> String {
format!("@{}", self.kullanici_adi)
}
}
Ozet trait’inin bu sürümünü kullanmak için, trait’i bir tür üzerinde uygularken yalnızca yazari_ozetle metodunu tanımlamamız yeterlidir:
pub trait Ozet {
fn yazari_ozetle(&self) -> String;
fn ozetle(&self) -> String {
format!(
"({} yazarından daha fazlasını okuyun...)",
self.yazari_ozetle()
)
}
}
pub struct SosyalGonderi {
pub kullanici_adi: String,
pub icerik: String,
pub yanit: bool,
pub yeniden_paylasim: bool,
}
impl Ozet for SosyalGonderi {
fn yazari_ozetle(&self) -> String {
format!("@{}", self.kullanici_adi)
}
}
yazari_ozetle metodunu tanımladıktan sonra SosyalGonderi struct’ının örnekleri üzerinde ozetle metodunu çağırabiliriz; ozetle’nin varsayılan uygulaması da bizim sağladığımız yazari_ozetle tanımını çağırır. yazari_ozetle metodunu uyguladığımız için Ozet trait’i, fazladan kod yazmamıza gerek bırakmadan ozetle metodunun davranışını bize kazandırmış olur. Kullanımı şöyle görünür:
use toplayici::{self, Ozet, SosyalGonderi};
fn main() {
let gonderi = SosyalGonderi {
kullanici_adi: String::from("horse_ebooks"),
icerik: String::from(
"elbette, muhtemelen zaten bildiğiniz gibi, insanlar",
),
yanit: false,
yeniden_paylasim: false,
};
println!("1 yeni gönderi: {}", gonderi.ozetle());
}
Bu kod ekrana 1 yeni gönderi: (@horse_ebooks yazarından daha fazlasını okuyun...) yazdırır.
Bir metodun varsayılan uygulamasını, aynı metodun geçersiz kılınan uygulamasının içinden çağırmanın mümkün olmadığına dikkat edin.
Trait’leri Parametre Olarak Kullanma
Artık trait’lerin nasıl tanımlanacağını ve uygulanacağını bildiğinize göre, pek çok farklı türü kabul edebilen fonksiyonlar tanımlamak için trait’leri nasıl kullanacağımızı inceleyebiliriz. Liste 10-13’te HaberMakalesi ve SosyalGonderi türleri üzerinde uyguladığımız Ozet trait’ini kullanarak, Ozet trait’ini uygulayan bir türdeki oge parametresi üzerinde ozetle metodunu çağıran bir bildir fonksiyonu tanımlayacağız. Bunun için impl Trait sözdizimini şu şekilde kullanırız:
pub trait Ozet {
fn ozetle(&self) -> String;
}
pub struct HaberMakalesi {
pub manset: String,
pub konum: String,
pub yazar: String,
pub icerik: String,
}
impl Ozet for HaberMakalesi {
fn ozetle(&self) -> String {
format!("{}, {} ({})", self.manset, self.yazar, self.konum)
}
}
pub struct SosyalGonderi {
pub kullanici_adi: String,
pub icerik: String,
pub yanit: bool,
pub yeniden_paylasim: bool,
}
impl Ozet for SosyalGonderi {
fn ozetle(&self) -> String {
format!("{}: {}", self.kullanici_adi, self.icerik)
}
}
pub fn bildir(oge: &impl Ozet) {
println!("Son dakika haberi! {}", oge.ozetle());
}
oge parametresi için somut bir tür yazmak yerine impl anahtar kelimesini ve ilgili trait adını belirtiyoruz. Bu parametre, belirtilen trait’i uygulayan herhangi bir türü kabul eder. bildir fonksiyonunun gövdesinde, oge üzerinde Ozet trait’inden gelen ozetle gibi her türlü metodu çağırabiliriz. bildir fonksiyonunu çağırırken argüman olarak herhangi bir HaberMakalesi veya SosyalGonderi örneğine referans geçirebiliriz. Fonksiyonu String veya i32 gibi başka bir türle çağıran kod ise bu türler Ozet trait’ini uygulamadığından derlenmeyecektir.
Trait Sınırı Sözdizimi
impl Trait sözdizimi doğrudan ve yalın durumlarda gayet kullanışlıdır; ancak aslında trait sınırı (trait bound) olarak bilinen daha uzun bir biçimin sözdizimsel şekeridir (syntax sugar) ve şu şekilde görünür:
pub fn bildir<T: Ozet>(oge: &T) {
println!("Son dakika haberi! {}", oge.ozetle());
}
Bu uzun biçim önceki bölümdeki örnekle tamamen eşdeğerdir ancak daha ayrıntılıdır. Trait sınırlarını, açılı ayraçlar içindeki jenerik tür parametresi bildiriminde iki nokta üst üsteden (:) hemen sonra yazarız.
impl Trait sözdizimi pratiktir ve basit durumlarda daha özlü kod yazmamızı sağlar; daha kapsamlı olan trait sınırı sözdizimi ise diğer durumlarda daha karmaşık kısıtlamaları ifade edebilir. Örneğin, Ozet trait’ini uygulayan iki parametre almak isteyebiliriz. Bunu impl Trait sözdizimiyle yazmak şöyle görünür:
pub fn bildir(oge1: &impl Ozet, oge2: &impl Ozet) {
Fonksiyonun oge1 ve oge2 parametrelerinin —her iki tür de Ozet trait’ini uyguladığı sürece— birbirinden farklı somut türlere sahip olmasına izin vermesini istiyorsak impl Trait kullanmak uygundur. Ancak her iki parametrenin de birebir aynı somut türe sahip olmasını zorunlu kılmak istiyorsak şu şekilde bir trait sınırı kullanmalıyız:
pub fn bildir<T: Ozet>(oge1: &T, oge2: &T) {
oge1 ve oge2 parametrelerinin türü olarak belirtilen jenerik T türü, fonksiyonu öyle kısıtlar ki oge1 ve oge2 argümanları olarak geçirilen değerlerin somut türü aynı olmak zorundadır.
+ Sözdizimi ile Birden Fazla Trait Sınırı
Birden fazla trait sınırı da belirtebiliriz. Diyelim ki bildir fonksiyonunun oge üzerinde ozetle metodunu çağırmasının yanı sıra Display biçimlendirmesini de kullanmasını istiyoruz: bildir tanımında oge’nin hem Display hem de Ozet trait’lerini uygulaması gerektiğini belirtiriz. Bunu + sözdizimiyle yapabiliriz:
pub fn bildir(oge: &(impl Ozet + Display)) {
+ sözdizimi, jenerik türler üzerindeki trait sınırlarında da geçerlidir:
pub fn bildir<T: Ozet + Display>(oge: &T) {
Bu iki trait sınırı belirtildiğinde, bildir fonksiyonunun gövdesi hem ozetle metodunu çağırabilir hem de oge değerini {} ile biçimlendirebilir.
where Cümlecikleriyle Daha Açık Trait Sınırları
Çok fazla trait sınırı kullanmanın okunabilirlik açısından dezavantajları vardır. Her jenerik tür parametresinin kendi trait sınırları olduğundan, birden fazla jenerik tür parametresi alan fonksiyonlarda fonksiyon adı ile parametre listesi arasına çok fazla trait sınırı bilgisi girebilir ve bu da fonksiyon imzasının okunmasını güçleştirir. Bu nedenle Rust, trait sınırlarını fonksiyon imzasından sonra gelen bir where cümleciği içinde belirtmek için alternatif bir sözdizimi sunar. Dolayısıyla şunu yazmak yerine:
fn bir_fonksiyon<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {
şu şekilde bir where cümleciği kullanabiliriz:
fn bir_fonksiyon<T, U>(t: &T, u: &U) -> i32
where
T: Display + Clone,
U: Clone + Debug,
{
unimplemented!()
}
Böylece fonksiyonun imzası kalabalıktan arınır: Çok sayıda trait sınırı içermeyen sıradan bir fonksiyonda olduğu gibi fonksiyon adı, parametre listesi ve dönüş türü birbirine yakın durur.
Trait Uygulayan Türler Döndürme
Bir trait’i uygulayan herhangi bir türdeki değeri döndürmek için dönüş konumunda da impl Trait sözdizimini kullanabiliriz:
pub trait Ozet {
fn ozetle(&self) -> String;
}
pub struct HaberMakalesi {
pub manset: String,
pub konum: String,
pub yazar: String,
pub icerik: String,
}
impl Ozet for HaberMakalesi {
fn ozetle(&self) -> String {
format!("{}, {} ({})", self.manset, self.yazar, self.konum)
}
}
pub struct SosyalGonderi {
pub kullanici_adi: String,
pub icerik: String,
pub yanit: bool,
pub yeniden_paylasim: bool,
}
impl Ozet for SosyalGonderi {
fn ozetle(&self) -> String {
format!("{}: {}", self.kullanici_adi, self.icerik)
}
}
fn ozetlenebilir_dondur() -> impl Ozet {
SosyalGonderi {
kullanici_adi: String::from("horse_ebooks"),
icerik: String::from(
"elbette, muhtemelen zaten bildiğiniz gibi, insanlar",
),
yanit: false,
yeniden_paylasim: false,
}
}
Dönüş türü olarak impl Ozet kullanarak, ozetlenebilir_dondur fonksiyonunun somut türün adını açıkça belirtmeksizin Ozet trait’ini uygulayan bir tür döndürdüğünü ifade ederiz. Bu örnekte ozetlenebilir_dondur bir SosyalGonderi döndürür, ancak fonksiyonu çağıran kodun bu somut türü bilmesi gerekmez.
Dönüş türünü yalnızca uyguladığı trait üzerinden belirtebilme olanağı, özellikle Bölüm 13’te ele alacağımız kapanışlar ve iteratörler bağlamında son derece kullanışlıdır. Kapanışlar ve iteratörler, yalnızca derleyicinin bildiği isimsiz türler ya da açıkça yazılması çok uzun süren karmaşık türler üretir. impl Trait sözdizimi, çok uzun bir tür adını tek tek yazmak zorunda kalmadan bir fonksiyonun Iterator trait’ini uygulayan bir tür döndürdüğünü özlü biçimde belirtmenizi sağlar.
Bununla birlikte, dönüş konumunda impl Trait’i yalnızca tek bir somut tür döndürüyorsanız kullanabilirsiniz. Örneğin, dönüş türü impl Ozet olarak belirtilen ancak koşula göre bir HaberMakalesi ya da bir SosyalGonderi döndürmeye çalışan aşağıdaki kod derlenmez:
pub trait Ozet {
fn ozetle(&self) -> String;
}
pub struct HaberMakalesi {
pub manset: String,
pub konum: String,
pub yazar: String,
pub icerik: String,
}
impl Ozet for HaberMakalesi {
fn ozetle(&self) -> String {
format!("{}, {} ({})", self.manset, self.yazar, self.konum)
}
}
pub struct SosyalGonderi {
pub kullanici_adi: String,
pub icerik: String,
pub yanit: bool,
pub yeniden_paylasim: bool,
}
impl Ozet for SosyalGonderi {
fn ozetle(&self) -> String {
format!("{}: {}", self.kullanici_adi, self.icerik)
}
}
fn ozetlenebilir_dondur(degistir: bool) -> impl Ozet {
if degistir {
HaberMakalesi {
manset: String::from(
"Penguenler Stanley Cup Şampiyonluğunu kazandı!",
),
konum: String::from("Pittsburgh, PA, ABD"),
yazar: String::from("Iceburgh"),
icerik: String::from(
"Pittsburgh Penguenleri bir kez daha NHL'deki en iyi \
hokey takımı.",
),
}
} else {
SosyalGonderi {
kullanici_adi: String::from("horse_ebooks"),
icerik: String::from(
"elbette, muhtemelen zaten bildiğiniz gibi, insanlar",
),
yanit: false,
yeniden_paylasim: false,
}
}
}
impl Trait sözdiziminin derleyicide nasıl uygulandığına bağlı kısıtlamalar nedeniyle aynı fonksiyondan hem HaberMakalesi hem de SosyalGonderi döndürülmesine izin verilmez. Bu davranışa sahip bir fonksiyonun nasıl yazılacağını Bölüm 18’deki “Ortak Davranışı Soyutlamak İçin Trait Nesnelerini Kullanma” bölümünde ele alacağız.
Not: Dönüş konumundaki -> impl Ozet ifadesi çalışma zamanında dinamik bir
tür seçimi yapmaz; derleyici fonksiyonun gövdesini inceleyerek döndürülen
tek bir somut türü derleme zamanında saptar ve çağıran kodu bu somut türe
göre derler. Bu, değerin mutlaka yığında saklanacağı anlamına gelmez; yerleşim
ve optimizasyonlar kullanımına bağlıdır. Yukarıdaki kodda HaberMakalesi ile
SosyalGonderi iki ayrı somut tür olduğundan, boyutları aynı olsaydı bile
derleyici error[E0308]: 'if' and 'else' have incompatible types (if ve
else kolları uyumsuz türlere sahip) hatası verir. Çalışma zamanındaki bir
koşula göre aynı trait’i uygulayan farklı somut türler döndürmeniz
gerektiğinde —derleyicinin hata çıktısındaki ipucunda da önerdiği üzere—
değerleri öbekte (heap) tutup dönüş türünü Box<dyn Ozet> şeklinde bir
trait nesnesi olarak tanımlayabilirsiniz (Box::new(HaberMakalesi { ... })
ve Box::new(SosyalGonderi { ... })). Bu yaklaşımın nasıl çalıştığını ve
dinamik gönderim (dynamic dispatch) ödünleşimlerini Bölüm 18’de
ayrıntılarıyla göreceğiz.
Trait Sınırlarını Kullanarak Metotları Koşullu Uygulama
Jenerik tür parametreleri kullanan bir impl bloğuyla birlikte bir trait sınırı belirterek, yalnızca belirtilen trait’leri uygulayan türler için koşullu olarak metotlar tanımlayabiliriz. Örneğin, Liste 10-15’teki Cift<T> türü, yeni bir Cift<T> örneği döndüren new ilişkili fonksiyonunu her zaman uygular (Bölüm 5’teki “Metot Sözdizimi” bölümünden hatırlayacağınız üzere Self, içinde bulunulan impl bloğunun türü —yani burada Cift<T>— için bir tür takma adıdır). Ancak bir sonraki impl bloğunda Cift<T>, karsilastir_goster metodunu yalnızca içindeki T türü hem karşılaştırma yapılmasını sağlayan PartialOrd trait’ini hem de ekrana yazdırmayı sağlayan Display trait’ini uyguluyorsa barındırır.
use std::fmt::Display;
struct Cift<T> {
x: T,
y: T,
}
impl<T> Cift<T> {
fn new(x: T, y: T) -> Self {
Self { x, y }
}
}
impl<T: Display + PartialOrd> Cift<T> {
fn karsilastir_goster(&self) {
if self.x >= self.y {
println!("En büyük üye x = {}", self.x);
} else {
println!("En büyük üye y = {}", self.y);
}
}
}
Ayrıca, belirli bir trait’i uygulayan her tür için başka bir trait’i de koşullu olarak uygulayabiliriz. Trait sınırlarını karşılayan herhangi bir tür üzerinde gerçekleştirilen bu tür genel trait uygulamalarına örtü uygulamaları (blanket implementations, kapsayıcı uygulamalar olarak da adlandırılır) denir ve bunlar Rust standart kütüphanesinde yaygın biçimde kullanılır. Örneğin standart kütüphane, Display trait’ini uygulayan her tür üzerinde ToString trait’ini uygular. Standart kütüphanedeki ilgili impl bloğu şu koda benzer:
impl<T: Display> ToString for T {
// --snip--
}
Standart kütüphanede bu örtü uygulaması bulunduğundan, Display trait’ini uygulayan herhangi bir tür üzerinde ToString trait’inin tanımladığı to_string metodunu çağırabiliriz. Örneğin tam sayılar Display trait’ini uyguladığı için bir tam sayıyı karşılık gelen String değerine şu şekilde dönüştürebiliriz:
#![allow(unused)]
fn main() {
let s = 3.to_string();
}
Örtü uygulamaları, ilgili trait’in dokümantasyonunda “Implementors” (Uygulayıcılar) başlığı altında listelenir.
Trait’ler ve trait sınırları, kod tekrarını azaltmak için jenerik tür parametreleri kullanan kodlar yazmamızı sağlarken, aynı zamanda derleyiciye bu jenerik türün belirli bir davranışa sahip olmasını istediğimizi belirtmemize de olanak tanır. Derleyici daha sonra bu trait sınırı bilgisini kullanarak kodumuzla birlikte kullanılan tüm somut türlerin doğru davranışı sunduğunu denetler. Dinamik tür sistemine sahip dillerde, bir tür üzerinde o türde tanımlı olmayan bir metodu çağırsaydık hatayla ancak çalışma zamanında karşılaşırdık. Oysa Rust bu hataları derleme zamanına taşır; böylece kodumuz henüz çalışmaya bile başlamadan sorunları düzeltmemizi zorunlu kılar. Üstelik davranışı derleme zamanında zaten doğruladığımız için çalışma zamanında bir türün ilgili davranışı destekleyip desteklemediğini denetleyen ek kodlar yazmamıza da gerek kalmaz. Bu sayede jeneriklerin esnekliğinden ödün vermeden performansı artırmış oluruz.
Ömürlerle Referansları Doğrulama
Ömürlerle Referansları Doğrulama
Ömürler (lifetimes), aslında şimdiye dek farkında olmadan kullandığımız bir başka jenerik türüdür. Ömürler, bir türün istediğimiz davranışa sahip olmasını sağlamak yerine, referansların onlara ihtiyaç duyduğumuz süre boyunca geçerli kalmasını güvence altına alır.
Bölüm 4’teki “Referanslar ve Ödünç Alma” kısmında değinmediğimiz bir ayrıntı, Rust’taki her referansın bir ömrü olmasıdır; yani o referansın geçerli olduğu kapsam. Tıpkı çoğu zaman türlerin derleyici tarafından çıkarılması gibi, ömürler de çoğu durumda örtüktür ve kendiliğinden çıkarılır. Nasıl yalnızca birden fazla türün mümkün olduğu belirsiz durumlarda açık tür bildirimi yazmamız gerekiyorsa, referansların ömürlerinin birbirleriyle birkaç farklı biçimde ilişkili olabileceği durumlarda da ömür bildirimleri eklememiz gerekir. Rust, çalışma zamanında kullanılan gerçek referansların kesinlikle geçerli olacağından emin olmak için bu ilişkileri jenerik ömür parametreleriyle bildirmemizi zorunlu tutar.
Ömür bildirimi kavramı diğer programlama dillerinin neredeyse hiçbirinde bulunmadığından ilk başta size yabancı gelecektir. Bu bölümde ömürlerin tüm ayrıntılarını ele almayacak olsak da, kavrama aşinalık kazanabilmeniz için ömür sözdizimiyle karşılaşacağınız yaygın senaryoları inceleyeceğiz.
Sarkan Referanslar
Ömürlerin temel amacı, sarkan referansları (dangling references) önlemektir. Sarkan referansların var olmasına izin verilseydi, bir program işaret etmeyi amaçladığı veri yerine bambaşka bir bellek alanındaki veriye başvururdu. Bir dış kapsama ve bir iç kapsama sahip olan Liste 10-16’daki programı ele alalım.
fn main() {
let r;
{
let x = 5;
r = &x;
}
println!("r: {r}");
}
Not: Liste 10-16, 10-17 ve 10-23’teki örnekler değişkenleri ilk değer atamadan
bildirir. Bu, değişkenin null değerini tuttuğu anlamına gelmez; henüz
başlatılmamış olduğu anlamına gelir. Derleyici, kullanılmadan önce bütün olası
yürütme yollarında bir değer atanmış olmasını zorunlu tutar. Değerin
bulunmamasını çalışma zamanında temsil etmek istediğimizde ise Option<T>
kullanırız.
Dış kapsam ilk değeri olmayan r adında bir değişken bildirir; iç kapsam ise ilk değeri 5 olan x adında bir değişken bildirir. İç kapsamın içinde r’nin değerini x’e bir referans olarak atamaya çalışırız. Ardından iç kapsam sona erer ve r’deki değeri yazdırmaya çalışırız. Bu kod derlenmez; çünkü biz r’yi kullanmaya çalışmadan önce r’nin başvurduğu değer kapsam dışına çıkmıştır. Aldığımız hata mesajı şöyledir:
$ cargo run
Compiling bolum10 v0.1.0 (file:///projects/bolum10)
error[E0597]: `x` does not live long enough
--> src/main.rs:6:13
|
5 | let x = 5;
| - binding `x` declared here
6 | r = &x;
| ^^ borrowed value does not live long enough
7 | }
| - `x` dropped here while still borrowed
8 |
9 | println!("r: {r}");
| - borrow later used here
For more information about this error, try `rustc --explain E0597`.
error: could not compile `bolum10` (bin "bolum10") due to 1 previous error
Hata mesajı, x değişkeninin “yeterince uzun yaşamadığını” (does not live long enough) bildirir. Bunun nedeni, iç kapsam 7. satırda sona erdiğinde x değişkeninin kapsam dışına çıkıp düşürülecek (dropped here while still borrowed) olmasıdır. Oysa r dış kapsam boyunca hâlâ geçerlidir; kapsamı daha geniş olduğu için onun “daha uzun yaşadığını” söyleriz. Rust bu kodun çalışmasına izin verseydi, r değişkeni x kapsam dışına çıktığında serbest bırakılan belleğe başvuruyor olurdu ve r ile yapmaya çalıştığımız hiçbir işlem doğru çalışmazdı. Peki Rust bu kodun geçersiz olduğunu nasıl anlar? Bir ödünç alma denetleyicisi kullanır.
Ödünç Alma Denetleyicisi
Rust derleyicisi, tüm ödünç almaların geçerli olup olmadığını belirlemek amacıyla kapsamları karşılaştıran bir ödünç alma denetleyicisine (borrow checker) sahiptir. Liste 10-17, Liste 10-16’daki kodun değişken ömürlerini gösteren açıklamalar eklenmiş hâlini sunar.
fn main() {
let r; // ---------+-- 'a
// |
{ // |
let x = 5; // -+-- 'b |
r = &x; // | |
} // -+ |
// |
println!("r: {r}"); // |
} // ---------+
r ve x değişkenlerinin sırasıyla 'a ve 'b olarak adlandırılan ömür bildirimleriBurada r’nin ömrünü 'a, x’in ömrünü ise 'b ile işaretledik. Gördüğünüz gibi içteki 'b bloğu, dıştaki 'a ömür bloğundan çok daha küçüktür. Derleme zamanında Rust bu iki ömrün büyüklüğünü karşılaştırır ve r’nin 'a ömrüne sahip olmasına karşın 'b ömrüne sahip bir belleğe başvurduğunu görür. 'b ömrü 'a’dan daha kısa olduğu için program reddedilir: Referansın işaret ettiği veri, referansın kendisi kadar uzun yaşamamaktadır.
Liste 10-18, sarkan referans oluşmayacak şekilde kodu düzeltir ve hiçbir hata vermeden derlenir.
fn main() {
let x = 5; // ----------+-- 'b
// |
let r = &x; // --+-- 'a |
// | |
println!("r: {r}"); // | |
// --+ |
} // ----------+
Burada x, bu kez 'a’dan daha geniş olan 'b ömrüne sahiptir. Bu durum r’nin x’e güvenle başvurabileceği anlamına gelir; çünkü Rust, x geçerli olduğu sürece r’deki referansın da her zaman geçerli olacağını bilir.
Referansların ömürlerinin ne anlama geldiğini ve Rust’ın referansların her zaman geçerli kalmasını sağlamak için kapsamları nasıl analiz ettiğini gördüğümüze göre, şimdi fonksiyon parametrelerindeki ve dönüş değerlerindeki jenerik ömürleri inceleyelim.
Fonksiyonlarda Jenerik Ömürler
İki string diliminden daha uzun olanını döndüren bir fonksiyon yazacağız. Bu fonksiyon iki string dilimi alacak ve tek bir string dilimi döndürecektir. en_uzun fonksiyonunu uyguladıktan sonra Liste 10-19’daki kod ekrana En uzun dizgi: abcd yazdırmalıdır.
fn main() {
let dizgi1 = String::from("abcd");
let dizgi2 = "xyz";
let sonuc = en_uzun(dizgi1.as_str(), dizgi2);
println!("En uzun dizgi: {sonuc}");
}
en_uzun fonksiyonunu çağıran main fonksiyonuen_uzun fonksiyonunun kendi parametrelerinin sahipliğini devralmasını istemediğimiz için, fonksiyonun String değerleri yerine birer referans olan string dilimlerini kabul etmesini istediğimize dikkat edin. Liste 10-19’da kullandığımız parametrelerin neden tam da aradığımız türler olduğuna dair daha ayrıntılı bilgi için Bölüm 4’teki “Parametre Olarak String Dilimleri” kısmına göz atabilirsiniz.
en_uzun fonksiyonunu Liste 10-20’de gösterildiği gibi yazmaya çalışırsak kod derlenmez.
fn main() {
let dizgi1 = String::from("abcd");
let dizgi2 = "xyz";
let sonuc = en_uzun(dizgi1.as_str(), dizgi2);
println!("En uzun dizgi: {sonuc}");
}
fn en_uzun(x: &str, y: &str) -> &str {
if x.len() > y.len() { x } else { y }
}
en_uzun fonksiyonu uygulamasıBunun yerine ömürlerle ilgili şu hatayı alırız:
$ cargo run
Compiling bolum10 v0.1.0 (file:///projects/bolum10)
error[E0106]: missing lifetime specifier
--> src/main.rs:9:33
|
9 | fn en_uzun(x: &str, y: &str) -> &str {
| ---- ---- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
help: consider introducing a named lifetime parameter
|
9 | fn en_uzun<'a>(x: &'a str, y: &'a str) -> &'a str {
| ++++ ++ ++ ++
For more information about this error, try `rustc --explain E0106`.
error: could not compile `bolum10` (bin "bolum10") due to 1 previous error
Hata başlığı ömür belirtecinin eksik olduğunu (missing lifetime specifier) bildirir; yardım (help) metni ise dönüş türünün ödünç alınmış bir değer içerdiğini ancak imzanın bu değerin x’ten mi yoksa y’den mi ödünç alındığını söylemediğini açıklayarak adlandırılmış bir jenerik ömür parametresi eklememizi önerir. Doğrusu döndürülen referansın hangisinden geleceğini biz de bilemeyiz; çünkü fonksiyon gövdesindeki if bloğu x’e ait bir referans döndürürken else bloğu y’ye ait bir referans döndürmektedir!
Bu fonksiyonu tanımlarken fonksiyona hangi somut değerlerin geçirileceğini bilmediğimizden if kolunun mu yoksa else kolunun mu çalışacağını bilemeyiz. Aynı şekilde fonksiyona geçirilecek referansların somut ömürlerini de bilmiyoruz; dolayısıyla döndürdüğümüz referansın her zaman geçerli olup olmayacağını anlamak için Liste 10-17 ve 10-18’de yaptığımız gibi doğrudan kapsamlara bakamayız. Ödünç alma denetleyicisi de bunu belirleyemez; çünkü x ve y’nin ömürlerinin dönüş değerinin ömrüyle nasıl bir ilişki içinde olduğunu bilmez. Bu hatayı gidermek için referanslar arasındaki ilişkiyi tanımlayan jenerik ömür parametreleri ekleyeceğiz; böylece ödünç alma denetleyicisi gerekli analizi yapabilecektir.
Ömür Bildirim Sözdizimi
Ömür bildirimleri (lifetime annotations), hiçbir referansın ne kadar yaşayacağını değiştirmez. Bunun yerine, ömürlerin kendisini etkilemeden birden fazla referansın ömürlerinin birbiriyle olan ilişkisini tarif eder. Tıpkı imzasında jenerik tür parametresi bulunan bir fonksiyonun herhangi bir türü kabul edebilmesi gibi, jenerik ömür parametresi belirten fonksiyonlar da herhangi bir ömre sahip referansları kabul edebilir.
Ömür bildirimlerinin biraz alışılmadık bir sözdizimi vardır: Ömür parametrelerinin adları kesme işaretiyle (') başlamalıdır ve tıpkı jenerik türlerde olduğu gibi genellikle tamamen küçük harfli ve çok kısa seçilir. Çoğu geliştirici ilk ömür bildirimi için 'a adını kullanır. Ömür parametresi bildirimlerini referansın & işaretinden hemen sonraya yazar, bildirim ile referansın türünü ayırmak için araya bir boşluk bırakırız.
Aşağıda birkaç örnek verilmiştir: ömür parametresi belirtilmemiş bir i32 referansı, 'a adında ömür parametresine sahip bir i32 referansı ve yine 'a ömrüne sahip değiştirilebilir bir i32 referansı:
&i32 // bir referans
&'a i32 // açık ömür bildirimine sahip bir referans
&'a mut i32 // açık ömür bildirimine sahip değiştirilebilir bir referans
Tek başına bir ömür bildiriminin pek bir anlamı yoktur; çünkü bu bildirimlerin amacı Rust’a birden fazla referansın jenerik ömür parametrelerinin birbiriyle nasıl ilişkili olduğunu anlatmaktır. Gelin en_uzun fonksiyonu bağlamında ömür bildirimlerinin birbirleriyle nasıl ilişkilendirildiğini inceleyelim.
Fonksiyon İmzalarında
Fonksiyon imzalarında ömür bildirimlerini kullanmak için, tıpkı jenerik tür parametrelerinde yaptığımız gibi, fonksiyon adı ile parametre listesi arasındaki açılı ayraçların (<>) içinde jenerik ömür parametrelerini bildirmemiz gerekir.
İmzanın şu kısıtlamayı ifade etmesini istiyoruz: Döndürülen referans, parametrelerin her ikisi de geçerli olduğu sürece geçerli olacaktır. Parametrelerin ömürleri ile dönüş değerinin ömrü arasındaki ilişki budur. Liste 10-21’de gösterildiği gibi ömre 'a adını verecek ve onu imzadaki her referansa ekleyeceğiz.
fn main() {
let dizgi1 = String::from("abcd");
let dizgi2 = "xyz";
let sonuc = en_uzun(dizgi1.as_str(), dizgi2);
println!("En uzun dizgi: {sonuc}");
}
fn en_uzun<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
'a ömrüne sahip olması gerektiğini belirten en_uzun fonksiyonu tanımıBu kod, Liste 10-19’daki main fonksiyonuyla birlikte kullanıldığında sorunsuz derlenmeli ve istediğimiz çıktıyı üretmelidir.
Fonksiyon imzası artık Rust’a şunu söyler: Belirli bir 'a ömrü için bu fonksiyon iki parametre alır ve her iki parametre de en az 'a ömrü kadar yaşayan birer string dilimidir. Fonksiyon imzası aynı zamanda fonksiyondan döndürülen string diliminin de en az 'a ömrü kadar yaşayacağını belirtir. Pratikte bu, en_uzun fonksiyonunun döndürdüğü referansın ömrünün, fonksiyon argümanlarının başvurduğu değerlerin ömürlerinden daha kısa olanına eşit olduğu anlamına gelir. Rust’ın bu kodu analiz ederken temel almasını istediğimiz ilişki tam olarak budur.
Unutmayın: Bu fonksiyon imzasında ömür parametrelerini belirttiğimizde, fonksiyona geçirilen ya da fonksiyondan döndürülen hiçbir değerin kapsamını veya bellekte kalma süresini değiştirmiyoruz. Yalnızca ödünç alma denetleyicisinin bu ömür kısıtlamalarına uymayan referansları reddetmesi gerektiğini belirtiyoruz. en_uzun fonksiyonunun x ve y’nin tam olarak ne kadar yaşayacağını bilmesi gerekmez; bu imzayı karşılayacak bir kapsamın 'a yerine konabilmesi yeterlidir.
Not: Değerin Kapsamı ile Referansın Ömrü ('a) Arasındaki Ayrım — Yeni başlayanların en sık düştüğü yanılgı, 'a gibi bir ömür bildiriminin veriyi bellekte daha uzun süre hayatta tutacağını sanmaktır. Bir değerin kapsamı (scope), o değerin bellekte ne zaman oluşturulup kapsam sonunda ne zaman temizleneceğini (drop edileceğini) belirler; bir referansın ömrü (lifetime) ise o referansın söz konusu değere güvenle başvurabildiği geçerlilik bölgesidir (ve bu ömür, değerin bellekte kaldığı süreyi asla aşamaz). Dolayısıyla açık bir 'a bildirimi:
- Değerlerin bellekte kalma süresini değiştirmez: Değerler kendi sahiplik ve düşürülme kurallarına göre temizlenir; bir ömür bildirimi bunların zamanını değiştirmez.
- Referansların ömür ilişkisini tarif eder: Ödünç alma denetleyicisine girdi referansları ile çıktı referansı arasındaki sözleşmeyi açıklar (örneğin
en_uzun<'a>imzasında: “Dönen referansı yalnızcaxveyreferanslarının ikisinin birden geçerli olduğu ortak kesişim bölgesi içinde kullanabilirsin”). - Sıfır çalışma zamanı maliyetlidir: Tüm doğrulama derleme zamanında yapılır; derlenmiş ikili dosyaya hiçbir ek işlem eklenmez.
Fonksiyonlarda ömürleri bildirirken bildirimler fonksiyon gövdesine değil, fonksiyon imzasına yazılır. Ömür bildirimleri tıpkı imzadaki türler gibi fonksiyon sözleşmesinin bir parçası hâline gelir. Fonksiyon imzalarının ömür sözleşmesini içermesi, Rust derleyicisinin yaptığı analizin çok daha yalın ve öngörülebilir olmasını sağlar. Bir fonksiyonun ömür bildirimlerinde veya çağrılma biçiminde bir sorun varsa, derleyici hataları kodumuzun tam olarak ilgili noktasına ve ihlal edilen kısıtlamaya kesin biçimde işaret edebilir. Rust derleyicisi ömür ilişkilerinde neyi kastettiğimize dair arka planda daha fazla tahminde bulunsaydı, sorunun asıl kaynağından çok uzaktaki bir kullanım noktasına işaret etmek zorunda kalabilirdi.
en_uzun fonksiyonuna somut referanslar geçirdiğimizde 'a yerine geçen somut ömür, x kapsamının y kapsamıyla örtüşen kısmıdır. Başka bir deyişle jenerik 'a ömrü, x ve y’nin ömürlerinden daha kısa olanına eşit somut ömrü alır. Döndürülen referansı da aynı 'a ömür parametresiyle bildirdiğimiz için, döndürülen referans da x ve y ömürlerinden kısa olanı boyunca geçerli olur.
Şimdi farklı somut ömürlere sahip referanslar geçirerek ömür bildirimlerinin en_uzun fonksiyonunu nasıl kısıtladığına bakalım. Liste 10-22 doğrudan çalışan yalın bir örnektir.
fn main() {
let dizgi1 = String::from("uzun dizgi uzundur");
{
let dizgi2 = String::from("xyz");
let sonuc = en_uzun(dizgi1.as_str(), dizgi2.as_str());
println!("En uzun dizgi: {sonuc}");
}
}
fn en_uzun<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
String değerlerinin referanslarıyla en_uzun fonksiyonunu kullanmaBu örnekte dizgi1 dış kapsamın sonuna kadar geçerlidir, dizgi2 iç kapsamın sonuna kadar geçerlidir ve sonuc da iç kapsamın sonuna kadar geçerli olan bir veriye başvurur. Bu kodu çalıştırdığınızda ödünç alma denetleyicisinin kullanımı onayladığını göreceksiniz; kod derlenecek ve ekrana En uzun dizgi: uzun dizgi uzundur yazdıracaktır.
Şimdi de sonuc değişkenindeki referansın ömrünün iki argümandan daha kısa ömürlü olanına uymak zorunda olduğunu gösteren bir örnek deneyelim. sonuc değişkeninin bildirimini iç kapsamın dışına taşıyacağız, ancak sonuc değişkenine değer atamasını dizgi2 ile aynı iç kapsamda bırakacağız. Ardından sonuc değişkenini kullanan println! çağrısını iç kapsam sona erdikten sonraya, yani dış kapsama taşıyacağız. Liste 10-23’teki bu kod derlenmeyecektir.
fn main() {
let dizgi1 = String::from("uzun dizgi uzundur");
let sonuc;
{
let dizgi2 = String::from("xyz");
sonuc = en_uzun(dizgi1.as_str(), dizgi2.as_str());
}
println!("En uzun dizgi: {sonuc}");
}
fn en_uzun<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
dizgi2 kapsam dışına çıktıktan sonra sonuc değişkenini kullanma girişimiBu kodu derlemeye çalıştığımızda şu hatayı alırız:
$ cargo run
Compiling bolum10 v0.1.0 (file:///projects/bolum10)
error[E0597]: `dizgi2` does not live long enough
--> src/main.rs:6:42
|
5 | let dizgi2 = String::from("xyz");
| ------ binding `dizgi2` declared here
6 | sonuc = en_uzun(dizgi1.as_str(), dizgi2.as_str());
| ^^^^^^ borrowed value does not live long enough
7 | }
| - `dizgi2` dropped here while still borrowed
8 | println!("En uzun dizgi: {sonuc}");
| ----- borrow later used here
For more information about this error, try `rustc --explain E0597`.
error: could not compile `bolum10` (bin "bolum10") due to 1 previous error
Hata mesajı, sonuc değişkeninin println! deyiminde geçerli olabilmesi için dizgi2’nin dış kapsamın sonuna kadar geçerli kalması gerektiğini gösterir. Rust bunu, fonksiyon parametrelerinin ve dönüş değerinin ömürlerini aynı 'a ömür parametresiyle bildirdiğimiz için bilir.
İnsan gözüyle bu koda baktığımızda dizgi1’in dizgi2’den daha uzun olduğunu ve dolayısıyla sonuc değişkeninin her zaman dizgi1’e bir referans tutacağını görebiliriz. dizgi1 henüz kapsam dışına çıkmadığı için dizgi1’e yapılan bir referans println! satırında hâlâ geçerli olacaktır. Ancak derleyici bu özel durumda referansın geçerli olduğunu göremez. Biz Rust’a en_uzun fonksiyonunun döndürdüğü referansın ömrünün, geçirilen referansların ömürlerinden kısa olanına eşit olduğunu bildirdik. Bu nedenle ödünç alma denetleyicisi, geçersiz bir referans barındırma ihtimali bulunduğu gerekçesiyle Liste 10-23’teki koda izin vermez.
en_uzun fonksiyonuna geçirilen referansların değerlerini, ömürlerini ve döndürülen referansın nasıl kullanıldığını değiştiren farklı deneyler tasarlamayı deneyin. Derlemeden önce deneylerinizin ödünç alma denetleyicisinden geçip geçmeyeceğine dair tahminler yürütün, ardından derleyerek haklı çıkıp çıkmadığınızı kontrol edin!
İlişkiler
Ömür parametrelerini nasıl belirtmeniz gerektiği, fonksiyonunuzun ne yaptığına bağlıdır. Örneğin en_uzun fonksiyonunun gövdesini en uzun string dilimi yerine her zaman ilk parametreyi döndürecek şekilde değiştirseydik, y parametresi üzerinde bir ömür belirtmemize gerek kalmazdı. Aşağıdaki kod sorunsuz derlenir:
fn main() {
let dizgi1 = String::from("abcd");
let dizgi2 = "efghijklmnopqrstuvwxyz";
let sonuc = en_uzun(dizgi1.as_str(), dizgi2);
println!("En uzun dizgi: {sonuc}");
}
fn en_uzun<'a>(x: &'a str, y: &str) -> &'a str {
x
}
Burada x parametresi ve dönüş türü için 'a ömür parametresini belirttik, ancak y parametresi için belirtmedik; çünkü y’nin ömrünün x’in ömrüyle ya da dönüş değeriyle hiçbir ilişkisi yoktur.
Bir fonksiyondan referans döndürürken, dönüş türünün ömür parametresi parametrelerden en az birinin ömür parametresiyle eşleşmelidir. Döndürülen referans parametrelerden birine başvurmuyorsa, fonksiyonun kendi içinde oluşturulmuş bir değere başvuruyor demektir. Ancak bu değer fonksiyon sonunda kapsam dışına çıkacağı için böyle bir referans kaçınılmaz olarak sarkan referans olur. en_uzun fonksiyonunun derlenmeyecek olan şu uygulama denemesini inceleyelim:
fn main() {
let dizgi1 = String::from("abcd");
let dizgi2 = "xyz";
let sonuc = en_uzun(dizgi1.as_str(), dizgi2);
println!("En uzun dizgi: {sonuc}");
}
fn en_uzun<'a>(x: &str, y: &str) -> &'a str {
let sonuc = String::from("gerçekten uzun dizgi");
sonuc.as_str()
}
Burada dönüş türü için 'a ömür parametresini belirtmiş olsak bile bu uygulama derlenmez; çünkü dönüş değerinin ömrü parametrelerin ömrüyle hiçbir şekilde ilişkili değildir. Aldığımız hata mesajı şöyledir:
$ cargo run
Compiling bolum10 v0.1.0 (file:///projects/bolum10)
error[E0515]: cannot return value referencing local variable `sonuc`
--> src/main.rs:11:5
|
11 | sonuc.as_str()
| -----^^^^^^^^^
| |
| returns a value referencing data owned by the current function
| `sonuc` is borrowed here
For more information about this error, try `rustc --explain E0515`.
error: could not compile `bolum10` (bin "bolum10") due to 1 previous error
Sorun şudur: sonuc yerel değişkeni en_uzun fonksiyonunun sonunda kapsam dışına çıkar ve bellekten temizlenir; biz ise fonksiyondan sonuc değişkenine bir referans döndürmeye çalışıyoruz (hata mesajındaki cannot return value referencing local variable `sonuc` ifadesi tam olarak bunu söyler). Sarkan referansı ortadan kaldıracak hiçbir ömür parametresi bildirimi yoktur ve Rust sarkan referans oluşturmamıza izin vermez. Böyle bir durumda en doğru çözüm, referans yerine String gibi sahipli bir veri türü döndürmektir; böylece değerin sahipliği çağıran fonksiyona taşınır ve değeri temizleme sorumluluğu çağıran fonksiyona geçer.
Özetle ömür sözdizimi, fonksiyonların çeşitli parametrelerinin ve dönüş değerlerinin ömürlerini birbirine bağlamakla ilgilidir. Bu bağlantılar kurulduktan sonra Rust, bellek açısından güvenli işlemlere izin vermek ve sarkan işaretçiler oluşturacak ya da bellek güvenliğini ihlal edecek işlemleri engellemek için yeterli bilgiye sahip olur.
Struct Tanımlarında
Şimdiye kadar tanımladığımız struct’ların tümü sahipli türler barındırıyordu. Struct’ları referans tutacak şekilde de tanımlayabiliriz; ancak bu durumda struct tanımındaki her referansa bir ömür bildirimi eklememiz gerekir. Liste 10-24’te bir string dilimi tutan OnemliAlinti adlı bir struct yer almaktadır.
struct OnemliAlinti<'a> {
bolum: &'a str,
}
fn main() {
let roman = String::from("Bana İsmail deyin. Birkaç yıl önce...");
let ilk_cumle = roman.split('.').next().unwrap();
let i = OnemliAlinti { bolum: ilk_cumle };
}
Bu struct, bir referans olan string dilimini barındıran bolum adlı tek bir alana sahiptir. Tıpkı jenerik veri türlerinde olduğu gibi, jenerik ömür parametresinin adını struct adından sonra açılı ayraçlar içinde bildiririz; böylece ömür parametresini struct tanımının gövdesinde kullanabiliriz. Bu bildirim, bir OnemliAlinti örneğinin bolum alanında tuttuğu referansın işaret ettiği veriden daha uzun yaşayamayacağı anlamına gelir.
Buradaki main fonksiyonu, roman değişkeninin sahip olduğu String değerinin ilk cümlesine referans tutan bir OnemliAlinti örneği oluşturur. roman içindeki veri OnemliAlinti örneği oluşturulmadan önce zaten mevcuttur. Ayrıca roman, OnemliAlinti örneği kapsam dışına çıktıktan sonra kapsam dışına çıkar; dolayısıyla OnemliAlinti örneğindeki referans geçerlidir.
Not: Struct tanımındaki 'a ömür parametresi, bu struct’ın bir veriyi ödünç
aldığını türün adına kazır: OnemliAlinti<'a> değeri, işaret ettiği verinin
en az 'a boyunca geçerli olması gereken bir referans taşır. Struct üzerinden
bu referansı kullanabildiğimiz süre, verinin geçerlilik süresini aşamaz.
Derleyici bu bilgiyle, struct örneğinin ödünç aldığı veriden daha uzun
yaşamasını engeller. Örneğin roman değişkenini bir iç kapsamda oluşturup
OnemliAlinti örneğini o kapsamın dışında kullanmaya çalışırsak, Liste
10-16’dakine benzer bir error[E0597]: `roman` does not live long enough
hatası alırız:
let alinti;
{
let roman = String::from("Bana İsmail deyin. Birkaç yıl önce...");
let ilk_cumle = roman.split('.').next().unwrap();
alinti = OnemliAlinti { bolum: ilk_cumle };
} // `roman` burada düşürülür; ancak `alinti` hâlâ ona başvuruyor
println!("{}", alinti.bolum);
Bir struct’ın referans yerine String gibi sahipli bir alan tutması bu kısıtlamayı ortadan kaldırır; ancak o zaman veri kopyalanır veya taşınır. Referans tutan struct’lar, büyük bir veriyi kopyalamadan onun bir parçasına “pencere” açmak istediğiniz durumlar için uygundur.
Ömür Elizyonu
Her referansın bir ömrü olduğunu ve referans kullanan fonksiyonlar ya da struct’lar için ömür parametreleri belirtmeniz gerektiğini öğrendiniz. Ancak Bölüm 4’teki Liste 4-9’da yazdığımız ve Liste 10-25’te yeniden gösterilen fonksiyon, hem parametresi hem de dönüş türü referans olmasına rağmen hiçbir ömür bildirimi olmadan derlenmişti.
fn ilk_kelime(s: &str) -> &str {
let baytlar = s.as_bytes();
for (i, &oge) in baytlar.iter().enumerate() {
if oge == b' ' {
return &s[0..i];
}
}
&s[..]
}
fn main() {
let benim_dizgim = String::from("merhaba dünya");
// ilk_kelime `String`lerin dilimleri üzerinde çalışır
let kelime = ilk_kelime(&benim_dizgim[..]);
let benim_dizgi_sabitim = "merhaba dünya";
// ilk_kelime string sabitlerinin dilimleri üzerinde çalışır
let kelime = ilk_kelime(&benim_dizgi_sabitim[..]);
// String sabitleri *zaten* string dilimi olduğundan,
// bu dilim sözdizimi olmadan da çalışır!
let kelime = ilk_kelime(benim_dizgi_sabitim);
}
Bu fonksiyonun ömür bildirimleri olmadan derlenebilmesinin tarihsel bir nedeni vardır: Rust’ın erken sürümlerinde (1.0 öncesinde) her referans açık bir ömür gerektirdiği için bu kod derlenmezdi. O dönemde fonksiyon imzası şöyle yazılırdı:
fn ilk_kelime<'a>(s: &'a str) -> &'a str {
Çok sayıda Rust kodu yazıldıktan sonra Rust ekibi, geliştiricilerin belirli durumlarda hep aynı ömür bildirimlerini tekrar tekrar yazdığını fark etti. Bu durumlar öngörülebilirdi ve birkaç belirlenimci (deterministic) deseni izliyordu. Ekip bu desenleri doğrudan derleyiciye kodladı; böylece ödünç alma denetleyicisi bu senaryolarda ömürleri kendiliğinden çıkarabilir hâle geldi ve açık bildirimlere gerek kalmadı.
Rust tarihinin bu parçası önemlidir; çünkü ileride yeni belirlenimci desenlerin ortaya çıkıp derleyiciye eklenmesi mümkündür. Gelecekte daha da az ömür bildirimi yazmamız gerekebilir.
Rust’ın referans analizine kodlanmış olan bu desenlere ömür elizyon kuralları (lifetime elision rules) denir. Elizyon sözcüğü “atlama, düşürme” anlamına gelir: Bu kurallar, belirli kalıplara uyan ömür bildirimlerinin yazılmadan atlanabilmesini sağlar. Bunlar programcıların uyması gereken kurallar değildir; derleyicinin göz önünde bulundurduğu bir dizi özel durumdur ve kodunuz bu durumlara uyuyorsa ömürleri açıkça yazmanız gerekmez.
Elizyon kuralları, tür çıkarımındaki gibi eksiksiz bir çıkarım (full inference) sağlamaz. Rust bu kuralları uyguladıktan sonra referansların ömürleri konusunda hâlâ bir belirsizlik kalırsa, derleyici kalan referansların ömrünün ne olması gerektiğini tahmin etmeye çalışmaz. Tahmin yürütmek yerine size bir derleme hatası verir; siz de amaçladığınız ilişkiyi ifade eden ömür bildirimlerini ekleyerek hatayı çözersiniz.
Fonksiyon veya metot parametrelerindeki ömürlere girdi ömürleri (input lifetimes), dönüş değerlerindeki ömürlere ise çıktı ömürleri (output lifetimes) denir.
Derleyici, açık bildirimlerin bulunmadığı durumlarda referansların ömürlerini belirlemek için üç kural uygular. Birinci kural girdi ömürleri için, ikinci ve üçüncü kurallar ise çıktı ömürleri için geçerlidir. Derleyici üç kuralın sonuna geldiğinde hâlâ ömrünü belirleyemediği çıktı referansları kalmışsa bir hata vererek durur. Bu kurallar fn tanımlarının yanı sıra impl blokları için de geçerlidir.
- Birinci kural: Derleyici, referans olan her parametreye ayrı bir ömür parametresi atar. Başka bir deyişle tek referans parametreli bir fonksiyon tek bir ömür parametresi alır:
fn foo<'a>(x: &'a i32); iki referans parametreli bir fonksiyon iki ayrı ömür parametresi alır:fn foo<'a, 'b>(x: &'a i32, y: &'b i32)ve bu böyle devam eder. - İkinci kural: Yalnızca tek bir girdi ömür parametresi varsa, o ömür tüm çıktı ömür parametrelerine atanır:
fn foo<'a>(x: &'a i32) -> &'a i32. - Üçüncü kural: Birden fazla girdi ömür parametresi varsa ancak fonksiyon bir metot olduğu için bunlardan biri
&selfveya&mut selfise,self’in ömrü tüm çıktı ömür parametrelerine atanır. Bu üçüncü kural, çok daha az sembol gerektirdiği için metotları okumayı ve yazmayı önemli ölçüde kolaylaştırır.
Şimdi kendimizi derleyicinin yerine koyalım ve Liste 10-25’teki ilk_kelime fonksiyonunun imzasında yer alan referansların ömürlerini bulmak için bu kuralları adım adım uygulayalım. İmza başlangıçta referanslarla ilişkilendirilmiş hiçbir ömür olmadan şöyledir:
fn ilk_kelime(s: &str) -> &str {
Ardından derleyici, her parametrenin kendi ömrünü almasını belirten birinci kuralı uygular. Alışıldığı üzere buna 'a adını verirsek imza şu hâle gelir:
fn ilk_kelime<'a>(s: &'a str) -> &str {
Tam olarak bir tane girdi ömrü bulunduğu için ikinci kural devreye girer. İkinci kural, tek girdi parametresinin ömrünün çıktı ömrüne atanmasını söyler; böylece imza şu şekli alır:
fn ilk_kelime<'a>(s: &'a str) -> &'a str {
Artık bu fonksiyon imzasındaki tüm referansların ömürleri bellidir ve derleyici, programcının imzaya açık ömür bildirimi eklemesine gerek kalmadan analizine devam edebilir.
Şimdi bir başka örneğe bakalım ve Liste 10-20’de üzerinde çalışmaya başladığımızda hiçbir ömür parametresi bulunmayan en_uzun fonksiyonunu ele alalım:
fn en_uzun(x: &str, y: &str) -> &str {
Birinci kuralı uygulayalım: Her referans parametresi kendi ömrünü alır. Bu kez bir yerine iki parametremiz olduğundan iki ayrı ömür elde ederiz:
fn en_uzun<'a, 'b>(x: &'a str, y: &'b str) -> &str {
Birden fazla girdi ömrü olduğu için ikinci kuralın uygulanamadığını görebilirsiniz. en_uzun bir metot değil serbest bir fonksiyon olduğundan ve parametrelerinden hiçbiri self olmadığından üçüncü kural da uygulanamaz. Üç kuralın tamamını işlettikten sonra dönüş türünün ömrünün ne olduğunu hâlâ belirleyemedik. Liste 10-20’deki kodu derlemeye çalışırken hata almamızın nedeni tam olarak budur: Derleyici ömür elizyon kurallarını sırasıyla uygulamış, ancak imzadaki referansların tüm ömürlerini yine de çözememiştir.
Üçüncü kural yalnızca metot imzalarında devreye girdiğinden, şimdi metot bağlamındaki ömürleri inceleyelim ve bu kural sayesinde metot imzalarında neden çoğu zaman açık ömür bildirimi yazmak zorunda kalmadığımızı görelim.
Metot Tanımlarında
Ömür parametrelerine sahip bir struct üzerinde metot tanımlarken, Liste 10-11’de gördüğümüz jenerik tür parametreleriyle aynı sözdizimini kullanırız. Ömür parametrelerini nerede bildirip nerede kullanacağımız, bunların struct alanlarıyla mı yoksa metot parametreleri ve dönüş değerleriyle mi ilişkili olduğuna bağlıdır.
Struct alanlarına ait ömür adlarının her zaman impl anahtar kelimesinden sonra bildirilmesi ve ardından struct adından sonra kullanılması gerekir; çünkü bu ömürler struct türünün ayrılmaz bir parçasıdır.
impl bloğu içindeki metot imzalarında yer alan referanslar, struct alanlarındaki referansların ömrüne bağlı olabileceği gibi onlardan tamamen bağımsız da olabilir. Ayrıca ömür elizyon kuralları sayesinde metot imzalarında açık ömür bildirimlerine çoğu zaman gerek kalmaz. Liste 10-24’te tanımladığımız OnemliAlinti struct’ını kullanarak birkaç örneğe bakalım.
İlk olarak tek parametresi self referansı olan ve dönüş değeri herhangi bir veriye referans olmayan (i32 döndüren) seviye adlı bir metot tanımlayalım:
struct OnemliAlinti<'a> {
bolum: &'a str,
}
impl<'a> OnemliAlinti<'a> {
fn seviye(&self) -> i32 {
3
}
}
impl<'a> OnemliAlinti<'a> {
fn duyur_ve_bolumu_dondur(&self, duyuru: &str) -> &str {
println!("Lütfen dikkat: {duyuru}");
self.bolum
}
}
fn main() {
let roman = String::from("Bana İsmail deyin. Birkaç yıl önce...");
let ilk_cumle = roman.split('.').next().unwrap();
let i = OnemliAlinti { bolum: ilk_cumle };
}
impl sonrasındaki ömür parametresi bildirimi (impl<'a>) ve tür adından sonraki kullanımı (OnemliAlinti<'a>) zorunludur; ancak birinci elizyon kuralı sayesinde self referansının ömrünü açıkça bildirmek zorunda değiliz.
Şimdi de üçüncü ömür elizyon kuralının devreye girdiği bir örneğe bakalım:
struct OnemliAlinti<'a> {
bolum: &'a str,
}
impl<'a> OnemliAlinti<'a> {
fn seviye(&self) -> i32 {
3
}
}
impl<'a> OnemliAlinti<'a> {
fn duyur_ve_bolumu_dondur(&self, duyuru: &str) -> &str {
println!("Lütfen dikkat: {duyuru}");
self.bolum
}
}
fn main() {
let roman = String::from("Bana İsmail deyin. Birkaç yıl önce...");
let ilk_cumle = roman.split('.').next().unwrap();
let i = OnemliAlinti { bolum: ilk_cumle };
}
Burada iki girdi ömrü vardır; dolayısıyla Rust birinci ömür elizyon kuralını uygulayarak hem &self hem de duyuru parametrelerine kendi ömürlerini verir. Ardından parametrelerden biri &self olduğu için üçüncü kural uyarınca dönüş türü &self’in ömrünü alır ve böylece tüm ömürler belirlenmiş olur.
Not: Üçüncü kuralın dönüş türüne &self’in ömrünü vermesi her zaman istediğiniz ilişki olmayabilir. Yukarıdaki metot self.bolum döndürdüğü için bu varsayılan doğrudur; ancak metot duyuru parametresine ait bir referans döndürseydi derleyici, duyuru’nun ömrü &self’in ömrüyle ilişkilendirilmediği için lifetime may not live long enough benzeri bir hata verirdi. Böyle durumlarda imzaya fn ornek<'b>(&self, duyuru: &'b str) -> &'b str gibi açık bir ömür bildirimi ekleyerek kastettiğiniz ilişkiyi belirtirsiniz. Rust’ta ayrıca, elizyon kurallarına bırakılan bir ömrü açıkça “atlanmış” olarak işaretleyen '_ (anonim ömür) sözdizimi vardır. Örneğin fn yazdir(a: OnemliAlinti<'_>) imzası, OnemliAlinti türünün bir referans taşıdığını okuyucuya gösterir ama ömre ad vermeye gerek bırakmaz; derleyici '_ yerine elizyon kurallarıyla belirlediği ömrü koyar. Başkalarının yazdığı Rust kodunda impl Trait for Sarmalayici<'_> ya da fmt::Formatter<'_> gibi kullanımlara sıkça rastlayacaksınız.
Statik Ömür
Değinmemiz gereken özel ömürlerden biri de 'static ömrüdür; bu ömür, ilgili referansın programın tüm çalışma süresi boyunca yaşayabileceğini ifade eder. Tüm string sabitleri 'static ömre sahiptir ve bunu açıkça şu şekilde yazabiliriz:
#![allow(unused)]
fn main() {
let s: &'static str = "Statik bir ömrüm var.";
}
Bu metnin baytları doğrudan programın derlenmiş ikili dosyasının (binary) içine gömülür ve program çalıştığı sürece her zaman erişilebilirdir. Bu nedenle tüm string sabitlerinin ömrü 'static’tir.
Derleyici hata mesajlarının yardım kısımlarında 'static ömrünü kullanmanız yönünde öneriler görebilirsiniz. Ancak bir referans için 'static ömrünü yazmadan önce, elinizdeki referansın işaret ettiği verinin gerçekten programınızın sonuna dek yaşayıp yaşamadığını —ve yaşamasını gerçekten isteyip istemediğinizi— dikkatlice düşünün. Çoğu zaman 'static ömrünü öneren bir hata mesajı, sarkan bir referans oluşturma girişiminden ya da mevcut ömürlerin birbiriyle uyuşmamasından kaynaklanır. Bu tür durumlarda doğru çözüm 'static yazmak değil, altta yatan sahiplik veya ömür uyumsuzluğunu gidermektir.
Not: &'static T ve T: 'static Uyarısına Yaklaşım — Derleyici çıktılarında 'static ifadesiyle iki farklı biçimde karşılaşabilirsiniz ve bunları birbirine karıştırmamak çok önemlidir:
&'static T(statik referans): Yalnızca program kapanana dek bellekte kalacağı garanti edilen verilere (string sabitleri veyastaticdeğişkenler gibi) işaret edebilir. Fonksiyon içindeki yerel bir değişkenden ödünç alınan referansa&'staticyazmak hatayı çözmez; çünkü yerel değişken fonksiyon bittiğinde bellekten silinir.T: 'static(statik ömür sınırı): Özellikle Bölüm 16’da göreceğimizthread::spawngibi veriyi başka bir thread’e (iş parçacığına) aktaran API’lerde derleyiciT: 'statickısıtlamasından söz edebilir. Bu kısıtlama verinin mutlaka&'staticbir referans olmasını gerektirmez; türün içinde kısa ömürlü ödünç alınmış referans bulunmamasını ister. DolayısıylaString,Vec<T>veyai32gibi sahipli türler deT: 'staticsınırını doğal olarak karşılar. Derleyici'staticönerdiğinde kendinize önce “Burada ödünç alınmış kısa ömürlü bir referans yerineStringgibi sahipli bir değeri taşıyarak mı ilerlemeliyim?” sorusunu sormanız neredeyse her zaman doğru çözüme götürür.
Jenerik Tür Parametreleri, Trait Sınırları ve Ömürler
Şimdi jenerik tür parametrelerini, trait sınırlarını ve ömürleri tek bir fonksiyonda bir arada belirtmenin sözdizimine kısaca göz atalım!
fn main() {
let dizgi1 = String::from("abcd");
let dizgi2 = "xyz";
let sonuc =
duyuruyla_en_uzun(dizgi1.as_str(), dizgi2, "Bugün birinin doğum günü!");
println!("En uzun dizgi: {sonuc}");
}
use std::fmt::Display;
fn duyuruyla_en_uzun<'a, T>(x: &'a str, y: &'a str, dyr: T) -> &'a str
where
T: Display,
{
println!("Duyuru! {dyr}");
if x.len() > y.len() {
x
} else {
y
}
}
Bu duyuruyla_en_uzun fonksiyonu, Liste 10-21’de yazdığımız ve iki string diliminden daha uzun olanını döndüren en_uzun fonksiyonunun genişletilmiş hâlidir. Şimdi ek olarak, where cümleciğinde belirtildiği üzere Display trait’ini uygulayan herhangi bir somut türle doldurulabilen T jenerik türünde dyr adında fazladan bir parametre alır. Bu ek parametre {} biçimlendiricisiyle ekrana yazdırıldığı için Display trait sınırı zorunludur. Ömürler de bir çeşit jenerik olduğundan, 'a ömür parametresi ile T jenerik tür parametresinin bildirimleri fonksiyon adından sonraki açılı ayraçların içinde aynı listede yer alır (ömür parametreleri tür parametrelerinden önce yazılır).
Özet
Bu bölümde pek çok önemli konuyu ele aldık! Artık jenerik tür parametrelerini, trait’leri, trait sınırlarını ve jenerik ömür parametrelerini tanıdığınıza göre, kod tekrarına düşmeden pek çok farklı senaryoda çalışabilen esnek kodlar yazmaya hazırsınız. Jenerik tür parametreleri kodunuzu farklı türlere uygulamanıza olanak tanır. Trait’ler ve trait sınırları, türler jenerik olsa bile kodun ihtiyaç duyduğu davranışlara sahip olmalarını garanti eder. Ömür bildirimleri ise bu esnek kodda hiçbir sarkan referans oluşmamasını sağlar. Üstelik tüm bu analizler derleme zamanında gerçekleştirildiğinden çalışma zamanı performansına hiçbir ek yük getirmez!
İster inanın ister inanmayın, bu bölümde incelediğimiz konularda öğrenilecek daha pek çok ayrıntı var: Bölüm 18’de trait’leri kullanmanın bir başka yolu olan trait nesnelerini (trait objects) ele alacağız. Ayrıca yalnızca çok ileri düzey senaryolarda ihtiyaç duyacağınız daha karmaşık ömür bildirimi durumları da mevcuttur; bunlar için Rust Referansı’nı inceleyebilirsiniz. Sırada ise yazdığınız kodun tam olarak beklediğiniz gibi çalıştığından emin olabilmeniz için Rust’ta nasıl otomatik test yazacağınızı öğreneceğiniz bölüm var.
Otomatik Testler Yazma
Edsger W. Dijkstra, 1972 tarihli “Mütevazı Programcı” adlı makalesinde şöyle demiştir: “Programları test etmek, hataların varlığını göstermek için çok etkili bir yol olabilir; ancak hataların yokluğunu göstermek konusunda umutsuzca yetersizdir.” Elbette bu, elimizden geldiğince kapsamlı test yapmaya çalışmamamız gerektiği anlamına gelmez!
Programlarımızdaki doğruluk, kodumuzun amaçladığımız işi ne ölçüde yerine getirdiğidir. Rust, programların doğruluğuna büyük özen gösterilerek tasarlanmıştır; ancak doğruluk karmaşık bir kavramdır ve kanıtlanması kolay değildir. Rust’ın tür sistemi bu yükün çok büyük bir kısmını üstlense de her hatayı tek başına yakalayamaz. Bu nedenle Rust, otomatik yazılım testleri yazma desteğini doğrudan bünyesinde barındırır.
Diyelim ki kendisine geçirilen sayıya 2 ekleyen iki_ekle adında bir fonksiyon yazdık. Bu fonksiyonun imzası, parametre olarak bir tam sayı aldığını ve sonuç olarak yine bir tam sayı döndürdüğünü belirtir. Fonksiyonu yazıp derlediğimizde Rust, şimdiye kadar öğrendiğiniz tür denetimlerinin ve ödünç alma denetimlerinin tamamını uygular; böylece örneğin bu fonksiyona yanlışlıkla bir String değeri veya geçersiz bir referans geçirmediğimizden emin olur. Ne var ki Rust, bu fonksiyonun tam olarak bizim amaçladığımız işlemi yapıp yapmadığını —söz gelimi parametreye 10 eklemek ya da parametreden 50 çıkarmak yerine gerçekten 2 ekleyip döndürdüğünü— kontrol edemez! İşte testler tam bu noktada devreye girer.
Örneğin iki_ekle fonksiyonuna 3 geçirdiğimizde dönen değerin 5 olduğunu doğrulayan testler yazabiliriz. Kodumuzda ne zaman bir değişiklik yapsak, mevcut doğru davranışın değişmediğinden emin olmak için bu testleri çalıştırabiliriz.
Test yazmak karmaşık bir beceridir. İyi testlerin nasıl yazılacağına dair her ayrıntıyı tek bir bölümde kapsamamız mümkün olmasa da bu bölümde Rust’ın test olanaklarının nasıl çalıştığını inceleyeceğiz. Testlerinizi yazarken kullanabileceğiniz öznitelikleri ve makroları, testleri çalıştırmak için sunulan varsayılan davranışları ve seçenekleri, ayrıca testleri birim testleri ve entegrasyon testleri şeklinde nasıl düzenleyeceğinizi ele alacağız.
Not: Pek çok programlama dilinde test yazmaya başlamadan önce üçüncü parti bir test çerçevesi seçmek, kurmak ve yapılandırmak gerekir. Rust’ta ise test altyapısı doğrudan dile ve Cargo’ya yerleşiktir: #[test] ve #[should_panic] gibi öznitelikler ile assert!, assert_eq! ve assert_ne! doğrulama makroları standart kurulumla hazır gelir. cargo test komutu hiçbir ek paket gerektirmeden birim testlerini, entegrasyon testlerini ve hatta API belgelendirmesindeki kod örneklerini (belgelendirme testleri) tek komutla derleyip varsayılan olarak paralel biçimde çalıştırır. Böylece derleyicinin tür sistemi ve ödünç alma denetleyicisiyle derleme zamanında sağladığı yapısal güvenliği, iş mantığınızı doğrulayan standart bir test akışıyla zahmetsizce tamamlayabilirsiniz.
Nasıl Test Yazılır
Nasıl Test Yazılır
Testler, test dışı kodun beklenen şekilde çalışıp çalışmadığını doğrulayan Rust fonksiyonlarıdır. Test fonksiyonlarının gövdeleri tipik olarak şu üç adımı gerçekleştirir:
- Gerekli verileri veya durumu hazırlamak.
- Test etmek istediğiniz kodu çalıştırmak.
- Sonuçların beklediğiniz gibi olduğunu doğrulamak.
Rust’ın bu adımları yerine getiren testler yazmak için özel olarak sunduğu test özniteliği (attribute), birkaç doğrulama makrosu ve should_panic özniteliği gibi olanakları inceleyelim.
Not: Yukarıdaki üç adım, yazılım mühendisliğinde Hazırla–Çalıştır–Doğrula (Arrange–Act–Assert, kısaca AAA) düzeni olarak bilinir. Birim testlerinizi bu üç aşamaya göre kurgulamak —ve Liste 11-6’da göreceğiniz gibi aşamaları gerektiğinde boş bir satırla ayırmak— her testin tek bir davranışa odaklanmasını sağlar; test başarısız olduğunda sorunun kurulumdan mı, çağrılan fonksiyondan mı yoksa beklenen sonuçtan mı kaynaklandığını bir bakışta görmenizi kolaylaştırır.
Test Fonksiyonlarını Yapılandırma
En yalın haliyle Rust’ta bir test, test özniteliğiyle işaretlenmiş bir fonksiyondur. Öznitelikler, Rust kod parçaları hakkındaki meta verilerdir; Bölüm 5’te struct’larla birlikte kullandığımız derive özniteliği buna bir örnektir. Bir fonksiyonu test fonksiyonuna dönüştürmek için fn anahtar kelimesinden önceki satıra #[test] eklemeniz yeterlidir. Testlerinizi cargo test komutuyla çalıştırdığınızda Rust, #[test] ile işaretlenmiş fonksiyonları yürüten ve her bir test fonksiyonunun geçip geçmediğini bildiren bir test çalıştırıcısı ikili dosyası derler.
Cargo ile yeni bir kütüphane projesi oluşturduğumuzda, içinde örnek bir test fonksiyonu bulunan bir test modülü bizim için otomatik olarak üretilir. Bu modül, testlerinizi yazmanız için hazır bir şablon sunar; böylece her yeni projeye başladığınızda tam yapıyı ve sözdizimini aramak zorunda kalmazsınız. İstediğiniz kadar ek test fonksiyonu ve test modülü ekleyebilirsiniz!
Gerçek bir kodu test etmeye geçmeden önce, bu şablon test üzerinde denemeler yaparak testlerin çalışma biçimine dair bazı temel noktaları keşfedeceğiz. Ardından kendi yazdığımız kodu çağıran ve davranışının doğru olduğunu sınayan gerçek dünya testleri yazacağız.
İki sayıyı toplayacak toplayici adında yeni bir kütüphane projesi oluşturalım:
$ cargo new toplayici --lib
Created library `toplayici` project
$ cd toplayici
toplayici kütüphanenizdeki src/lib.rs dosyasının içeriği (tanımlayıcılar Türkçeleştirildiğinde) Liste 11-1’deki gibi görünmelidir.
pub fn topla(sol: u64, sag: u64) -> u64 {
sol + sag
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn calisiyor() {
let sonuc = topla(2, 2);
assert_eq!(sonuc, 4);
}
}
cargo new tarafından otomatik olarak üretilen kodDosya, test edecek bir kodumuz olması için örnek bir topla fonksiyonuyla başlar (cargo new --lib komutu varsayılan şablonda İngilizce add ve it_works adlarını üretir; örneklerimizle uyumlu olması için burada topla ve calisiyor adlarını kullanıyoruz).
Şimdilik yalnızca calisiyor fonksiyonuna odaklanalım. #[test] özniteliğine dikkat edin: Bu öznitelik fonksiyonun bir test fonksiyonu olduğunu belirtir; böylece test çalıştırıcısı bu fonksiyonu bir test olarak ele alması gerektiğini bilir. Ortak senaryoları hazırlamak veya sık tekrarlanan işlemleri yürütmek amacıyla tests modülü içinde test olmayan yardımcı fonksiyonlar da bulundurabiliriz; bu yüzden hangi fonksiyonların test olduğunu her zaman açıkça belirtmemiz gerekir.
Örnek fonksiyon gövdesi, topla fonksiyonunu 2 ve 2 argümanlarıyla çağırmanın sonucunu tutan sonuc değişkeninin 4’e eşit olduğunu doğrulamak (assert) için assert_eq! makrosunu kullanır. Bu doğrulama ifadesi, tipik bir testin biçimine örnek teşkil eder. Testin geçtiğini görmek için çalıştıralım.
cargo test komutu, Liste 11-2’de gösterildiği gibi projemizdeki tüm testleri çalıştırır.
$ cargo test
Compiling toplayici v0.1.0 (file:///projects/toplayici)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.57s
Running unittests src/lib.rs (target/debug/deps/toplayici-01ad14159ff659ab)
running 1 test
test tests::calisiyor ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests toplayici
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Cargo testi derledi ve çalıştırdı. Çıktıda 1 testin çalıştırıldığını belirten running 1 test satırını görüyoruz. Bir sonraki satırda, üretilen test fonksiyonunun adı (tests::calisiyor) ve bu testi çalıştırma sonucunun başarılı olduğunu gösteren ok ifadesi yer alır. Genel özet satırındaki test result: ok. tüm testlerin geçtiği anlamına gelir; 1 passed; 0 failed kısmı ise geçen ve başarısız olan testlerin toplam sayısını verir.
Bir testi belirli bir çalıştırmada yürütülmemesi için yok sayılmış olarak işaretlemek mümkündür; bunu bu bölümün ilerleyen kısımlarındaki “Açıkça İstenmedikçe Testleri Yok Sayma” başlığında ele alacağız. Burada böyle bir işaretleme yapmadığımız için özette 0 ignored (0 yok sayıldı) yazar. Ayrıca cargo test komutuna bir argüman geçirerek yalnızca adı belirli bir metinle eşleşen testleri çalıştırabiliriz; buna filtreleme denir ve bu konuyu “Testlerin Bir Alt Kümesini İsme Göre Çalıştırma” başlığında inceleyeceğiz. Çalıştırılan testleri filtrelemediğimiz için özetin sonunda 0 filtered out (0 filtrelendi) ifadesi yer alır.
0 measured (0 ölçüldü) istatistiği, performansı ölçen kıyaslama (benchmark) testleri içindir. Kıyaslama testleri, bu satırların yazıldığı sırada yalnızca Rust’ın gecelik (nightly) sürümünde kullanılabilmektedir. Daha fazla bilgi edinmek için kıyaslama testleri hakkındaki belgelere göz atabilirsiniz.
Test çıktısının Doc-tests toplayici ile başlayan bir sonraki bölümü ise belgelendirme testlerinin (Doc-tests) sonuçlarına ayrılmıştır. Henüz herhangi bir belgelendirme testimiz yok; ancak Rust, API belgelendirmemizde yer alan kod örneklerini derleyip çalıştırabilir. Bu özellik, belgelerinizin kodunuzla her zaman uyumlu kalmasına yardımcı olur! Belgelendirme testlerinin nasıl yazılacağını Bölüm 14’teki “Belgelendirme Yorumlarını Test Olarak Kullanma” başlığında ele alacağız. Şimdilik Doc-tests çıktısını göz ardı edeceğiz.
Testi kendi ihtiyaçlarımıza göre özelleştirmeye başlayalım. İlk olarak calisiyor fonksiyonunun adını, örneğin aşağıdaki gibi kesif olacak şekilde değiştirin:
Dosya adı: src/lib.rs
pub fn topla(sol: u64, sag: u64) -> u64 {
sol + sag
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kesif() {
let sonuc = topla(2, 2);
assert_eq!(sonuc, 4);
}
}
Ardından cargo test komutunu tekrar çalıştırın. Çıktı artık calisiyor yerine kesif adını gösterir:
$ cargo test
Compiling toplayici v0.1.0 (file:///projects/toplayici)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.59s
Running unittests src/lib.rs (target/debug/deps/toplayici-92948b65e88960b4)
running 1 test
test tests::kesif ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests toplayici
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Şimdi bir test daha ekleyeceğiz, ancak bu kez başarısız olan bir test yazacağız! Test fonksiyonunun içindeki bir kod paniklediğinde test başarısız olur. Her test ayrı bir thread’de (iş parçacığında) çalıştırılır ve ana thread bir test thread’inin çöktüğünü gördüğünde o testi başarısız olarak işaretler. Bölüm 9’da panik oluşturmanın en basit yolunun panic! makrosunu çağırmak olduğundan bahsetmiştik. Yeni testi baska_bir_test adında bir fonksiyon olarak ekleyin; src/lib.rs dosyanız Liste 11-3’teki gibi görünecektir.
pub fn topla(sol: u64, sag: u64) -> u64 {
sol + sag
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kesif() {
let sonuc = topla(2, 2);
assert_eq!(sonuc, 4);
}
#[test]
fn baska_bir_test() {
panic!("Bu testi başarısız yap");
}
}
panic! makrosunu çağırdığımız için başarısız olacak ikinci bir test eklemecargo test komutuyla testleri tekrar çalıştırın. Çıktı Liste 11-4’teki gibi görünmeli; kesif testimizin geçtiğini, baska_bir_test testimizin ise başarısız olduğunu göstermelidir.
$ cargo test
Compiling toplayici v0.1.0 (file:///projects/toplayici)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.72s
Running unittests src/lib.rs (target/debug/deps/toplayici-92948b65e88960b4)
running 2 tests
test tests::baska_bir_test ... FAILED
test tests::kesif ... ok
failures:
---- tests::baska_bir_test stdout ----
thread 'tests::baska_bir_test' (6019162) panicked at src/lib.rs:17:9:
Bu testi başarısız yap
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::baska_bir_test
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
test tests::baska_bir_test satırında ok yerine FAILED (başarısız) yazar. Tek tek test sonuçları ile genel özet arasında iki yeni bölüm belirir: İlk bölüm, her bir test başarısızlığının ayrıntılı nedenini gösterir. Bu örnekte tests::baska_bir_test testinin, src/lib.rs dosyasının 17. satırında Bu testi başarısız yap mesajıyla paniklediği için başarısız olduğu ayrıntısını görürüz. İkinci bölüm ise yalnızca başarısız olan tüm testlerin adlarını listeler; çok sayıda testin ve uzun hata çıktılarının bulunduğu projelerde bu liste oldukça kullanışlıdır. Başarısız olan bir testin adını kullanarak hataları daha kolay ayıklamak üzere yalnızca o testi çalıştırabiliriz; testleri çalıştırma yöntemlerini “Testlerin Nasıl Çalıştırılacağını Kontrol Etme” bölümünde daha ayrıntılı ele alacağız.
En sonda özet satırı görüntülenir: Genel test sonucumuz FAILED olarak raporlanmıştır. Bir testimiz geçmiş, bir testimiz ise başarısız olmuştur.
Farklı senaryolarda test sonuçlarının nasıl göründüğünü incelediğimize göre, şimdi testlerde panic! dışında işimize yarayacak diğer makrolara bakalım.
assert! ile Sonuçları Kontrol Etme
Standart kütüphane tarafından sağlanan assert! makrosu, bir testteki belirli bir koşulun true olarak değerlendirildiğinden emin olmak istediğinizde kullanışlıdır. assert! makrosuna mantıksal (bool) bir değer üreten bir argüman veririz. Değer true ise hiçbir şey olmaz ve test geçer. Değer false ise assert! makrosu panic! çağırarak testin başarısız olmasına yol açar. assert! makrosunu kullanmak, kodumuzun amaçladığımız şekilde çalışıp çalışmadığını denetlememize yardımcı olur.
Bölüm 5, Liste 5-15’te bir Dikdortgen struct’ı ve başka bir dikdörtgeni içine alıp alamayacağını kontrol eden bir metot (kapsayabilir_mi) kullanmıştık. Bu kodu src/lib.rs dosyasına koyalım ve ardından assert! makrosunu kullanarak onun için birkaç test yazalım.
#[derive(Debug)]
struct Dikdortgen {
genislik: u32,
yukseklik: u32,
}
impl Dikdortgen {
fn kapsayabilir_mi(&self, diger: &Dikdortgen) -> bool {
self.genislik > diger.genislik && self.yukseklik > diger.yukseklik
}
}
Dikdortgen struct’ı ve kapsayabilir_mi metodukapsayabilir_mi metodu bir bool döndürür; bu da onu assert! makrosu için biçilmiş kaftan yapar. Liste 11-6’da genişliği 8, yüksekliği 7 olan bir Dikdortgen örneği oluşturup bunun genişliği 5, yüksekliği 1 olan başka bir Dikdortgen örneğini tutabildiğini doğrulayarak kapsayabilir_mi metodunu sınayan bir test yazıyoruz.
#[derive(Debug)]
struct Dikdortgen {
genislik: u32,
yukseklik: u32,
}
impl Dikdortgen {
fn kapsayabilir_mi(&self, diger: &Dikdortgen) -> bool {
self.genislik > diger.genislik && self.yukseklik > diger.yukseklik
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn buyuk_kucugu_kapsayabilir_mi() {
let buyuk = Dikdortgen {
genislik: 8,
yukseklik: 7,
};
let kucuk = Dikdortgen {
genislik: 5,
yukseklik: 1,
};
assert!(buyuk.kapsayabilir_mi(&kucuk));
}
}
kapsayabilir_mi testitests modülünün içindeki use super::*; satırına dikkat edin. tests modülü, Bölüm 7’deki “Modül Ağacındaki Bir Öğeye Başvurmak İçin Yollar” başlığında ele aldığımız olağan görünürlük kurallarına uyan sıradan bir modüldür. tests modülü bir iç modül olduğundan, dış modülde yer alan ve test edilen kodu iç modülün kapsamına dahil etmemiz gerekir. Burada bir glob (*) operatörü kullandığımız için dış modülde tanımladığımız her şey bu tests modülü içinde erişilebilir hale gelir.
Testimizi buyuk_kucugu_kapsayabilir_mi olarak adlandırdık ve ihtiyacımız olan iki Dikdortgen örneğini oluşturduk. Ardından assert! makrosunu çağırıp ona buyuk.kapsayabilir_mi(&kucuk) ifadesinin sonucunu geçirdik. Bu ifadenin true döndürmesi gerekir, dolayısıyla testimiz geçmelidir. Haydi deneyip görelim!
$ cargo test
Compiling rectangle v0.1.0 (file:///projects/rectangle)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)
running 1 test
test tests::buyuk_kucugu_kapsayabilir_mi ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests rectangle
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Test geçti! Şimdi bir test daha ekleyelim ve bu kez küçük bir dikdörtgenin kendisinden büyük bir dikdörtgeni tutamayacağını doğrulayalım:
Dosya adı: src/lib.rs
#[derive(Debug)]
struct Dikdortgen {
genislik: u32,
yukseklik: u32,
}
impl Dikdortgen {
fn kapsayabilir_mi(&self, diger: &Dikdortgen) -> bool {
self.genislik > diger.genislik && self.yukseklik > diger.yukseklik
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn buyuk_kucugu_kapsayabilir_mi() {
// --snip--
let buyuk = Dikdortgen {
genislik: 8,
yukseklik: 7,
};
let kucuk = Dikdortgen {
genislik: 5,
yukseklik: 1,
};
assert!(buyuk.kapsayabilir_mi(&kucuk));
}
#[test]
fn kucuk_buyugu_tutamaz() {
let buyuk = Dikdortgen {
genislik: 8,
yukseklik: 7,
};
let kucuk = Dikdortgen {
genislik: 5,
yukseklik: 1,
};
assert!(!kucuk.kapsayabilir_mi(&buyuk));
}
}
Bu senaryoda kapsayabilir_mi metodunun doğru sonucu false olduğundan, sonucu assert! makrosuna geçirmeden önce ! operatörüyle değillememiz gerekir. Böylece kapsayabilir_mi metodu false döndürdüğünde testimiz geçecektir:
$ cargo test
Compiling rectangle v0.1.0 (file:///projects/rectangle)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)
running 2 tests
test tests::buyuk_kucugu_kapsayabilir_mi ... ok
test tests::kucuk_buyugu_tutamaz ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests rectangle
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
İki test de geçti! Şimdi kodumuza bir hata yerleştirdiğimizde test sonuçlarımıza ne olacağını görelim. Genişlikleri karşılaştırırken büyüktür işaretini (>) küçüktür işaretiyle (<) değiştirerek kapsayabilir_mi metodunun uygulamasını bozalım:
#[derive(Debug)]
struct Dikdortgen {
genislik: u32,
yukseklik: u32,
}
// --snip--
impl Dikdortgen {
fn kapsayabilir_mi(&self, diger: &Dikdortgen) -> bool {
self.genislik < diger.genislik && self.yukseklik > diger.yukseklik
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn buyuk_kucugu_kapsayabilir_mi() {
let buyuk = Dikdortgen {
genislik: 8,
yukseklik: 7,
};
let kucuk = Dikdortgen {
genislik: 5,
yukseklik: 1,
};
assert!(buyuk.kapsayabilir_mi(&kucuk));
}
#[test]
fn kucuk_buyugu_tutamaz() {
let buyuk = Dikdortgen {
genislik: 8,
yukseklik: 7,
};
let kucuk = Dikdortgen {
genislik: 5,
yukseklik: 1,
};
assert!(!kucuk.kapsayabilir_mi(&buyuk));
}
}
Testleri şimdi çalıştırmak şu çıktıyı üretir:
$ cargo test
Compiling rectangle v0.1.0 (file:///projects/rectangle)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)
running 2 tests
test tests::buyuk_kucugu_kapsayabilir_mi ... FAILED
test tests::kucuk_buyugu_tutamaz ... ok
failures:
---- tests::buyuk_kucugu_kapsayabilir_mi stdout ----
thread 'tests::buyuk_kucugu_kapsayabilir_mi' (6020788) panicked at src/lib.rs:28:9:
assertion failed: buyuk.kapsayabilir_mi(&kucuk)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::buyuk_kucugu_kapsayabilir_mi
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
Testlerimiz hatayı yakaladı! buyuk.genislik değeri 8 ve kucuk.genislik değeri 5 olduğundan, kapsayabilir_mi içindeki genişlik karşılaştırması artık false döndürür; çünkü 8, 5’ten küçük değildir.
assert_eq! ve assert_ne! ile Eşitliği Test Etme
İşlevselliği doğrulamanın yaygın bir yolu, test edilen kodun ürettiği sonuç ile döndürmesini beklediğiniz değer arasında eşitlik kontrolü yapmaktır. Bunu assert! makrosuna == operatörünü kullanan bir ifade geçirerek de yapabilirsiniz. Ancak bu o kadar sık yapılan bir denetimdir ki standart kütüphane bu işi daha pratik biçimde gerçekleştirmek için bir çift makro sunar: assert_eq! ve assert_ne!. Bu makrolar iki argümanı sırasıyla eşitlik veya eşitsizlik açısından karşılaştırır. Üstelik doğrulama başarısız olduğunda her iki değeri de ekrana yazdırarak testin neden başarısız olduğunu görmeyi kolaylaştırırlar; oysa assert! makrosu false sonucuna yol açan değerleri göstermeden yalnızca == ifadesinin false döndürdüğünü bildirir.
Liste 11-7’de parametresine 2 ekleyen iki_ekle adında bir fonksiyon yazıyor ve ardından bu fonksiyonu assert_eq! makrosuyla test ediyoruz.
pub fn iki_ekle(a: u64) -> u64 {
a + 2
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn iki_ekliyor() {
let sonuc = iki_ekle(2);
assert_eq!(sonuc, 4);
}
}
assert_eq! makrosunu kullanarak iki_ekle fonksiyonunu test etmeTestin geçip geçmediğini kontrol edelim!
$ cargo test
Compiling toplayici v0.1.0 (file:///projects/toplayici)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
Running unittests src/lib.rs (target/debug/deps/toplayici-92948b65e88960b4)
running 1 test
test tests::iki_ekliyor ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests toplayici
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
iki_ekle(2) çağrısının sonucunu tutan sonuc adında bir değişken oluşturuyoruz. Ardından assert_eq! makrosuna argüman olarak sonuc ve 4 değerlerini geçiriyoruz. Bu testin çıktı satırı test tests::iki_ekliyor ... ok şeklindedir ve ok metni testimizin geçtiğini gösterir!
assert_eq! makrosunun başarısız olduğunda nasıl bir çıktı ürettiğini görmek için kodumuza bir hata ekleyelim. iki_ekle fonksiyonunun uygulamasını 2 yerine 3 ekleyecek şekilde değiştirin:
pub fn iki_ekle(a: u64) -> u64 {
a + 3
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn iki_ekliyor() {
let sonuc = iki_ekle(2);
assert_eq!(sonuc, 4);
}
}
Testleri tekrar çalıştırın:
$ cargo test
Compiling toplayici v0.1.0 (file:///projects/toplayici)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.61s
Running unittests src/lib.rs (target/debug/deps/toplayici-92948b65e88960b4)
running 1 test
test tests::iki_ekliyor ... FAILED
failures:
---- tests::iki_ekliyor stdout ----
thread 'tests::iki_ekliyor' (6020955) panicked at src/lib.rs:12:9:
assertion `left == right` failed
left: 5
right: 4
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::iki_ekliyor
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
Testimiz hatayı yakaladı! tests::iki_ekliyor testi başarısız oldu; hata mesajı bize başarısız olan doğrulamanın left == right olduğunu ve left (sol) ile right (sağ) değerlerinin ne olduğunu açıkça söylüyor. Bu bilgi hata ayıklamaya hemen başlamamıza yardımcı olur: iki_ekle(2) çağrısının sonucunu verdiğimiz left argümanı 5 iken right argümanı 4 idi. Çok sayıda testimizin bulunduğu projelerde bu çıktının ne kadar işe yarayacağını tahmin edebilirsiniz.
Bazı programlama dillerinde ve test çerçevelerinde eşitlik doğrulama fonksiyonlarının parametrelerine expected (beklenen) ve actual (gerçekleşen) adı verilir; dolayısıyla argümanları belirttiğimiz sıra önem taşır. Rust’ta ise bu parametreler left ve right olarak adlandırılır; beklediğimiz değer ile kodun ürettiği değeri hangi sırayla yazdığımızın bir önemi yoktur. Bu testteki doğrulamayı assert_eq!(4, sonuc) olarak da yazabilirdik ve başarısızlık durumunda yine assertion `left == right` failed şeklinde aynı hata mesajını alırdık.
assert_ne! makrosu ise verdiğimiz iki değer birbirine eşit değilse geçer, eşitse başarısız olur. Bu makro en çok bir değerin ne olacağından emin olmadığımız, ancak kesinlikle ne olmaması gerektiğini bildiğimiz durumlarda kullanışlıdır. Örneğin girdisini bir şekilde değiştirmesi garanti edilen, ancak bu değişikliğin biçimi testleri çalıştırdığımız haftanın gününe bağlı olan bir fonksiyonu test ediyorsak, doğrulanabilecek en iyi koşul fonksiyon çıktısının girdiye eşit olmaması olabilir.
Perde arkasında assert_eq! ve assert_ne! makroları sırasıyla == ve != operatörlerini kullanır. Doğrulamalar başarısız olduğunda bu makrolar argümanlarını hata ayıklama (debug) biçimlendirmesiyle yazdırır; bu da karşılaştırılan değerlerin hem PartialEq hem de Debug trait’lerini uygulaması gerektiği anlamına gelir. Tüm ilkel türler ve standart kütüphane türlerinin büyük çoğunluğu bu trait’leri halihazırda uygular. Kendi tanımladığınız struct ve enum’larda ise türlerin eşitliğini sınayabilmek için PartialEq trait’ini, doğrulama başarısız olduğunda değerleri yazdırabilmek için de Debug trait’ini uygulamanız gerekir. Bölüm 5’teki Liste 5-12’de değindiğimiz üzere her iki trait de türetilebilir (derivable) olduğundan, bu işlem genellikle struct veya enum tanımınıza #[derive(PartialEq, Debug)] özniteliğini eklemek kadar basittir. Bu ve diğer türetilebilir trait’ler hakkında daha fazla ayrıntı için Ek C, “Türetilebilir Trait’ler” bölümüne bakabilirsiniz.
Not: Testiniz başarılı olsa bile assert_eq!(sol, sag) ve assert_ne!(sol, sag) makroları her iki trait’in de uygulanmış olmasını derleme zamanında zorunlu tutar: Makro genişletildiğinde karşılaştırma için PartialEq (== / !=), başarısızlık kolundaki hata mesajı için ise Debug ({:?}) biçimlendirme kodu üretilir. Tam eşitlik (Eq) yerine kısmi eşitlik (PartialEq) istenmesinin nedeni, NaN != NaN kuralı yüzünden Eq uygulamayan f32/f64 gibi kayan noktalı sayıların da testlerde karşılaştırılabilmesini sağlamaktır. Kendi struct veya enum türünüze #[derive(PartialEq, Debug)] eklediğinizde, o türün içindeki tüm alanların da bu iki trait’i uygulaması gerektiğini unutmayın.
Özel Başarısızlık Mesajları Ekleme
Başarısızlık mesajıyla birlikte yazdırılacak özel bir açıklamayı assert!, assert_eq! ve assert_ne! makrolarına isteğe bağlı argümanlar olarak da ekleyebilirsiniz. Zorunlu argümanlardan sonra belirtilen tüm ek argümanlar format! makrosuna (Bölüm 8’deki “+ veya format! ile Birleştirme” başlığında ele alınmıştı) aktarılır; dolayısıyla {} yer tutucuları içeren bir biçimlendirme metni ve bu yer tutuculara yerleşecek değerleri geçirebilirsiniz. Özel mesajlar bir doğrulamanın ne anlama geldiğini belgelemek açısından oldukça yararlıdır; test başarısız olduğunda koddaki sorunun ne olduğuna dair çok daha net bir fikir verir.
Örneğin insanları ismen selamlayan bir fonksiyonumuz olduğunu ve fonksiyona geçirdiğimiz ismin çıktıda yer alıp almadığını test etmek istediğimizi varsayalım:
Dosya adı: src/lib.rs
pub fn selamlama(isim: &str) -> String {
format!("Merhaba {isim}!")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn selamlama_isim_iceriyor() {
let sonuc = selamlama("Carol");
assert!(sonuc.contains("Carol"));
}
}
Bu programa ilişkin gereksinimler henüz kesinleşmedi ve selamlamanın başındaki Merhaba metninin ileride değişeceğinden neredeyse eminiz. Gereksinimler her değiştiğinde testi de güncellemek zorunda kalmak istemediğimiz için, selamlama fonksiyonundan dönen değer üzerinde tam eşitlik kontrolü yapmak yerine yalnızca çıktının girdi parametresindeki metni içerdiğini doğrulamayı tercih ettik.
Şimdi selamlama fonksiyonunu isim parametresini dışarıda bırakacak şekilde değiştirerek bu koda bir hata ekleyelim ve varsayılan test başarısızlığının nasıl göründüğüne bakalım:
pub fn selamlama(isim: &str) -> String {
String::from("Merhaba!")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn selamlama_isim_iceriyor() {
let sonuc = selamlama("Carol");
assert!(sonuc.contains("Carol"));
}
}
Bu testi çalıştırmak aşağıdaki çıktıyı üretir:
$ cargo test
Compiling greeter v0.1.0 (file:///projects/greeter)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.91s
Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)
running 1 test
test tests::selamlama_isim_iceriyor ... FAILED
failures:
---- tests::selamlama_isim_iceriyor stdout ----
thread 'tests::selamlama_isim_iceriyor' (6021143) panicked at src/lib.rs:12:9:
assertion failed: sonuc.contains("Carol")
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::selamlama_isim_iceriyor
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
Bu sonuç yalnızca doğrulamanın başarısız olduğunu ve hangi satırda gerçekleştiğini bildirir. Daha kullanışlı bir başarısızlık mesajı, selamlama fonksiyonundan dönen gerçek değeri de yazdırırdı. Şimdi özel bir başarısızlık mesajı ekleyelim. Bu mesaj, selamlama fonksiyonundan aldığımız gerçek değerle doldurulan bir yer tutucu içeren bir biçimlendirme dizgisinden oluşsun:
pub fn selamlama(isim: &str) -> String {
String::from("Merhaba!")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn selamlama_isim_iceriyor() {
let sonuc = selamlama("Carol");
assert!(
sonuc.contains("Carol"),
"Selamlama isim içermiyordu, değer `{sonuc}` idi"
);
}
}
Artık testi çalıştırdığımızda çok daha bilgilendirici bir hata mesajı alırız:
$ cargo test
Compiling greeter v0.1.0 (file:///projects/greeter)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.93s
Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)
running 1 test
test tests::selamlama_isim_iceriyor ... FAILED
failures:
---- tests::selamlama_isim_iceriyor stdout ----
thread 'tests::selamlama_isim_iceriyor' (6021333) panicked at src/lib.rs:12:9:
Selamlama isim içermiyordu, değer `Merhaba!` idi
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::selamlama_isim_iceriyor
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
Test çıktısında gerçekten elde ettiğimiz değeri görebiliyoruz; bu da beklediğimiz sonuç yerine gerçekte ne olup bittiğini anlayıp hatayı ayıklamamıza yardımcı olur.
should_panic ile Panikleri Kontrol Etme
Dönüş değerlerini kontrol etmenin yanı sıra, kodumuzun hata durumlarını beklediğimiz gibi ele alıp almadığını denetlemek de önemlidir. Örneğin Bölüm 9, Liste 9-13’te oluşturduğumuz Tahmin türünü düşünün. Tahmin türünü kullanan diğer kodlar, Tahmin örneklerinin yalnızca 1 ile 100 arasındaki değerleri barındıracağı güvencesine dayanır. Bu aralığın dışındaki bir değerle Tahmin örneği oluşturma girişiminin panikle sonuçlandığından emin olan bir test yazabiliriz.
Bunu test fonksiyonumuza should_panic özniteliğini ekleyerek yaparız. Fonksiyonun içindeki kod paniklerse test geçer; kod paniklemezse test başarısız olur.
Liste 11-8, Tahmin::new fonksiyonunun hata koşullarının beklediğimiz durumda gerçekleştiğini denetleyen bir testi gösterir.
pub struct Tahmin {
deger: i32,
}
impl Tahmin {
pub fn new(deger: i32) -> Tahmin {
if deger < 1 || deger > 100 {
panic!(
"Tahmin değeri 1 ile 100 arasında olmalıdır, {deger} alındı."
);
}
Tahmin { deger }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic]
fn yuzden_buyuk() {
Tahmin::new(200);
}
}
panic! çağrısına neden olacağını test etme#[should_panic] özniteliğini #[test] özniteliğinden sonra ve uygulanacağı test fonksiyonundan hemen önce yerleştiririz. Bu test geçtiğinde elde edilen sonuca bakalım:
$ cargo test
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)
running 1 test
test tests::yuzden_buyuk - should panic ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests guessing_game
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Harika görünüyor! Şimdi new fonksiyonundaki değerin 100’den büyük olması durumunda panikleme koşulunu kaldırarak kodumuza bir hata ekleyelim:
pub struct Tahmin {
deger: i32,
}
// --snip--
impl Tahmin {
pub fn new(deger: i32) -> Tahmin {
if deger < 1 {
panic!(
"Tahmin değeri 1 ile 100 arasında olmalıdır, {deger} alındı."
);
}
Tahmin { deger }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic]
fn yuzden_buyuk() {
Tahmin::new(200);
}
}
Liste 11-8’deki testi çalıştırdığımızda test başarısız olacaktır:
$ cargo test
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.62s
Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)
running 1 test
test tests::yuzden_buyuk - should panic ... FAILED
failures:
---- tests::yuzden_buyuk stdout ----
note: test did not panic as expected at src/lib.rs:23:8
failures:
tests::yuzden_buyuk
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
Bu durumda çok açıklayıcı bir mesaj almayız; ancak test fonksiyonuna baktığımızda #[should_panic] ile işaretlendiğini görürüz. Aldığımız başarısızlık sonucu, test fonksiyonundaki kodun paniğe yol açmadığı anlamına gelir.
Yalnızca should_panic kullanan testler yeterince kesin olmayabilir. Bir should_panic testi, beklediğimizden tamamen farklı bir nedenle paniklese bile geçer. should_panic testlerini daha kesin hale getirmek için should_panic özniteliğine isteğe bağlı expected parametresini ekleyebiliriz. Test altyapısı, panik mesajının belirtilen metni içerip içermediğini kontrol eder. Örneğin Liste 11-9’da new fonksiyonunun, değerin çok küçük ya da çok büyük olmasına bağlı olarak farklı mesajlarla paniklediği güncellenmiş Tahmin koduna bakalım.
pub struct Tahmin {
deger: i32,
}
// --snip--
impl Tahmin {
pub fn new(deger: i32) -> Tahmin {
if deger < 1 {
panic!(
"Tahmin değeri 1'den büyük veya 1'e eşit olmalıdır, {deger} alındı."
);
} else if deger > 100 {
panic!(
"Tahmin değeri 100'den küçük veya 100'e eşit olmalıdır, {deger} alındı."
);
}
Tahmin { deger }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic(expected = "100'den küçük veya 100'e eşit")]
fn yuzden_buyuk() {
Tahmin::new(200);
}
}
panic! oluşumunu test etmeBu test geçecektir; çünkü should_panic özniteliğinin expected parametresine yazdığımız metin, Tahmin::new fonksiyonunun paniklerken ürettiği mesajın bir alt dizgisidir. Beklediğimiz panik mesajının tamamını, yani Tahmin değeri 100'den küçük veya 100'e eşit olmalıdır, 200 alındı. metnini de belirtebilirdik. Neyi belirteceğiniz, panik mesajının ne kadarının benzersiz veya dinamik olduğuna ve testinizin ne ölçüde kesin olmasını istediğinize bağlıdır. Bu örnekte panik mesajının bir alt dizgisini vermek, test fonksiyonundaki kodun else if deger > 100 kolunu çalıştırdığından emin olmak için yeterlidir.
expected mesajına sahip bir should_panic testi başarısız olduğunda ne olacağını görmek için, if deger < 1 ve else if deger > 100 bloklarının gövdelerini birbiriyle değiştirerek kodumuza bir kez daha hata ekleyelim:
pub struct Tahmin {
deger: i32,
}
impl Tahmin {
pub fn new(deger: i32) -> Tahmin {
if deger < 1 {
panic!(
"Tahmin değeri 100'den küçük veya 100'e eşit olmalıdır, {deger} alındı."
);
} else if deger > 100 {
panic!(
"Tahmin değeri 1'den büyük veya 1'e eşit olmalıdır, {deger} alındı."
);
}
Tahmin { deger }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic(expected = "100'den küçük veya 100'e eşit")]
fn yuzden_buyuk() {
Tahmin::new(200);
}
}
Bu kez should_panic testini çalıştırdığımızda test başarısız olur:
$ cargo test
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)
running 1 test
test tests::yuzden_buyuk - should panic ... FAILED
failures:
---- tests::yuzden_buyuk stdout ----
thread 'tests::yuzden_buyuk' (6021675) panicked at src/lib.rs:12:13:
Tahmin değeri 1'den büyük veya 1'e eşit olmalıdır, 200 alındı.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
note: panic did not contain expected string
panic message: "Tahmin değeri 1'den büyük veya 1'e eşit olmalıdır, 200 alındı."
expected substring: "100'den küçük veya 100'e eşit"
failures:
tests::yuzden_buyuk
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
Hata mesajı, testin beklediğimiz gibi gerçekten paniklediğini ancak panik mesajının beklenen 100'den küçük veya 100'e eşit dizgisini içermediğini gösterir. Bu durumda elde ettiğimiz gerçek panik mesajı Tahmin değeri 1'den büyük veya 1'e eşit olmalıdır, 200 alındı. şeklindeydi. Böylece hatamızın nerede olduğunu hemen bulabiliriz!
Testlerde Result<T, E> Kullanma
Şimdiye kadarki tüm testlerimiz başarısız olduklarında panikliyordu. Testlerimizi Result<T, E> döndürecek şekilde de yazabiliriz! İşte Liste 11-1’deki testin paniklemek yerine Result<T, E> kullanacak ve hata durumunda bir Err döndürecek biçimde yeniden yazılmış hali:
pub fn topla(sol: u64, sag: u64) -> u64 {
sol + sag
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn calisiyor() -> Result<(), String> {
let sonuc = topla(2, 2);
if sonuc == 4 {
Ok(())
} else {
Err(String::from("iki artı iki dört etmez"))
}
}
}
calisiyor fonksiyonu artık Result<(), String> dönüş türüne sahiptir. Fonksiyon gövdesinde assert_eq! makrosunu çağırmak yerine, test başarılı olduğunda Ok(()), başarısız olduğunda ise içinde sorunu açıklayan bir String barındıran Err varyantı döndürüyoruz.
Testleri Result<T, E> döndürecek şekilde yazmak, test gövdesinde soru işareti operatörünü (?) kullanmanıza olanak tanır; bu da içindeki herhangi bir adım Err varyantı döndürdüğünde başarısız olması gereken çok adımlı testleri yazmak için son derece pratik bir yoldur.
Result<T, E> kullanan testlerde #[should_panic] özniteliğini kullanamazsınız. Bir işlemin Err varyantı döndürdüğünü doğrulamak için Result<T, E> değeri üzerinde soru işareti operatörünü (?) kullanmayın (aksi hâlde ? operatörü hatayı dışarı yayarak testi başarısız kılar). Bunun yerine assert!(deger.is_err()) ifadesini kullanın.
Not: Bir test birden fazla hata türü üretebilen adımlar içeriyorsa (örneğin önce bir dosya okuyup sonra içindeki metni sayıya çeviriyorsa) her adımın hatasını String’e dönüştürmek zahmetlidir. Bu gibi durumlarda testin dönüş türünü, Bölüm 9’da gördüğünüz Box<dyn Error> ile Result<(), Box<dyn std::error::Error>> olarak yazmak yaygın bir alışkanlıktır: ? operatörü Error trait’ini uygulayan her hatayı kendiliğinden bu kutuya dönüştürür, test ise ilk Err döndüren adımda hata mesajıyla birlikte başarısız olur. Test başarısız olduğunda test çalıştırıcısı, Err içindeki değeri Debug biçimlendirmesiyle ({:?}) ekrana yazdırır; dolayısıyla E türünün Debug trait’ini uygulaması gerekir.
Artık test yazmanın çeşitli yollarını öğrendiğinize göre, testlerimizi çalıştırdığımızda neler olup bittiğine bakalım ve cargo test ile kullanabileceğimiz farklı seçenekleri inceleyelim.
Testlerin Nasıl Çalıştırılacağını Kontrol Etme
Testlerin Nasıl Çalıştırılacağını Kontrol Etme
Tıpkı cargo run komutunun kodunuzu derleyip ardından üretilen ikili dosyayı çalıştırması gibi, cargo test komutu da kodunuzu test modunda derler ve üretilen test ikili dosyasını çalıştırır. cargo test tarafından üretilen ikilinin varsayılan davranışı, tüm testleri paralel olarak çalıştırmak ve testlerin çalışması sırasında üretilen çıktıyı yakalamaktır; böylece bu çıktı ekranda görüntülenmez ve test sonuçlarıyla ilgili çıktıyı okumak kolaylaşır. Bununla birlikte, bu varsayılan davranışı değiştirmek için komut satırı seçenekleri belirtebilirsiniz.
Bazı komut satırı seçenekleri cargo test komutuna, bazıları ise üretilen test ikili dosyasına iletilir. Bu iki argüman türünü birbirinden ayırmak için önce cargo test’e gidecek argümanları, ardından -- ayırıcısını ve sonrasında test ikilisine gidecek argümanları sıralarsınız. cargo test --help komutunu çalıştırmak cargo test ile kullanabileceğiniz seçenekleri listelerken, cargo test -- --help komutunu çalıştırmak ayırıcıdan sonra kullanabileceğiniz seçenekleri gösterir. Bu seçenekler rustc Kitabı’nın “Tests” bölümünde de belgelenmiştir.
Not: Pratikte -- ayırıcısının solundaki ve sağındaki seçenekler iki farklı katmanı kontrol eder:
--öncesi (cargo testbayrakları): Cargo’nun hangi test hedeflerini derleyip çalıştıracağını belirler. Örneğincargo test --libyalnızca kütüphane birim testlerini,cargo test --test entegrasyon_testiise yalnızca belirtilen entegrasyon testi dosyasını derleyip çalıştırır (belgelendirme testlerini ve diğer hedefleri atlar).--sonrası (test çalıştırıcısı bayrakları): Derlenen test ikilisinin testleri yürütme ve çıktıyı raporlama biçimini değiştirir. Bu bölümde ele alacağımız--test-threads,--show-output,--ignoredve--include-ignoredbayraklarının yanı sıra; standart çıktıyı hiç yakalamadan anlık olarak terminale yazdırmak için--no-capture(veya eski adıyla--nocapture) ya da alt dizgi araması yerine modül yolunu da içeren tam test adıyla eşleşme yapmak için--exact(cargo test tests::yuz -- --exactgibi) bayraklarını bu kısımda kullanabilirsiniz. İki katmanın bayraklarını aynı komutta birleştirmek de mümkündür: örneğincargo test --lib -- --show-output.
Testleri Paralel veya Ardışık Olarak Çalıştırma
Birden fazla testi çalıştırdığınızda bunlar varsayılan olarak thread’ler (iş parçacıkları) aracılığıyla paralel biçimde çalışır; bu sayede testler daha kısa sürede tamamlanır ve çok daha hızlı geri bildirim alırsınız. Testler aynı anda çalıştığı için testlerinizin birbirine ya da paylaşılan herhangi bir duruma bağımlı olmadığından emin olmalısınız; geçerli çalışma dizini veya çevre değişkenleri gibi ortak bir ortam da bu paylaşılan duruma dahildir.
Örneğin testlerinizin her birinin diskte test-ciktisi.txt adında bir dosya oluşturan ve bu dosyaya veri yazan bir kod çalıştırdığını düşünelim. Ardından her test bu dosyadaki veriyi okur ve dosyanın —her testte farklı olan— belirli bir değeri içerdiğini doğrular. Testler aynı anda çalıştığı için, bir test dosyaya yazma ile dosyayı okuma işlemleri arasındayken başka bir test aynı dosyanın üzerine yazabilir. Bu durumda dosyayı ilk yazan test, kendi beklediği içerik değiştirilmiş olduğu için başarısız olabilir. Sorun, sınanan işlevden değil, paralel testlerin aynı dosyayı paylaşmasından kaynaklanır. Çözümlerden biri her testin farklı bir dosyaya yazmasını sağlamaktır; diğer bir çözüm ise testleri teker teker çalıştırmaktır.
Testleri paralel çalıştırmak istemiyorsanız veya kullanılan thread sayısı üzerinde daha hassas bir denetim kurmak istiyorsanız, test ikili dosyasına --test-threads bayrağını ve kullanmak istediğiniz thread sayısını gönderebilirsiniz. Aşağıdaki örneğe göz atalım:
$ cargo test -- --test-threads=1
Test thread’i sayısını 1 olarak ayarlayarak programa hiçbir paralellik kullanmamasını söylüyoruz. Testleri tek bir thread üzerinden çalıştırmak paralel çalıştırmaya kıyasla daha uzun sürer; ancak ortak bir durumu paylaşsalar bile testler birbirleriyle çakışmaz.
Fonksiyon Çıktısını Gösterme
Varsayılan olarak bir test başarılı olduğunda, Rust’ın test kütüphanesi standart çıktıya yazdırılan her şeyi yakalar. Örneğin bir testin içinde println! çağırırsak ve test geçerse, terminalde println! çıktısını görmeyiz; yalnızca testin geçtiğini belirten satırı görürüz. Bir test başarısız olursa, standart çıktıya yazdırılan her şeyi hata mesajının geri kalanıyla birlikte görürüz.
Örnek olarak Liste 11-10’da, aldığı parametrenin değerini ekrana yazdırıp 10 döndüren basit bir fonksiyon ile biri geçen, diğeri başarısız olan iki test yer almaktadır.
fn yazdir_ve_10_dondur(a: i32) -> i32 {
println!("{a} değerini aldım");
10
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bu_test_gececek() {
let deger = yazdir_ve_10_dondur(4);
assert_eq!(deger, 10);
}
#[test]
fn bu_test_basarisiz_olacak() {
let deger = yazdir_ve_10_dondur(8);
assert_eq!(deger, 5);
}
}
println! çağıran bir fonksiyon için testlerBu testleri cargo test ile çalıştırdığımızda aşağıdaki çıktıyı görürüz:
$ cargo test
Compiling silly-function v0.1.0 (file:///projects/silly-function)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
Running unittests src/lib.rs (target/debug/deps/silly_function-160869f38cff9166)
running 2 tests
test tests::bu_test_basarisiz_olacak ... FAILED
test tests::bu_test_gececek ... ok
failures:
---- tests::bu_test_basarisiz_olacak stdout ----
8 değerini aldım
thread 'tests::bu_test_basarisiz_olacak' (6019863) panicked at src/lib.rs:19:9:
assertion `left == right` failed
left: 10
right: 5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::bu_test_basarisiz_olacak
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
Bu çıktının hiçbir yerinde, geçen test çalıştırıldığında yazdırılan 4 değerini aldım ifadesini görmediğimize dikkat edin; bu çıktı yakalanmıştır. Başarısız olan testin ürettiği 8 değerini aldım çıktısı ise testin neden başarısız olduğunu da gösteren test özeti bölümünde yer alır.
Geçen testlerin yazdırdığı değerleri de görmek istiyorsak --show-output bayrağıyla Rust’a başarılı testlerin çıktısını da göstermesini söyleyebiliriz:
$ cargo test -- --show-output
Liste 11-10’daki testleri --show-output bayrağıyla yeniden çalıştırdığımızda aşağıdaki çıktıyı görürüz:
$ cargo test -- --show-output
Compiling silly-function v0.1.0 (file:///projects/silly-function)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.60s
Running unittests src/lib.rs (target/debug/deps/silly_function-160869f38cff9166)
running 2 tests
test tests::bu_test_basarisiz_olacak ... FAILED
test tests::bu_test_gececek ... ok
successes:
---- tests::bu_test_gececek stdout ----
4 değerini aldım
successes:
tests::bu_test_gececek
failures:
---- tests::bu_test_basarisiz_olacak stdout ----
8 değerini aldım
thread 'tests::bu_test_basarisiz_olacak' (6022313) panicked at src/lib.rs:19:9:
assertion `left == right` failed
left: 10
right: 5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::bu_test_basarisiz_olacak
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
Testlerin Bir Alt Kümesini İsme Göre Çalıştırma
Kapsamlı bir test paketinin tamamını çalıştırmak bazen uzun sürebilir. Kodun belirli bir bölümü üzerinde çalışıyorsanız yalnızca o kodla ilgili testleri çalıştırmak isteyebilirsiniz. Çalıştırmak istediğiniz testlerin adını veya adlarını cargo test komutuna argüman olarak vererek hangi testlerin çalıştırılacağını seçebilirsiniz.
Testlerin bir alt kümesinin nasıl çalıştırılacağını göstermek için önce Liste 11-11’de görüldüğü gibi iki_ekle fonksiyonumuz için üç test oluşturalım ve hangilerini çalıştıracağımızı seçelim.
pub fn iki_ekle(a: u64) -> u64 {
a + 2
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn iki_ve_iki_ekle() {
let sonuc = iki_ekle(2);
assert_eq!(sonuc, 4);
}
#[test]
fn uc_ve_iki_ekle() {
let sonuc = iki_ekle(3);
assert_eq!(sonuc, 5);
}
#[test]
fn yuz() {
let sonuc = iki_ekle(100);
assert_eq!(sonuc, 102);
}
}
Daha önce gördüğümüz gibi testleri hiçbir argüman vermeden çalıştırırsak tüm testler paralel olarak çalışır:
$ cargo test
Compiling toplayici v0.1.0 (file:///projects/toplayici)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.62s
Running unittests src/lib.rs (target/debug/deps/toplayici-ac13090f6eeacc92)
running 3 tests
test tests::uc_ve_iki_ekle ... ok
test tests::yuz ... ok
test tests::iki_ve_iki_ekle ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests toplayici
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Tek Bir Testi Çalıştırma
Yalnızca tek bir testi çalıştırmak için o test fonksiyonunun adını cargo test komutuna iletebiliriz:
$ cargo test yuz
Compiling toplayici v0.1.0 (file:///projects/toplayici)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.69s
Running unittests src/lib.rs (target/debug/deps/toplayici-ac13090f6eeacc92)
running 1 test
test tests::yuz ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s
Yalnızca yuz adlı test çalıştırıldı; diğer iki test bu isimle eşleşmedi. Test çıktısı, özet satırının sonunda 2 filtered out (2 test filtrelendi) ifadesini göstererek çalıştırılmayan başka testlerimiz de olduğunu bize bildirir.
Bu yöntemle birden fazla testin adını aynı anda belirleyemeyiz; cargo test komutuna verilen değerlerden yalnızca ilki kullanılır. Ancak birden fazla testi çalıştırmanın bir yolu vardır.
Birden Fazla Testi Çalıştırmak İçin Filtreleme
Bir test adının yalnızca bir kısmını belirtebiliriz; bu durumda adı belirtilen ifadeyi içeren tüm testler çalıştırılır. Örneğin testlerimizden ikisinin adı ekle içerdiğinden, cargo test ekle komutunu çalıştırarak bu iki testi yürütebiliriz:
$ cargo test ekle
Compiling toplayici v0.1.0 (file:///projects/toplayici)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.61s
Running unittests src/lib.rs (target/debug/deps/toplayici-ac13090f6eeacc92)
running 2 tests
test tests::uc_ve_iki_ekle ... ok
test tests::iki_ve_iki_ekle ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.00s
Bu komut, adında ekle geçen tüm testleri çalıştırdı ve yuz adlı testi dışarıda bıraktı. Ayrıca bir testin içinde yer aldığı modülün de test adının bir parçası haline geldiğine dikkat edin (tests::yuz gibi); dolayısıyla modül adına göre filtreleme yaparak bir modüldeki tüm testleri çalıştırabiliriz.
Açıkça İstenmedikçe Testleri Yok Sayma
Bazen belirli birkaç testin çalışması çok uzun sürebilir; bu nedenle cargo test komutunun çoğu çalıştırmasında bunları kapsam dışı bırakmak isteyebilirsiniz. Çalıştırmak istediğiniz diğer tüm testleri tek tek argüman olarak listelemek yerine, burada gösterildiği gibi zaman alıcı testleri ignore özniteliğiyle işaretleyerek hariç tutabilirsiniz:
Dosya adı: src/lib.rs
pub fn topla(sol: u64, sag: u64) -> u64 {
sol + sag
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn calisiyor() {
let sonuc = topla(2, 2);
assert_eq!(sonuc, 4);
}
#[test]
#[ignore]
fn pahali_test() {
// çalışması bir saat süren kod
}
}
#[test] satırından sonra, hariç tutmak istediğimiz teste #[ignore] satırını ekliyoruz. Artık testlerimizi çalıştırdığımızda calisiyor testi çalışır, ancak pahali_test çalışmaz:
$ cargo test
Compiling toplayici v0.1.0 (file:///projects/toplayici)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.60s
Running unittests src/lib.rs (target/debug/deps/toplayici-ac13090f6eeacc92)
running 2 tests
test tests::pahali_test ... ignored
test tests::calisiyor ... ok
test result: ok. 1 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests toplayici
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
pahali_test fonksiyonu çıktıda ignored (yok sayıldı) olarak listelenir. Yalnızca yok sayılan testleri çalıştırmak istersek cargo test -- --ignored komutunu kullanabiliriz:
$ cargo test -- --ignored
Compiling toplayici v0.1.0 (file:///projects/toplayici)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.61s
Running unittests src/lib.rs (target/debug/deps/toplayici-ac13090f6eeacc92)
running 1 test
test tests::pahali_test ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.00s
Doc-tests toplayici
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Hangi testlerin çalıştırılacağını kontrol ederek cargo test sonuçlarınızın hızlıca dönmesini sağlayabilirsiniz. Yok sayılan testlerin sonuçlarını kontrol etmenin mantıklı olduğu bir aşamaya geldiğinizde ve sonuçları bekleyecek vaktiniz olduğunda cargo test -- --ignored komutunu çalıştırabilirsiniz. Yok sayılmış olsun ya da olmasın tüm testleri birlikte çalıştırmak isterseniz cargo test -- --include-ignored komutunu kullanabilirsiniz.
Test Organizasyonu
Test Organizasyonu
Bölümün başında belirttiğimiz gibi test yazmak karmaşık bir disiplindir ve farklı geliştiriciler farklı terminolojiler ile organizasyon biçimleri kullanır. Rust topluluğu testleri iki ana kategoride ele alır: birim testleri (unit tests) ve entegrasyon testleri (integration tests). Birim testleri daha küçük ve odaklıdır; her seferinde tek bir modülü yalıtılmış biçimde sınar ve gizli (private) arayüzleri de test edebilir. Entegrasyon testleri ise tamamen kütüphanenizin dışındadır; kodunuzu tıpkı başka bir harici kodun kullanacağı biçimde, yalnızca açık (public) arayüz üzerinden kullanır ve tek bir testte birden fazla modülü bir arada çalıştırabilir.
Kütüphanenizi oluşturan parçaların hem tek başına hem de bir arada beklediğiniz gibi çalıştığından emin olmak için her iki test türünü de yazmak önemlidir.
Birim Testleri
Birim testlerinin amacı, kodun hangi kısımlarının beklendiği gibi çalışıp hangilerinin çalışmadığını hızla tespit edebilmek için her bir kod birimini geri kalan koddan yalıtılmış olarak test etmektir. Birim testlerini, src dizini altındaki her dosyanın içine, test ettikleri kodla aynı dosyaya yerleştirirsiniz. Yerleşik kural, her dosyada test fonksiyonlarını barındıracak tests adında bir modül oluşturmak ve bu modülü cfg(test) ile işaretlemektir.
tests Modülü ve #[cfg(test)]
tests modülü üzerindeki #[cfg(test)] özniteliği Rust’a test kodunu cargo build çalıştırdığınızda değil, yalnızca cargo test çalıştırdığınızda derlemesini ve çalıştırmasını söyler. Bu sayede yalnızca kütüphaneyi derlemek istediğinizde derleme süresinden kazanır, ayrıca testler dahil edilmediği için üretilen derlenmiş çıktıda yer tasarrufu sağlarsınız. Birazdan göreceğiniz üzere entegrasyon testleri farklı bir dizinde yer aldığından #[cfg(test)] özniteliğine ihtiyaç duymaz. Ancak birim testleri asıl kodla aynı dosyalarda bulunduğundan, derlenmiş sonuca dahil edilmemeleri gerektiğini belirtmek için #[cfg(test)] kullanırsınız.
Bu bölümün ilk kısmında yeni toplayici projesini oluşturduğumuzda Cargo’nun bizim için şu kodu ürettiğini hatırlayın:
Dosya adı: src/lib.rs
pub fn topla(sol: u64, sag: u64) -> u64 {
sol + sag
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn calisiyor() {
let sonuc = topla(2, 2);
assert_eq!(sonuc, 4);
}
}
Otomatik olarak üretilen tests modülü üzerindeki cfg özniteliği İngilizce configuration (yapılandırma) kelimesinin kısaltmasıdır ve Rust’a takip eden öğenin yalnızca belirli bir yapılandırma seçeneği sağlandığında derlemeye dahil edilmesi gerektiğini bildirir. Bu örnekte yapılandırma seçeneği, testleri derlemek ve çalıştırmak amacıyla Rust tarafından sağlanan test seçeneğidir. Cargo, cfg özniteliği sayesinde test kodumuzu yalnızca testleri cargo test komutuyla açıkça çalıştırdığımızda derler. Bu kural yalnızca #[test] ile işaretlenmiş fonksiyonlar için değil, modülün içinde yer alabilecek tüm yardımcı fonksiyonlar için de geçerlidir.
Not: #[cfg(test)] özniteliğinin src/lib.rs içindeki test modülünü normal
derlemelerden nasıl dışladığını ve tests/ dizinindeki entegrasyon
testlerinde neden gerekmediğini derleme hedefleri üzerinden kolayca
anlayabiliriz:
- src/lib.rs (ve src/ altındaki diğer modüller):
cargo buildveyacargo build --releaseçalıştırdığınızda modül ağacının tamamı kütüphane çıktısının bir parçası olarak derlenir.mod testsüzerine#[cfg(test)]koymayı unutursanız#[test]fonksiyonları dışarıda kalsa bile modülün kendisi, içindeki yardımcı fonksiyonlar veusebildirimleri normal derlemeye dahil edilir; bu durum gereksizdead_code(kullanılmayan kod) uyarılarına, ikili dosya boyutunun büyümesine ve modül içinde yalnızca[dev-dependencies]altında tanımlı bir test crate’i kullanılıyorsacargo buildsırasında derleme hatasına yol açar. - tests/ dizini: Cargo bu dizini özel bir test hedefi olarak tanır; içindeki dosyaları
cargo buildveyacargo runsırasında derleyiciye hiç göndermez, yalnızcacargo testçalıştırıldığında ayrı birer test crate’i olarak derler. Dolayısıyla tests/ altındaki dosyalarda#[cfg(test)]yazmak gereksizdir.
Gizli Fonksiyon Testleri
Test topluluğunda gizli fonksiyonların doğrudan test edilip edilmemesi gerektiği konusunda süregelen bir tartışma vardır ve diğer pek çok dil gizli fonksiyonları test etmeyi zorlaştırır ya da imkânsız kılar. Hangi test yaklaşımını benimserseniz benimseyin, Rust’ın gizlilik kuralları gizli fonksiyonları test etmenize olanak tanır. Gizli ic_toplayici fonksiyonunu içeren Liste 11-12’deki koda göz atalım.
pub fn iki_ekle(a: u64) -> u64 {
ic_toplayici(a, 2)
}
fn ic_toplayici(sol: u64, sag: u64) -> u64 {
sol + sag
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ic() {
let sonuc = ic_toplayici(2, 2);
assert_eq!(sonuc, 4);
}
}
ic_toplayici fonksiyonunun pub olarak işaretlenmediğine dikkat edin. Testler yalnızca sıradan Rust kodudur ve tests modülü de sıradan bir modüldür. “Modül Ağacındaki Bir Öğeye Başvurmak İçin Yollar” bölümünde ele aldığımız gibi, alt modüllerdeki öğeler ata modüllerindeki öğeleri kullanabilir. Bu testte tests modülünün üst modülüne ait tüm öğeleri use super::* ile kapsama dahil ediyoruz; böylece test fonksiyonumuz ic_toplayici fonksiyonunu doğrudan çağırabiliyor. Gizli fonksiyonların test edilmemesi gerektiğini düşünüyorsanız, Rust’ta sizi bunu yapmaya zorlayan hiçbir kural yoktur.
Entegrasyon Testleri
Rust’ta entegrasyon testleri kütüphanenizin tamamen dışındadır. Kütüphanenizi tıpkı diğer harici kodların kullanacağı biçimde kullanırlar; dolayısıyla kütüphanenizin yalnızca açık API’sinin parçası olan fonksiyonları çağırabilirler. Amaçları, kütüphanenizin birden fazla parçasının bir arada doğru çalışıp çalışmadığını sınamaktır. Tek başına doğru çalışan kod birimleri bir araya getirildiğinde sorun çıkarabilir; bu nedenle entegre edilmiş kodun da test kapsamına alınması önemlidir. Entegrasyon testleri oluşturmak için öncelikle bir tests dizinine ihtiyacınız vardır.
tests Dizini
Proje dizinimizin en üst seviyesinde, src dizininin hemen yanında bir tests dizini oluştururuz. Cargo, entegrasyon testi dosyalarını bu dizinde araması gerektiğini bilir. Ardından bu dizinde istediğimiz kadar test dosyası oluşturabiliriz; Cargo bu dosyaların her birini ayrı bir crate olarak derleyecektir.
Şimdi bir entegrasyon testi oluşturalım. Liste 11-12’deki kod src/lib.rs dosyasında dururken bir tests dizini oluşturun ve içine tests/entegrasyon_testi.rs adında yeni bir dosya ekleyin. Dizin yapınız şu şekilde görünmelidir:
toplayici
├── Cargo.lock
├── Cargo.toml
├── src
│ └── lib.rs
└── tests
└── entegrasyon_testi.rs
tests/entegrasyon_testi.rs dosyasına Liste 11-13’teki kodu girin.
use toplayici::iki_ekle;
#[test]
fn iki_ekliyor() {
let sonuc = iki_ekle(2);
assert_eq!(sonuc, 4);
}
toplayici crate’indeki bir fonksiyonun entegrasyon testitests dizinindeki her dosya ayrı bir crate olduğu için kütüphanemizi her bir test crate’inin kapsamına açıkça dahil etmemiz gerekir. Bu yüzden kodun en üstüne, birim testlerinde ihtiyaç duymadığımız use toplayici::iki_ekle; satırını ekliyoruz.
tests/entegrasyon_testi.rs dosyasındaki herhangi bir kodu #[cfg(test)] ile işaretlememize gerek yoktur. Cargo tests dizinini özel olarak ele alır ve bu dizindeki dosyaları yalnızca cargo test çalıştırdığımızda derler. Şimdi cargo test komutunu çalıştıralım:
$ cargo test
Compiling toplayici v0.1.0 (file:///projects/toplayici)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.13s
Running unittests src/lib.rs (target/debug/deps/toplayici-ac13090f6eeacc92)
running 1 test
test tests::ic ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running tests/entegrasyon_testi.rs (target/debug/deps/entegrasyon_testi-5737c6970c195272)
running 1 test
test iki_ekliyor ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests toplayici
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Çıktıdaki üç bölüm sırasıyla birim testlerini, entegrasyon testini ve belgelendirme testlerini içerir. Herhangi bir bölümdeki bir test başarısız olursa izleyen bölümlerin çalıştırılmayacağına dikkat edin. Örneğin bir birim testi başarısız olursa entegrasyon ve belgelendirme testleri için hiçbir çıktı üretilmez; çünkü bu testler yalnızca tüm birim testleri geçtiğinde çalıştırılır.
Birim testlerine ayrılan ilk bölüm şimdiye kadar gördüğümüzle aynıdır: her birim testi için bir satır (Liste 11-12’de eklediğimiz ic adlı test) ve ardından birim testleri için bir özet satırı yer alır.
Entegrasyon testleri bölümü Running tests/entegrasyon_testi.rs satırıyla başlar. Hemen ardından bu entegrasyon testi dosyasındaki her test fonksiyonu için bir satır ve Doc-tests toplayici bölümü başlamadan hemen önce entegrasyon testi sonuçlarına ait özet satırı gelir.
Her entegrasyon testi dosyasının çıktıda kendine ait bir bölümü vardır; dolayısıyla tests dizinine daha fazla dosya eklersek çıktıda daha fazla entegrasyon testi bölümü görürüz.
Not: Özetlemek gerekirse cargo test komutu testleri şu üç aşamalı sırayla yürütür:
- Birim testleri (
Running unittests src/lib.rsve varsasrc/main.rs): Modüllerde tanımlanan testleri, gizli (private) öğelere de erişebilecekleri bağlamda çalıştırır. Testler kendiliğinden yalıtılmaz; paylaşılan dosya ve küresel durum gibi kaynakları test kodunun yönetmesi gerekir. - Entegrasyon testleri (
Running tests/<dosya_adı>.rs): tests/ dizininin kökündeki her.rsdosyasını ayrı bir harici crate olarak derleyip yalnızca açık (pub) API üzerinden sınar. - Belgelendirme testleri (
Doc-tests <crate_adı>): API belgelendirme yorumlarındaki (///) kod örneklerini derleyip çalıştırarak belgelerin kodla uyumlu kaldığını doğrular.
Cargo varsayılan olarak erken durdurma (fail-fast) davranışı uygular: Bir aşamada hata çıkarsa sonraki aşamaları çalıştırmaz. Birim testlerinde hata olsa bile tüm entegrasyon ve belgelendirme testlerinin de çalıştırılmasını isterseniz cargo test --no-fail-fast komutunu kullanabilirsiniz.
Belirli bir entegrasyon testi fonksiyonunu, fonksiyonun adını cargo test komutuna argüman olarak geçirerek yine tek başına çalıştırabiliriz. Belirli bir entegrasyon testi dosyasındaki tüm testleri çalıştırmak içinse cargo test komutunun --test argümanını ve ardından (.rs uzantısı olmadan) dosyanın adını kullanabilirsiniz:
$ cargo test --test entegrasyon_testi
Compiling toplayici v0.1.0 (file:///projects/toplayici)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.12s
Running tests/entegrasyon_testi.rs (target/debug/deps/entegrasyon_testi-5737c6970c195272)
running 1 test
test iki_ekliyor ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Bu komut yalnızca tests/entegrasyon_testi.rs dosyasındaki testleri çalıştırır.
Entegrasyon Testlerinde Alt Modüller
Entegrasyon testlerinizin sayısı arttıkça bunları düzenli tutmak için tests dizininde birden fazla dosya oluşturmak isteyebilirsiniz; örneğin test fonksiyonlarını sınadıkları işlevselliğe göre gruplandırabilirsiniz. Daha önce belirttiğimiz gibi tests dizinindeki her dosya kendi başına ayrı bir crate olarak derlenir; bu da son kullanıcıların crate’inizi kullanma biçimini daha gerçekçi şekilde taklit eden ayrı kapsamlar oluşturmak açısından oldukça faydalıdır. Ancak bu, tests dizinindeki dosyaların, Bölüm 7’de kodu modüllere ve dosyalara ayırırken öğrendiğiniz src dizini davranışını paylaşmadığı anlamına gelir.
tests dizinindeki dosyaların bu farklı davranışı, en çok birden fazla entegrasyon testi dosyasında kullanmak istediğiniz bir grup yardımcı fonksiyonunuz olduğunda ve bunları ortak bir modüle çıkarmak için Bölüm 7’deki “Modülleri Farklı Dosyalara Ayırma” adımlarını izlemeye çalıştığınızda göze çarpar. Örneğin (Rust projelerinde genellikle tests/common.rs olarak adlandırılan) tests/ortak.rs adında bir dosya oluşturup içine kurulum adlı bir fonksiyon yerleştirirsek, birden fazla test dosyasındaki çeşitli test fonksiyonlarından çağırmak istediğimiz hazırlık kodlarını kurulum içine ekleyebiliriz:
Dosya adı: tests/ortak.rs
pub fn kurulum() {
// kütüphanenizin testlerine özgü kurulum kodu buraya gelecek
}
Testleri yeniden çalıştırdığımızda, ortak.rs dosyası hiçbir test fonksiyonu içermemesine ve kurulum fonksiyonunu henüz hiçbir yerden çağırmamamıza rağmen test çıktısında bu dosya için yeni bir bölüm açıldığını görürüz:
$ cargo test
Compiling toplayici v0.1.0 (file:///projects/toplayici)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.14s
Running unittests src/lib.rs (target/debug/deps/toplayici-ac13090f6eeacc92)
running 1 test
test tests::ic ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running tests/ortak.rs (target/debug/deps/ortak-65e36ecf6e3af4a8)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running tests/entegrasyon_testi.rs (target/debug/deps/entegrasyon_testi-5737c6970c195272)
running 1 test
test iki_ekliyor ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests toplayici
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Test sonuçlarında ortak için running 0 tests satırının görüntülenmesi istediğimiz bir durum değildir; amacımız yalnızca diğer entegrasyon testi dosyalarıyla ortak kod paylaşmaktı. Test çıktısında ortak bölümünün görünmesini önlemek için tests/ortak.rs oluşturmak yerine tests/ortak/mod.rs dosyasını oluşturacağız. Proje dizinimiz artık şu şekilde görünür:
├── Cargo.lock
├── Cargo.toml
├── src
│ └── lib.rs
└── tests
├── ortak
│ └── mod.rs
└── entegrasyon_testi.rs
Bu yapı, Bölüm 7’deki “Alternatif Dosya Yolları” kısmında değindiğimiz ve Rust’ın desteklemeye devam ettiği eski adlandırma kuralıdır. Dosyayı bu şekilde adlandırmak Rust’a ortak modülünü bağımsız bir entegrasyon testi dosyası olarak ele almamasını söyler. kurulum fonksiyonunun kodunu tests/ortak/mod.rs dosyasına taşıyıp tests/ortak.rs dosyasını sildiğimizde test çıktısındaki bu bölüm artık görünmez. tests dizininin alt dizinlerinde yer alan dosyalar ayrı crate’ler olarak derlenmez ve test çıktısında kendilerine ait bir bölüm oluşturmaz.
tests/ortak/mod.rs dosyasını oluşturduktan sonra onu herhangi bir entegrasyon testi dosyasından bir modül olarak kullanabiliriz. Aşağıda, tests/entegrasyon_testi.rs dosyasındaki iki_ekliyor testinden kurulum fonksiyonunun çağrılmasına dair örnek yer almaktadır:
Dosya adı: tests/entegrasyon_testi.rs
use toplayici::iki_ekle;
mod ortak;
#[test]
fn iki_ekliyor() {
ortak::kurulum();
let sonuc = iki_ekle(2);
assert_eq!(sonuc, 4);
}
mod ortak; bildiriminin Liste 7-21’de gösterdiğimiz modül bildirimiyle birebir aynı olduğuna dikkat edin. Ardından test fonksiyonunun içinde ortak::kurulum() fonksiyonunu çağırabiliriz.
İkili Crate’ler İçin Entegrasyon Testleri
Projemiz yalnızca bir src/main.rs dosyası içeren ve src/lib.rs dosyası bulunmayan bir ikili crate ise, tests dizininde entegrasyon testleri oluşturup src/main.rs dosyasında tanımlanan fonksiyonları use deyimiyle kapsama dahil edemeyiz. Yalnızca kütüphane crate’leri diğer crate’lerin kullanabileceği fonksiyonları dışarıya açar; ikili crate’ler ise doğrudan tek başlarına çalıştırılmak üzere tasarlanmıştır. (Bu kısıtlama yalnızca entegrasyon testleri için geçerlidir: src/main.rs dosyasının içine #[cfg(test)] mod tests biçiminde birim testleri yazmaya devam edebilirsiniz; cargo test çıktısındaki Running unittests src/main.rs satırı bu testlere aittir.)
Bir ikili dosya üreten Rust projelerinin, src/lib.rs dosyasında yer alan asıl iş mantığını çağıran yalın bir src/main.rs dosyasına sahip olmasının başlıca nedenlerinden biri budur. Bu mimari kullanıldığında entegrasyon testleri, kütüphane crate’ini use ile kapsama dahil ederek temel işlevselliği test edebilir. Temel işlevsellik doğru çalışıyorsa src/main.rs dosyasındaki az miktardaki kod da sorunsuz çalışacaktır ve bu küçük kod parçasının ayrıca test edilmesine gerek kalmaz.
Özet
Rust’ın test özellikleri, kodunuzda değişiklikler yapsanız bile beklediğiniz şekilde çalışmaya devam etmesini güvence altına almak için kodun nasıl davranması gerektiğini tanımlamanızı sağlar. Birim testleri bir kütüphanenin farklı parçalarını ayrı ayrı sınar ve gizli uygulama ayrıntılarını test edebilir. Entegrasyon testleri ise kütüphanenin pek çok parçasının bir arada doğru çalışıp çalışmadığını denetler ve kodu tıpkı harici bir kodun kullanacağı biçimde, kütüphanenin açık API’si üzerinden test eder. Rust’ın tür sistemi ve sahiplik kuralları pek çok hata türünü henüz derleme aşamasında önlemeye yardımcı olsa da, kodunuzdan beklenen davranışla ilgili mantık hatalarını azaltmak için testler büyük önem taşır.
Şimdi bu bölümde ve önceki bölümlerde öğrendiklerinizi bir araya getirerek gerçek bir proje üzerinde çalışalım!
Bir G/Ç (I/O) Projesi: Komut Satırı Programı Geliştirme
Bu bölümde hem şimdiye kadar edindiğiniz pek çok beceriyi gözden geçirip pekiştirecek hem de standart kütüphanenin birkaç yeni özelliğini keşfedeceğiz. Artık dağarcığınızda bulunan Rust kavramlarını pratiğe dökmek için dosya ve komut satırı girdi/çıktı işlemleriyle etkileşime giren bir komut satırı aracı geliştireceğiz.
Rust’ın hızı, güvenliği, tek ikili dosya çıktısı üretmesi ve çapraz platform desteği, onu komut satırı araçları oluşturmak için ideal bir dil hâline getirir. Bu nedenle projemizde, klasik komut satırı arama aracı grep’in (globally search a regular expression and print — bir düzenli ifadeyi metnin genelinde ara ve yazdır) kendi sürümünü geliştireceğiz. En basit kullanım senaryosunda grep, belirtilen bir dosyada belirtilen bir string’i arar. Bunun için grep, argüman olarak bir dosya yolu ve bir string alır; ardından dosyayı okur, bu string argümanını içeren satırları bulur ve ekrana yazdırır.
Bu süreçte, geliştirdiğimiz aracın diğer pek çok komut satırı uygulamasında standart olan terminal özelliklerinden nasıl yararlanacağını da göstereceğiz. Kullanıcının aracımızın davranışını yapılandırabilmesi için bir çevre değişkeninin değerini okuyacağız. Ayrıca hata mesajlarını standart çıktı (stdout) yerine standart hata konsol akışına (stderr) yazdıracağız; böylece örneğin kullanıcı başarılı çıktıları bir dosyaya yönlendirirken hata mesajlarını ekranda görmeye devam edebilecek.
Rust topluluğu üyelerinden Andrew Gallant, ripgrep adında tam donanımlı ve son derece hızlı bir grep uygulaması geliştirdi. Onunla kıyaslandığında bizim sürümümüz oldukça sade kalacak; ancak bu bölüm size ripgrep gibi gerçek dünya projelerini anlamak için ihtiyaç duyacağınız temel arka plan bilgisini kazandıracak.
Geliştireceğimiz grep projesi, şimdiye kadar öğrendiğiniz pek çok kavramı bir araya getirecek:
- Kodu düzenleme (Bölüm 7)
- Vektörleri ve string’leri kullanma (Bölüm 8)
- Hataları yönetme (Bölüm 9)
- Uygun yerlerde trait’leri ve ömürleri kullanma (Bölüm 10)
- Test yazma (Bölüm 11)
Ayrıca Bölüm 13 ve Bölüm 18’de ayrıntılı olarak ele alınacak olan kapanışları (closures), iteratörleri (iterators) ve trait nesnelerini (trait objects) de kısaca tanıtacağız.
Not: Rust, ek bir çalışma zamanı ortamı gerektirmeyen tek parça ikili çıktısı ve yüksek performansı sayesinde modern komut satırı ekosisteminin en güçlü dillerinden biridir; örneğin terminalde rg komutuyla çalıştırılan ripgrep, pek çok kod düzenleyicinin dahili arama altyapısına da güç verir. Gerçek dünya projelerinde alt komutlar, bayraklar (--help, --version) ve karmaşık argüman doğrulamaları için genellikle clap gibi kütüphanelerden yararlanılır; bu ekosistemi derinlemesine incelemek isterseniz Rust CLI Çalışma Grubu’nun hazırladığı Command Line Applications in Rust rehberine göz atabilirsiniz. Bu bölümde geliştireceğimiz minigrep projesinde ise temel ilkeleri doğrudan kavramak amacıyla hiçbir harici bağımlılık kullanmayacak; argüman okuma, dosya işlemleri, hata yönetimi, src/main.rs ile src/lib.rs ayrımı ve test süreçlerini yalnızca standart kütüphaneyle sıfırdan inşa edeceğiz.
Komut Satırı Argümanlarını Kabul Etme
Komut Satırı Argümanlarını Kabul Etme
Her zaman olduğu gibi cargo new komutuyla yeni bir proje oluşturalım. Sisteminizde hâlihazırda kurulu olabilecek grep aracından ayırt edebilmek için projemize minigrep adını vereceğiz:
$ cargo new minigrep
Created binary (application) `minigrep` project
$ cd minigrep
İlk görevimiz, minigrep aracının iki komut satırı argümanını kabul etmesini sağlamaktır: dosya yolu ve aranacak string. Başka bir deyişle programımızı şu biçimde çalıştırabilmek istiyoruz: önce cargo run komutu, ardından sonraki argümanların cargo’ya değil programımıza ait olduğunu belirten iki tire (--), sonra aranacak string ve son olarak içinde arama yapılacak dosyanın yolu:
$ cargo run -- aranacakstring ornek-dosyaadi.txt
Şu anda cargo new tarafından oluşturulan program, kendisine verdiğimiz argümanları işleyemez. crates.io üzerinde komut satırı argümanlarını kabul eden bir program yazmayı kolaylaştıran hazır kütüphaneler bulunsa da bu kavramı yeni öğrendiğiniz için bu özelliği kendimiz uygulayalım.
Argüman Değerlerini Okuma
minigrep’in kendisine ilettiğimiz komut satırı argümanlarının değerlerini okuyabilmesi için Rust standart kütüphanesinde yer alan std::env::args fonksiyonuna ihtiyacımız vardır. Bu fonksiyon, minigrep’e aktarılan komut satırı argümanları üzerinde bir iteratör (iterator) döndürür. İteratörleri Bölüm 13’te ayrıntılı olarak ele alacağız. Şimdilik iteratörler hakkında yalnızca iki ayrıntıyı bilmeniz yeterlidir: İteratörler bir dizi değer üretir ve bir iteratör üzerinde collect metodunu çağırarak onu, ürettiği tüm öğeleri barındıran vektör gibi bir koleksiyona dönüştürebiliriz.
Liste 12-1’deki kod, minigrep programınızın kendisine aktarılan tüm komut satırı argümanlarını okumasını ve ardından bu değerleri bir vektörde toplamasını sağlar.
use std::env;
fn main() {
let argumanlar: Vec<String> = env::args().collect();
dbg!(argumanlar);
}
İlk olarak, args fonksiyonunu kullanabilmek için bir use deyimiyle std::env modülünü kapsama dahil ediyoruz. std::env::args fonksiyonunun iç içe iki modül katmanında yer aldığına dikkat edin. Bölüm 7’de ele aldığımız gibi, kullanmak istediğimiz fonksiyon birden fazla modül katmanının içinde yer aldığında doğrudan fonksiyonun kendisini değil, üst modülünü kapsama dahil etmeyi tercih ederiz. Böylece std::env içindeki diğer fonksiyonları da kolayca kullanabiliriz. Ayrıca bu yaklaşım, use std::env::args ekleyip fonksiyonu yalnızca args adıyla çağırmaktan çok daha nettir; çünkü tek başına args çağrısı kolaylıkla mevcut modülde tanımlanmış yerel bir fonksiyon sanılabilir.
args Fonksiyonu ve Geçersiz Unicode
Argümanlardan herhangi biri geçersiz Unicode içeriyorsa std::env::args fonksiyonunun panikleyeceğini unutmayın. Programınızın geçersiz Unicode barındıran argümanları da kabul etmesi gerekiyorsa bunun yerine std::env::args_os fonksiyonunu kullanın. Bu fonksiyon, String değerleri yerine OsString değerleri üreten bir iteratör döndürür. OsString değerleri platformdan platforma farklılık gösterdiği ve String değerlerine kıyasla kullanımı daha karmaşık olduğu için burada sadelik adına std::env::args kullanmayı tercih ettik. (Türkçe karakterler geçerli Unicode kodlamasına sahip olduğundan std::env::args Türkçe metin ve dosya adlarıyla sorunsuz çalışır; “geçersiz Unicode” durumu yalnızca işletim sisteminden UTF-8/UTF-16 kurallarına uymayan bozuk diziler geldiğinde ortaya çıkar.)
main fonksiyonunun ilk satırında env::args fonksiyonunu çağırıyor ve döndürdüğü iteratörü, ürettiği tüm değerleri içeren bir vektöre dönüştürmek için hemen ardından collect metodunu kullanıyoruz. collect metoduyla pek çok farklı koleksiyon türü oluşturulabildiğinden, string’lerden oluşan bir vektör (Vec<String>) istediğimizi belirtmek için argumanlar değişkeninin türünü açıkça bildiriyoruz. Rust’ta değişkenlerin türünü açıkça belirtmeniz çok nadiren gerekse de derleyici hangi koleksiyon türünü istediğinizi tek başına çıkaramadığı için collect, tür bildirimi eklemeniz gereken başlıca metotlardan biridir.
Not: collect metodu dönüş türü üzerinde jeneriktir ve standart kütüphanedeki FromIterator trait’ini uygulayan Vec<String>, String veya HashSet<String> gibi pek çok farklı tür üretebilir; bu yüzden hiçbir tür bilgisi vermezseniz derleyici error[E0283]: type annotations needed (tür bildirimleri gerekli) hatası üretir. Liste 12-1’deki let argumanlar: Vec<String> = ... yazımına alternatif olarak, öğe türünü derleyicinin çıkarmasına bırakan let argumanlar: Vec<_> = env::args().collect(); biçimini ya da türü doğrudan metot çağrısında belirten turbofish sözdizimini (let argumanlar = env::args().collect::<Vec<String>>();) de sıkça görebilirsiniz.
Son olarak hata ayıklama makrosu dbg! ile vektörü yazdırıyoruz. Kodu önce hiçbir argüman vermeden, ardından iki argümanla çalıştırmayı deneyelim:
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.61s
Running `target/debug/minigrep`
[src/main.rs:5:5] argumanlar = [
"target/debug/minigrep",
]
$ cargo run -- aranan samanlik
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.57s
Running `target/debug/minigrep aranan samanlik`
[src/main.rs:5:5] argumanlar = [
"target/debug/minigrep",
"aranan",
"samanlik",
]
Vektördeki ilk değerin, ikili dosyamızın adı olan "target/debug/minigrep" olduğuna dikkat edin. Bu davranış, C dilindeki argüman listesi kuralıyla örtüşür ve programların çalıştırıldıkları adı çalışma sırasında öğrenmesine olanak tanır. Mesajlarda programın adını yazdırmak ya da programı çağırmak için kullanılan komut satırı takma adına göre programın davranışını değiştirmek istediğinizde program adına erişebilmek genellikle kullanışlıdır. Ancak bu bölümün amaçları doğrultusunda bu ilk değeri yok sayacak ve yalnızca ihtiyacımız olan iki argümanı kaydedeceğiz.
Not: İkinci komuttaki -- ayırıcısı, Cargo’ya kendisinden sonra gelen argümanları Cargo seçeneği olarak yorumlamayıp doğrudan derlenen ikili dosyaya iletmesini söyler; örneğin programınıza --help gibi tireyle başlayan bir argüman göndermek istediğinizde -- kullanmazsanız Cargo kendi yardım ekranını gösterir. Vektörün argumanlar[0] konumunda çalıştırılabilir dosya yolunun yer alması ise C ve POSIX standartlarındaki argv[0] geleneğinden gelir: Örneğin Linux’taki busybox veya Rust araç zincirini yöneten rustup gibi çok amaçlı ikili dosyalar, hangi adla (cargo, rustc, rustdoc vb.) çağrıldıklarını bu ilk elemandan okuyarak tek bir ikili dosya üzerinden farklı araçlar gibi davranabilir.
Argüman Değerlerini Değişkenlere Kaydetme
Programımız şu anda komut satırı argümanları olarak belirtilen değerlere erişebiliyor. Şimdi bu iki argümanın değerini programın geri kalanı boyunca kullanabilmek üzere değişkenlere kaydetmemiz gerekiyor. Bunu Liste 12-2’de yapıyoruz.
use std::env;
fn main() {
let argumanlar: Vec<String> = env::args().collect();
let sorgu = &argumanlar[1];
let dosya_yolu = &argumanlar[2];
println!("Aranan: {sorgu}");
println!("Dosya: {dosya_yolu}");
}
Vektörü yazdırdığımızda gördüğümüz gibi programın adı vektördeki ilk değeri, yani argumanlar[0] konumunu kaplar; bu yüzden kendi argümanlarımızı 1 numaralı indeksten itibaren okumaya başlıyoruz. minigrep’in aldığı ilk argüman aradığımız string olduğundan, ilk argümana ait bir referansı sorgu değişkenine atıyoruz. İkinci argüman ise dosya yolu olacağından, ikinci argümana ait bir referansı dosya_yolu değişkenine yerleştiriyoruz.
Kodun amaçladığımız gibi çalıştığını kanıtlamak için bu değişkenlerin değerlerini geçici olarak ekrana yazdırıyoruz. Programı bu kez test ve ornek.txt argümanlarıyla tekrar çalıştıralım:
$ cargo run -- test ornek.txt
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep test ornek.txt`
Aranan: test
Dosya: ornek.txt
Harika, program çalışıyor! İhtiyacımız olan argümanların değerleri doğru değişkenlere kaydediliyor. Daha sonra, kullanıcının hiç argüman girmemesi gibi olası hatalı durumlarla başa çıkmak için hata yönetimi ekleyeceğiz (örneğin şu hâliyle programı argümansız çalıştırırsanız &argumanlar[1] erişimi sınır aşımı nedeniyle panikleyecektir); şimdilik bu durumu göz ardı edip programa dosya okuma yeteneği kazandırmaya odaklanacağız.
Bir Dosyayı Okuma
Bir Dosyayı Okuma
Şimdi, dosya_yolu argümanında belirtilen dosyayı okuma işlevselliğini ekleyeceğiz. Öncelikle bu özelliği test etmek için örnek bir dosyaya ihtiyacımız var: Birkaç satıra yayılan ve tekrar eden bazı kelimeler barındıran kısa bir metin dosyası kullanacağız. Liste 12-3’te yer alan Necip Fazıl Kısakürek şiiri bu iş için gayet uygundur (orijinal kitapta Emily Dickinson’ın “I’m Nobody! Who are you?” şiirinin bulunduğu poem.txt dosyası kullanılır; Türkçe çeviride ise ilerleyen bölümlerdeki arama ve büyük/küçük harf duyarlılığı örnekleriyle uyumlu olması için bu şiir tercih edilmiştir). Projenizin kök dizininde siir.txt adında bir dosya oluşturun ve “Beklenen” şiirinin dizelerini bu dosyaya girin.
Ne hasta bekler sabahı,
Ne taze ölüyü mezar.
Ne de şeytan, bir günahı,
Seni beklediğim kadar.
Geçti istemem gelmeni,
Yokluğunda buldum seni;
Bırak vehmimde gölgeni
Gelme, artık neye yarar?
Metin dosyasını hazırladıktan sonra src/main.rs dosyasını düzenleyin ve Liste 12-4’te gösterildiği gibi dosyayı okuyacak kodu ekleyin.
use std::env;
use std::fs;
fn main() {
// --snip--
let argumanlar: Vec<String> = env::args().collect();
let sorgu = &argumanlar[1];
let dosya_yolu = &argumanlar[2];
println!("Aranan: {sorgu}");
println!("Dosya: {dosya_yolu}");
let icerik = fs::read_to_string(dosya_yolu).expect("Dosya okunamadı");
println!("Metin içeriği:\n{icerik}");
}
İlk olarak bir use deyimiyle standart kütüphanenin ilgili bölümünü kapsama dahil ediyoruz: Dosyalarla çalışmak için std::fs modülüne ihtiyacımız var.
main fonksiyonunda eklediğimiz yeni fs::read_to_string çağrısı dosya_yolu değerini alır, ilgili dosyayı açar ve dosya içeriğini barındıran std::io::Result<String> türünde bir değer döndürür. Hemen ardından gelen .expect("Dosya okunamadı") çağrısı ise okuma başarılı olduğunda bu Result içindeki String değerini icerik değişkenine çıkarır; dosya okunamazsa programı belirtilen mesajla panikletir.
Sonrasında, programın bu aşamaya kadar doğru çalıştığını kontrol edebilmek için dosya okunduktan sonra icerik değerini ekrana yazdıran geçici bir println! çağrısı daha ekliyoruz.
Henüz arama kısmını uygulamadığımız için ilk komut satırı argümanı olarak herhangi bir string, ikinci argüman olarak da siir.txt dosyasını vererek bu kodu çalıştıralım:
$ cargo run -- mezar siir.txt
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep mezar siir.txt`
Aranan: mezar
Dosya: siir.txt
Metin içeriği:
Ne hasta bekler sabahı,
Ne taze ölüyü mezar.
Ne de şeytan, bir günahı,
Seni beklediğim kadar.
Geçti istemem gelmeni,
Yokluğunda buldum seni;
Bırak vehmimde gölgeni
Gelme, artık neye yarar?
Not: fs::read_to_string, arka planda dosyayı açan, geçerli UTF-8 metninin tamamını tek seferde öbekte tahsis edilen bir String içine okuyan ve işlem bittiğinde dosya tanıtıcısını otomatik olarak kapatan pratik bir yardımcı fonksiyondur. Küçük ve orta boyutlu metin dosyaları için bu yöntem son derece kullanışlıdır; ancak gigabaytlarca büyüklükteki metin veya günlük dosyalarında tüm içeriği tek seferde belleğe yüklememek için dosyayı std::fs::File::open ile açıp std::io::BufReader ve BufRead::lines aracılığıyla satır satır akış hâlinde okumak çok daha verimlidir. Ayrıca komutta verdiğimiz siir.txt gibi göreli dosya yolları, derlenen ikili dosyanın bulunduğu target/debug/ dizinine göre değil, programın çalıştırıldığı mevcut çalışma dizinine göre çözümlenir; cargo run komutunu proje kök dizininde çalıştırdığımız için program kök dizindeki siir.txt dosyasını doğrudan bulur.
Harika! Kod dosyanın içeriğini okudu ve ardından ekrana yazdırdı. Ancak kodumuzun birkaç tasarım kusuru var. Şu anda main fonksiyonu birden fazla sorumluluk üstleniyor (hem argümanları ayrıştırıyor hem de dosyayı okuyor) ve sorgu ile dosya_yolu gibi yapılandırma değişkenleri ortak bir yapıda gruplanmadan dağınık biçimde tutuluyor; oysa genel olarak her fonksiyon yalnızca tek bir görevden sorumlu olduğunda fonksiyonlar çok daha anlaşılır ve bakımı kolay olur. Diğer önemli sorun ise hataları olabileceği kadar iyi yönetmiyor olmamız: Olası hata noktaları ve expect çağrıları kodun içine dağılmış durumda, üstelik üretilen hata mesajları hatanın gerçek nedenini kullanıcıya açıklamakta yetersiz kalıyor. Programımız henüz küçük olduğu için bu kusurlar şimdilik büyük bir sorun oluşturmuyor; ancak program büyüdükçe bunları temiz bir şekilde düzeltmek zorlaşacaktır. Daha az miktardaki kodu yeniden düzenlemek çok daha kolay olduğundan, program geliştirirken yeniden düzenlemeye erken aşamada başlamak iyi bir alışkanlıktır. Bir sonraki adımda bunu yapacağız.
Modülerliği ve Hata Yönetimini İyileştirmek İçin Yeniden Düzenleme
Modülerliği ve Hata Yönetimini İyileştirmek İçin Yeniden Düzenleme
Programımızı iyileştirmek için, programın yapısıyla ve olası hataları nasıl ele aldığıyla ilgili dört sorunu çözeceğiz. Birincisi, main fonksiyonumuz şu anda iki ayrı işi yerine getiriyor: Argümanları ayrıştırıyor ve dosyaları okuyor. Programımız büyüdükçe main fonksiyonunun üstlendiği bağımsız görevlerin sayısı da artacaktır. Bir fonksiyonun sorumlulukları çoğaldıkça nasıl çalıştığını anlamak, onu test etmek ve bir parçasını bozmadan üzerinde değişiklik yapmak zorlaşır. En doğrusu işlevselliği her fonksiyon yalnızca tek bir görevden sorumlu olacak şekilde ayırmaktır.
Bu durum aynı zamanda ikinci sorunla da bağlantılıdır: sorgu ve dosya_yolu programımızın yapılandırma değişkenleriyken, icerik gibi değişkenler programın asıl mantığını yürütmek için kullanılır. main uzadıkça kapsama dahil etmemiz gereken değişken sayısı artar; kapsamda ne kadar çok değişken bulunursa her birinin amacını takip etmek o kadar güçleşir. Amaçlarını netleştirmek adına yapılandırma değişkenlerini tek bir yapıda gruplamak en doğrusudur.
Üçüncü sorun, dosya okuma işlemi başarısız olduğunda hata mesajı yazdırmak için expect kullanmış olmamız, ancak bu mesajın yalnızca Dosya okunamadı yazdırmasıdır. Oysa bir dosyayı okumak pek çok farklı nedenden başarısız olabilir: Örneğin dosya hiç olmayabilir ya da dosyayı açma iznimiz bulunmayabilir. Şu anda koşul ne olursa olsun her hata için aynı mesajı yazdırıyoruz; bu da kullanıcıya sorunun kaynağı hakkında doğrudan anlaşılır bir bilgi vermez!
Dördüncüsü, hataları ele almak için expect kullanıyoruz ve kullanıcı programımızı yeterli argüman belirtmeden çalıştırırsa Rust’tan sorunu açıkça anlatmayan bir index out of bounds (indeks sınırların dışında) hatası alır. Hata yönetimi kodunun tamamı tek bir yerde toplansaydı, ileride hata yönetimi mantığının değişmesi gerektiğinde koda bakım yapan geliştiricilerin bakacağı tek bir nokta olurdu. Tüm hata yönetimi kodunu tek bir yerde toplamak, son kullanıcılarımız için gerçekten anlamlı mesajlar yazdırdığımızdan emin olmamızı da sağlar.
Projemizi yeniden düzenleyerek (refactoring) bu dört sorunu adım adım ele alalım.
İkili Projelerde İlgi Alanlarını Ayırma
main fonksiyonuna birden fazla görevin sorumluluğunu yüklemek, pek çok ikili projede karşılaşılan yaygın bir organizasyon sorunudur. Bu nedenle Rust topluluğu, main fonksiyonu büyümeye başladığında ikili bir programdaki ilgi alanlarının ayrılması (separation of concerns) için kullanışlı bir yol geliştirmiştir. Bu süreç şu adımlardan oluşur:
- Programınızı bir main.rs ve bir lib.rs dosyasına bölün ve programınızın asıl mantığını lib.rs dosyasına taşıyın.
- Komut satırı ayrıştırma mantığınız küçük olduğu sürece
mainfonksiyonunda kalabilir. - Komut satırı ayrıştırma mantığı karmaşıklaşmaya başladığında, onu
mainfonksiyonundan çıkarıp başka fonksiyonlara veya türlere taşıyın.
Bu süreçten sonra main fonksiyonunda kalan sorumluluklar yalnızca şunlarla sınırlı olmalıdır:
- Komut satırı ayrıştırma mantığını argüman değerleriyle çağırmak
- Diğer yapılandırmaları hazırlamak
- lib.rs içindeki bir
calistirfonksiyonunu çağırmak calistirbir hata döndürürse bu hatayı ele almak
Bu desen tamamen ilgi alanlarını ayırmakla ilgilidir: main.rs programın çalıştırılmasını üstlenirken, lib.rs eldeki görevin tüm iş mantığını barındırır. main fonksiyonunu doğrudan test edemediğiniz için bu yapı, programınızın tüm mantığını main fonksiyonunun dışına taşıyarak test etmenize olanak tanır. main fonksiyonunda kalan kod ise yalnızca okuyarak doğruluğunu teyit edebileceğiniz kadar küçük olacaktır. Bu süreci izleyerek programımızı yeniden yapılandıralım.
Argüman Ayrıştırıcısını Çıkarma
Öncelikle argümanları ayrıştırma işlevselliğini, main fonksiyonunun çağıracağı ayrı bir fonksiyona çıkaracağız. Liste 12-5, src/main.rs içinde tanımlayacağımız yeni yapilandirma_ayristir fonksiyonunu çağıran main fonksiyonunun güncellenmiş başlangıcını göstermektedir.
use std::env;
use std::fs;
fn main() {
let argumanlar: Vec<String> = env::args().collect();
let (sorgu, dosya_yolu) = yapilandirma_ayristir(&argumanlar);
// --snip--
println!("Aranan: {sorgu}");
println!("Dosya: {dosya_yolu}");
let icerik = fs::read_to_string(dosya_yolu).expect("Dosya okunamadı");
println!("Metin içeriği:\n{icerik}");
}
fn yapilandirma_ayristir(argumanlar: &[String]) -> (&str, &str) {
let sorgu = &argumanlar[1];
let dosya_yolu = &argumanlar[2];
(sorgu, dosya_yolu)
}
main fonksiyonundan yapilandirma_ayristir fonksiyonunu çıkarmaKomut satırı argümanlarını hâlâ bir vektörde topluyoruz; ancak main fonksiyonunun içinde 1 numaralı indeksteki argüman değerini sorgu değişkenine, 2 numaralı indeksteki argüman değerini de dosya_yolu değişkenine atamak yerine vektörün tamamını (&argumanlar yazımıyla, bir dilim olarak) yapilandirma_ayristir fonksiyonuna iletiyoruz. Hangi argümanın hangi değişkene karşılık geleceğini belirleyen mantığı artık yapilandirma_ayristir fonksiyonu barındırır ve elde ettiği değerleri main fonksiyonuna geri döndürür. sorgu ve dosya_yolu değişkenlerini hâlâ main içinde oluşturuyoruz, fakat komut satırı argümanları ile değişkenlerin nasıl eşleşeceğini belirleme sorumluluğu artık main fonksiyonuna ait değildir.
Bu değişiklik küçük programımız için ilk bakışta fazladan iş gibi görünebilir; ancak yeniden düzenlemeyi küçük ve artımlı adımlarla gerçekleştiriyoruz. Bu değişikliği yaptıktan sonra argüman ayrıştırmanın hâlâ sorunsuz çalıştığını doğrulamak için programı tekrar çalıştırın. İlerlemenizi sık sık kontrol etmek, bir sorun ortaya çıktığında nedenini hızla tespit etmenize yardımcı olur.
Yapılandırma Değerlerini Gruplama
yapilandirma_ayristir fonksiyonunu daha da iyileştirmek için küçük bir adım daha atabiliriz. Şu anda bir demet döndürüyor, hemen ardından bu demeti tekrar tekil değişkenlere ayırıyoruz. Bu durum, henüz doğru soyutlamaya ulaşmadığımızın bir işaretidir.
İyileştirme fırsatı olduğunu gösteren bir diğer ipucu da yapilandirma_ayristir adındaki yapilandirma sözcüğüdür: Bu isim, döndürdüğümüz iki değerin birbiriyle yakından ilişkili olduğunu ve tek bir yapılandırma bilgisinin parçaları olduğunu ima eder. Şu anda bu iki değeri bir demet içinde gruplamak dışında verinin yapısında bu anlamı ifade etmiyoruz. Bunun yerine iki değeri tek bir struct içine koyacak ve struct alanlarının her birine anlamlı bir ad vereceğiz. Böylece ileride bu kod üzerinde çalışacak geliştiriciler, farklı değerlerin birbiriyle nasıl ilişkili olduğunu ve ne işe yaradığını çok daha kolay anlayacaktır.
Liste 12-6, yapilandirma_ayristir fonksiyonunda yapılan bu iyileştirmeleri göstermektedir.
use std::env;
use std::fs;
fn main() {
let argumanlar: Vec<String> = env::args().collect();
let yapilandirma = yapilandirma_ayristir(&argumanlar);
println!("Aranan: {}", yapilandirma.sorgu);
println!("Dosya: {}", yapilandirma.dosya_yolu);
let icerik =
fs::read_to_string(yapilandirma.dosya_yolu).expect("Dosya okunamadı");
// --snip--
println!("Metin içeriği:\n{icerik}");
}
struct Yapilandirma {
sorgu: String,
dosya_yolu: String,
}
fn yapilandirma_ayristir(argumanlar: &[String]) -> Yapilandirma {
let sorgu = argumanlar[1].clone();
let dosya_yolu = argumanlar[2].clone();
Yapilandirma { sorgu, dosya_yolu }
}
yapilandirma_ayristir fonksiyonunu bir Yapilandirma struct’ı örneği döndürecek şekilde yeniden düzenlemesorgu ve dosya_yolu adında alanlara sahip Yapilandirma adlı bir struct tanımladık. yapilandirma_ayristir fonksiyonunun imzası artık bir Yapilandirma değeri döndürdüğünü belirtiyor. Fonksiyonun gövdesinde, eskiden argumanlar içindeki String değerlerine referans veren string dilimleri döndürürken, artık Yapilandirma struct’ını sahiplenilmiş String değerleri tutacak şekilde tanımlıyoruz. main içindeki argumanlar değişkeni argüman değerlerinin sahibidir ve yapilandirma_ayristir fonksiyonunun bu değerleri yalnızca ödünç almasına izin verir; dolayısıyla Yapilandirma struct’ı argumanlar içindeki değerlerin sahipliğini doğrudan almaya çalışsaydı Rust’ın ödünç alma kurallarını ihlal etmiş olurduk.
String verilerini yönetmenin birkaç farklı yolu vardır; bunların en kolayı —biraz verimsiz olsa da— değerler üzerinde clone metodunu çağırmaktır. Bu yöntem, Yapilandirma örneğinin sahip olabilmesi için verinin tam bir kopyasını üretir; bu da string verisine bir referans saklamaya kıyasla biraz daha fazla zaman ve bellek gerektirir. Buna karşılık veriyi klonlamak, referansların ömürlerini yönetmek zorunda kalmayacağımız için kodumuzu son derece sade tutar; bu koşullar altında sadelik kazanmak uğruna az miktarda performanstan feragat etmek yerinde bir ödünleşimdir (trade-off).
clone Kullanımının Ödünleşimleri
Pek çok Rust programcısı, çalışma zamanı maliyeti nedeniyle sahiplik sorunlarını çözmek için clone kullanmaktan kaçınma eğilimindedir. Bölüm 13’te bu tür durumlarda çok daha verimli yöntemlerin nasıl kullanılacağını öğreneceksiniz. Ancak şimdilik ilerlemeye devam etmek adına birkaç string’i kopyalamakta hiçbir sakınca yoktur; çünkü bu kopyaları program başlarken yalnızca bir kez oluşturacaksınız ve hem dosya yolunuz hem de sorgu metniniz oldukça küçüktür. İlk denemede kodu aşırı optimize etmeye çalışmaktansa, biraz verimsiz de olsa çalışan bir programa sahip olmak daha iyidir. Rust’ta deneyim kazandıkça doğrudan en verimli çözümle başlamak kolaylaşacaktır; ancak şimdilik clone çağırmak tamamen kabul edilebilirdir.
main fonksiyonunu, yapilandirma_ayristir tarafından döndürülen Yapilandirma örneğini yapilandirma adlı bir değişkene yerleştirecek şekilde güncelledik ve daha önce ayrı sorgu ve dosya_yolu değişkenlerini kullanan kodu Yapilandirma struct’ının alanlarını kullanacak biçimde değiştirdik.
Artık kodumuz, sorgu ve dosya_yolu değerlerinin birbiriyle ilişkili olduğunu ve programın nasıl çalışacağını yapılandırma amacı taşıdığını çok daha net ifade ediyor. Bu değerleri kullanan herhangi bir kod, onları yapilandirma örneğinin amaçlarına göre adlandırılmış alanlarında bulacağını bilir.
Yapilandirma İçin Bir Yapıcı Oluşturma
Şimdiye kadar komut satırı argümanlarını ayrıştırmaktan sorumlu mantığı main fonksiyonundan çıkarıp yapilandirma_ayristir fonksiyonuna yerleştirdik. Bu adım, sorgu ve dosya_yolu değerlerinin birbiriyle ilişkili olduğunu ve bu ilişkinin kodumuzda yansıtılması gerektiğini görmemize yardımcı oldu. Ardından sorgu ve dosya_yolu’nun ortak amacını adlandırmak ve bu değerleri yapilandirma_ayristir fonksiyonundan anlamlı alan adlarıyla döndürebilmek için Yapilandirma struct’ını ekledik.
Artık yapilandirma_ayristir fonksiyonunun amacı bir Yapilandirma örneği oluşturmak olduğuna göre, yapilandirma_ayristir fonksiyonunu bağımsız bir fonksiyondan Yapilandirma struct’ı ile ilişkili new adında bir yapıcı (constructor) fonksiyona dönüştürebiliriz. Bu değişiklik kodu daha deyimsel hâle getirecektir. Standart kütüphanedeki String gibi türlerin örneklerini String::new çağrısıyla oluşturabiliyoruz. Benzer şekilde yapilandirma_ayristir fonksiyonunu Yapilandirma ile ilişkili bir new fonksiyonuna dönüştürdüğümüzde, Yapilandirma::new çağrısıyla Yapilandirma örnekleri oluşturabileceğiz. Liste 12-7 yapmamız gereken değişiklikleri göstermektedir.
use std::env;
use std::fs;
fn main() {
let argumanlar: Vec<String> = env::args().collect();
let yapilandirma = Yapilandirma::new(&argumanlar);
println!("Aranan: {}", yapilandirma.sorgu);
println!("Dosya: {}", yapilandirma.dosya_yolu);
let icerik =
fs::read_to_string(yapilandirma.dosya_yolu).expect("Dosya okunamadı");
println!("Metin içeriği:\n{icerik}");
// --snip--
}
// --snip--
struct Yapilandirma {
sorgu: String,
dosya_yolu: String,
}
impl Yapilandirma {
fn new(argumanlar: &[String]) -> Yapilandirma {
let sorgu = argumanlar[1].clone();
let dosya_yolu = argumanlar[2].clone();
Yapilandirma { sorgu, dosya_yolu }
}
}
yapilandirma_ayristir fonksiyonunu Yapilandirma::new olarak değiştirmemain fonksiyonunda daha önce yapilandirma_ayristir çağırdığımız yeri Yapilandirma::new çağıracak şekilde güncelledik. yapilandirma_ayristir fonksiyonunun adını new olarak değiştirdik ve onu Yapilandirma ile ilişkilendiren bir impl bloğunun içine taşıdık. Çalıştığından emin olmak için bu kodu tekrar derlemeyi deneyin.
Hata Yönetimini Düzeltme
Şimdi hata yönetimimizi düzeltmeye odaklanacağız. argumanlar vektörü üçten az öğe barındırıyorsa 1 veya 2 numaralı indeksteki değerlere erişmeye çalışmanın programın paniklemesine yol açacağını hatırlayın. Programı hiçbir argüman vermeden çalıştırmayı deneyin; çıktı şu şekilde görünecektir:
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep`
thread 'main' (6023615) panicked at src/main.rs:27:21:
index out of bounds: the len is 1 but the index is 1
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Çıktıdaki index out of bounds: the len is 1 but the index is 1 (indeks sınırların dışında: uzunluk 1 ama indeks 1) satırı, programcılara yönelik teknik bir hata mesajıdır. Son kullanıcılarımızın neyi eksik yaptıklarını ve programı nasıl çalıştırmaları gerektiğini anlamalarına yardımcı olmaz. Şimdi bunu düzeltelim.
Hata Mesajını İyileştirme
Liste 12-8’de, 1 ve 2 numaralı indekslere erişmeden önce dilimin yeterince uzun olduğunu doğrulayan bir kontrolü new fonksiyonuna ekliyoruz. Dilim yeterince uzun değilse program panikler, ancak bu kez çok daha anlaşılır bir hata mesajı görüntüler.
use std::env;
use std::fs;
fn main() {
let argumanlar: Vec<String> = env::args().collect();
let yapilandirma = Yapilandirma::new(&argumanlar);
println!("Aranan: {}", yapilandirma.sorgu);
println!("Dosya: {}", yapilandirma.dosya_yolu);
let icerik =
fs::read_to_string(yapilandirma.dosya_yolu).expect("Dosya okunamadı");
println!("Metin içeriği:\n{icerik}");
}
struct Yapilandirma {
sorgu: String,
dosya_yolu: String,
}
impl Yapilandirma {
// --snip--
fn new(argumanlar: &[String]) -> Yapilandirma {
if argumanlar.len() < 3 {
panic!("yeterli argüman yok");
}
// --snip--
let sorgu = argumanlar[1].clone();
let dosya_yolu = argumanlar[2].clone();
Yapilandirma { sorgu, dosya_yolu }
}
}
Bu kod, deger argümanı geçerli değer aralığının dışındaysa panic! çağırdığımız Liste 9-13’teki Tahmin::new fonksiyonuna benzer. Burada bir değer aralığını denetlemek yerine argumanlar diliminin uzunluğunun en az 3 olduğunu kontrol ediyoruz; böylece fonksiyonun geri kalanı bu koşulun sağlandığı varsayımıyla güvenle çalışabiliyor. argumanlar üçten az öğeye sahipse bu koşul true olur ve programı derhal sonlandırmak üzere panic! makrosunu çağırırız.
new fonksiyonuna eklediğimiz bu birkaç satırla birlikte hata çıktısının artık nasıl göründüğünü görmek için programı tekrar hiçbir argüman vermeden çalıştıralım:
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep`
thread 'main' (6023776) panicked at src/main.rs:26:13:
yeterli argüman yok
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Bu çıktı daha iyi: Artık makul bir hata mesajımız var. Ne var ki çıktıda kullanıcılarımıza göstermek istemediğimiz fazladan teknik ayrıntılar da yer alıyor. Görünüşe göre Liste 9-13’te kullandığımız teknik burası için en uygun seçim değil: Bölüm 9’da ele aldığımız üzere bir panic! çağrısı, kullanıcı kaynaklı bir kullanım hatasından ziyade kod içindeki bir programlama hatası için uygundur. Bunun yerine Bölüm 9’da öğrendiğiniz diğer tekniği —başarıyı veya hatayı ifade eden bir Result döndürmeyi— kullanacağız.
panic! Çağırmak Yerine Result Döndürme
Paniklemek yerine, başarılı durumda bir Yapilandirma örneği barındıran, hata durumunda ise sorunu açıklayan bir Result değeri döndürebiliriz. Aynı zamanda fonksiyonun adını new yerine olustur (İngilizce Rust kodlarındaki yaygın kuralıyla build) olarak değiştireceğiz; çünkü pek çok Rust programcısı new adındaki fonksiyonların asla başarısız olmamasını bekler. Yapilandirma::olustur, main fonksiyonuyla iletişim kurarken bir sorun oluştuğunu bildirmek için Result türünü kullanabilir. Ardından main fonksiyonunu, bir panic! çağrısının yol açtığı thread 'main' ve RUST_BACKTRACE gibi teknik metinler olmadan Err varyantını kullanıcılarımız için sade ve pratik bir hata mesajına dönüştürecek şekilde güncelleyebiliriz.
Liste 12-9, artık Yapilandirma::olustur adını verdiğimiz fonksiyonun dönüş türünde ve Result döndürmek için fonksiyon gövdesinde yapmamız gereken değişiklikleri göstermektedir. Bir sonraki listede main fonksiyonunu da güncelleyene kadar bu kodun derlenmeyeceğini unutmayın.
use std::env;
use std::fs;
fn main() {
let argumanlar: Vec<String> = env::args().collect();
let yapilandirma = Yapilandirma::new(&argumanlar);
println!("Aranan: {}", yapilandirma.sorgu);
println!("Dosya: {}", yapilandirma.dosya_yolu);
let icerik =
fs::read_to_string(yapilandirma.dosya_yolu).expect("Dosya okunamadı");
println!("Metin içeriği:\n{icerik}");
}
struct Yapilandirma {
sorgu: String,
dosya_yolu: String,
}
impl Yapilandirma {
fn olustur(argumanlar: &[String]) -> Result<Yapilandirma, &'static str> {
if argumanlar.len() < 3 {
return Err("yeterli argüman yok");
}
let sorgu = argumanlar[1].clone();
let dosya_yolu = argumanlar[2].clone();
Ok(Yapilandirma { sorgu, dosya_yolu })
}
}
Yapilandirma::olustur fonksiyonundan Result döndürmeolustur fonksiyonumuz başarı durumunda bir Yapilandirma örneği, hata durumunda ise bir string sabiti (&'static str) içeren bir Result döndürür. Hata değerlerimiz her zaman 'static ömre sahip string sabitleri olacaktır.
Fonksiyon gövdesinde iki değişiklik yaptık: Kullanıcı yeterli sayıda argüman sağlamadığında panic! çağırmak yerine artık bir Err değeri döndürüyoruz ve dönüş değeri olan Yapilandirma örneğini Ok içine sarmalıyoruz. Bu değişiklikler fonksiyonun yeni tür imzasıyla uyumlu olmasını sağlar.
Yapilandirma::olustur fonksiyonundan bir Err değeri döndürmek, main fonksiyonunun olustur’dan dönen Result değerini ele almasına ve hata durumunda süreci çok daha temiz bir şekilde sonlandırmasına olanak tanır.
Yapilandirma::olustur’u Çağırma ve Hataları Ele Alma
Hata durumunu ele almak ve kullanıcı dostu bir mesaj yazdırmak için Liste 12-10’da gösterildiği gibi main fonksiyonunu Yapilandirma::olustur tarafından döndürülen Result değerini işleyecek şekilde güncellememiz gerekir. Ayrıca komut satırı aracından sıfır dışı (nonzero) bir hata koduyla çıkma sorumluluğunu da panic! makrosundan devralıp kendimiz uygulayacağız. Sıfır dışı bir çıkış durumu kodu, programımızı çağıran sürece programın bir hata durumuyla sonlandığını bildirmeye yarayan yerleşik bir kuraldır.
use std::env;
use std::fs;
use std::process;
fn main() {
let argumanlar: Vec<String> = env::args().collect();
let yapilandirma =
Yapilandirma::olustur(&argumanlar).unwrap_or_else(|hata| {
println!("Argümanları ayrıştırırken problem oluştu: {hata}");
process::exit(1);
});
// --snip--
println!("Aranan: {}", yapilandirma.sorgu);
println!("Dosya: {}", yapilandirma.dosya_yolu);
let icerik =
fs::read_to_string(yapilandirma.dosya_yolu).expect("Dosya okunamadı");
println!("Metin içeriği:\n{icerik}");
}
struct Yapilandirma {
sorgu: String,
dosya_yolu: String,
}
impl Yapilandirma {
fn olustur(argumanlar: &[String]) -> Result<Yapilandirma, &'static str> {
if argumanlar.len() < 3 {
return Err("yeterli argüman yok");
}
let sorgu = argumanlar[1].clone();
let dosya_yolu = argumanlar[2].clone();
Ok(Yapilandirma { sorgu, dosya_yolu })
}
}
Yapilandirma oluşturma işlemi başarısız olursa hata koduyla çıkış yapmaBu listede henüz ayrıntılı olarak incelemediğimiz bir metot kullandık: Standart kütüphanede Result<T, E> üzerinde tanımlı olan unwrap_or_else. unwrap_or_else kullanmak, panik üretmeyen özel bir hata yönetimi tanımlamamıza olanak verir. Eğer Result değeri Ok ise bu metodun davranışı unwrap ile aynıdır: Ok varyantının sarmaladığı iç değeri döndürür. Ancak değer Err ise bu metot, tanımlayıp unwrap_or_else’e argüman olarak geçirdiğimiz isimsiz bir fonksiyon olan kapanış (closure) içindeki kodu çalıştırır. Kapanışları Bölüm 13’te daha ayrıntılı olarak ele alacağız. Şimdilik bilmeniz gereken tek şey, unwrap_or_else metodunun Err varyantının içindeki değeri —yani Liste 12-9’da eklediğimiz "yeterli argüman yok" statik string sabitini— dikey çubuklar arasında yer alan hata parametresi üzerinden kapanışımıza aktaracağıdır. Böylece kapanışın içindeki kod çalıştığında bu hata değerini kullanabilir.
Standart kütüphaneden process modülünü kapsama dahil etmek için yeni bir use satırı ekledik. Hata durumunda çalışacak kapanışın içindeki kod yalnızca iki satırdan oluşur: hata değerini yazdırıyor ve hemen ardından process::exit fonksiyonunu çağırıyoruz. process::exit fonksiyonu programı anında durdurur ve kendisine argüman olarak verilen sayıyı çıkış durum kodu olarak döndürür. Bu yaklaşım Liste 12-8’de kullandığımız panic! tabanlı yönetime benzer, ancak artık fazladan teknik çıktıların hiçbirini almayız. Deneyelim:
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
Running `target/debug/minigrep`
Argümanları ayrıştırırken problem oluştu: yeterli argüman yok
Harika! Bu çıktı kullanıcılarımız için çok daha anlaşılır ve dostça.
Not: C ve POSIX geleneğini izleyen işletim sistemlerinde bir sürecin 0 çıkış koduyla sonlanması işlemin başarılı olduğunu, 1 gibi sıfırdan farklı herhangi bir tam sayıyla sonlanması ise bir hata oluştuğunu belirtir (kabukta son komutun çıkış kodunu echo $? ile görebilirsiniz). Doğru çıkış kodu döndürmek, komut satırı araçlarının betiklerde veya minigrep aranan siir.txt && diger_komut gibi kabuk zincirlerinde güvenilir biçimde kullanılabilmesini sağlar; minigrep 1 koduyla çıktığında kabuk bir hata oluştuğunu anlar ve && sonrasındaki komutu çalıştırmaz. Ayrıca process::exit, panik mekanizmasının aksine yığını geri sarmadan (stack unwinding yapmadan) ve yerel değişkenlerin Drop yıkıcılarını çalıştırmadan süreci anında sonlandırır; bu nedenle onu derinlerdeki yardımcı fonksiyonlar yerine, burada yaptığımız gibi tüm hata yönetiminin toplandığı main fonksiyonunda çağırmak en temiz yaklaşımdır.
Mantığı main’den Çıkarma
Yapılandırma ayrıştırma kısmını yeniden düzenlemeyi tamamladığımıza göre şimdi programın asıl mantığına dönelim. “İkili Projelerde İlgi Alanlarını Ayırma” bölümünde belirttiğimiz gibi, şu anda main fonksiyonunda bulunan ve yapılandırmayı hazırlamak ya da hataları ele almakla ilgisi olmayan tüm mantığı barındıracak calistir adında bir fonksiyon çıkaracağız. İşimiz bittiğinde main fonksiyonu özlü ve bakarak doğrulanması kolay bir hâle gelecek; geri kalan tüm mantık içinse testler yazabileceğiz.
Liste 12-11, calistir fonksiyonunu ayırmaya yönelik bu küçük ve artımlı adımı göstermektedir.
use std::env;
use std::fs;
use std::process;
fn main() {
// --snip--
let argumanlar: Vec<String> = env::args().collect();
let yapilandirma =
Yapilandirma::olustur(&argumanlar).unwrap_or_else(|hata| {
println!("Argümanları ayrıştırırken problem oluştu: {hata}");
process::exit(1);
});
println!("Aranan: {}", yapilandirma.sorgu);
println!("Dosya: {}", yapilandirma.dosya_yolu);
calistir(yapilandirma);
}
fn calistir(yapilandirma: Yapilandirma) {
let icerik =
fs::read_to_string(yapilandirma.dosya_yolu).expect("Dosya okunamadı");
println!("Metin içeriği:\n{icerik}");
}
// --snip--
struct Yapilandirma {
sorgu: String,
dosya_yolu: String,
}
impl Yapilandirma {
fn olustur(argumanlar: &[String]) -> Result<Yapilandirma, &'static str> {
if argumanlar.len() < 3 {
return Err("yeterli argüman yok");
}
let sorgu = argumanlar[1].clone();
let dosya_yolu = argumanlar[2].clone();
Ok(Yapilandirma { sorgu, dosya_yolu })
}
}
calistir fonksiyonunu çıkarmacalistir fonksiyonu artık dosyayı okumaktan başlayarak main içindeki kalan tüm mantığı barındırıyor. Bu fonksiyon argüman olarak Yapilandirma örneğini alır.
calistir Fonksiyonundan Hata Döndürme
Geri kalan program mantığı calistir fonksiyonuna ayrıldığına göre, Liste 12-9’da Yapilandirma::olustur ile yaptığımız gibi hata yönetimini iyileştirebiliriz. expect çağrısıyla programın paniklemesine izin vermek yerine, calistir fonksiyonu bir şeyler ters gittiğinde bir Result<T, E> değeri döndürecektir. Böylece hataları ele alma mantığını kullanıcı dostu bir biçimde main içinde birleştirmeye devam edebiliriz. Liste 12-12, calistir fonksiyonunun imzasında ve gövdesinde yapmamız gereken değişiklikleri göstermektedir.
use std::env;
use std::fs;
use std::process;
use std::error::Error;
// --snip--
fn main() {
let argumanlar: Vec<String> = env::args().collect();
let yapilandirma =
Yapilandirma::olustur(&argumanlar).unwrap_or_else(|hata| {
println!("Argümanları ayrıştırırken problem oluştu: {hata}");
process::exit(1);
});
println!("Aranan: {}", yapilandirma.sorgu);
println!("Dosya: {}", yapilandirma.dosya_yolu);
calistir(yapilandirma);
}
fn calistir(yapilandirma: Yapilandirma) -> Result<(), Box<dyn Error>> {
let icerik = fs::read_to_string(yapilandirma.dosya_yolu)?;
println!("Metin içeriği:\n{icerik}");
Ok(())
}
struct Yapilandirma {
sorgu: String,
dosya_yolu: String,
}
impl Yapilandirma {
fn olustur(argumanlar: &[String]) -> Result<Yapilandirma, &'static str> {
if argumanlar.len() < 3 {
return Err("yeterli argüman yok");
}
let sorgu = argumanlar[1].clone();
let dosya_yolu = argumanlar[2].clone();
Ok(Yapilandirma { sorgu, dosya_yolu })
}
}
calistir fonksiyonunu Result döndürecek şekilde değiştirmeBurada üç önemli değişiklik yaptık. Birincisi, calistir fonksiyonunun dönüş türünü Result<(), Box<dyn Error>> olarak değiştirdik. Bu fonksiyon daha önce birim türü olan () döndürüyordu; başarılı (Ok) durumda dönen değer olarak yine () türünü koruyoruz.
Hata türü içinse Box<dyn Error> trait nesnesini kullandık (ve dosyanın en üstüne bir use deyimi ekleyerek std::error::Error trait’ini kapsama dahil ettik). Trait nesnelerini Bölüm 18’de ayrıntılı olarak işleyeceğiz. Şimdilik Box<dyn Error> ifadesinin, fonksiyonun Error trait’ini uygulayan bir tür döndüreceği anlamına geldiğini, ancak dönen değerin somut olarak hangi tür olacağını önceden belirtmek zorunda olmadığımızı bilmeniz yeterlidir. Bu bize farklı hata durumlarında farklı türlerde hata değerleri döndürebilme esnekliği kazandırır. Buradaki dyn anahtar kelimesi dinamik (dynamic) sözcüğünün kısaltmasıdır.
Not: Box<dyn Error> yazımındaki Box sarmalayıcısına neden ihtiyaç duyduğumuzu merak edebilirsiniz. dyn Error, “Error trait’ini uygulayan herhangi bir tür” anlamına gelir; ancak bu türlerin her birinin bellekte kapladığı alan farklı olabileceğinden derleyici bir dyn Error değerinin boyutunu derleme zamanında bilemez. Oysa bir fonksiyonun dönüş değerinin boyutu sabit olmak zorundadır. Bölüm 15’te ayrıntılı ele alacağımız Box<T>, asıl hata değerini öbekte saklayıp geriye her zaman aynı boyutta olan bir işaretçi bıraktığı için bu sorunu çözer. Bu kalıp, hata türünü tek tek sıralamak istemediğiniz uygulama kodunda (özellikle main ve calistir gibi üst düzey fonksiyonlarda) son derece yaygındır; Bölüm 9’da gördüğümüz gibi main fonksiyonunun kendisi de Result<(), Box<dyn Error>> döndürebilir.
İkincisi, Bölüm 9’da incelediğimiz gibi expect çağrısını kaldırıp yerine ? operatörünü koyduk. Hata durumunda panic! çağırmak yerine ? operatörü, hata değerini çağıran kodun ele alması için mevcut fonksiyondan dışarı döndürür (örneğin fs::read_to_string’in döndürdüğü std::io::Error hatasını standart From dönüşümüyle otomatik olarak Box<dyn Error> türüne çevirerek iletir).
Üçüncüsü, calistir fonksiyonu artık başarı durumunda bir Ok değeri döndürüyor. Fonksiyon imzasında calistir’ın başarı türünü () olarak bildirdiğimiz için birim türü değerini Ok içine sarmalamamız gerekir. Bu Ok(()) sözdizimi ilk bakışta biraz tuhaf görünebilir; ancak () türünü bu şekilde kullanmak, calistir fonksiyonunu yalnızca yan etkileri için çağırdığımızı ve döndürdüğü bir değere ihtiyaç duymadığımızı belirtmenin deyimsel yoludur.
Bu kodu çalıştırdığınızda derlenecek, ancak ekranda bir uyarı görüntüleyecektir:
$ cargo run -- mezar siir.txt
Compiling minigrep v0.1.0 (file:///projects/minigrep)
warning: unused `Result` that must be used
--> src/main.rs:20:5
|
20 | calistir(yapilandirma);
| ^^^^^^^^^^^^^^^^^^^^^^
|
= note: this `Result` may be an `Err` variant, which should be handled
= note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by default
help: use `let _ = ...` to ignore the resulting value
|
20 | let _ = calistir(yapilandirma);
| +++++++
warning: `minigrep` (bin "minigrep") generated 1 warning
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.71s
Running `target/debug/minigrep mezar siir.txt`
Aranan: mezar
Dosya: siir.txt
Metin içeriği:
Ne hasta bekler sabahı,
Ne taze ölüyü mezar.
Ne de şeytan, bir günahı,
Seni beklediğim kadar.
Geçti istemem gelmeni,
Yokluğunda buldum seni;
Bırak vehmimde gölgeni
Gelme, artık neye yarar?
Rust derleyicisi, kodumuzun Result değerini yok saydığını (unused Result that must be used) ve bu Result değerinin bir hata oluştuğuna işaret edebileceğini bildiriyor. Bir hata oluşup oluşmadığını kontrol etmediğimiz için derleyici burada muhtemelen bir hata yönetimi kodu yazmak istediğimizi bize hatırlatıyor! Şimdi bu sorunu giderelim.
main İçinde calistir Fonksiyonundan Dönen Hataları Ele Alma
Hataları kontrol edip ele almak için Liste 12-10’da Yapilandirma::olustur ile kullandığımız tekniğe çok benzeyen, ancak küçük bir fark barındıran bir yaklaşım kullanacağız:
Dosya adı: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;
fn main() {
// --snip--
let argumanlar: Vec<String> = env::args().collect();
let yapilandirma =
Yapilandirma::olustur(&argumanlar).unwrap_or_else(|hata| {
println!("Argümanları ayrıştırırken problem oluştu: {hata}");
process::exit(1);
});
println!("Aranan: {}", yapilandirma.sorgu);
println!("Dosya: {}", yapilandirma.dosya_yolu);
if let Err(e) = calistir(yapilandirma) {
println!("Uygulama hatası: {e}");
process::exit(1);
}
}
fn calistir(yapilandirma: Yapilandirma) -> Result<(), Box<dyn Error>> {
let icerik = fs::read_to_string(yapilandirma.dosya_yolu)?;
println!("Metin içeriği:\n{icerik}");
Ok(())
}
struct Yapilandirma {
sorgu: String,
dosya_yolu: String,
}
impl Yapilandirma {
fn olustur(argumanlar: &[String]) -> Result<Yapilandirma, &'static str> {
if argumanlar.len() < 3 {
return Err("yeterli argüman yok");
}
let sorgu = argumanlar[1].clone();
let dosya_yolu = argumanlar[2].clone();
Ok(Yapilandirma { sorgu, dosya_yolu })
}
}
calistir fonksiyonunun bir Err değeri döndürüp döndürmediğini kontrol etmek ve döndürdüyse process::exit(1) çağrısıyla programı sonlandırmak için unwrap_or_else yerine if let kullandık. Çünkü calistir fonksiyonu, Yapilandirma::olustur fonksiyonunun Yapilandirma örneği döndürmesi gibi unwrap ile içinden çıkarıp kullanmak isteyeceğimiz bir değer döndürmez. Başarı durumunda calistir yalnızca () döndürdüğünden, tek önemsediğimiz şey bir hatayı yakalamaktır; dolayısıyla içinden yalnızca () çıkacak bir değeri açmak için unwrap_or_else kullanmamıza gerek yoktur.
Hem if let bloğunun hem de unwrap_or_else kapanışının gövdesi iki durumda da birebir aynıdır: Hatayı yazdırır ve programdan çıkarız.
Kodu Bir Kütüphane Crate’ine Bölme
minigrep projemiz şu ana kadar oldukça iyi görünüyor! Şimdi src/main.rs dosyasını bölecek ve kodun bir kısmını src/lib.rs dosyasına koyacağız. Böylece kodu doğrudan test edebilecek ve sorumluluğu çok daha az olan bir src/main.rs dosyası elde edeceğiz.
Metin aramaktan sorumlu kodu src/main.rs yerine src/lib.rs içinde tanımlayalım; bu sayede biz (veya minigrep kütüphanemizi kullanan herhangi biri) arama fonksiyonunu minigrep ikili dosyamız dışındaki farklı bağlamlardan da çağırabileceğiz.
İlk olarak Liste 12-13’te gösterildiği gibi src/lib.rs içinde, gövdesinde unimplemented! makrosunu çağıran ara fonksiyonunun imzasını tanımlayalım. Bir sonraki bölümde fonksiyonun uygulamasını doldurduğumuzda bu imzayı çok daha ayrıntılı açıklayacağız.
pub fn ara<'a>(sorgu: &str, icerik: &'a str) -> Vec<&'a str> {
unimplemented!();
}
ara fonksiyonunu tanımlamaBu kod derlenir; ancak ara fonksiyonu çağrıldığında unimplemented! makrosu panik oluşturur. Bu makro, henüz uygulamadığımız fonksiyonun gövdesi için geçici bir yer tutucudur.
ara fonksiyonunu kütüphane crate’imizin dışa açık API’sinin bir parçası olarak işaretlemek için fonksiyon tanımında pub anahtar kelimesini kullandık. Artık ikili crate’imizden kullanabileceğimiz ve doğrudan test edebileceğimiz bir kütüphane crate’imiz var!
Şimdi Liste 12-14’te gösterildiği gibi src/lib.rs içinde tanımladığımız kodu src/main.rs içindeki ikili crate’in kapsamına dahil etmemiz ve çağırmamız gerekiyor.
use std::env;
use std::error::Error;
use std::fs;
use std::process;
// --snip--
use minigrep::ara;
fn main() {
// --snip--
let argumanlar: Vec<String> = env::args().collect();
let yapilandirma =
Yapilandirma::olustur(&argumanlar).unwrap_or_else(|hata| {
println!("Argümanları ayrıştırırken problem oluştu: {hata}");
process::exit(1);
});
if let Err(e) = calistir(yapilandirma) {
println!("Uygulama hatası: {e}");
process::exit(1);
}
}
// --snip--
struct Yapilandirma {
sorgu: String,
dosya_yolu: String,
}
impl Yapilandirma {
fn olustur(argumanlar: &[String]) -> Result<Yapilandirma, &'static str> {
if argumanlar.len() < 3 {
return Err("yeterli argüman yok");
}
let sorgu = argumanlar[1].clone();
let dosya_yolu = argumanlar[2].clone();
Ok(Yapilandirma { sorgu, dosya_yolu })
}
}
fn calistir(yapilandirma: Yapilandirma) -> Result<(), Box<dyn Error>> {
let icerik = fs::read_to_string(yapilandirma.dosya_yolu)?;
for satir in ara(&yapilandirma.sorgu, &icerik) {
println!("{satir}");
}
Ok(())
}
minigrep kütüphane crate’inin ara fonksiyonunu kullanmaKütüphane crate’indeki ara fonksiyonunu ikili crate’in kapsamına getirmek için use minigrep::ara satırını ekliyoruz. Ardından calistir fonksiyonunda dosyanın tüm içeriğini ekrana yazdırmak yerine ara fonksiyonunu çağırıyor ve &yapilandirma.sorgu değeri ile &icerik değerini argüman olarak iletiyoruz. Sonrasında calistir, ara fonksiyonundan dönen ve sorguyla eşleşen her bir satırı yazdırmak için bir for döngüsü kullanır. Ayrıca bu aşama, herhangi bir hata oluşmadığında programımızın yalnızca arama sonuçlarını yazdırması için main fonksiyonunda sorguyu ve dosya yolunu ekrana basan geçici println! çağrılarını kaldırmak için de doğru zamandır.
ara fonksiyonunun herhangi bir yazdırma işlemi gerçekleşmeden önce tüm sonuçları döndüreceği bir vektörde toplayacağına dikkat edin. Eşleşen satırlar bulundukları anda yazdırılmadığı için bu uygulama büyük dosyalarda arama yaparken sonuçları görüntülemekte yavaş kalabilir; Bölüm 13’te iteratörleri kullanarak bu durumu nasıl iyileştirebileceğimizi ele alacağız.
Not: Cargo, aynı pakette hem src/lib.rs hem de src/main.rs dosyalarını gördüğünde paket adıyla (minigrep) bir kütüphane crate’i ve aynı adla bir ikili crate derler. Rust’ta entegrasyon testleri (tests/ dizini) ve harici paketler src/main.rs içindeki öğeleri içe aktaramaz; yalnızca src/lib.rs içindeki pub öğelere (use minigrep::ara; gibi) erişebilir. Gerçek dünyadaki büyük Rust komut satırı araçlarında —bu bölümün başındaki “İkili Projelerde İlgi Alanlarını Ayırma” kılavuzunda da belirtildiği üzere— bu mimari bir adım daha ileri taşınarak Yapilandirma ve calistir dahil neredeyse tüm uygulama katmanı src/lib.rs altına yerleştirilir (biz ise bu bölümde örneği sade tutmak amacıyla yalnızca arama fonksiyonlarını src/lib.rs içinde tanımlıyoruz); src/main.rs ise yalnızca argümanları alıp kütüphaneyi çağıran ve hataları çıkış koduna dönüştüren ince bir kabuk olarak bırakılır.
Epey iş çıkardık ama gelecekteki geliştirmelerimiz için çok sağlam bir zemin hazırladık! Artık hataları ele almak çok daha kolay ve kodumuz çok daha modüler. Bundan sonraki çalışmalarımızın neredeyse tamamını src/lib.rs içinde gerçekleştireceğiz.
Yeni kazandığımız bu modülerlikten yararlanarak eski kodla zor olacak ama yeni yapıyla son derece kolaylaşan bir şey yapalım: Biraz test yazalım!
Test Güdümlü Geliştirme ile İşlevsellik Ekleme
Test Güdümlü Geliştirme ile İşlevsellik Ekleme
Arama mantığını src/lib.rs içine alıp main fonksiyonundan ayırdığımıza göre, artık kodumuzun çekirdek işlevselliği için test yazmak çok daha kolay. İkili dosyamızı komut satırından çalıştırmak zorunda kalmadan fonksiyonları çeşitli argümanlarla doğrudan çağırabilir ve dönüş değerlerini kontrol edebiliriz.
Bu bölümde, aşağıdaki adımlardan oluşan test güdümlü geliştirme (test-driven development — TDD) sürecini kullanarak minigrep programına arama mantığını ekleyeceğiz:
- Başarısız olan bir test yazın ve beklediğiniz nedenden dolayı başarısız olduğundan emin olmak için testi çalıştırın.
- Yeni testin geçmesini sağlayacak kadar kod yazın veya mevcut kodu değiştirin.
- Yeni eklediğiniz veya değiştirdiğiniz kodu yeniden düzenleyin ve testlerin geçmeye devam ettiğinden emin olun.
- Birinci adımdan itibaren tekrarlayın!
Yazılım geliştirmenin pek çok yolundan yalnızca biri olsa da TDD, kod tasarımını yönlendirmeye yardımcı olur. Testi geçirecek kodu yazmadan önce testin kendisini yazmak, geliştirme süreci boyunca yüksek test kapsamını korumayı sağlar.
Dosya içeriğinde sorgu metnini fiilen arayacak ve sorguyla eşleşen satırların bir listesini üretecek işlevselliği test güdümlü olarak uygulayacağız. Bu işlevselliği ara adında bir fonksiyona ekleyeceğiz.
Başarısız Olan Bir Test Yazma
Bölüm 11’de yaptığımız gibi src/lib.rs dosyasına bir test fonksiyonu barındıran tests modülü ekleyeceğiz. Test fonksiyonu, ara fonksiyonunun sahip olmasını istediğimiz davranışı tanımlar: Bir sorgu ve aranacak metni alacak, metinden yalnızca sorguyu içeren satırları döndürecektir. Liste 12-15 bu testi göstermektedir.
pub fn ara<'a>(sorgu: &str, icerik: &'a str) -> Vec<&'a str> {
unimplemented!();
}
// --snip--
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tek_sonuc() {
let sorgu = "güven";
let icerik = "\
Güven:
güvenli, hızlı, üretken.
Üçünü de seç.";
assert_eq!(vec!["güvenli, hızlı, üretken."], ara(sorgu, icerik));
}
}
ara işlevselliği için başarısız bir test oluşturmaBu test "güven" string’ini arar. Üzerinde arama yaptığımız metin üç satırdan oluşur ve büyük/küçük harf duyarlılığı nedeniyle bunlardan yalnızca ikincisi küçük harfli "güven" ifadesini barındırır (açılış çift tırnağından hemen sonra gelen ters eğik çizginin \, Rust’a bu çok satırlı string sabitinin başına yeni satır karakteri koymamasını söylediğine dikkat edin). Ardından ara fonksiyonundan dönen değerin yalnızca beklediğimiz satırı içerdiğini doğruluyoruz.
Bu test derlenir; ancak çalıştırıldığında unimplemented! makrosu "not implemented" (uygulanmadı) mesajıyla paniklediği için başarısız olur. TDD ilkelerine uygun olarak küçük bir adım atalım: Liste 12-16’da gösterildiği gibi ara fonksiyonunu her zaman boş bir vektör döndürecek şekilde tanımlayarak fonksiyon çağrıldığında paniklememesini sağlayacak kadar kod ekleyelim. Böylece test, beklediğimiz asıl nedenden dolayı —boş bir vektör, "güvenli, hızlı, üretken." satırını barındıran vektörle eşleşmediği için— başarısız olacaktır.
pub fn ara<'a>(sorgu: &str, icerik: &'a str) -> Vec<&'a str> {
vec![]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tek_sonuc() {
let sorgu = "güven";
let icerik = "\
Güven:
güvenli, hızlı, üretken.
Üçünü de seç.";
assert_eq!(vec!["güvenli, hızlı, üretken."], ara(sorgu, icerik));
}
}
ara fonksiyonunu çağrıldığında paniklemeyecek kadar tanımlamaŞimdi ara fonksiyonunun imzasında neden açık bir 'a ömrü tanımlamamız ve bu ömrü icerik argümanıyla dönüş değerinde kullanmamız gerektiğini inceleyelim. Bölüm 10’dan hatırlayacağınız üzere ömür parametreleri, hangi argümanın ömrünün dönüş değerinin ömrüne bağlı olduğunu belirtir. Bu örnekte, döndürülen vektörün (sorgu argümanına değil) icerik argümanının dilimlerine referans veren string dilimleri barındıracağını ifade ediyoruz.
Başka bir deyişle Rust’a, ara fonksiyonu tarafından döndürülen verinin ara fonksiyonuna icerik argümanıyla aktarılan veri kadar uzun yaşayacağını söylüyoruz. Bu çok önemlidir! Bir referansın geçerli kalabilmesi için dilim tarafından işaret edilen verinin de geçerli olması gerekir; derleyici icerik yerine sorgu üzerinden string dilimleri oluşturduğumuzu varsayarsa güvenlik denetimini yanlış yapacaktır.
Ömür bildirimlerini unutup bu fonksiyonu derlemeye çalışırsak şu hatayı alırız:
$ cargo build
Compiling minigrep v0.1.0 (file:///projects/minigrep)
error[E0106]: missing lifetime specifier
--> src/lib.rs:1:46
|
1 | pub fn ara(sorgu: &str, icerik: &str) -> Vec<&str> {
| ---- ---- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `sorgu` or `icerik`
help: consider introducing a named lifetime parameter
|
1 | pub fn ara<'a>(sorgu: &'a str, icerik: &'a str) -> Vec<&'a str> {
| ++++ ++ ++ ++
For more information about this error, try `rustc --explain E0106`.
error: could not compile `minigrep` (lib) due to 1 previous error
Rust, çıktı için iki parametreden hangisine ihtiyacımız olduğunu kendiliğinden bilemez; bu yüzden bunu açıkça belirtmemiz gerekir. Derleyicinin yardım (help) metninin tüm parametreler ve çıktı türü için aynı ömür parametresini kullanmayı önerdiğine dikkat edin; oysa bu bizim durumumuzda yanlıştır! Metnimizin tamamını barındıran parametre icerik olduğundan ve biz o metnin eşleşen bölümlerini döndürmek istediğimizden, ömür sözdizimiyle dönüş değerine bağlanması gereken tek parametrenin icerik olduğunu biliyoruz.
Diğer programlama dilleri fonksiyon imzasında argümanları dönüş değerlerine bağlamanızı gerektirmez, ancak bu pratik zamanla çok daha doğal gelecektir. Bu örneği Bölüm 10’daki “Ömürlerle Referansları Doğrulama” bölümünde yer alan örneklerle karşılaştırmak isteyebilirsiniz.
Not: Derleyicinin önerdiği pub fn ara<'a>(sorgu: &'a str, icerik: &'a str) -> Vec<&'a str> imzası teknik olarak derlense bile dönüş değerinin ömrünü
gereksiz yere sorgu parametresinin ömrüne de kısıtlar. Örneğin uzun ömürlü
bir icerik metni üzerinde, içteki kısa bir kapsamda oluşturulan geçici bir
sorgu: String aradığınızı düşünün: Arama bittikten ve sorgu kapsam dışına
çıkıp düşürüldükten sonra bile dönen satır dilimlerini kullanabilmelisiniz;
çünkü bu dilimler metin baytlarını kopyalamadan (zero-copy) doğrudan
icerik içindeki parçalara işaret eder. Döndürülen Vec dilimleri saklamak
için bellek ayırabilir; kopyalamadığımız şey metnin kendisidir. İmzamızı
ara<'a>(sorgu: &str, icerik: &'a str) -> Vec<&'a str> şeklinde yazdığımızda
sorgu, Bölüm 10’da ele aldığımız ömür elizyon kuralları (lifetime elision
rules) sayesinde 'a’dan tamamen bağımsız kendi ömrünü alır; böylece sorgu
yalnızca arama süresince ödünç alınır, sonuç vektörü ise icerik yaşadığı
sürece geçerli kalır.
Testi Geçecek Kodu Yazma
Şu anda testimiz her zaman boş bir vektör döndürdüğümüz için başarısız oluyor. Bunu düzeltmek ve ara fonksiyonunu uygulamak için programımızın şu adımları izlemesi gerekir:
- İçeriğin her satırı üzerinde yineleme yapmak.
- Satırın sorgu string’imizi barındırıp barındırmadığını kontrol etmek.
- Barındırıyorsa döndüreceğimiz değerler listesine eklemek.
- Barındırmıyorsa hiçbir şey yapmamak.
- Eşleşen sonuçların listesini döndürmek.
Satırlar üzerinde yineleme yapmaktan başlayarak her adımı sırasıyla uygulayalım.
lines Metodu ile Satırlar Üzerinde Yineleme Yapma
Rust, string’ler üzerinde satır satır gezinmeyi sağlamak için amacına uygun biçimde lines olarak adlandırılmış kullanışlı bir metoda sahiptir; bu metot Liste 12-17’de gösterildiği gibi çalışır. Kodun henüz derlenmeyeceğini unutmayın.
pub fn ara<'a>(sorgu: &str, icerik: &'a str) -> Vec<&'a str> {
for satir in icerik.lines() {
// satir ile bir şeyler yapın
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tek_sonuc() {
let sorgu = "güven";
let icerik = "\
Güven:
güvenli, hızlı, üretken.
Üçünü de seç.";
assert_eq!(vec!["güvenli, hızlı, üretken."], ara(sorgu, icerik));
}
}
icerik içindeki her bir satır üzerinde yineleme yapmalines metodu bir iteratör döndürür. İteratörleri Bölüm 13’te derinlemesine ele alacağız. Ancak iteratörü bu şekilde kullanmayı daha önce Liste 3-5’te gördüğünüzü hatırlayın: Orada bir koleksiyondaki her öğe üzerinde belirli bir kodu çalıştırmak için for döngüsünü bir iteratörle birlikte kullanmıştık.
Her Satırda Sorguyu Arama
Sırada mevcut satırın sorgu string’imizi içerip içermediğini kontrol etmek var. Neyse ki string’lerin bu işi bizim yerimize yapan contains adında kullanışlı bir metodu bulunuyor! Liste 12-18’de gösterildiği gibi ara fonksiyonuna bir contains metodu çağrısı ekleyin. Bu kodun da henüz derlenmeyeceğini unutmayın.
pub fn ara<'a>(sorgu: &str, icerik: &'a str) -> Vec<&'a str> {
for satir in icerik.lines() {
if satir.contains(sorgu) {
// satir ile bir şeyler yapın
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tek_sonuc() {
let sorgu = "güven";
let icerik = "\
Güven:
güvenli, hızlı, üretken.
Üçünü de seç.";
assert_eq!(vec!["güvenli, hızlı, üretken."], ara(sorgu, icerik));
}
}
sorgu içindeki string’i barındırıp barındırmadığını kontrol edecek işlevselliği eklemeŞu anda işlevselliği adım adım inşa ediyoruz. Kodun derlenmesini sağlamak için, fonksiyon imzasında belirttiğimiz üzere gövdeden bir değer döndürmemiz gerekiyor.
Eşleşen Satırları Saklama
Bu fonksiyonu tamamlamak için döndürmek istediğimiz eşleşen satırları saklayacak bir yola ihtiyacımız var. Bunun için for döngüsünden önce değiştirilebilir bir vektör oluşturabilir ve eşleşen her satir dilimini vektöre eklemek üzere push metodunu çağırabiliriz. for döngüsünden sonra da Liste 12-19’da gösterildiği gibi bu vektörü döndürürüz.
pub fn ara<'a>(sorgu: &str, icerik: &'a str) -> Vec<&'a str> {
let mut sonuclar = Vec::new();
for satir in icerik.lines() {
if satir.contains(sorgu) {
sonuclar.push(satir);
}
}
sonuclar
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tek_sonuc() {
let sorgu = "güven";
let icerik = "\
Güven:
güvenli, hızlı, üretken.
Üçünü de seç.";
assert_eq!(vec!["güvenli, hızlı, üretken."], ara(sorgu, icerik));
}
}
Artık ara fonksiyonu yalnızca sorgu içeren satırları döndürmeli ve testimiz geçmelidir. Testi çalıştıralım:
$ cargo test
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `test` profile [unoptimized + debuginfo] target(s) in 1.22s
Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)
running 1 test
test tests::tek_sonuc ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
Running unittests src/main.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests minigrep
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Testimiz geçti; yani kodun çalıştığını biliyoruz!
Bu noktada, aynı işlevselliği koruduğumuzdan emin olmak için testleri geçer durumda tutarken arama fonksiyonunun uygulamasını yeniden düzenleme fırsatlarını değerlendirebiliriz. Arama fonksiyonundaki kod fena değil, ancak iteratörlerin bazı faydalı özelliklerinden henüz yararlanmıyor. İteratörleri ayrıntılı olarak inceleyeceğimiz Bölüm 13’te bu örneğe geri dönecek ve kodu nasıl iyileştirebileceğimize bakacağız.
Not: Uyguladığımız bu üç aşamalı döngü yazılım dünyasında Kırmızı–Yeşil–Yeniden Düzenle (Red–Green–Refactor) olarak adlandırılır: Önce beklenen nedenle başarısız olan testi görürsünüz (Kırmızı), ardından en yalın kodla testi geçirirsiniz (Yeşil) ve son olarak çalışan testlerin güvencesi altında kodu temizlersiniz (Yeniden Düzenle). Ayrıca kullandığımız str::lines metodunun platformlar arası önemli bir kolaylık sağladığına dikkat edin: Metni hem Unix tarzı (\n) hem de Windows tarzı (\r\n) satır sonlarından böler ve satır sonu karakterlerini döndürdüğü &str dilimlerine dahil etmez; böylece siir.txt hangi işletim sisteminde kaydedilmiş olursa olsun arama ve karşılaştırma sonuçlarımız birebir aynı kalır.
Artık programın tamamı çalışmalı! İlk olarak Necip Fazıl Kısakürek şiirinden tam olarak tek bir dize döndürmesi gereken bir kelimeyle deneyelim: mezar.
$ cargo run -- mezar siir.txt
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.38s
Running `target/debug/minigrep mezar siir.txt`
Ne taze ölüyü mezar.
Harika! Şimdi bek gibi birden fazla satırla eşleşecek bir ifadeyi deneyelim:
$ cargo run -- bek siir.txt
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep bek siir.txt`
Ne hasta bekler sabahı,
Seni beklediğim kadar.
Ve son olarak, şiirde hiçbir yerde geçmeyen bir kelimeyi —örneğin monomorphization— aradığımızda hiçbir satır dönmediğinden emin olalım:
$ cargo run -- monomorphization siir.txt
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep monomorphization siir.txt`
Mükemmel! Klasik bir aracın kendi mini sürümünü geliştirdik ve uygulamaların nasıl yapılandırılacağı konusunda pek çok şey öğrendik. Ayrıca dosya giriş ve çıkışı, ömürler, test yazımı ve komut satırı ayrıştırma konularında da deneyim kazandık.
Bu projeyi tamamlamak için çevre değişkenleriyle nasıl çalışılacağını ve standart hataya (standard error) nasıl yazdırılacağını kısaca göstereceğiz; her ikisi de komut satırı programları yazarken oldukça faydalıdır.
Çevre Değişkenleri ile Çalışma
Çevre Değişkenleri ile Çalışma
minigrep ikili dosyasına ek bir özellik kazandırarak programımızı geliştireceğiz: kullanıcının bir çevre değişkeni (environment variable) aracılığıyla etkinleştirebileceği büyük/küçük harf duyarsız arama seçeneği. Bu özelliği bir komut satırı seçeneği olarak da tasarlayabilir ve kullanıcıların ihtiyaç duydukları her seferinde bunu girmesini zorunlu tutabilirdik; ancak bunun yerine bir çevre değişkeni kullanarak kullanıcılarımızın değişkeni yalnızca bir kez tanımlamasını ve o terminal oturumu boyunca yapacakları tüm aramaların büyük/küçük harfe duyarsız çalışmasını sağlıyoruz.
Büyük/Küçük Harf Duyarsız Arama İçin Başarısız Olan Bir Test Yazma
İlk olarak minigrep kütüphanesine, çevre değişkenine bir değer atandığında çağrılacak buyuk_kucuk_harf_duyarsiz_ara adlı yeni bir fonksiyon ekleyeceğiz. TDD sürecini izlemeye devam edeceğimiz için ilk adımımız yine başarısız olan bir test yazmak olacak. Liste 12-20’de gösterildiği gibi yeni buyuk_kucuk_harf_duyarsiz_ara fonksiyonu için yeni bir test ekleyecek ve iki test arasındaki farkı netleştirmek amacıyla eski testimizin adını tek_sonuc yerine buyuk_kucuk_harf_duyarli olarak değiştireceğiz.
pub fn ara<'a>(sorgu: &str, icerik: &'a str) -> Vec<&'a str> {
let mut sonuclar = Vec::new();
for satir in icerik.lines() {
if satir.contains(sorgu) {
sonuclar.push(satir);
}
}
sonuclar
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn buyuk_kucuk_harf_duyarli() {
let sorgu = "güven";
let icerik = "\
Güven:
güvenli, hızlı, üretken.
Üçünü de seç.
Güven kolay kazanılmaz.";
assert_eq!(vec!["güvenli, hızlı, üretken."], ara(sorgu, icerik));
}
#[test]
fn buyuk_kucuk_harf_duyarsiz() {
let sorgu = "gÜvEn";
let icerik = "\
Güven:
önce sağlamlık gelir.
Planlı çalış, sakin ol.
güven kazanılır.";
assert_eq!(
vec!["Güven:", "güven kazanılır."],
buyuk_kucuk_harf_duyarsiz_ara(sorgu, icerik)
);
}
}
Eski testin icerik metnini de düzenlediğimize dikkat edin. Büyük/küçük harfe duyarlı arama yaptığımızda "güven" sorgusuyla eşleşmemesi gereken, büyük G harfiyle başlayan "Güven kolay kazanılmaz." satırını ekledik. Eski testi bu şekilde güncellemek, daha önce geliştirdiğimiz büyük/küçük harf duyarlı arama işlevselliğini yanlışlıkla bozmadığımızdan emin olmamıza yardımcı olur. Bu test şimdi geçmeli ve biz büyük/küçük harf duyarsız arama üzerinde çalışırken de geçmeye devam etmelidir.
Büyük/küçük harfe duyarsız arama için yazdığımız yeni test ise sorgu olarak "gÜvEn" ifadesini kullanıyor. Eklemek üzere olduğumuz buyuk_kucuk_harf_duyarsiz_ara fonksiyonunda "gÜvEn" sorgusu, her ikisi de sorgudan farklı büyük/küçük harf kullanımına sahip olsa bile büyük G harfi içeren "Güven:" satırıyla ve "güven kazanılır." satırıyla eşleşmelidir. Bu bizim başarısız testimizdir ve henüz buyuk_kucuk_harf_duyarsiz_ara fonksiyonunu tanımlamadığımız için derlenmeyecektir. Testin derlenip başarısız olduğunu görmek isterseniz, Liste 12-16’da ara fonksiyonu için yaptığımıza benzer biçimde her zaman boş bir vektör döndüren iskelet bir uygulama ekleyebilirsiniz.
buyuk_kucuk_harf_duyarsiz_ara Fonksiyonunu Uygulama
Liste 12-21’de gösterilen buyuk_kucuk_harf_duyarsiz_ara fonksiyonu, ara fonksiyonuyla neredeyse aynı olacaktır. Tek fark şudur: sorgu’yu ve her bir satir’ı küçük harfe dönüştüreceğiz; böylece girdi argümanları hangi büyük/küçük harf diziliminde olursa olsun, satırın sorguyu içerip içermediğini kontrol ettiğimiz anda her ikisi de küçük harfli olacaktır.
pub fn ara<'a>(sorgu: &str, icerik: &'a str) -> Vec<&'a str> {
let mut sonuclar = Vec::new();
for satir in icerik.lines() {
if satir.contains(sorgu) {
sonuclar.push(satir);
}
}
sonuclar
}
pub fn buyuk_kucuk_harf_duyarsiz_ara<'a>(
sorgu: &str,
icerik: &'a str,
) -> Vec<&'a str> {
let sorgu = sorgu.to_lowercase();
let mut sonuclar = Vec::new();
for satir in icerik.lines() {
if satir.to_lowercase().contains(&sorgu) {
sonuclar.push(satir);
}
}
sonuclar
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn buyuk_kucuk_harf_duyarli() {
let sorgu = "güven";
let icerik = "\
Güven:
güvenli, hızlı, üretken.
Üçünü de seç.
Güven kolay kazanılmaz.";
assert_eq!(vec!["güvenli, hızlı, üretken."], ara(sorgu, icerik));
}
#[test]
fn buyuk_kucuk_harf_duyarsiz() {
let sorgu = "gÜvEn";
let icerik = "\
Güven:
önce sağlamlık gelir.
Planlı çalış, sakin ol.
güven kazanılır.";
assert_eq!(
vec!["Güven:", "güven kazanılır."],
buyuk_kucuk_harf_duyarsiz_ara(sorgu, icerik)
);
}
}
buyuk_kucuk_harf_duyarsiz_ara fonksiyonunu tanımlamaİlk olarak sorgu string’ini küçük harfe dönüştürüyor ve orijinal sorgu değişkenini gölgeleyerek aynı adlı yeni bir değişkende saklıyoruz. Sorgu üzerinde to_lowercase metodunu çağırmamız gereklidir; böylece kullanıcının sorgusu "güven", "GÜVEN", "Güven" veya "gÜvEn" değerlerinden hangisi olursa olsun sorguyu sanki "güven" girilmiş gibi ele alır ve büyük/küçük harf farkını yok sayarız. to_lowercase temel Unicode dönüşümlerini işleyebilse de yüzde 100 isabetli değildir. Gerçek bir uygulama geliştiriyor olsaydık burada biraz daha kapsamlı bir işlem yapmamız gerekirdi; ancak bu bölümün konusu Unicode değil çevre değişkenleri olduğundan şimdilik bunu bu şekilde bırakacağız.
sorgu değişkeninin artık bir string dilimi (&str) değil, bir String olduğuna dikkat edin; çünkü to_lowercase çağrısı mevcut veriye referans vermek yerine yeni veri oluşturur. Örneğin sorgunun "gÜvEn" olduğunu varsayalım: Bu string dilimi doğrudan ödünç alabileceğimiz küçük harfli bir ü veya e barındırmaz; bu nedenle öbekte "güven" metnini tutan yeni bir String tahsis etmemiz gerekir. Artık sorgu değişkenini contains metoduna argüman olarak aktarırken başına & işareti eklememiz gerekir, çünkü contains metodunun imzası bir string dilimi alacak şekilde tanımlanmıştır.
Ardından tüm karakterleri küçük harfe dönüştürmek için her satir üzerinde de bir to_lowercase çağrısı ekliyoruz. Artık hem satir hem de sorgu küçük harfe dönüştürüldüğüne göre, sorgunun büyük/küçük harf yazımı ne olursa olsun eşleşmeleri bulacağız.
Not: Burada hem bellek yönetimi hem de Türkçe metin işleme açısından dikkat çekici iki incelik vardır:
- Bellek Tahsisi ve Dilim Ömrü:
sorgu.to_lowercase()çağrısınıfordöngüsünün dışında yalnızca bir kez yaparak her satır için sorguyu tekrar tekrar öbekte tahsis etmekten kaçınıyoruz. Döngü içindesatir.to_lowercase()karşılaştırma amacıyla geçici birStringüretip satır sonunda düşürülse de, eşleşme sağlandığındasonuclar.push(satir)ifadesiyle bu geçici kopyayı değil, doğrudanicerikiçindeki orijinal&'a strdilimini döndürüyoruz; böylece sonuçlarda satırın özgün büyük/küçük harf yazımı korunur. - Türkçede
'İ've'I'Harflerinin Unicode Davranışı: Standart kütüphanedekistr::to_lowercasemetodu yerel ayardan (locale) bağımsız genel Unicode kurallarını uygular. Bu nedenle Türkçe büyük noktalı'İ'(U+0130, 2 bayt) üzerinde çağrıldığında tek bir'i'yerine'i'harfi ve birleştirici üst nokta karakteri üretir ("i\u{307}", ikicharve 3 bayt — bu durumto_lowercasemetodunun neden aynı bayt uzunluğunu koruyamayıp yeni birStringdöndürmek zorunda olduğunun da somut bir örneğidir). Benzer şekilde noktasız'I'harfini Türkçe'ı'yerine İngilizce kurallarıyla'i'harfine dönüştürür. Sonuç olarak"İSTANBUL".to_lowercase().contains("istanbul")ve"Işık".to_lowercase().contains("ışık")ifadelerifalsedöndürür! Gerçek dünya Türkçe metin arama uygulamalarında karşılaştırma öncesinde'İ' -> 'i've'I' -> 'ı'dönüşümünü açıkça uygulamak veya ICU4X ekosistemindekiicu_casemapgibi yerel ayar duyarlı (locale-aware) crate’lerden yararlanmak gerekir.
Bakalım bu uygulama testleri geçecek mi:
$ cargo test
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `test` profile [unoptimized + debuginfo] target(s) in 1.33s
Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)
running 2 tests
test tests::buyuk_kucuk_harf_duyarsiz ... ok
test tests::buyuk_kucuk_harf_duyarli ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running unittests src/main.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests minigrep
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Harika! Testler geçti. Şimdi yeni buyuk_kucuk_harf_duyarsiz_ara fonksiyonunu calistir fonksiyonunun içinden çağıralım. İlk olarak Yapilandirma struct’ına büyük/küçük harfe duyarlı ve duyarsız arama arasında geçiş yapacak bir yapılandırma seçeneği ekleyeceğiz. Bu alanı henüz hiçbir yerde ilklendirmediğimiz için şu aşamada derleyici hatası alacağız:
Dosya adı: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;
use minigrep::{ara, buyuk_kucuk_harf_duyarsiz_ara};
// --snip--
fn main() {
let argumanlar: Vec<String> = env::args().collect();
let yapilandirma =
Yapilandirma::olustur(&argumanlar).unwrap_or_else(|hata| {
println!("Argümanları ayrıştırırken problem oluştu: {hata}");
process::exit(1);
});
if let Err(e) = calistir(yapilandirma) {
println!("Uygulama hatası: {e}");
process::exit(1);
}
}
pub struct Yapilandirma {
pub sorgu: String,
pub dosya_yolu: String,
pub buyuk_kucuk_harf_yoksay: bool,
}
impl Yapilandirma {
fn olustur(argumanlar: &[String]) -> Result<Yapilandirma, &'static str> {
if argumanlar.len() < 3 {
return Err("yeterli argüman yok");
}
let sorgu = argumanlar[1].clone();
let dosya_yolu = argumanlar[2].clone();
Ok(Yapilandirma { sorgu, dosya_yolu })
}
}
fn calistir(yapilandirma: Yapilandirma) -> Result<(), Box<dyn Error>> {
let icerik = fs::read_to_string(yapilandirma.dosya_yolu)?;
let sonuclar = if yapilandirma.buyuk_kucuk_harf_yoksay {
buyuk_kucuk_harf_duyarsiz_ara(&yapilandirma.sorgu, &icerik)
} else {
ara(&yapilandirma.sorgu, &icerik)
};
for satir in sonuclar {
println!("{satir}");
}
Ok(())
}
Mantıksal (bool) bir değer tutan buyuk_kucuk_harf_yoksay alanını ekledik. Ardından Liste 12-22’de gösterildiği gibi calistir fonksiyonunun buyuk_kucuk_harf_yoksay alanının değerini kontrol etmesini ve buna göre ara fonksiyonunu mu yoksa buyuk_kucuk_harf_duyarsiz_ara fonksiyonunu mu çağıracağına karar vermesini sağlamamız gerekiyor. Bu kod henüz derlenmeyecektir.
use std::env;
use std::error::Error;
use std::fs;
use std::process;
use minigrep::{ara, buyuk_kucuk_harf_duyarsiz_ara};
// --snip--
fn main() {
let argumanlar: Vec<String> = env::args().collect();
let yapilandirma =
Yapilandirma::olustur(&argumanlar).unwrap_or_else(|hata| {
println!("Argümanları ayrıştırırken problem oluştu: {hata}");
process::exit(1);
});
if let Err(e) = calistir(yapilandirma) {
println!("Uygulama hatası: {e}");
process::exit(1);
}
}
pub struct Yapilandirma {
pub sorgu: String,
pub dosya_yolu: String,
pub buyuk_kucuk_harf_yoksay: bool,
}
impl Yapilandirma {
fn olustur(argumanlar: &[String]) -> Result<Yapilandirma, &'static str> {
if argumanlar.len() < 3 {
return Err("yeterli argüman yok");
}
let sorgu = argumanlar[1].clone();
let dosya_yolu = argumanlar[2].clone();
Ok(Yapilandirma { sorgu, dosya_yolu })
}
}
fn calistir(yapilandirma: Yapilandirma) -> Result<(), Box<dyn Error>> {
let icerik = fs::read_to_string(yapilandirma.dosya_yolu)?;
let sonuclar = if yapilandirma.buyuk_kucuk_harf_yoksay {
buyuk_kucuk_harf_duyarsiz_ara(&yapilandirma.sorgu, &icerik)
} else {
ara(&yapilandirma.sorgu, &icerik)
};
for satir in sonuclar {
println!("{satir}");
}
Ok(())
}
yapilandirma.buyuk_kucuk_harf_yoksay içindeki değere bağlı olarak ara veya buyuk_kucuk_harf_duyarsiz_ara fonksiyonunu çağırmaSon olarak çevre değişkenini kontrol etmemiz gerekiyor. Çevre değişkenleriyle çalışmaya yönelik fonksiyonlar, standart kütüphanenin hâlihazırda src/main.rs dosyasının en üstünde kapsama dahil ettiğimiz env modülünde yer alır. Liste 12-23’te gösterildiği gibi, IGNORE_CASE adlı çevre değişkenine herhangi bir değer atanıp atanmadığını öğrenmek için env modülündeki var fonksiyonunu kullanacağız.
use std::env;
use std::error::Error;
use std::fs;
use std::process;
use minigrep::{ara, buyuk_kucuk_harf_duyarsiz_ara};
fn main() {
let argumanlar: Vec<String> = env::args().collect();
let yapilandirma =
Yapilandirma::olustur(&argumanlar).unwrap_or_else(|hata| {
println!("Argümanları ayrıştırırken problem oluştu: {hata}");
process::exit(1);
});
if let Err(e) = calistir(yapilandirma) {
println!("Uygulama hatası: {e}");
process::exit(1);
}
}
pub struct Yapilandirma {
pub sorgu: String,
pub dosya_yolu: String,
pub buyuk_kucuk_harf_yoksay: bool,
}
impl Yapilandirma {
fn olustur(argumanlar: &[String]) -> Result<Yapilandirma, &'static str> {
if argumanlar.len() < 3 {
return Err("yeterli argüman yok");
}
let sorgu = argumanlar[1].clone();
let dosya_yolu = argumanlar[2].clone();
let buyuk_kucuk_harf_yoksay = env::var("IGNORE_CASE").is_ok();
Ok(Yapilandirma {
sorgu,
dosya_yolu,
buyuk_kucuk_harf_yoksay,
})
}
}
fn calistir(yapilandirma: Yapilandirma) -> Result<(), Box<dyn Error>> {
let icerik = fs::read_to_string(yapilandirma.dosya_yolu)?;
let sonuclar = if yapilandirma.buyuk_kucuk_harf_yoksay {
buyuk_kucuk_harf_duyarsiz_ara(&yapilandirma.sorgu, &icerik)
} else {
ara(&yapilandirma.sorgu, &icerik)
};
for satir in sonuclar {
println!("{satir}");
}
Ok(())
}
IGNORE_CASE adlı çevre değişkeninde herhangi bir değer olup olmadığını kontrol etmeBurada buyuk_kucuk_harf_yoksay adında yeni bir değişken oluşturuyoruz. Değerini belirlemek için env::var fonksiyonunu çağırıyor ve ona IGNORE_CASE çevre değişkeninin adını aktarıyoruz. env::var fonksiyonu bir Result döndürür: Çevre değişkeni herhangi bir değere ayarlanmışsa bu değişkenin değerini barındıran başarılı Ok varyantı, çevre değişkeni tanımlanmamışsa Err varyantı döner.
Çevre değişkeninin tanımlanıp tanımlanmadığını öğrenmek için Result üzerindeki is_ok metodunu kullanıyoruz; değişken tanımlıysa program büyük/küçük harf duyarsız arama yapacaktır. IGNORE_CASE çevre değişkeni hiçbir değere ayarlanmamışsa is_ok metodu false döndürecek ve program büyük/küçük harfe duyarlı arama gerçekleştirecektir. Çevre değişkeninin değeriyle ilgilenmiyoruz; yalnızca tanımlanmış olup olmaması bizim için yeterli olduğundan unwrap, expect ya da Result üzerinde gördüğümüz diğer metotlar yerine is_ok metodunu kullanıyoruz.
buyuk_kucuk_harf_yoksay değişkenindeki değeri Yapilandirma örneğine aktarıyoruz; böylece calistir fonksiyonu Liste 12-22’de uyguladığımız şekilde bu değeri okuyup buyuk_kucuk_harf_duyarsiz_ara veya ara fonksiyonlarından hangisini çağıracağına karar verebilir.
Haydi deneyelim! İlk olarak programımızı çevre değişkenini tanımlamadan ve tamamı küçük harflerden oluşan ne sorgusuyla çalıştıracağız. Büyük/küçük harfe duyarlı aramada yalnızca küçük harfli ne ifadesini barındıran satırla eşleşmesini bekleriz:
$ cargo run -- ne siir.txt
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep ne siir.txt`
Gelme, artık neye yarar?
Görünüşe göre bu kısım sorunsuz çalışmaya devam ediyor! Şimdi programı IGNORE_CASE değişkenini 1 değerine ayarlayarak yine aynı ne sorgusuyla çalıştıralım:
$ IGNORE_CASE=1 cargo run -- ne siir.txt
PowerShell kullanıyorsanız çevre değişkenini tanımlamayı ve programı çalıştırmayı ayrı komutlar hâlinde yazmanız gerekir:
PS> $Env:IGNORE_CASE=1; cargo run -- ne siir.txt
Bu komut IGNORE_CASE değişkeninin mevcut kabuk oturumunuzun geri kalanı boyunca kalıcı olmasını sağlar. Değişkeni Remove-Item cmdlet’i ile kaldırabilirsiniz:
PS> Remove-Item Env:IGNORE_CASE
Not: Kabuklar (shells) arasında çevre değişkeni tanımlama sözdizimi ve değişkenin ömrü farklılık gösterir:
- Bash / Zsh (Linux, macOS, Git Bash): Komutun hemen önüne yazılan
IGNORE_CASE=1 cargo run ...ifadesi değişkeni yalnızca o komut süresince geçici olarak tanımlar; oturum genelinde kalıcı yapmak içinexport IGNORE_CASE=1, silmek içinunset IGNORE_CASEkullanılır. - PowerShell:
$Env:IGNORE_CASE=1ataması mevcut oturum boyunca kalıcıdır; kaldırmak içinRemove-Item Env:IGNORE_CASEçalıştırılır. - Windows Komut İstemi (
cmd.exe):set IGNORE_CASE=1komutuyla oturum boyunca tanımlanır ve ardındancargo run -- ne siir.txtçalıştırılır; değişkeni silmek içinset IGNORE_CASE=kullanılır (=işaretinin çevresinde boşluk bırakmamaya dikkat edin).
Ayrıca kodumuzda değerin içeriğini değil yalnızca varlığını (env::var("IGNORE_CASE").is_ok()) denetlediğimiz için, değişken silinmediği sürece IGNORE_CASE=0 veya IGNORE_CASE=false atamalarının da büyük/küçük harf duyarsız aramayı etkin tutacağını unutmayın.
Artık büyük harf barındırabilecek ne eşleşmelerini, yani Ne ile başlayan dizeleri de elde etmeliyiz:
Ne hasta bekler sabahı,
Ne taze ölüyü mezar.
Ne de şeytan, bir günahı,
Gelme, artık neye yarar?
Mükemmel, Ne içeren satırları da aldık! minigrep programımız artık bir çevre değişkeniyle kontrol edilen büyük/küçük harf duyarsız arama yapabiliyor. Böylece hem komut satırı argümanları hem de çevre değişkenleri kullanılarak belirlenen seçenekleri nasıl yöneteceğinizi öğrenmiş oldunuz.
Bazı programlar aynı yapılandırma için hem argümanlara hem de çevre değişkenlerine izin verir. Bu gibi durumlarda program, ikisinden birinin öncelikli olmasına karar verir. Kendi başınıza yapabileceğiniz ek bir alıştırma olarak, büyük/küçük harf duyarlılığını hem bir komut satırı argümanıyla hem de bir çevre değişkeniyle kontrol etmeyi deneyin. Program biri büyük/küçük harfe duyarlı, diğeri büyük/küçük harfi yok sayacak biçimde ayarlanarak çalıştırıldığında komut satırı argümanının mı yoksa çevre değişkeninin mi öncelikli olması gerektiğine karar verin.
std::env modülü çevre değişkenleriyle çalışmak için daha pek çok kullanışlı özellik barındırır; nelerin mevcut olduğunu görmek için standart kütüphane belgelerine göz atabilirsiniz.
Hataları Standart Hata Çıktısına Yönlendirme
Hataları Standart Hata Çıktısına Yönlendirme
Şu anda println! makrosunu kullanarak tüm çıktılarımızı terminale yazıyoruz. Çoğu terminalde iki tür çıktı akışı bulunur: Genel bilgiler için standart çıktı (standard output, stdout) ve hata mesajları için standart hata (standard error, stderr). Bu ayrım sayesinde kullanıcılar, bir programın başarılı çıktısını bir dosyaya yönlendirirken hata mesajlarının yine de ekrana yazdırılmasını sağlayabilir.
println! makrosu yalnızca standart çıktıya yazdırabilir; dolayısıyla standart hataya yazdırmak için başka bir araç kullanmamız gerekir.
Hataların Nereye Yazıldığını Kontrol Etme
İlk olarak, standart hataya yazdırmak istediğimiz hata mesajları da dahil olmak üzere minigrep tarafından üretilen tüm çıktının şu anda standart çıktıya nasıl yazıldığını gözlemleyelim. Bunu görmek için bilerek bir hataya yol açarken standart çıktı akışını bir dosyaya yönlendireceğiz. Standart hata akışını yönlendirmeyeceğimiz için standart hataya gönderilen her türlü içerik ekranda görünmeye devam edecektir.
Komut satırı programlarının hata mesajlarını standart hata akışına göndermesi beklenir; böylece standart çıktı akışını bir dosyaya yönlendirsek bile hata mesajlarını ekranda görmeye devam edebiliriz. Programımız şu anda bu kurala uygun davranmıyor: Birazdan hata mesajı çıktısını ekrana basmak yerine dosyaya kaydettiğini göreceğiz!
Bu davranışı göstermek için programı > operatörü ve standart çıktı akışını yönlendirmek istediğimiz cikti.txt dosya yoluyla çalıştıracağız. Hataya yol açması için hiçbir argüman iletmeyeceğiz:
$ cargo run > cikti.txt
> sözdizimi, kabuğa standart çıktının içeriğini ekran yerine cikti.txt dosyasına yazmasını söyler. Ekranda görmeyi beklediğimiz hata mesajıyla karşılaşmadık; demek ki mesaj dosyaya yazılmış olmalı. cikti.txt dosyasının içeriği şöyledir:
Argümanları ayrıştırırken problem oluştu: yeterli argüman yok
Gerçekten de hata mesajımız standart çıktıya yazdırılıyor. Bu tür hata mesajlarının standart hataya yazdırılması çok daha kullanışlıdır; böylece dosyaya yalnızca başarılı bir çalıştırmadan elde edilen veriler kaydedilir. Şimdi bunu değiştireceğiz.
Hataları Standart Hata Çıktısına Yazdırma
Hata mesajlarının yazdırılma biçimini değiştirmek için Liste 12-24’teki kodu kullanacağız. Bu bölümün başlarında yaptığımız yeniden düzenleme sayesinde, hata mesajlarını yazdıran tüm kodlar tek bir fonksiyonda, yani main içinde toplanmış durumda. Standart kütüphane, standart hata akışına yazdıran eprintln! makrosunu sunar; dolayısıyla hata yazdırmak için println! çağırdığımız iki yeri eprintln! kullanacak şekilde değiştirelim.
use std::env;
use std::error::Error;
use std::fs;
use std::process;
use minigrep::{ara, buyuk_kucuk_harf_duyarsiz_ara};
fn main() {
let argumanlar: Vec<String> = env::args().collect();
let yapilandirma =
Yapilandirma::olustur(&argumanlar).unwrap_or_else(|hata| {
eprintln!("Argümanları ayrıştırırken problem oluştu: {hata}");
process::exit(1);
});
if let Err(e) = calistir(yapilandirma) {
eprintln!("Uygulama hatası: {e}");
process::exit(1);
}
}
pub struct Yapilandirma {
pub sorgu: String,
pub dosya_yolu: String,
pub buyuk_kucuk_harf_yoksay: bool,
}
impl Yapilandirma {
fn olustur(argumanlar: &[String]) -> Result<Yapilandirma, &'static str> {
if argumanlar.len() < 3 {
return Err("yeterli argüman yok");
}
let sorgu = argumanlar[1].clone();
let dosya_yolu = argumanlar[2].clone();
let buyuk_kucuk_harf_yoksay = env::var("IGNORE_CASE").is_ok();
Ok(Yapilandirma {
sorgu,
dosya_yolu,
buyuk_kucuk_harf_yoksay,
})
}
}
fn calistir(yapilandirma: Yapilandirma) -> Result<(), Box<dyn Error>> {
let icerik = fs::read_to_string(yapilandirma.dosya_yolu)?;
let sonuclar = if yapilandirma.buyuk_kucuk_harf_yoksay {
buyuk_kucuk_harf_duyarsiz_ara(&yapilandirma.sorgu, &icerik)
} else {
ara(&yapilandirma.sorgu, &icerik)
};
for satir in sonuclar {
println!("{satir}");
}
Ok(())
}
eprintln! kullanarak hata mesajlarını standart çıktı yerine standart hataya yazdırmaŞimdi programı hiçbir argüman vermeden ve standart çıktıyı > ile yönlendirerek aynı şekilde tekrar çalıştıralım:
$ cargo run > cikti.txt
Argümanları ayrıştırırken problem oluştu: yeterli argüman yok
Artık hatayı ekranda görüyoruz ve cikti.txt dosyası hiçbir şey içermiyor; komut satırı programlarından beklenen davranış da tam olarak budur.
Şimdi programı hataya yol açmayan argümanlarla tekrar çalıştıralım, ancak standart çıktıyı yine aşağıdaki gibi bir dosyaya yönlendirelim:
$ cargo run -- ne siir.txt > cikti.txt
Terminalde herhangi bir çıktı görmeyeceğiz ve cikti.txt dosyası arama sonuçlarımızı barındıracak:
Dosya adı: cikti.txt
Gelme, artık neye yarar?
Bu sonuç, artık başarılı çıktılar için standart çıktıyı, hata mesajları içinse standart hatayı olması gerektiği gibi kullandığımızı gösterir.
Not: Unix türevi sistemlerde (Linux, macOS) ve modern Windows kabuklarında başlatılan her sürece otomatik olarak numaralandırılmış üç standart akış (dosya tanıtıcısı, file descriptor) atanır:
0—stdin(standart girdi): Klavyeden veya başka bir süreçten gelen veriyi okur (std::io::stdin()).1—stdout(standart çıktı): Programın ürettiği normal sonuçları taşır (print!,println!).2—stderr(standart hata): Hata, uyarı ve tanılama mesajlarını taşır (eprint!,eprintln!,dbg!).
Kabukta yazdığınız > operatörü aslında 1> ifadesinin kısaltmasıdır ve yalnızca stdout akışını yönlendirir. Yalnızca hata mesajlarını bir dosyaya kaydetmek isterseniz cargo run 2> hatalar.txt, hem normal çıktıyı hem de hata mesajlarını aynı dosyada birleştirmek isterseniz cargo run > tum_cikti.txt 2>&1 sözdizimini kullanabilirsiniz.
eprintln! kullanımı özellikle borulama (piping, |) işlemlerinde hayati önem taşır. Örneğin eşleşen satır sayısını bulmak için minigrep ne siir.txt | wc -l gibi bir komut zinciri kurduğunuzda kabuk, birinci programın yalnızca stdout (1) akışını ikinci programın stdin (0) akışına bağlar. Hataları println! ile stdout’a yazarsanız sonraki program hata metnini gerçek veri sanıp yanlış sonuç üretir (örneğin hata mesajının satırını da sayar!) ya da ayrıştırma hatasıyla çöker; eprintln! kullandığınızda ise hata mesajları boruya karışmadan doğrudan ekranda görünür ve sonraki araca yalnızca temiz veri aktarılır.
Özet
Bu bölümde, şimdiye kadar öğrendiğiniz pek çok temel kavramı yeniden hatırladık ve Rust’ta yaygın G/Ç işlemlerinin nasıl gerçekleştirileceğini ele aldık. Komut satırı argümanlarını, dosyaları, çevre değişkenlerini ve hata yazdırmaya yarayan eprintln! makrosunu kullanarak artık komut satırı uygulamaları geliştirmeye hazırsınız. Bunları önceki bölümlerdeki kavramlarla bir araya getirdiğinizde kodunuz iyi organize edilmiş ve iyi test edilmiş olacak; verileri uygun veri yapılarında verimli biçimde saklayıp hataları düzgünce yönetecektir.
Bir sonraki bölümde fonksiyonel dillerden esinlenen bazı Rust özelliklerini inceleyeceğiz: kapanışlar ve iteratörler.
Fonksiyonel Dil Özellikleri: İteratörler ve Kapanışlar
Rust’ın tasarımı mevcut pek çok dil ve teknikten ilham almıştır; bu esin kaynaklarının en önemlilerinden biri de fonksiyonel programlamadır (functional programming). Fonksiyonel tarzda programlama; çoğu zaman fonksiyonları başka fonksiyonlara argüman olarak geçirmek, diğer fonksiyonlardan döndürmek veya daha sonra çalıştırılmak üzere değişkenlere atamak gibi yollarla birer değer olarak kullanmayı içerir.
Bu bölümde fonksiyonel programlamanın ne olup olmadığı tartışmasına girmeyeceğiz; bunun yerine Rust’ın, genellikle “fonksiyonel” olarak nitelendirilen pek çok dildeki özelliklere benzeyen bazı yeteneklerini inceleyeceğiz.
Özellikle şu konuları ele alacağız:
- Bir değişkende saklayabileceğiniz, fonksiyon benzeri bir yapı olan kapanışlar (closures)
- Bir dizi öğeyi sırayla işlemenin bir yolu olan iteratörler (iterators)
- Bölüm 12’deki G/Ç projesini iyileştirmek için kapanışların ve iteratörlerin nasıl kullanılacağı
- Kapanışların ve iteratörlerin performansı (sürprizi şimdiden bozalım: Düşündüğünüzden daha hızlıdırlar!)
Desen eşleştirme ve enum’lar gibi fonksiyonel yaklaşımdan etkilenen diğer bazı Rust özelliklerini daha önce ele almıştık. Kapanışlara ve iteratörlere hâkim olmak, hızlı ve deyimsel Rust kodu yazmanın önemli bir parçası olduğundan bu bölümün tamamını bu iki konuya ayıracağız.
Not: Pek çok üst düzey dilde fonksiyonel programlama desenleri — kapanışlar,
map ve filter gibi zincirleme iteratör adaptörleri ya da Option ve
Result üzerindeki unwrap_or_else, and_then gibi kombinatörler — öbek
tahsisleri, dinamik çağrılar veya ara koleksiyon kopyaları nedeniyle çalışma
zamanında ek maliyet doğurabilir. Rust’ta ise bu yapılar sıfır maliyetli
soyutlama (zero-cost abstraction) ilkesiyle tasarlanmıştır: Her kapanışın
benzersiz bir somut türü vardır; derleyici uygun optimizasyon ayarlarında
iteratör zincirlerini tek bir döngüye dönüştürebilir ve çağrıları satır içine
(inlining) açabilir. Bu optimizasyonların her durumda uygulanacağı veya
bütün adaptörlerin bellek tahsisinden kaçınacağı garanti edilmez. Sonuç
olarak, okunabilirliği yüksek ve bildirimsel fonksiyonel Rust kodu çoğu
durumda elle yazılmış alt düzey bir döngüyle aynı verimlilikte makine koduna
derlenir; bu bölümün sonunda bunu bir ölçümle de göreceğiz.
Kapanışlar (Closures)
Kapanışlar (Closures)
Rust’taki kapanışlar (closures), bir değişkende saklayabileceğiniz veya başka fonksiyonlara argüman olarak aktarabileceğiniz anonim fonksiyonlardır. Kapanışı bir yerde oluşturup daha sonra farklı bir bağlamda çalıştırmak üzere başka bir yerde çağırabilirsiniz. Normal fonksiyonların aksine kapanışlar, tanımlandıkları kapsamdaki değerleri yakalayabilir (capture). Bu özelliklerin kodun yeniden kullanımına ve davranışların özelleştirilmesine nasıl olanak tanıdığını birlikte inceleyeceğiz.
Çevreyi Yakalamak
İlk olarak, tanımlandıkları çevredeki değerleri daha sonra kullanmak üzere yakalamak için kapanışlardan nasıl yararlanabileceğimizi inceleyeceğiz. Senaryomuz şöyle: Gömlek şirketimiz zaman zaman promosyon olarak e-posta listesindeki bir kişiye özel, sınırlı sayıda üretilmiş bir gömlek hediye ediyor. E-posta listesindeki kişiler isteğe bağlı olarak profillerine favori renklerini ekleyebiliyor. Ücretsiz gömlek için seçilen kişi favori rengini belirtmişse o renkteki gömleği alıyor. Favori bir renk belirtmemişse şirketin stoklarında o an en fazla hangi renk varsa onu alıyor.
Bunu uygulamanın pek çok yolu vardır. Bu örnekte, basitlik adına mevcut renk sayısını sınırlayarak Kirmizi ve Mavi varyantlarına sahip GomlekRengi adlı bir enum kullanacağız. Şirketin envanterini ise stokta bulunan gömlek renklerini tutan Vec<GomlekRengi> türünde gomlekler adlı bir alana sahip Envanter struct’ı ile temsil ediyoruz. Envanter üzerinde tanımlanan hediye_et metodu, ücretsiz gömlek kazanan kişinin isteğe bağlı renk tercihini alır ve alacağı gömlek rengini döndürür. Bu yapı Liste 13-1’de gösterilmiştir.
#[derive(Debug, PartialEq, Copy, Clone)]
enum GomlekRengi {
Kirmizi,
Mavi,
}
struct Envanter {
gomlekler: Vec<GomlekRengi>,
}
impl Envanter {
fn hediye_et(&self, kullanici_tercihi: Option<GomlekRengi>) -> GomlekRengi {
kullanici_tercihi.unwrap_or_else(|| self.en_cok_stoklanan())
}
fn en_cok_stoklanan(&self) -> GomlekRengi {
let mut kirmizi_sayisi = 0;
let mut mavi_sayisi = 0;
for renk in &self.gomlekler {
match renk {
GomlekRengi::Kirmizi => kirmizi_sayisi += 1,
GomlekRengi::Mavi => mavi_sayisi += 1,
}
}
if kirmizi_sayisi > mavi_sayisi {
GomlekRengi::Kirmizi
} else {
GomlekRengi::Mavi
}
}
}
fn main() {
let magaza = Envanter {
gomlekler: vec![GomlekRengi::Mavi, GomlekRengi::Kirmizi, GomlekRengi::Mavi],
};
let kullanici_tercihi1 = Some(GomlekRengi::Kirmizi);
let hediye1 = magaza.hediye_et(kullanici_tercihi1);
println!(
"{:?} tercihine sahip kullanıcı {:?} alıyor",
kullanici_tercihi1, hediye1
);
let kullanici_tercihi2 = None;
let hediye2 = magaza.hediye_et(kullanici_tercihi2);
println!(
"{:?} tercihine sahip kullanıcı {:?} alıyor",
kullanici_tercihi2, hediye2
);
}
main fonksiyonunda tanımlanan magaza, bu sınırlı üretim promosyonunda dağıtılmak üzere kalan iki mavi ve bir kırmızı gömleğe sahiptir. hediye_et metodunu kırmızı gömlek tercih eden bir kullanıcı ve herhangi bir tercihi olmayan başka bir kullanıcı için çağırıyoruz.
Bu kod da pek çok farklı şekilde yazılabilirdi; burada doğrudan kapanışlara odaklanmak adına, kapanış kullanan hediye_et metodunun gövdesi dışında daha önce öğrendiğiniz kavramlara bağlı kaldık. hediye_et metodunda kullanıcı tercihini Option<GomlekRengi> türünde bir parametre olarak alıyor ve kullanici_tercihi üzerinde unwrap_or_else metodunu çağırıyoruz. Option<T> üzerindeki unwrap_or_else metodu standart kütüphane tarafından tanımlanmıştır. Tek bir argüman alır: kendisi hiçbir argüman almayan ve T türünde (Option<T>’nin Some varyantında saklanan tür, yani bu örnekte GomlekRengi) bir değer döndüren bir kapanış. Option<T> değeri Some varyantıysa unwrap_or_else, Some içindeki değeri doğrudan döndürür. Option<T> değeri None varyantıysa unwrap_or_else bu kapanışı çağırır ve kapanışın ürettiği değeri döndürür.
unwrap_or_else metoduna argüman olarak || self.en_cok_stoklanan() kapanış ifadesini veriyoruz. Bu, hiçbir parametre almayan bir kapanıştır (kapanışın parametreleri olsaydı iki dikey çubuk |...| arasında yer alırdı). Kapanışın gövdesi self.en_cok_stoklanan() metodunu çağırır. Kapanışı burada tanımlıyoruz; unwrap_or_else uygulaması ise sonuca gerçekten ihtiyaç duyulursa (yani değer None ise) kapanışı daha sonra çalıştıracaktır.
Bu kodu çalıştırdığımızda aşağıdaki çıktıyı alırız:
$ cargo run
Compiling gomlek-sirketi v0.1.0 (file:///projects/gomlek-sirketi)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.27s
Running `target/debug/gomlek-sirketi`
Some(Kirmizi) tercihine sahip kullanıcı Kirmizi alıyor
None tercihine sahip kullanıcı Mavi alıyor
Buradaki dikkat çekici nokta, mevcut Envanter örneği üzerinde self.en_cok_stoklanan() metodunu çağıran bir kapanış aktarmış olmamızdır. Standart kütüphanenin tanımladığımız Envanter veya GomlekRengi türleri ya da bu senaryoda çalıştırmak istediğimiz mantık hakkında hiçbir şey bilmesine gerek kalmadı. Kapanış, self (Envanter örneği) için değiştirilemez bir referans yakalar ve bunu belirttiğimiz kodla birlikte unwrap_or_else metoduna aktarır. Normal fonksiyonlar ise çevrelerini bu şekilde yakalayamaz.
Kapanış Türlerini Çıkarsamak ve Açıklamak
Fonksiyonlar ile kapanışlar arasında başka farklar da vardır. Kapanışlar genellikle fn fonksiyonlarında olduğu gibi parametrelerin veya dönüş değerinin türlerini açıkça belirtmenizi (tür açıklaması / type annotation eklemenizi) gerektirmez. Fonksiyonlarda tür açıklamaları zorunludur; çünkü bu türler kullanıcılarınıza sunulan açık arayüzün bir parçasıdır. Bu arayüzü katı bir şekilde tanımlamak, bir fonksiyonun hangi türde değerler aldığı ve döndürdüğü konusunda herkesin mutabık kalmasını sağlamak açısından önemlidir. Kapanışlar ise böyle dışa açık bir arayüzde kullanılmaz: değişkenlerde saklanır ve bir isim verilmeden ya da kütüphanemizin kullanıcılarına açılmadan kullanılırlar.
Kapanışlar genellikle kısadır ve her yerde geçerli genel senaryolardan ziyade yalnızca dar bir bağlamda anlam taşır. Bu sınırlı bağlamlar içinde derleyici, çoğu değişkenin türünü çıkarsayabildiği gibi kapanışların parametre türlerini ve dönüş türünü de çıkarsayabilir (derleyicinin kapanışlarda da tür açıklamasına ihtiyaç duyduğu nadir durumlar vardır).
Değişkenlerde olduğu gibi, zorunlu olandan daha ayrıntılı yazma pahasına açıklığı ve okunabilirliği artırmak istersek kapanışlara da tür açıklamaları ekleyebiliriz. Tür açıklamaları eklenmiş bir kapanış, Liste 13-2’deki tanıma benzer. Bu örnekte kapanışı, Liste 13-1’de yaptığımız gibi argüman olarak aktardığımız noktada tanımlamak yerine ayrı tanımlayıp bir değişkende saklıyoruz.
use std::thread;
use std::time::Duration;
fn egzersiz_olustur(yogunluk: u32, rastgele_sayi: u32) {
let pahali_kapanis = |sayi: u32| -> u32 {
println!("yavaşça hesaplanıyor...");
thread::sleep(Duration::from_secs(2));
sayi
};
if yogunluk < 25 {
println!("Bugün {} şınav çekin!", pahali_kapanis(yogunluk));
println!("Ardından {} mekik çekin!", pahali_kapanis(yogunluk));
} else {
if rastgele_sayi == 3 {
println!("Bugün ara verin! Bol su içmeyi unutmayın!");
} else {
println!(
"Bugün {} dakika koşun!",
pahali_kapanis(yogunluk)
);
}
}
}
fn main() {
let simule_edilmis_kullanici_degeri = 10;
let simule_edilmis_rastgele_sayi = 7;
egzersiz_olustur(simule_edilmis_kullanici_degeri, simule_edilmis_rastgele_sayi);
}
Tür açıklamaları eklendiğinde kapanışların sözdizimi fonksiyonların sözdizimine oldukça benzer. Karşılaştırmak amacıyla aşağıda parametresine 1 ekleyen bir fonksiyon ile aynı davranışa sahip kapanış tanımlarını görüyoruz. İlgili kısımları hizalamak için fazladan boşluklar ekledik. Bu örnek, dikey çubukların (|...|) kullanımı ve isteğe bağlı sözdizimi esnekliği dışında kapanış sözdiziminin fonksiyon sözdizimine ne kadar benzediğini gösterir:
fn bir_ekle_v1 (x: u32) -> u32 { x + 1 }
let bir_ekle_v2 = |x: u32| -> u32 { x + 1 };
let bir_ekle_v3 = |x| { x + 1 };
let bir_ekle_v4 = |x| x + 1 ;
İlk satır bir fonksiyon tanımını, ikinci satır ise türleri tamamen belirtilmiş bir kapanış tanımını gösterir. Üçüncü satırda kapanış tanımından tür açıklamalarını kaldırıyoruz. Dördüncü satırda ise kapanış gövdesi tek bir ifadeden oluştuğu için isteğe bağlı olan süslü parantezleri kaldırıyoruz. Bunların tümü çağrıldıklarında aynı davranışı üretecek geçerli tanımlardır. bir_ekle_v3 ve bir_ekle_v4 satırlarının derlenebilmesi için bu kapanışların bir yerde çağrılması gerekir; çünkü parametre ve dönüş türleri kullanımlarından çıkarsanacaktır. Bu durum, Rust’ın türü çıkarsayabilmesi için ya tür açıklamasına ya da içine belirli türden değerler eklenmesine ihtiyaç duyan let v = Vec::new(); deyimine benzer.
Kapanış tanımlarında derleyici, her bir parametre ve dönüş değeri için tek bir somut tür çıkarsar. Örneğin Liste 13-3, yalnızca parametre olarak aldığı değeri olduğu gibi döndüren kısa bir kapanış tanımını göstermektedir. Bu kapanış, bu örneğin amacı dışında pek işlevsel değildir. Tanıma herhangi bir tür açıklaması eklemediğimize dikkat edin. Tür açıklaması bulunmadığı için kapanışı herhangi bir türle çağırabiliriz; burada ilk çağrıyı String ile yapıyoruz. Ardından ornek_kapanis’ı bir tam sayıyla çağırmaya çalışırsak derleme hatası alırız.
fn main() {
let ornek_kapanis = |x| x;
let s = ornek_kapanis(String::from("merhaba"));
let n = ornek_kapanis(5);
}
Derleyici bize şu hatayı verir:
$ cargo run
Compiling kapanis-ornek v0.1.0 (file:///projects/kapanis-ornek)
error[E0308]: mismatched types
--> src/main.rs:5:27
|
5 | let n = ornek_kapanis(5);
| ------------- ^ expected `String`, found integer
| |
| arguments to this function are incorrect
|
note: expected because the closure was earlier called with an argument of type `String`
--> src/main.rs:4:27
|
4 | let s = ornek_kapanis(String::from("merhaba"));
| ------------- ^^^^^^^^^^^^^^^^^^^^^^^ expected because this argument is of type `String`
| |
| in this closure call
note: closure parameter defined here
--> src/main.rs:2:26
|
2 | let ornek_kapanis = |x| x;
| ^
help: try using a conversion method
|
5 | let n = ornek_kapanis(5.to_string());
| ++++++++++++
For more information about this error, try `rustc --explain E0308`.
error: could not compile `kapanis-ornek` (bin "kapanis-ornek") due to 1 previous error
ornek_kapanis’ı String değeriyle ilk kez çağırdığımızda derleyici, x parametresinin türünü ve kapanışın dönüş türünü String olarak çıkarsar. Bu türler artık ornek_kapanis içindeki kapanışa sabitlenir ve aynı kapanışı farklı bir türle tekrar çağırmaya çalıştığımızda tür uyuşmazlığı hatası (E0308) alırız.
Referansları Yakalamak veya Sahipliği Taşımak
Kapanışlar çevrelerindeki değerleri, bir fonksiyonun parametre alabildiği üç yolla birebir örtüşen üç şekilde yakalayabilir: değiştirilemez olarak ödünç alma (&T), değiştirilebilir olarak ödünç alma (&mut T) ve sahipliği alma (T). Kapanış, gövdesinin yakalanan değerlerle ne yaptığına bakarak bunlardan hangisini kullanacağına kendisi karar verir.
Liste 13-4’te, değeri yazdırmak için yalnızca değiştirilemez bir referans yeterli olduğundan liste adlı vektöre değiştirilemez bir referans yakalayan bir kapanış tanımlıyoruz.
fn main() {
let liste = vec![1, 2, 3];
println!("Kapanışı tanımlamadan önce: {liste:?}");
let sadece_odunc_alir = || println!("Kapanıştan: {liste:?}");
println!("Kapanışı çağırmadan önce: {liste:?}");
sadece_odunc_alir();
println!("Kapanışı çağırdıktan sonra: {liste:?}");
}
Bu örnek ayrıca bir değişkenin bir kapanış tanımına bağlanabileceğini ve daha sonra bu değişken adını sanki bir fonksiyon adıymış gibi parantezlerle (sadece_odunc_alir()) kullanarak kapanışı çağırabileceğimizi gösterir.
Aynı anda liste için birden çok değiştirilemez referans bulunabildiğinden, liste’ye hem kapanış tanımlanmadan önce hem kapanış tanımlandıktan sonra (henüz çağrılmadan önce) hem de kapanış çağrıldıktan sonra erişmeye devam edebiliriz. Bu kod derlenir, çalışır ve şunu yazdırır:
$ cargo run
Compiling kapanis-ornek v0.1.0 (file:///projects/kapanis-ornek)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
Running `target/debug/kapanis-ornek`
Kapanışı tanımlamadan önce: [1, 2, 3]
Kapanışı çağırmadan önce: [1, 2, 3]
Kapanıştan: [1, 2, 3]
Kapanışı çağırdıktan sonra: [1, 2, 3]
Bir sonraki adımda, Liste 13-5’te liste vektörüne bir öğe eklemesi için kapanış gövdesini değiştiriyoruz. Kapanış artık değiştirilebilir bir referans yakalar.
fn main() {
let mut liste = vec![1, 2, 3];
println!("Kapanışı tanımlamadan önce: {liste:?}");
let mut degistirilebilir_odunc_alir = || liste.push(7);
degistirilebilir_odunc_alir();
println!("Kapanışı çağırdıktan sonra: {liste:?}");
}
Bu kod derlenir, çalışır ve şunu yazdırır:
$ cargo run
Compiling kapanis-ornek v0.1.0 (file:///projects/kapanis-ornek)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
Running `target/debug/kapanis-ornek`
Kapanışı tanımlamadan önce: [1, 2, 3]
Kapanışı çağırdıktan sonra: [1, 2, 3, 7]
degistirilebilir_odunc_alir kapanışının tanımlanması ile çağrılması arasında artık bir println! satırı bulunmadığına dikkat edin: degistirilebilir_odunc_alir tanımlandığı anda liste’ye değiştirilebilir bir referans yakalar (kapanış çağrıldığında kendi içindeki bu değiştirilebilir referansı kullandığı için degistirilebilir_odunc_alir değişkeninin kendisi de mut olarak tanımlanmıştır). Kapanış çağrıldıktan sonra bir daha kullanılmadığı için değiştirilebilir ödünç alma sona erer. Kapanış tanımı ile kapanış çağrısı arasında yazdırma amacıyla değiştirilemez bir ödünç almaya izin verilmez; çünkü etkin bir değiştirilebilir ödünç alma varken başka hiçbir ödünç almaya izin verilmez. Hangi hata mesajını aldığınızı görmek için araya bir println! eklemeyi deneyin!
Kapanış gövdesi aslında sahipliğe ihtiyaç duymasa bile kapanışın çevreden kullandığı değerlerin sahipliğini almasını zorlamak istiyorsanız parametre listesinden önce move anahtar kelimesini kullanabilirsiniz.
Bu teknik en çok, bir kapanışı yeni bir thread’e (iş parçacığına) aktarırken verinin sahipliğinin yeni thread’e geçmesi için kullanılır. Bölüm 16’da eşzamanlılıktan bahsederken thread’leri ve onları neden kullanmak isteyeceğinizi ayrıntılı olarak ele alacağız; şimdilik move anahtar kelimesini gerektiren bir kapanışla yeni bir thread başlatmayı kısaca inceleyelim. Liste 13-6, vektörü ana thread yerine yeni bir thread içinde yazdırmak üzere Liste 13-4’ün uyarlanmış hâlini göstermektedir.
use std::thread;
fn main() {
let liste = vec![1, 2, 3];
println!("Kapanışı tanımlamadan önce: {liste:?}");
thread::spawn(move || println!("Thread'den: {liste:?}"))
.join()
.unwrap();
}
liste’nin sahipliğini almaya zorlamak için move kullanmakYeni bir thread başlatıyor (thread::spawn) ve bu thread’e çalıştırması için argüman olarak bir kapanış veriyoruz. Kapanış gövdesi listeyi yazdırır. Liste 13-4’te kapanış, yazdırma işlemi için gereken en düşük erişim düzeyi bu olduğundan liste’yi yalnızca değiştirilemez bir referansla yakalamıştı. Bu örnekte ise kapanış gövdesi hâlâ yalnızca değiştirilemez bir referansa ihtiyaç duysa da kapanış tanımının başına move anahtar kelimesini ekleyerek liste’nin kapanışa taşınması gerektiğini belirtmeliyiz. Ana thread, yeni thread üzerinde join çağrısı yapmadan önce başka işlemler gerçekleştirseydi yeni thread ana thread’in geri kalanından önce bitebileceği gibi ana thread de daha önce sonlanabilirdi. Ana thread liste’nin sahipliğini elinde tutmaya devam etse ve yeni thread bitmeden sonlanıp liste’yi bellekten düşürseydi (drop), yeni thread içindeki değiştirilemez referans geçersiz kalırdı. Bu nedenle derleyici, referansın geçerliliğini güvence altına almak için liste’nin yeni thread’e verilen kapanışa taşınmasını zorunlu kılar. Hangi derleyici hatalarını aldığınızı görmek için move anahtar kelimesini kaldırmayı veya kapanış tanımlandıktan sonra ana thread’de liste’yi kullanmayı deneyin!
Yakalanan Değerleri Kapanışların Dışına Taşımak
Bir kapanış tanımlandığı çevreden bir değerin referansını veya sahipliğini yakaladığında (dolayısıyla varsa nelerin kapanışın içine taşındığını belirlediğinde), kapanış gövdesindeki kod da kapanış daha sonra çalıştırıldığında bu referanslara veya değerlere ne olacağını tanımlar (dolayısıyla varsa nelerin kapanışın dışına taşındığını belirler).
Bir kapanış gövdesi aşağıdakilerden herhangi birini yapabilir: yakalanan bir değeri kapanışın dışına taşıyabilir, yakalanan değeri değiştirebilir, değeri dışarı taşımayıp değiştirmeden kullanabilir ya da baştan itibaren çevreden hiçbir şey yakalamayabilir.
Bir kapanışın çevredeki değerleri yakalama ve işleme biçimi, kapanışın hangi trait’leri uygulayacağını belirler; fonksiyonlar ve struct’lar da ne tür kapanışları kabul edebileceklerini bu trait’ler aracılığıyla ifade eder. Kapanışlar, gövdelerinin yakalanan değerleri nasıl işlediğine bağlı olarak bu üç Fn trait’inden birini, ikisini veya üçünü birden birikimli bir biçimde otomatik olarak uygular:
FnOnce, bir kez çağrılabilen kapanışlar için geçerlidir. Tüm kapanışlar çağrılabildiği için en azından bu trait’i uygular. Yakaladığı değerleri gövdesinin dışına taşıyan bir kapanış yalnızca bir kez çağrılabileceğinden yalnızcaFnOncetrait’ini uygular; diğerFntrait’lerini uygulamaz.FnMut, yakaladığı değerleri gövdesinin dışına taşımayan ancak bu değerleri değiştirebilen kapanışlar için geçerlidir. Bu kapanışlar birden fazla kez çağrılabilir.Fn, yakaladığı değerleri gövdesinin dışına taşımayan ve bu değerleri değiştirmeyen kapanışların yanı sıra çevresinden hiçbir şey yakalamayan kapanışlar için geçerlidir. Bu kapanışlar çevrelerini değiştirmeden birden fazla kez çağrılabilir; bu özellik, bir kapanışın eşzamanlı olarak birden çok kez çağrıldığı durumlarda büyük önem taşır.
Not: Standart kütüphanede bu üç trait birbirinin alt trait’i olarak tanımlanmıştır (Fn: FnMut ve FnMut: FnOnce). Dolayısıyla bu trait’leri uygulayan kapanış kümeleri arasında FnOnce ⊇ FnMut ⊇ Fn ilişkisi vardır: Fn uygulayan her kapanış aynı zamanda FnMut ve FnOnce beklentilerini, FnMut uygulayan her kapanış da FnOnce beklentisini otomatik olarak karşılar.
Ayrıca move anahtar kelimesi ile FnOnce arasındaki ayrıma dikkat edin:
move, kapanışın çevredeki değerleri nasıl yakalayacağını (sahipliği
kapanışın içine taşıyarak) zorlar; kapanışın hangi Fn trait’lerini
uygulayacağını ise kapanış gövdesinin yakalanan değerle ne yaptığı
belirler. Dolayısıyla move kullanan bir kapanış, yakaladığı değerleri
gövdesinden dışarı taşımıyor ve değiştirilebilir erişimle kullanmıyorsa Fn
ve FnMut trait’lerini de uygular. Yakalanan verilere değiştirilebilir
erişim gerekiyor ancak değerler dışarı taşınmıyorsa FnMut ve FnOnce
uygular. Cell gibi içsel değiştirilebilirlik türleriyle paylaşımlı erişim
üzerinden yapılan değişiklikler ise tek başına Fn uygulanmasını engellemez.
Liste 13-1’de kullandığımız Option<T> üzerindeki unwrap_or_else metodunun tanımına bakalım:
impl<T> Option<T> {
pub fn unwrap_or_else<F>(self, f: F) -> T
where
F: FnOnce() -> T
{
match self {
Some(x) => x,
None => f(),
}
}
}
Hatırlayacağınız gibi T, bir Option’ın Some varyantındaki değerin türünü temsil eden jenerik türdür. Bu T türü aynı zamanda unwrap_or_else fonksiyonunun dönüş türüdür: örneğin bir Option<String> üzerinde unwrap_or_else çağıran kod sonuç olarak bir String elde eder.
Ardından, unwrap_or_else fonksiyonunun ek bir F jenerik tür parametresine sahip olduğuna dikkat edin. F türü, unwrap_or_else çağrılırken sağladığımız kapanış olan f parametresinin türüdür.
Jenerik F türü üzerinde belirtilen trait sınırı FnOnce() -> T şeklindedir; bu da F’nin bir kez çağrılabilmesi, hiçbir argüman almaması ve T türünde bir değer döndürmesi gerektiği anlamına gelir. Trait sınırında FnOnce kullanılması, unwrap_or_else metodunun f’yi en fazla bir kez çağıracağı garantisini ifade eder. unwrap_or_else gövdesine baktığımızda Option değeri Some ise f’nin hiç çağrılmayacağını, None ise yalnızca bir kez çağrılacağını görebiliriz. Tüm kapanışlar FnOnce trait’ini uyguladığından, unwrap_or_else her üç tür kapanışı da kabul eder ve olabildiğince esnek davranır.
Not: Yapmak istediğimiz iş çevreden bir değer yakalamayı gerektirmiyorsa, Fn trait’lerinden birini uygulayan bir argüman beklenen yerlerde kapanış yerine doğrudan bir fonksiyonun adını da kullanabiliriz. Örneğin bir Option<Vec<T>> değeri üzerinde, değer None olduğunda yeni ve boş bir vektör elde etmek için unwrap_or_else(Vec::new) çağrısı yapabiliriz. Derleyici, bir fonksiyon tanımı için Fn trait’lerinden hangileri uygunsa onları otomatik olarak uygular.
Not: Aynı trait sınırlarını kullanarak kapanış alan fonksiyonları kendiniz de yazabilirsiniz. Her kapanışın derleyici tarafından üretilen, adı olmayan ve kendine özgü bir türü vardır; bu yüzden bir kapanış parametresinin türünü doğrudan yazamazsınız. Bunun yerine, Bölüm 10’da gördüğümüz impl Trait sözdizimiyle ya da jenerik bir tür parametresiyle “şu imzaya sahip Fn trait’ini uygulayan herhangi bir tür” dersiniz. Örneğin aşağıdaki fonksiyon, i32 alıp i32 döndüren herhangi bir kapanışı (veya sıradan fonksiyonu) kabul eder ve onu iki kez uygular:
fn iki_kez_uygula(f: impl Fn(i32) -> i32, x: i32) -> i32 {
f(f(x))
}
fn main() {
let carpan = 3;
let uc_kati = |x| x * carpan; // çevresinden `carpan` değerini yakalar
assert_eq!(iki_kez_uygula(uc_kati, 2), 18);
assert_eq!(iki_kez_uygula(|x| x + 1, 2), 4);
}
Fonksiyon gövdesinde f iki kez çağrıldığı için trait sınırı olarak FnOnce
kullanamazdık; bu örnekte kapanışı paylaşımlı erişimle çağırmak üzere Fn
sınırı tercih edildi. Ancak Fn, FnMut’tan daha kısıtlayıcıdır ve daha az
kapanışı kabul eder. Yakalanan durumu değiştiren kapanışlara da izin vermek
için parametreyi mut f: impl FnMut(i32) -> i32 olarak tanımlayıp
gövdeyi let ara_sonuc = f(x); f(ara_sonuc) biçiminde yazabiliriz.
İç içe f(f(x)) yazımı iki değiştirilebilir ödünç almayı çakıştıracağı
için çağrıları burada ayırırız. İki kez çağırmak için FnMut yeterlidir. Hangi Fn trait’ini isteyeceğinize
karar verirken kural basittir: Fonksiyonunuz kapanışı nasıl kullanacaksa (bir
kez mi, birden çok kez mi, eşzamanlı mı?) en az onu gerektiren trait’i seçin.
Bir kapanışı fonksiyondan döndürmek ise ek incelikler barındırır; bunu Bölüm
20’de ele alacağız.
Şimdi dilimler üzerinde tanımlanan standart kütüphane metodu sort_by_key’e bakalım; böylece unwrap_or_else metodundan nasıl ayrıldığını ve sort_by_key’in trait sınırı için neden FnOnce yerine FnMut kullandığını görebiliriz. Bu metodun aldığı kapanış, dilimde o an incelenen öğeye bir referans şeklinde tek bir argüman alır ve sıralanabilen K türünde bir değer döndürür. Bu fonksiyon, bir dilimi her bir öğenin belirli bir özelliğine göre sıralamak istediğinizde kullanışlıdır. Liste 13-7’de Dikdortgen örneklerinden oluşan bir listemiz var ve bunları genislik alanlarına göre küçükten büyüğe sıralamak için sort_by_key kullanıyoruz.
#[derive(Debug)]
struct Dikdortgen {
genislik: u32,
yukseklik: u32,
}
fn main() {
let mut liste = [
Dikdortgen { genislik: 10, yukseklik: 1 },
Dikdortgen { genislik: 3, yukseklik: 5 },
Dikdortgen { genislik: 7, yukseklik: 12 },
];
liste.sort_by_key(|r| r.genislik);
println!("{liste:#?}");
}
sort_by_key kullanmakBu kod şunu yazdırır:
$ cargo run
Compiling dikdortgenler v0.1.0 (file:///projects/dikdortgenler)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.41s
Running `target/debug/dikdortgenler`
[
Dikdortgen {
genislik: 3,
yukseklik: 5,
},
Dikdortgen {
genislik: 7,
yukseklik: 12,
},
Dikdortgen {
genislik: 10,
yukseklik: 1,
},
]
sort_by_key metodunun bir FnMut kapanışı alacak şekilde tanımlanmasının nedeni, kapanışı birden çok kez çağırmasıdır: dilimdeki her öğe için bir kez. |r| r.genislik kapanışı çevresinden hiçbir şey yakalamaz, değiştirmez veya dışarı taşımaz; dolayısıyla trait sınırı gereksinimlerini karşılar.
Buna karşılık Liste 13-8, çevreden bir değeri dışarı taşıdığı için yalnızca FnOnce trait’ini uygulayan bir kapanış örneğini göstermektedir. Derleyici bu kapanışı sort_by_key ile kullanmamıza izin vermez.
#[derive(Debug)]
struct Dikdortgen {
genislik: u32,
yukseklik: u32,
}
fn main() {
let mut liste = [
Dikdortgen { genislik: 10, yukseklik: 1 },
Dikdortgen { genislik: 3, yukseklik: 5 },
Dikdortgen { genislik: 7, yukseklik: 12 },
];
let mut siralama_islemleri = vec![];
let deger = String::from("kapanış çağrıldı");
liste.sort_by_key(|r| {
siralama_islemleri.push(deger);
r.genislik
});
println!("{liste:#?}");
}
sort_by_key ile bir FnOnce kapanışı kullanmaya çalışmakBu örnek, liste sıralanırken sort_by_key metodunun kapanışı kaç kez çağırdığını saymak için kurgulanmış dolambaçlı (ve çalışmayan) bir yöntemdir. Kod bu sayımı, kapanışın çevresinde bulunan String türündeki deger değişkenini siralama_islemleri vektörüne ekleyerek (push) yapmaya çalışır. Kapanış deger’i yakalar ve ardından deger’in sahipliğini siralama_islemleri vektörüne aktararak onu kapanışın dışına taşır. Bu kapanış yalnızca bir kez çağrılabilir; ikinci kez çağırmaya çalışmak işe yaramaz, çünkü deger artık tekrar siralama_islemleri vektörüne eklenebilmek üzere çevrede değildir! Bu nedenle bu kapanış yalnızca FnOnce trait’ini uygular. Kodu derlemeye çalıştığımızda, kapanışın FnMut trait’ini uygulaması gerektiği için deger değişkeninin kapanış dışına taşınamayacağını belirten şu hatayı alırız:
$ cargo run
Compiling dikdortgenler v0.1.0 (file:///projects/dikdortgenler)
error[E0507]: cannot move out of `deger`, a captured variable in an `FnMut` closure
--> src/main.rs:18:33
|
15 | let deger = String::from("kapanış çağrıldı");
| ----- -------------------------------- move occurs because `deger` has type `String`, which does not implement the `Copy` trait
| |
| captured outer variable
16 |
17 | liste.sort_by_key(|r| {
| --- captured by this `FnMut` closure
18 | siralama_islemleri.push(deger);
| ^^^^^ `deger` is moved here
|
help: `Fn` and `FnMut` closures require captured values to be able to be consumed multiple times, but `FnOnce` closures may consume them only once
--> /rustc/58cf8f9c35b355199727627d07ae34a4e2846d67/library/alloc/src/slice.rs:249:11
help: consider cloning the value if the performance cost is acceptable
|
18 | siralama_islemleri.push(deger.clone());
| ++++++++
For more information about this error, try `rustc --explain E0507`.
error: could not compile `dikdortgenler` (bin "dikdortgenler") due to 1 previous error
Hata mesajı, kapanış gövdesinde deger değişkenini çevrenin dışına taşıyan satırı işaret eder; yardımcı (help) satırında da Fn ve FnMut kapanışlarının yakalanan değerleri birden çok kez kullanabilmeyi gerektirdiği, FnOnce kapanışlarının ise bunları yalnızca bir kez tüketebileceği belirtilir. Bunu düzeltmek için kapanış gövdesini değerleri çevrenin dışına taşımayacak şekilde değiştirmemiz gerekir. Çevrede bir sayaç tutmak ve kapanış gövdesinde bu sayacın değerini artırmak, kapanışın kaç kez çağrıldığını saymanın çok daha doğrudan ve anlaşılır bir yoludur. Liste 13-9’daki kapanış sort_by_key ile sorunsuz çalışır; çünkü siralama_islemi_sayisi sayacına yalnızca değiştirilebilir bir referans yakalar ve dolayısıyla birden fazla kez çağrılabilir.
#[derive(Debug)]
struct Dikdortgen {
genislik: u32,
yukseklik: u32,
}
fn main() {
let mut liste = [
Dikdortgen { genislik: 10, yukseklik: 1 },
Dikdortgen { genislik: 3, yukseklik: 5 },
Dikdortgen { genislik: 7, yukseklik: 12 },
];
let mut siralama_islemi_sayisi = 0;
liste.sort_by_key(|r| {
siralama_islemi_sayisi += 1;
r.genislik
});
println!("{liste:#?}, {siralama_islemi_sayisi} işlemde sıralandı");
}
sort_by_key ile bir FnMut kapanışı kullanılmasına izin verilirKapanışlardan yararlanan fonksiyonları veya türleri tanımlarken ya da kullanırken Fn trait’leri büyük önem taşır. Sıradaki kısımda iteratörleri ele alacağız. Pek çok iteratör metodu argüman olarak kapanış alır; bu nedenle devam ederken kapanışlarla ilgili bu ayrıntıları aklınızda bulundurun!
İteratörlerle Bir Dizi Öğeyi İşleme
İteratörlerle Bir Dizi Öğeyi İşleme
İteratör (iterator) deseni, bir dizi öğe üzerinde sırayla belirli bir işlemi gerçekleştirmenize olanak tanır. Bir iteratör, her bir öğe üzerinde ilerleme ve dizinin ne zaman sona erdiğini belirleme mantığından sorumludur. İteratörleri kullandığınızda bu mantığı her seferinde kendiniz yeniden uygulamak zorunda kalmazsınız.
Rust’ta iteratörler tembeldir (lazy); yani iteratörü tüketip sonuna kadar kullanan metotları çağırana kadar hiçbir etkileri olmaz. Örneğin Liste 13-10’daki kod, Vec<T> üzerinde tanımlı iter metodunu çağırarak v1 vektöründeki öğeler üzerinde bir iteratör oluşturur. Bu kod tek başına yararlı bir iş yapmaz.
fn main() {
let v1 = vec![1, 2, 3];
let v1_iter = v1.iter();
}
Oluşturulan iteratör v1_iter değişkeninde saklanır. Bir iteratör oluşturduktan sonra onu çeşitli şekillerde kullanabiliriz. Liste 3-5’te, bir dizinin her bir öğesi üzerinde kod çalıştırmak için for döngüsü kullanarak dizi boyunca ilerlemiştik. Arka planda bu döngü örtük olarak bir iteratör oluşturup ardından onu tüketmişti; ancak bunun tam olarak nasıl çalıştığını şimdiye dek ayrıntısına girmeden geçmiştik.
Liste 13-11’deki örnekte, iteratörün oluşturulmasını for döngüsü içinde kullanımından ayırıyoruz. for döngüsü v1_iter içindeki iteratörle çalıştırıldığında, iteratördeki her bir öğe döngünün bir adımında (iterasyonunda) kullanılır ve her değer ekrana yazdırılır.
fn main() {
let v1 = vec![1, 2, 3];
let v1_iter = v1.iter();
for deger in v1_iter {
println!("Alındı: {deger}");
}
}
for döngüsünde kullanmakStandart kütüphanelerinde iteratör bulunmayan dillerde aynı işlevselliği muhtemelen 0 indeksinden başlayan bir sayaç değişkeni tanımlayarak, vektörden değer okumak için bu değişkenle indeksleme yaparak ve sayaç vektördeki toplam öğe sayısına ulaşana dek döngü içinde değişkenin değerini artırarak yazardınız.
İteratörler tüm bu mantığı sizin yerinize üstlenerek hata yapmaya açık, kendini tekrarlayan kodları azaltır. Üstelik yalnızca vektörler gibi indekslenebilen veri yapılarıyla değil, çok farklı türdeki öğe dizileriyle de aynı mantığı kullanabilmeniz için size büyük esneklik kazandırır. Gelin iteratörlerin bunu nasıl başardığını inceleyelim.
Iterator Trait’i ve next Metodu
Tüm iteratörler standart kütüphanede tanımlanan Iterator adlı bir trait’i uygular. Bu trait’in tanımı şöyledir:
#![allow(unused)]
fn main() {
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
// varsayılan uygulamaya sahip metotlar gösterilmemiştir
}
}
Bu tanımda yeni bir sözdizimi kullanıldığına dikkat edin: type Item ve Self::Item, bu trait ile bağlantılı bir ilişkili tür (associated type) tanımlar. İlişkili türleri Bölüm 20’de derinlemesine ele alacağız. Şimdilik bilmeniz gereken tek şey, bu kodun Iterator trait’ini uygularken aynı zamanda bir Item türü tanımlamanızı zorunlu kıldığı ve bu Item türünün next metodunun dönüş türünde kullanıldığıdır. Başka bir deyişle Item türü, iteratörden döndürülecek öğelerin türü olacaktır.
Iterator trait’i, onu uygulayan türlerin yalnızca tek bir metodu tanımlamasını gerektirir: her çağrıldığında iteratörün sıradaki öğesini Some içine sarılmış olarak döndüren, yineleme sona erdiğinde ise None döndüren next metodu.
İteratörler üzerinde next metodunu doğrudan çağırabiliriz; Liste 13-12, vektörden oluşturulan iteratör üzerinde art arda yapılan next çağrılarının hangi değerleri döndürdüğünü göstermektedir.
#[cfg(test)]
mod tests {
#[test]
fn iterator_gosterimi() {
let v1 = vec![1, 2, 3];
let mut v1_iter = v1.iter();
assert_eq!(v1_iter.next(), Some(&1));
assert_eq!(v1_iter.next(), Some(&2));
assert_eq!(v1_iter.next(), Some(&3));
assert_eq!(v1_iter.next(), None);
}
}
next metodunu çağırmakv1_iter değişkenini değiştirilebilir (mut) olarak tanımlamamız gerektiğine dikkat edin: Bir iteratör üzerinde next metodunu çağırmak, iteratörün dizinin neresinde kaldığını takip etmek için tuttuğu iç durumu değiştirir. Başka bir deyişle bu kod iteratörü tüketir, yani sonuna dek harcar. Her next çağrısı iteratörden bir öğeyi alıp tüketir. for döngüsü kullandığımızda ise v1_iter’i mut yapmamıza gerek kalmamıştı; çünkü döngü v1_iter’in sahipliğini almış ve arka planda onu değiştirilebilir hâle getirmişti.
Ayrıca next çağrılarından elde ettiğimiz değerlerin vektördeki değerlere değiştirilemez referanslar olduğuna dikkat edin. iter metodu, değiştirilemez referanslar üreten bir iteratör oluşturur. Eğer v1’in sahipliğini alan ve doğrudan sahip olunan değerler döndüren bir iteratör oluşturmak istersek iter yerine into_iter çağırabiliriz. Benzer şekilde, değiştirilebilir referanslar üzerinde ilerlemek istersek iter yerine iter_mut çağırabiliriz.
Not: Bir Vec<T> üzerinde iteratör oluşturmanın bu üç yolu, sahiplik ve ödünç
alma kurallarıyla birebir örtüşür ve for döngüsü sözdizimiyle doğrudan
eşleşir:
v.iter()↔for x in &v: Öğeleri değiştirilemez olarak ödünç alır (Item = &T). Koleksiyon okunur; döngü bittikten sonravolduğu gibi kullanılmaya devam edilebilir.v.iter_mut()↔for x in &mut v: Öğeleri değiştirilebilir olarak ödünç alır (Item = &mut T). Öğeler yerinde güncellenebilir (*x += 1gibi) ve döngü sonrasındavvarlığını korur.v.into_iter()↔for x in v: Vektörün sahipliğini alır ve onu tüketir (Item = T). Öğelerin sahipliği tek tek dışarı taşınır; döngüden sonravartık kullanılamaz (fordöngüsü arka plandaIntoIteratortrait’i üzerindeninto_iterçağırır).
Diğer koleksiyonlarda aynı sahiplik ayrımı geçerlidir, ancak öğe türleri
değişebilir: HashMap<K, V> için iter metodu (&K, &V), iter_mut metodu
(&K, &mut V), into_iter ise (K, V) üretir. Ayrıca elemanları Copy olan
sabit boyutlu bir dizi de Copy olduğundan, değer üzerinden iterasyonda
dizinin kopyası tüketilebilir ve asıl dizi kullanılabilir durumda kalır.
İteratörü Tüketen Metotlar
Iterator trait’i, standart kütüphane tarafından varsayılan uygulamaları sağlanmış pek çok farklı metoda sahiptir; bu metotların tamamını standart kütüphane API belgelerindeki Iterator trait’i sayfasında inceleyebilirsiniz. Bu metotlardan bazıları kendi tanımları içinde next metodunu çağırır; kendi türünüz için Iterator trait’ini uygularken next metodunu tanımlamanızın zorunlu olmasının nedeni de budur.
next metodunu çağıran bu metotlara tüketen adaptörler (consuming adapters) adı verilir; çünkü bunları çağırmak iteratörü sonuna kadar kullanıp tüketir. Bunun bir örneği sum metodudur: İteratörün sahipliğini alır ve art arda next çağırarak öğeler üzerinde ilerler, dolayısıyla iteratörü tüketir. İlerledikçe her bir öğeyi ara toplama ekler ve yineleme tamamlandığında genel toplamı döndürür. Liste 13-13, sum metodunun kullanımını gösteren bir test içermektedir.
#[cfg(test)]
mod tests {
#[test]
fn iterator_toplami() {
let v1 = vec![1, 2, 3];
let v1_iter = v1.iter();
let toplam: i32 = v1_iter.sum();
assert_eq!(toplam, 6);
}
}
sum metodunu çağırmaksum metodu üzerinde çağrıldığı iteratörün sahipliğini aldığı için, sum çağrısından sonra v1_iter’i tekrar kullanmamıza izin verilmez.
Başka İteratörler Üreten Metotlar
İteratör adaptörleri (iterator adapters), Iterator trait’i üzerinde tanımlanan ve iteratörü tüketmeyen metotlardır. Bunun yerine orijinal iteratörün belirli bir yönünü değiştirerek yeni ve farklı iteratörler üretirler.
Liste 13-14, öğeler üzerinde ilerlenirken her bir öğe için çağrılacak bir kapanış alan map iteratör adaptörünün kullanımını göstermektedir. map metodu, değiştirilmiş öğeleri üreten yeni bir iteratör döndürür. Buradaki kapanış sayesinde, vektördeki her öğenin 1 artırılmış hâlini üreten yeni bir iteratör elde ederiz.
fn main() {
let v1: Vec<i32> = vec![1, 2, 3];
v1.iter().map(|x| x + 1);
}
map iteratör adaptörünü çağırmakAncak bu kod derlendiğinde bir uyarı üretir:
$ cargo run
Compiling iteratorler v0.1.0 (file:///projects/iteratorler)
warning: unused `Map` that must be used
--> src/main.rs:4:5
|
4 | v1.iter().map(|x| x + 1);
| ^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: iterators are lazy and do nothing unless consumed
= note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by default
help: use `let _ = ...` to ignore the resulting value
|
4 | let _ = v1.iter().map(|x| x + 1);
| +++++++
warning: `iteratorler` (bin "iteratorler") generated 1 warning
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.47s
Running `target/debug/iteratorler`
Liste 13-14’teki kod gerçekte hiçbir iş yapmaz; belirttiğimiz kapanış hiçbir zaman çağrılmaz. Derleyici uyarısı (iterators are lazy and do nothing unless consumed — “iteratörler tembeldir ve tüketilmedikçe hiçbir şey yapmaz”) bize bunun nedenini hatırlatır: İteratör adaptörleri tembeldir ve sonuç elde etmek için iteratörü burada tüketmemiz gerekir.
Bu uyarıyı gidermek ve iteratörü tüketmek için, Liste 12-1’de env::args ile birlikte kullandığımız collect metodundan yararlanacağız. Bu metot iteratörü tüketir ve üretilen değerleri bir koleksiyon veri türünde toplar.
Liste 13-15’te, map çağrısından dönen iteratör üzerindeki yineleme sonuçlarını bir vektörde topluyoruz. Elde edilen bu vektör, orijinal vektördeki her bir öğenin 1 artırılmış hâlini içerir.
fn main() {
let v1: Vec<i32> = vec![1, 2, 3];
let v2: Vec<_> = v1.iter().map(|x| x + 1).collect();
assert_eq!(v2, vec![2, 3, 4]);
}
map metodunu, ardından yeni iteratörü tüketip bir vektör oluşturmak için collect metodunu çağırmakmap bir kapanış aldığından, her bir öğe üzerinde gerçekleştirmek istediğimiz herhangi bir işlemi belirtebiliriz. Bu durum, Iterator trait’inin sağladığı yineleme davranışını yeniden kullanırken kapanışların belirli davranışları özelleştirmenize nasıl olanak tanıdığını gösteren harika bir örnektir.
Karmaşık işlemleri okunabilir bir biçimde gerçekleştirmek için birden fazla iteratör adaptörü çağrısını birbirine zincirleyebilirsiniz. Ancak tüm iteratörler tembel olduğundan, iteratör adaptörü çağrılarından somut sonuçlar elde etmek için zincirin sonunda mutlaka tüketen adaptör metotlarından birini çağırmanız gerekir.
Not: İteratör Adaptörleri ile Tüketen Adaptörler Arasındaki Fark: Bir iteratör hattını kurarken metotları iki gruba ayırmak zihinsel modeli çok netleştirir:
- İteratör adaptörleri (
map,filter,take,skip,enumerate,zipvb.) hattın ara halkalarıdır: Yalnızca yapılacak dönüşümün tarifini taşıyan yeni bir iteratör sarmalayıcısı (örneğinMapveyaFilterstruct’ı) döndürürler; tek başlarınanextçağırmaz ve hiçbir öğeyi işlemezler. Standart kütüphanede bu türler#[must_use]özniteliğiyle işaretlendiğinden, zinciri tüketmeyi unuttuğunuzda derleyiciunused_must_useuyarısıyla sizi uyarır. - Tüketen adaptörler (
collect,sum,count,for_each,any,all,findvb.; birfordöngüsü de aynı rolü üstlenir) hattın son halkasıdır: Arka plandanextmetodunu tekrar tekrar çağırarak tüm zinciri harekete geçirir ve nihai sonucu üretir. AyrıcacollectyalnızcaVec<T>değil,StringveyaHashMap<K, V>gibi pek çok farklı koleksiyon üretebildiğinden, Liste 13-15’tekilet v2: Vec<_>gibi bir tür açıklamasıyla hedef koleksiyonu belirtmek (öğe türünü_ile yine derleyiciye bırakarak) yaygın bir Rust deyimidir.
Çevrelerini Yakalayan Kapanışlar
Pek çok iteratör adaptörü argüman olarak kapanış alır ve iteratör adaptörlerine argüman olarak vereceğimiz kapanışlar çoğu zaman kendi çevrelerini yakalayan kapanışlar olacaktır.
Bu örnekte, argüman olarak bir kapanış alan filter metodunu kullanacağız. Kapanış iteratörden bir öğe alır ve bir bool döndürür. Kapanış true döndürürse değer, filter tarafından üretilen iteratöre dahil edilir; false döndürürse dahil edilmez.
Liste 13-16’da, Ayakkabi struct örneklerinden oluşan bir koleksiyon üzerinde ilerlemek için çevresinden ayakkabi_numarasi değişkenini yakalayan bir kapanışla birlikte filter metodunu kullanıyoruz. Bu işlem yalnızca belirtilen numaradaki ayakkabıları döndürecektir.
#[derive(PartialEq, Debug)]
struct Ayakkabi {
numara: u32,
stil: String,
}
fn numaradaki_ayakkabilar(ayakkabilar: Vec<Ayakkabi>, ayakkabi_numarasi: u32) -> Vec<Ayakkabi> {
ayakkabilar.into_iter().filter(|a| a.numara == ayakkabi_numarasi).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn numaraya_gore_filtreler() {
let ayakkabilar = vec![
Ayakkabi {
numara: 10,
stil: String::from("spor ayakkabı"),
},
Ayakkabi {
numara: 13,
stil: String::from("sandalet"),
},
Ayakkabi {
numara: 10,
stil: String::from("bot"),
},
];
let benim_numaramda = numaradaki_ayakkabilar(ayakkabilar, 10);
assert_eq!(
benim_numaramda,
vec![
Ayakkabi {
numara: 10,
stil: String::from("spor ayakkabı")
},
Ayakkabi {
numara: 10,
stil: String::from("bot")
},
]
);
}
}
ayakkabi_numarasi değişkenini yakalayan bir kapanışla filter metodunu kullanmaknumaradaki_ayakkabilar fonksiyonu, parametre olarak bir ayakkabı vektörünün sahipliğini ve bir ayakkabı numarasını alır. Yalnızca belirtilen numaradaki ayakkabıları içeren bir vektör döndürür.
numaradaki_ayakkabilar fonksiyonunun gövdesinde, vektörün sahipliğini alan bir iteratör oluşturmak için into_iter çağırıyoruz. Ardından bu iteratörü, yalnızca kapanışın true döndürdüğü öğeleri barındıran yeni bir iteratöre uyarlamak üzere filter çağırıyoruz (into_iter öğelerin sahipliğini taşısa da filter, öğeyi tutup tutmayacağına karar verirken onu henüz tüketmemek için kapanışa öğenin referansını aktarır).
Kapanış, çevreden ayakkabi_numarasi parametresini yakalar ve bu değeri her bir ayakkabının numarasıyla karşılaştırarak yalnızca belirtilen numaradaki ayakkabıları tutar. Son olarak collect çağrısı, uyarlanmış iteratörün ürettiği değerleri fonksiyon tarafından döndürülecek bir vektörde toplar.
Test, numaradaki_ayakkabilar fonksiyonunu çağırdığımızda yalnızca belirttiğimiz değerle aynı numaraya sahip ayakkabıların geri döndüğünü göstermektedir.
Not: Bu bölümde yalnızca standart kütüphanenin sunduğu iteratörleri kullandık; ancak Iterator trait’ini kendi türleriniz için uygulamak da son derece kolaydır ve bunu yaptığınız anda map, filter, zip, sum, collect gibi tüm adaptör ve tüketici metotlar ile for döngüsü desteği kendiliğinden gelir. Tek yapmanız gereken Item türünü belirtip next metodunu yazmaktır. Örneğin 1’den 5’e kadar sayan basit bir iteratör:
struct Sayac {
deger: u32,
}
impl Iterator for Sayac {
type Item = u32;
fn next(&mut self) -> Option<Self::Item> {
if self.deger < 5 {
self.deger += 1;
Some(self.deger)
} else {
None
}
}
}
fn main() {
// Yalnızca `next` tanımladık; `sum`, `map`, `filter` ve `collect` hazır geliyor.
let toplam: u32 = Sayac { deger: 0 }.sum();
assert_eq!(toplam, 15);
let tek_kareler: Vec<u32> = Sayac { deger: 0 }
.map(|x| x * x)
.filter(|x| x % 2 == 1)
.collect();
assert_eq!(tek_kareler, vec![1, 9, 25]);
}
next metodunun &mut self almasına dikkat edin: İteratörün “nerede kaldığı” bilgisi (deger alanı) her çağrıda güncellenir; None döndürdükten sonra ise iterasyon biter. Bu küçük sözleşme, bir dosyanın satırlarından ağ bağlantısına gelen paketlere kadar her türlü veri dizisini aynı zincirleme metotlarla işleyebilmenizi sağlar.
G/Ç (I/O) Projemizi İyileştirme
G/Ç (I/O) Projemizi İyileştirme
İteratörler hakkındaki bu yeni bilgilerimizle, kodun çeşitli bölümlerini daha net ve özlü hâle getirmek üzere iteratörleri kullanarak Bölüm 12’deki G/Ç projesini iyileştirebiliriz. İteratörlerin Yapilandirma::olustur ve ara fonksiyonlarının uygulamalarını nasıl iyileştirebileceğine bakalım.
Bir İteratör Kullanarak clone Çağrısını Kaldırma
Liste 12-6’da, String değerlerinden oluşan bir dilim alıp dilime indeksle erişerek ve Yapilandirma struct’ının kendi değerlerine sahip olabilmesi için bu değerleri klonlayarak bir Yapilandirma örneği oluşturan kodu eklemiştik. Liste 13-17’de, Yapilandirma::olustur fonksiyonunun Liste 12-23’teki uygulamasını yeniden gösteriyoruz.
use std::env;
use std::error::Error;
use std::fs;
use std::process;
use minigrep::{ara, buyuk_kucuk_harf_duyarsiz_ara};
fn main() {
let argumanlar: Vec<String> = env::args().collect();
let yapilandirma =
Yapilandirma::olustur(&argumanlar).unwrap_or_else(|hata| {
println!("Argümanları ayrıştırırken problem oluştu: {hata}");
process::exit(1);
});
if let Err(e) = calistir(yapilandirma) {
println!("Uygulama hatası: {e}");
process::exit(1);
}
}
pub struct Yapilandirma {
pub sorgu: String,
pub dosya_yolu: String,
pub buyuk_kucuk_harf_yoksay: bool,
}
impl Yapilandirma {
fn olustur(argumanlar: &[String]) -> Result<Yapilandirma, &'static str> {
if argumanlar.len() < 3 {
return Err("yeterli argüman yok");
}
let sorgu = argumanlar[1].clone();
let dosya_yolu = argumanlar[2].clone();
let buyuk_kucuk_harf_yoksay = env::var("IGNORE_CASE").is_ok();
Ok(Yapilandirma {
sorgu,
dosya_yolu,
buyuk_kucuk_harf_yoksay,
})
}
}
fn calistir(yapilandirma: Yapilandirma) -> Result<(), Box<dyn Error>> {
let icerik = fs::read_to_string(yapilandirma.dosya_yolu)?;
let sonuclar = if yapilandirma.buyuk_kucuk_harf_yoksay {
buyuk_kucuk_harf_duyarsiz_ara(&yapilandirma.sorgu, &icerik)
} else {
ara(&yapilandirma.sorgu, &icerik)
};
for satir in sonuclar {
println!("{satir}");
}
Ok(())
}
Yapilandirma::olustur fonksiyonunun yeniden gösterimiO sırada, verimsiz clone çağrıları için endişelenmememiz gerektiğini, çünkü bunları ileride kaldıracağımızı söylemiştik. İşte o an geldi!
Burada clone çağrısına ihtiyaç duymamızın nedeni, argumanlar parametresinde String öğelerinden oluşan bir dilim bulunması, ancak olustur fonksiyonunun argumanlar değerinin sahibi olmamasıydı. Bir Yapilandirma örneğinin sahipliğini döndürebilmek ve bu örneğin kendi verilerine sahip olmasını sağlamak için Yapilandirma’nın sorgu ve dosya_yolu alanlarına atanacak değerleri klonlamak zorunda kalmıştık.
İteratörler hakkındaki yeni bilgilerimizle, olustur fonksiyonunu bir dilimi ödünç almak yerine argüman olarak bir iteratörün sahipliğini alacak şekilde değiştirebiliriz. Dilimin uzunluğunu denetleyen ve belirli konumlara indeksle erişen kod yerine iteratör işlevselliğini kullanacağız. Değerlere erişim işini iteratör üstleneceği için bu değişiklik Yapilandirma::olustur fonksiyonunun ne yaptığını çok daha net hâle getirecektir.
Yapilandirma::olustur iteratörün sahipliğini aldığında ve ödünç alma yoluyla çalışan indeksleme işlemlerini bıraktığında, clone çağırıp öbekte yeni bir bellek tahsisi yapmak yerine String değerlerini doğrudan iteratörden Yapilandirma içine taşıyabiliriz.
Döndürülen İteratörü Doğrudan Kullanma
G/Ç projenizin src/main.rs dosyasını açın; dosya şu şekilde görünmelidir:
Dosya adı: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;
use minigrep::{ara, buyuk_kucuk_harf_duyarsiz_ara};
fn main() {
let argumanlar: Vec<String> = env::args().collect();
let yapilandirma =
Yapilandirma::olustur(&argumanlar).unwrap_or_else(|hata| {
eprintln!("Argümanları ayrıştırırken problem oluştu: {hata}");
process::exit(1);
});
// --snip--
if let Err(e) = calistir(yapilandirma) {
eprintln!("Uygulama hatası: {e}");
process::exit(1);
}
}
pub struct Yapilandirma {
pub sorgu: String,
pub dosya_yolu: String,
pub buyuk_kucuk_harf_yoksay: bool,
}
impl Yapilandirma {
fn olustur(argumanlar: &[String]) -> Result<Yapilandirma, &'static str> {
if argumanlar.len() < 3 {
return Err("yeterli argüman yok");
}
let sorgu = argumanlar[1].clone();
let dosya_yolu = argumanlar[2].clone();
let buyuk_kucuk_harf_yoksay = env::var("IGNORE_CASE").is_ok();
Ok(Yapilandirma {
sorgu,
dosya_yolu,
buyuk_kucuk_harf_yoksay,
})
}
}
fn calistir(yapilandirma: Yapilandirma) -> Result<(), Box<dyn Error>> {
let icerik = fs::read_to_string(yapilandirma.dosya_yolu)?;
let sonuclar = if yapilandirma.buyuk_kucuk_harf_yoksay {
buyuk_kucuk_harf_duyarsiz_ara(&yapilandirma.sorgu, &icerik)
} else {
ara(&yapilandirma.sorgu, &icerik)
};
for satir in sonuclar {
println!("{satir}");
}
Ok(())
}
Öncelikle Liste 12-24’teki main fonksiyonunun başlangıcını, bu kez iteratör kullanan Liste 13-18’deki kodla değiştireceğiz. Yapilandirma::olustur fonksiyonunu da güncelleyene kadar bu kod derlenmeyecektir.
use std::env;
use std::error::Error;
use std::fs;
use std::process;
use minigrep::{ara, buyuk_kucuk_harf_duyarsiz_ara};
fn main() {
let yapilandirma =
Yapilandirma::olustur(env::args()).unwrap_or_else(|hata| {
eprintln!("Argümanları ayrıştırırken problem oluştu: {hata}");
process::exit(1);
});
// --snip--
if let Err(e) = calistir(yapilandirma) {
eprintln!("Uygulama hatası: {e}");
process::exit(1);
}
}
pub struct Yapilandirma {
pub sorgu: String,
pub dosya_yolu: String,
pub buyuk_kucuk_harf_yoksay: bool,
}
impl Yapilandirma {
fn olustur(argumanlar: &[String]) -> Result<Yapilandirma, &'static str> {
if argumanlar.len() < 3 {
return Err("yeterli argüman yok");
}
let sorgu = argumanlar[1].clone();
let dosya_yolu = argumanlar[2].clone();
let buyuk_kucuk_harf_yoksay = env::var("IGNORE_CASE").is_ok();
Ok(Yapilandirma {
sorgu,
dosya_yolu,
buyuk_kucuk_harf_yoksay,
})
}
}
fn calistir(yapilandirma: Yapilandirma) -> Result<(), Box<dyn Error>> {
let icerik = fs::read_to_string(yapilandirma.dosya_yolu)?;
let sonuclar = if yapilandirma.buyuk_kucuk_harf_yoksay {
buyuk_kucuk_harf_duyarsiz_ara(&yapilandirma.sorgu, &icerik)
} else {
ara(&yapilandirma.sorgu, &icerik)
};
for satir in sonuclar {
println!("{satir}");
}
Ok(())
}
env::args dönüş değerini Yapilandirma::olustur fonksiyonuna aktarmaenv::args fonksiyonu zaten bir iteratör döndürür! İteratör değerlerini bir vektörde toplayıp ardından Yapilandirma::olustur fonksiyonuna bir dilim geçirmek yerine, artık env::args tarafından döndürülen iteratörün sahipliğini doğrudan Yapilandirma::olustur fonksiyonuna aktarıyoruz.
Sırada Yapilandirma::olustur tanımını güncellemek var. Yapilandirma::olustur fonksiyonunun imzasını Liste 13-19’daki gibi değiştirelim. Fonksiyon gövdesini henüz güncellemediğimiz için bu kod şimdilik derlenmeyecektir.
use std::env;
use std::error::Error;
use std::fs;
use std::process;
use minigrep::{ara, buyuk_kucuk_harf_duyarsiz_ara};
fn main() {
let yapilandirma =
Yapilandirma::olustur(env::args()).unwrap_or_else(|hata| {
eprintln!("Argümanları ayrıştırırken problem oluştu: {hata}");
process::exit(1);
});
if let Err(e) = calistir(yapilandirma) {
eprintln!("Uygulama hatası: {e}");
process::exit(1);
}
}
pub struct Yapilandirma {
pub sorgu: String,
pub dosya_yolu: String,
pub buyuk_kucuk_harf_yoksay: bool,
}
impl Yapilandirma {
fn olustur(
mut argumanlar: impl Iterator<Item = String>,
) -> Result<Yapilandirma, &'static str> {
// --snip--
if argumanlar.len() < 3 {
return Err("yeterli argüman yok");
}
let sorgu = argumanlar[1].clone();
let dosya_yolu = argumanlar[2].clone();
let buyuk_kucuk_harf_yoksay = env::var("IGNORE_CASE").is_ok();
Ok(Yapilandirma {
sorgu,
dosya_yolu,
buyuk_kucuk_harf_yoksay,
})
}
}
fn calistir(yapilandirma: Yapilandirma) -> Result<(), Box<dyn Error>> {
let icerik = fs::read_to_string(yapilandirma.dosya_yolu)?;
let sonuclar = if yapilandirma.buyuk_kucuk_harf_yoksay {
buyuk_kucuk_harf_duyarsiz_ara(&yapilandirma.sorgu, &icerik)
} else {
ara(&yapilandirma.sorgu, &icerik)
};
for satir in sonuclar {
println!("{satir}");
}
Ok(())
}
Yapilandirma::olustur imzasını bir iteratör bekleyecek şekilde güncellemeenv::args fonksiyonunun standart kütüphane belgeleri, döndürdüğü iteratörün türünün std::env::Args olduğunu ve bu türün String değerleri üretecek şekilde Iterator trait’ini uyguladığını gösterir.
Yapilandirma::olustur fonksiyonunun imzasını, argumanlar parametresi &[String] yerine impl Iterator<Item = String> trait sınırına sahip jenerik bir tür olacak şekilde güncelledik. Bölüm 10’un “Trait’leri Parametre Olarak Kullanma” kısmında ele aldığımız impl Trait sözdiziminin bu kullanımı, argumanlar parametresinin Iterator trait’ini uygulayan ve String öğeleri döndüren herhangi bir tür olabileceği anlamına gelir.
argumanlar değerinin sahipliğini aldığımız ve üzerinde ilerleyerek iç durumunu değiştireceğimiz için, argumanlar parametresinin tanımına mut anahtar kelimesini ekleyerek onu değiştirilebilir hâle getirebiliriz.
Iterator Trait Metotlarını Kullanma
Şimdi Yapilandirma::olustur fonksiyonunun gövdesini düzelteceğiz. argumanlar parametresi Iterator trait’ini uyguladığı için üzerinde next metodunu çağırabileceğimizi biliyoruz! Liste 13-20, Liste 12-23’teki kodu next metodunu kullanacak şekilde günceller.
use std::env;
use std::error::Error;
use std::fs;
use std::process;
use minigrep::{ara, buyuk_kucuk_harf_duyarsiz_ara};
fn main() {
let yapilandirma =
Yapilandirma::olustur(env::args()).unwrap_or_else(|hata| {
eprintln!("Argümanları ayrıştırırken problem oluştu: {hata}");
process::exit(1);
});
if let Err(e) = calistir(yapilandirma) {
eprintln!("Uygulama hatası: {e}");
process::exit(1);
}
}
pub struct Yapilandirma {
pub sorgu: String,
pub dosya_yolu: String,
pub buyuk_kucuk_harf_yoksay: bool,
}
impl Yapilandirma {
fn olustur(
mut argumanlar: impl Iterator<Item = String>,
) -> Result<Yapilandirma, &'static str> {
argumanlar.next();
let sorgu = match argumanlar.next() {
Some(arg) => arg,
None => return Err("Sorgu metni alınamadı"),
};
let dosya_yolu = match argumanlar.next() {
Some(arg) => arg,
None => return Err("Dosya yolu alınamadı"),
};
let buyuk_kucuk_harf_yoksay = env::var("IGNORE_CASE").is_ok();
Ok(Yapilandirma {
sorgu,
dosya_yolu,
buyuk_kucuk_harf_yoksay,
})
}
}
fn calistir(yapilandirma: Yapilandirma) -> Result<(), Box<dyn Error>> {
let icerik = fs::read_to_string(yapilandirma.dosya_yolu)?;
let sonuclar = if yapilandirma.buyuk_kucuk_harf_yoksay {
buyuk_kucuk_harf_duyarsiz_ara(&yapilandirma.sorgu, &icerik)
} else {
ara(&yapilandirma.sorgu, &icerik)
};
for satir in sonuclar {
println!("{satir}");
}
Ok(())
}
Yapilandirma::olustur gövdesini değiştirmeenv::args dönüş değerindeki ilk öğenin programın adı olduğunu hatırlayın. Bu değeri yok sayıp bir sonrakine geçmek istediğimiz için önce next metodunu çağırıyor ve dönüş değeriyle hiçbir şey yapmıyoruz. Ardından Yapilandirma’nın sorgu alanına yerleştirmek istediğimiz değeri almak için next metodunu tekrar çağırıyoruz. next çağrısı Some döndürürse değeri çıkarmak için match ifadesini kullanıyoruz; None döndürürse yeterli argüman verilmemiş demektir ve bir Err değeriyle fonksiyondan erken dönüyoruz. Aynı işlemi dosya_yolu değeri için de uyguluyoruz.
İteratör Adaptörleriyle Kodu Netleştirme
G/Ç projemizdeki ara fonksiyonunda da iteratörlerden yararlanabiliriz. Fonksiyonun Liste 12-19’daki hâli, Liste 13-21’de yeniden gösterilmiştir.
pub fn ara<'a>(sorgu: &str, icerik: &'a str) -> Vec<&'a str> {
let mut sonuclar = Vec::new();
for satir in icerik.lines() {
if satir.contains(sorgu) {
sonuclar.push(satir);
}
}
sonuclar
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tek_sonuc() {
let sorgu = "güven";
let icerik = "\
Güven:
güvenli, hızlı, üretken.
Üçünü de seç.";
assert_eq!(vec!["güvenli, hızlı, üretken."], ara(sorgu, icerik));
}
}
ara fonksiyonunun uygulamasıİteratör adaptörü metotlarını kullanarak bu kodu çok daha özlü bir şekilde yazabiliriz. Üstelik bu yaklaşım, değiştirilebilir bir ara sonuclar vektörü tanımlama ihtiyacını da ortadan kaldırır. Fonksiyonel programlama stili, kodu daha anlaşılır kılmak adına değiştirilebilir durum (mutable state) miktarını en aza indirmeyi tercih eder. Değiştirilebilir durumu ortadan kaldırmak, sonuclar vektörüne eşzamanlı erişimi yönetmek zorunda kalmayacağımız için ileride arama işlemini paralel hâle getirmemizi de kolaylaştırabilir. Liste 13-22 bu değişikliği göstermektedir.
pub fn ara<'a>(sorgu: &str, icerik: &'a str) -> Vec<&'a str> {
icerik
.lines()
.filter(|satir| satir.contains(sorgu))
.collect()
}
pub fn buyuk_kucuk_harf_duyarsiz_ara<'a>(
sorgu: &str,
icerik: &'a str,
) -> Vec<&'a str> {
let sorgu = sorgu.to_lowercase();
let mut sonuclar = Vec::new();
for satir in icerik.lines() {
if satir.to_lowercase().contains(&sorgu) {
sonuclar.push(satir);
}
}
sonuclar
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn buyuk_kucuk_harf_duyarli() {
let sorgu = "güven";
let icerik = "\
Güven:
güvenli, hızlı, üretken.
Üçünü de seç.
Güven kolay kazanılmaz.";
assert_eq!(vec!["güvenli, hızlı, üretken."], ara(sorgu, icerik));
}
#[test]
fn buyuk_kucuk_harf_duyarsiz() {
let sorgu = "gÜvEn";
let icerik = "\
Güven:
önce sağlamlık gelir.
Planlı çalış, sakin ol.
güven kazanılır.";
assert_eq!(
vec!["Güven:", "güven kazanılır."],
buyuk_kucuk_harf_duyarsiz_ara(sorgu, icerik)
);
}
}
ara fonksiyonunun uygulamasında iteratör adaptörü metotlarını kullanmaara fonksiyonunun amacının icerik içindeki sorgu metnini barındıran tüm satırları döndürmek olduğunu hatırlayın. Liste 13-16’daki filter örneğine benzer şekilde bu kod, yalnızca satir.contains(sorgu) ifadesinin true döndürdüğü satırları tutmak için filter adaptörünü kullanır. Ardından eşleşen satırları collect metoduyla yeni bir vektörde toplarız. Çok daha sade! Aynı değişikliği buyuk_kucuk_harf_duyarsiz_ara fonksiyonunda da iteratör metotlarını kullanacak şekilde yapabilirsiniz.
Daha ileri bir iyileştirme olarak, collect çağrısını kaldırıp dönüş türünü impl Iterator<Item = &'a str> olarak değiştirerek ara fonksiyonundan doğrudan bir iteratör döndürebilir ve böylece fonksiyonun kendisini bir iteratör adaptörüne dönüştürebilirsiniz. Bu durumda testleri de güncellemeniz gerekeceğini unutmayın! Davranış farkını gözlemlemek için bu değişikliği yapmadan önce ve yaptıktan sonra minigrep aracınızla büyük bir dosyada arama yapmayı deneyin. Değişiklikten önce program tüm eşleşmeleri bir vektörde toplayana dek ekrana hiçbir sonuç yazdırmazken, değişiklikten sonra calistir fonksiyonundaki for döngüsü iteratörün tembelliğinden (laziness) yararlanabildiği için eşleşen her satır bulunur bulunmaz anında yazdırılacaktır.
Not: Bu bölümde yaptığımız iki yeniden düzenleme, Rust’ta sahiplik sistemi ile fonksiyonel iteratör zincirlerinin nasıl uyum içinde çalıştığını somut biçimde gösterir:
- Öbek Tahsisinden (
clone) Kaçınma: ÖncekiYapilandirma::olustur(&[String])imzasında argüman dilimi yalnızca ödünç alındığı için her.clone()çağrısı öbekte (heap) yeni bellek tahsis edip karakter verisini bayt bayt kopyalıyordu.impl Iterator<Item = String>ile iteratörün sahipliğini doğrudan tükettiğimizde iseargumanlar.next()çağrısı mevcutStringdeğerinin sahipliğini (yalnızca yığındaki işaretçi, uzunluk ve kapasite bilgisini) ek öbek tahsisi yapmadanYapilandirmaalanlarına taşır. Üstelik parametre somutstd::env::Argstürü yerineimpl Iterator<Item = String>kabul ettiği için, birim testlerinde gerçek komut satırı argümanlarına ihtiyaç duymadan doğrudan bellek içi bir iteratör geçirerekYapilandirma::olusturfonksiyonunu kolayca test edebilirsiniz. - Değiştirilebilir Ara Durumu Kaldırma ve Paralelleştirme:
arafonksiyonunda döngü içinde adım adım büyütülenlet mut sonuclarvektörü yerineicerik.lines().filter(...).collect()yazmak, değiştirilebilir ara durumu (mutable intermediate state) tamamen ortadan kaldırır. İleride çok büyük dosyalarda çok çekirdekli arama yapmak istediğinizde, paylaşılan birsonuclarvektörünü kilitlerle (Mutex) senkronize etmekle uğraşmak yerine — örneğin ekosistemde yaygın kullanılanrayoncrate’indekipar_lines()adaptörüne geçerek — neredeyse aynı iteratör zinciriyle aramayı güvenli biçimde paralelleştirebilirsiniz.
Döngüler ve İteratörler Arasında Seçim Yapma
Sıradaki mantıklı soru, kendi kodunuzda hangi stili ve neden seçmeniz gerektiğidir: Liste 13-21’deki orijinal uygulama mı, yoksa Liste 13-22’deki iteratör kullanan sürüm mü (doğrudan iteratör döndürmek yerine tüm sonuçları toplayıp döndürdüğümüzü varsayarsak)? Çoğu Rust programcısı iteratör stilini kullanmayı tercih eder. İlk başta alışması biraz daha zor olsa da çeşitli iteratör adaptörlerine ve ne işe yaradıklarına aşina olduğunuzda iteratörleri okumak çok daha kolaylaşır. Döngü ayrıntılarıyla ve yeni vektörler oluşturmakla uğraşmak yerine kod, döngünün üst düzey amacına odaklanır. Bu yaklaşım sıradan, tekrar eden kodu soyutlayarak, iteratördeki her öğenin sağlaması gereken filtreleme koşulu gibi bu koda özgü asıl mantığın çok daha net görülmesini sağlar.
Peki bu iki uygulama gerçekten eşdeğer midir? Sezgisel olarak daha alt düzey olan döngünün daha hızlı çalışacağı düşünülebilir. Şimdi performans konusunu ele alalım.
Döngüler ve İteratörlerde Performans
Döngüler ve İteratörlerde Performans
Döngü mü yoksa iteratör mü kullanacağınıza karar vermek için hangi uygulamanın daha hızlı olduğunu bilmeniz gerekir: ara fonksiyonunun açık bir for döngüsü kullanan sürümü mü, yoksa iteratörlerle yazılan sürümü mü?
Sir Arthur Conan Doyle’un The Adventures of Sherlock Holmes (Sherlock Holmes’un Maceraları) kitabının tüm metnini bir String içine yükleyip metinde the kelimesini aradığımız bir performans kıyaslaması (benchmark) çalıştırdık. ara fonksiyonunun for döngüsü kullanan sürümü ile iteratör kullanan sürümünün kıyaslama sonuçları şöyledir:
test bench_search_for ... bench: 19,620,300 ns/iter (+/- 915,700)
test bench_search_iter ... bench: 19,234,900 ns/iter (+/- 657,200)
Her iki uygulama da birbirine çok yakın bir performansa sahiptir! Buradaki amacımız iki sürümün birebir eşdeğer olduğunu kanıtlamak değil, bu iki uygulamanın performans açısından birbirine kıyasla nasıl sonuç verdiğine dair genel bir fikir edinmek olduğundan kıyaslama kodunu burada açıklamayacağız.
Daha kapsamlı bir kıyaslama için icerik olarak farklı boyutlarda çeşitli metinleri, sorgu olarak farklı uzunluklarda çeşitli kelimeleri ve diğer pek çok varyasyonu denemeniz gerekir. Buradaki asıl nokta şudur: İteratörler üst düzey bir soyutlama olsa da, alt düzey kodu bizzat kendiniz yazmışsınız gibi neredeyse aynı makine koduna derlenir. İteratörler Rust’ın sıfır maliyetli soyutlamalarından (zero-cost abstractions) biridir; bununla, soyutlamayı kullanmanın çalışma zamanında hiçbir ek yük getirmediğini kastediyoruz. Bu yaklaşım, C++ dilinin ilk tasarımcısı ve uygulayıcısı Bjarne Stroustrup’un 2012 ETAPS konferansındaki “Foundations of C++” (C++’ın Temelleri) başlıklı açılış sunumunda sıfır ek yük (zero-overhead) ilkesini tanımlama biçimiyle örtüşür:
Genel olarak C++ uygulamaları sıfır ek yük ilkesine uyar: Kullanmadığınız şeyin bedelini ödemezsiniz. Dahası, kullandığınız şeyi elle kodlayarak daha iyisini yazamazsınız.
Pek çok durumda iteratör kullanan Rust kodu, elle yazacağınız assembly koduyla birebir aynı çıktıyı üretecek şekilde derlenir. Döngü açma (loop unrolling) ve dizi erişimlerindeki sınır denetimini (bounds checking) ortadan kaldırma gibi derleyici optimizasyonları devreye girerek ortaya çıkan kodu son derece verimli hâle getirir. Artık bunu bildiğinize göre iteratörleri ve kapanışları hiçbir çekince duymadan kullanabilirsiniz! Kodu üst düzey ve okunaklı tutarken çalışma zamanında herhangi bir performans kaybına yol açmazlar.
Not: Klasik indeksli for i in 0..v.len() { ... v[i] ... } döngülerine kıyasla iteratör zincirlerinin neden çoğu zaman en az onlar kadar hızlı — hatta bazen daha hızlı — çalıştığını ve performansı doğru ölçmenin inceliklerini şu iki noktada özetleyebiliriz:
- Sınır Denetimlerinin Kaldırılması, Yazmaçlar ve Otomatik Vektörleştirme (SIMD): Rust bellek güvenliğini korumak adına her
v[i]indeks erişimindei < v.len()sınır denetimi uygular. Karmaşık indeks aritmetiği içeren döngülerde derleyici (LLVM), indeksin asla sınırı aşmayacağını her zaman statik olarak kanıtlayamayabilir; kalan sınır denetimleri ise hem fazladan koşullu dallanma buyruğu üretir hem de otomatik vektörleştirmeyi (SIMD — tek buyrukla birden fazla veriyi paralel işleme) engelleyebilir. Buna karşılıkv.iter(),zip,mapvefoldgibi iteratör zincirleri dilimin bellekteki sınırları boyunca yapısal olarak güvenli biçimde ilerler. Derleyici bu sayede sınır denetimlerini güvenle kaldırabilir, ara değerleri bellek yerine doğrudan işlemci yazmaçlarında (registers) tutabilir ve örneğin ses kod çözücülerdeki doğrusal tahmin hesaplamaları (linear prediction coding) gibi yoğun sayısal döngülerde döngü açma ile SIMD buyruklarını çok daha etkin biçimde uygulayabilir. - Gerçekçi Performans Ölçümü (
--releaseve Kıyaslama Kütüphaneleri): Sıfır maliyetli soyutlamalar, derleyicinin fonksiyonları satır içine açma (inlining) ve döngü birleştirme optimizasyonlarına dayanır. Varsayılancargo runkomutunun kullandığıdevprofilinde vedevprofilinden miras alantestprofilinde (cargo test), derleme hızını korumak için optimizasyon düzeyi varsayılan olarak0’dır; bu modda iteratör zincirleri yanıltıcı biçimde yavaş çalışabilir. Bu nedenle hız ölçümlerini her zaman Bölüm 14’te inceleyeceğimizreleaseprofiliyle (cargo build --releaseveyacargo run --release) yapmalı; hassas mikro-kıyaslamalarda ise ısınma turları ve istatistiksel sapma analizi sunancriterionveyadivangibi kıyaslama (benchmark) crate’lerini kullanmalısınız.
Özet
Kapanışlar ve iteratörler, fonksiyonel programlama dillerindeki fikirlerden esinlenen Rust özellikleridir. Bu özellikler, Rust’ın üst düzey fikirleri alt düzey kod performansıyla net bir biçimde ifade edebilme yeteneğine katkıda bulunur. Kapanışların ve iteratörlerin uygulamaları, çalışma zamanı performansını olumsuz etkilemeyecek şekilde tasarlanmıştır. Bu yaklaşım, Rust’ın sıfır maliyetli soyutlamalar sunma hedefinin önemli bir parçasıdır.
G/Ç projemizin ifade gücünü artırdığımıza göre, şimdi projeyi dünyayla paylaşmamıza yardımcı olacak bazı cargo özelliklerine bakalım.
Cargo ve Crates.io Hakkında Daha Fazlası
Şimdiye kadar kodumuzu derlemek, çalıştırmak ve test etmek için Cargo’nun yalnızca en temel özelliklerini kullandık; ancak Cargo bundan çok daha fazlasını yapabilir. Bu bölümde, aşağıdaki işlemleri nasıl gerçekleştireceğinizi göstermek üzere Cargo’nun daha gelişmiş bazı özelliklerini ele alacağız:
- Sürüm profilleriyle derlemenizi özelleştirme
- Kütüphaneleri crates.io üzerinde yayınlama
- Büyük projeleri çalışma alanlarıyla düzenleme
- crates.io üzerinden
cargo installile ikili dosyaları kurma - Özel komutlarla Cargo’yu genişletme
Cargo, bu bölümde ele aldığımız işlevlerden çok daha fazlasını sunabilir; dolayısıyla tüm özelliklerinin eksiksiz bir açıklaması için Cargo belgelerine göz atabilirsiniz.
Not: Pek çok programlama dilinde derleme sistemi, bağımlılık yöneticisi, test çerçevesi ve belgelendirme üreteci birbirinden bağımsız üçüncü parti araçlar olarak geliştiği için projeden projeye farklı yapılandırmalarla karşılaşmak yaygındır. Rust ekosisteminde ise bu görevlerin tamamını tek ve standart bir araç olarak Cargo üstlenir:
- Cargo.toml ve Cargo.lock dosyaları üzerinden tekrarlanabilir paket ve bağımlılık yönetimi sağlar.
rustcderleyicisini sürüm profillerine göre yönlendirir.cargo testile birim, entegrasyon ve belgelendirme testlerini çalıştırır.cargo docile kaynak koddaki belgelendirme yorumlarından standart HTML API belgeleri üretir.- Çalışma alanı (workspace) düzenleyicisi olarak birbiriyle ilişkili birden çok paketi ortak bir bağımlılık ağacı altında yönetir.
Bu bütünleşik yapı sayesinde herhangi bir açık kaynaklı ya da kurumsal Rust projesini indirdiğinizde, ek bir araç zinciri öğrenmeye gerek kalmadan aynı standart komutlarla derleyebilir, test edebilir ve belgelendirebilirsiniz.
Sürüm Profilleriyle Derlemeleri Özelleştirme
Sürüm Profilleriyle Derlemeleri Özelleştirme
Rust’ta sürüm profilleri (release profiles), bir programcının kodu derlemeye yönelik çeşitli seçenekler üzerinde daha fazla kontrol sahibi olmasını sağlayan, önceden tanımlanmış ve farklı yapılandırmalara sahip özelleştirilebilir profillerdir. Her profil diğerlerinden bağımsız olarak yapılandırılır.
Cargo’nun iki ana profili vardır: cargo build komutunu çalıştırdığınızda Cargo’nun kullandığı dev profili ve cargo build --release komutunu çalıştırdığınızda kullandığı release profili. dev profili geliştirme süreci için uygun varsayılan ayarlarla tanımlanmıştır; release profili ise sürüm derlemeleri için uygun varsayılan ayarlara sahiptir.
Bu profil adları derleme çıktılarınızdan tanıdık gelebilir:
$ cargo build
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
$ cargo build --release
Finished `release` profile [optimized] target(s) in 0.32s
Çıktıdaki dev ve release ifadeleri, derleyicinin kullandığı bu farklı profilleri gösterir.
Projenin Cargo.toml dosyasına açıkça herhangi bir [profile.*] bölümü eklemediğiniz sürece Cargo, her profil için tanımlı varsayılan ayarları uygular. Özelleştirmek istediğiniz herhangi bir profil için [profile.*] bölümleri ekleyerek varsayılan ayarların dilediğiniz bir alt kümesini geçersiz kılabilirsiniz. Örneğin dev ve release profilleri için opt-level ayarının varsayılan değerleri şöyledir:
Dosya adı: Cargo.toml
[profile.dev]
opt-level = 0
[profile.release]
opt-level = 3
opt-level (optimizasyon düzeyi) ayarı, Rust’ın kodunuza ne kadar optimizasyon uygulayacağını belirler ve 0 ile 3 arasında bir değer alır. Daha fazla optimizasyon uygulamak derleme süresini uzatır; dolayısıyla geliştirme aşamasındaysanız ve kodunuzu sık sık derliyorsanız, ortaya çıkan kod daha yavaş çalışsa bile derlemenin daha hızlı tamamlanması için daha az optimizasyon yapılmasını istersiniz. Bu nedenle dev profili için varsayılan opt-level değeri 0’dır. Kodunuzu yayınlamaya hazır olduğunuzda ise derlemeye daha fazla zaman ayırmak en doğrusudur. Sürüm modunda yalnızca bir kez derleme yaparsınız, ancak derlenmiş programı pek çok kez çalıştırırsınız; dolayısıyla sürüm modu, daha hızlı çalışan bir kod elde etmek adına daha uzun derleme süresini göze alır. release profili için varsayılan opt-level değerinin 3 olmasının nedeni budur.
Varsayılan bir ayarı, Cargo.toml dosyasında o ayar için farklı bir değer belirterek geçersiz kılabilirsiniz. Örneğin geliştirme profilinde optimizasyon düzeyi olarak 1’i kullanmak istersek projemizin Cargo.toml dosyasına şu iki satırı ekleyebiliriz:
Dosya adı: Cargo.toml
[profile.dev]
opt-level = 1
Bu kod, varsayılan 0 ayarını geçersiz kılar. Artık cargo build komutunu çalıştırdığımızda Cargo, dev profilinin diğer varsayılan ayarlarıyla birlikte opt-level için yaptığımız özelleştirmeyi kullanır. opt-level değerini 1 olarak belirlediğimiz için Cargo varsayılandan daha fazla optimizasyon uygular, ancak bu optimizasyonlar bir sürüm derlemesindeki kadar kapsamlı olmaz.
Her profile ait yapılandırma seçeneklerinin ve varsayılan değerlerin tam listesi için Cargo belgelerine göz atabilirsiniz.
Not: Üretim ortamına çıkacak gerçek dünya projelerinde, gömülü sistemlerde veya WebAssembly (wasm32) hedeflerinde yalnızca sayısal opt-level (0..=3) ile yetinmeyip çalıştırılabilir dosyanın hızını artıran ya da diskteki boyutunu küçülten başka profil ayarlarıyla da sıkça karşılaşırsınız:
- Boyut odaklı
opt-leveldeğerleri ("s"ve"z"):opt-levelayarı0..=3tam sayılarının yanı sıra ikili dosya boyutunu küçültmeye odaklanan"s"değerini ve döngü vektörleştirmeyi de kapatarak boyutu daha agresif biçimde küçülten"z"değerini alabilir. lto(Link-Time Optimization — Bağlama Zamanı Optimizasyonu): Varsayılan değerifalseolsa da bu durumda bile her crate kendi içinde sınırlı bir optimizasyondan geçer; crate’ler arası optimizasyon ise yapılmaz.lto = "thin"(derleme süresini makul tutarak crate’ler arası satır içi açma ve ölü kod eleme yapar) veyalto = true(tüm bağımlılık ağacını tek bütün hâlinde analiz eder) kullanıldığında çalışma hızı veya ikili dosya boyutu iyileşebilir; sonuç iş yüküne ve diğer derleme ayarlarına bağlıdır. Bu seçenekler derleme süresini de artırabilir, bu yüzden etkileri ölçülmelidir.codegen-units = 1: Cargo paralel derlemeyi hızlandırmak için bir crate’i varsayılan olarak birden fazla kod üretim birimine böler (releaseprofilinde16). Bu değeri1yapmak paralel kod üretimini devre dışı bırakıp derleme süresini uzatsa da derleyicinin tüm crate genelinde daha bütünsel optimizasyonlar üretmesine olanak tanır.panic = "abort": Bölüm 9’da gördüğümüz gibi, panik anında yığını geri sarmak ("unwind") yerine programı doğrudan sonlandırır; geri sarma tabloları üretilmediği için ikili dosya küçülür.strip = true(veya"symbols"/"debuginfo"): Sembol tablolarını ve hata ayıklama bilgilerini derlenen ikili dosyadan temizleyerek çalışma hızından ödün vermeden dosya boyutunu önemli ölçüde azaltır (ancak panik anındaki geri izleme (backtrace) çıktısında fonksiyon adları ve satır numaraları görünmez hâle gelir). Rust 1.77’den itibarenreleaseprofili,debugayarı açık değilse hata ayıklama bilgilerini (strip = "debuginfo") varsayılan olarak zaten temizler;strip = trueise buna ek olarak sembol tablosunu da kaldırır.
Örneğin hem yüksek çalışma performansı hem de küçük ikili dosya boyutu hedefleyen tipik bir üretim yapılandırması ve geliştirme sırasında yalnızca harici bağımlılıkları optimize etme ipucu Cargo.toml içinde şöyle görünebilir:
[profile.release]
lto = "thin"
codegen-units = 1
panic = "abort"
strip = true
# Geliştirme sırasında kendi kodunuz hızlı derlensin (opt-level = 0),
# ancak harici crate'ler hızlı çalışsın isterseniz:
[profile.dev.package."*"]
opt-level = 2
Crates.io'da Bir Crate Yayınlama
Crates.io’da Bir Crate Yayınlama
Projelerimizde bağımlılık olarak crates.io üzerindeki paketleri kullandık; ancak kendi paketlerinizi yayınlayarak kodunuzu başka geliştiricilerle de paylaşabilirsiniz. crates.io üzerindeki crate kayıt sistemi paketlerinizin kaynak kodunu dağıtır; dolayısıyla ağırlıklı olarak açık kaynaklı kodlara ev sahipliği yapar.
Rust ve Cargo, yayınladığınız paketin başkaları tarafından daha kolay keşfedilmesini ve kullanılmasını sağlayan çeşitli özellikler sunar. Önce bu özelliklerden bazılarını ele alacak, ardından bir paketin nasıl yayınlanacağını adım adım açıklayacağız.
Yararlı Belgelendirme Yorumları Yazma
Paketlerinizi doğru ve eksiksiz biçimde belgelendirmek, diğer kullanıcıların onları ne zaman ve nasıl kullanacağını anlamasına yardımcı olur; bu nedenle belgelendirme yazmaya zaman ayırmaya değer. Bölüm 3’te iki eğik çizgi (//) kullanarak Rust koduna nasıl yorum ekleneceğini görmüştük. Rust’ta ayrıca HTML belgelendirmesi üreten ve amacına uygun biçimde belgelendirme yorumu (documentation comment) olarak adlandırılan özel bir yorum türü de vardır. Üretilen HTML, açık API öğelerine yazılmış belgelendirme yorumlarının içeriğini gösterir ve crate’inizin içeride nasıl uygulandığıyla değil, nasıl kullanılacağıyla ilgilenen programcılara yöneliktir.
Belgelendirme yorumları iki yerine üç eğik çizgi (///) kullanır ve metni biçimlendirmek için Markdown sözdizimini destekler. Belgelendirme yorumlarını, belgeledikleri öğenin hemen üstüne yerleştirin. Liste 14-1, benim_crate adlı bir crate içinde yer alan bir_ekle fonksiyonu için yazılmış belgelendirme yorumlarını göstermektedir.
/// Verilen sayıya bir ekler.
///
/// # Örnekler
///
/// ```
/// let girdi = 5;
/// let yanit = benim_crate::bir_ekle(girdi);
///
/// assert_eq!(6, yanit);
/// ```
pub fn bir_ekle(x: i32) -> i32 {
x + 1
}
Burada bir_ekle fonksiyonunun ne yaptığını açıklıyor, Örnekler başlığıyla bir bölüm başlatıyor ve bir_ekle fonksiyonunun nasıl kullanılacağını gösteren örnek kodu paylaşıyoruz. Bu belgelendirme yorumundan HTML belgelendirmesi üretmek için cargo doc komutunu çalıştırabiliriz. Bu komut, Rust ile birlikte dağıtılan rustdoc aracını çalıştırır ve üretilen HTML dosyalarını target/doc dizinine yerleştirir.
Kolaylık sağlaması açısından cargo doc --open komutunu çalıştırmak, mevcut crate’inizin (ve tüm crate bağımlılıklarınızın) HTML belgelendirmesini derleyip sonucu doğrudan web tarayıcınızda açar. bir_ekle fonksiyonuna gittiğinizde, belgelendirme yorumlarındaki metnin Şekil 14-1’deki gibi işlendiğini görürsünüz.
Şekil 14-1: bir_ekle (add_one) fonksiyonu için üretilen
HTML belgelendirmesi
Sık Kullanılan Bölümler
Liste 14-1’de HTML çıktısında “Örnekler” (Examples) başlıklı bir bölüm oluşturmak için # Örnekler Markdown başlığını kullandık. Crate yazarlarının belgelendirmelerinde sıkça kullandığı diğer standart bölümler şunlardır:
- Panics (Panik Durumları): Belgelenen fonksiyonun hangi senaryolarda panikleyebileceğini açıklar. Programlarının paniklemesini istemeyen çağıranlar, fonksiyonu bu durumlarda çağırmadıklarından emin olmalıdır.
- Errors (Hatalar): Fonksiyon bir
Resultdöndürüyorsa, oluşabilecek hata türlerini ve hangi koşulların bu hataların döndürülmesine yol açabileceğini açıklamak çağıranlara büyük kolaylık sağlar; böylece farklı hata türlerini farklı şekillerde ele alan kodlar yazabilirler. - Safety (Güvenlik): Fonksiyonu çağırmak
unsafeise (güvenli olmayan kodu Bölüm 20’de ele alacağız), fonksiyonun nedenunsafeolduğunu açıklayan ve çağıranların sağlamasını beklediği değişmez koşulları (invariants) kapsayan bir bölüm bulunmalıdır.
Çoğu belgelendirme yorumunda bu bölümlerin tümüne ihtiyaç duyulmaz; ancak bu liste, kullanıcıların kodunuz hakkında bilmek isteyeceği yönleri size hatırlatan iyi bir kontrol listesidir.
Belgelendirme Yorumlarını Test Olarak Kullanma
Belgelendirme yorumlarınıza örnek kod blokları eklemek, kütüphanenizin nasıl kullanılacağını göstermenin yanı sıra çok değerli bir ek avantaj sağlar: cargo test komutunu çalıştırdığınızda, belgelendirmenizdeki kod örnekleri test olarak çalıştırılır! Örneklerle desteklenen bir belgelendirmeden daha iyisi yoktur; ancak belgelendirme yazıldıktan sonra kod değiştiği için artık çalışmayan örneklerden daha kötüsü de yoktur. Liste 14-1’deki bir_ekle fonksiyonunun belgelendirmesiyle cargo test çalıştırırsak test sonuçlarında şuna benzer bir bölüm görürüz:
Doc-tests benim_crate
running 1 test
test src/lib.rs - bir_ekle (line 5) ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.27s
Şimdi fonksiyonu ya da örneği, örnekteki assert_eq! makrosunun paniklemesine yol açacak şekilde değiştirip cargo test komutunu yeniden çalıştırırsak, belge testlerinin örnek ile kod arasındaki uyumsuzluğu anında yakaladığını görürüz!
Not: Belgelendirme testlerindeki kod bloklarında, satır başına # (diyez ve
boşluk) koyduğunuz satırlar cargo doc ile üretilen HTML belgesinde
okuyucudan gizlenir; ancak cargo test çalıştırıldığında derlemeye ve teste
eksiksiz dahil edilir. Bu ipucu, özellikle hazırlık kodlarını gizleyip
okuyucunun dikkatini doğrudan asıl API çağrısına odaklamak ya da örnek içinde
? operatörünü kullanabilmek için gereken Ok(()) dönüş satırını gizlemek
istediğinizde çok kullanışlıdır. Aşağıdaki örnekte okuyucu yalnızca ilk iki
satırı görür; rustdoc ise gizli son satır sayesinde kodu Result döndüren
bir fonksiyonun içine sararak derler:
/// # Örnekler
///
/// ```
/// let sayi: i32 = "42".parse()?;
/// assert_eq!(sayi, 42);
/// # Ok::<(), std::num::ParseIntError>(())
/// ```
Ayrıca kod bloğunun açılış satırına ekleyebileceğiniz no_run (kodu derler
ama ağ isteği veya dosya yazma gibi yan etkileri çalıştırmaz), should_panic
(örnek kodun paniklemesini bekler) ve compile_fail (hatalı kullanımın derleme
zamanında reddedildiğini doğrular) öznitelikleriyle belge testlerinizi farklı
senaryolara uyarlayabilirsiniz.
Kapsayıcı Öğeyi Belgeleyen Yorumlar
//! biçimindeki belgelendirme yorumları, yorumun ardından gelen öğelere değil, yorumu içeren öğeye belgelendirme ekler. Bu belgelendirme yorumlarını genellikle crate kök dosyasında (geleneksel olarak src/lib.rs) veya bir modülün içinde, crate’i ya da modülü bir bütün olarak belgelendirmek için kullanırız.
Örneğin bir_ekle fonksiyonunu barındıran benim_crate crate’inin amacını açıklayan bir belgelendirme eklemek için, Liste 14-2’de gösterildiği gibi src/lib.rs dosyasının başına //! ile başlayan belgelendirme yorumları yazarız.
//! # Benim Crate'im
//!
//! `benim_crate`, belirli hesaplamaları yapmayı daha kullanışlı hale getiren
//! yardımcı araçların bir koleksiyonudur.
/// Verilen sayıya bir ekler.
// --snip--
///
/// # Örnekler
///
/// ```
/// let girdi = 5;
/// let yanit = benim_crate::bir_ekle(girdi);
///
/// assert_eq!(6, yanit);
/// ```
pub fn bir_ekle(x: i32) -> i32 {
x + 1
}
benim_crate crate’inin bütünü için yazılan belgelendirme//! ile başlayan son satırdan sonra herhangi bir kod gelmediğine dikkat edin. Yorumları /// yerine //! ile başlattığımız için, bu yorumu izleyen bir öğeyi değil, bu yorumu barındıran öğeyi belgelendiriyoruz. Bu örnekte söz konusu öğe, crate kökü olan src/lib.rs dosyasıdır. Bu yorumlar crate’in tamamını açıklar.
cargo doc --open komutunu çalıştırdığımızda bu yorumlar, Şekil 14-2’de gösterildiği gibi benim_crate belgelendirmesinin ana sayfasında, crate içindeki açık öğeler listesinin hemen üstünde görüntülenir.
Öğelerin içinde yer alan belgelendirme yorumları özellikle crate’leri ve modülleri açıklamak için çok yararlıdır. Kapsayıcının genel amacını anlatmak ve kullanıcılarınızın crate organizasyonunu anlamasına yardımcı olmak için bunları kullanabilirsiniz.
Şekil 14-2: Crate’i bir bütün olarak açıklayan yorumu da
içeren benim_crate (my_crate) belgelendirmesi
Not: Rust’taki iki belgelendirme yorumu biçimi arasındaki fark, öznitelik (attribute) sözdizimiyle birebir aynı kuralı izler:
///(Dış belgelendirme yorumu — outer doc comment): Hemen ardından gelen öğeyi (fonksiyon,struct,enum,traitveyamodbildirimi) belgeler ve derleyici tarafından#[doc = "..."]özniteliğine dönüştürülür. Ardında belgelenecek bir öğe bulunmazsa derleme hatası oluşur.//!(İç belgelendirme yorumu — inner doc comment): İçinde bulunduğu kapsayıcıyı (src/lib.rs crate kökünü ya da birmodbloğunu) belgeler ve#![doc = "..."]özniteliğine dönüştürülür. Bu yüzden dosyanın veya modülün en başında, diğer öğelerden önce yer almalıdır.
Crates.io üzerinde yayınladığınız her açık kaynaklı kütüphanenin //! ve ///
yorumları, docs.rs tarafından otomatik
olarak derlenip çevrim içi API kılavuzu olarak sunulur.
Kullanışlı Bir Açık API Dışa Aktarma
Bir crate yayınlarken açık API’nizin yapısı büyük önem taşır. Crate’inizi kullanan kişiler modül yapısına sizin kadar aşina değildir; dolayısıyla crate’iniz geniş bir modül hiyerarşisine sahipse kullanmak istedikleri parçaları bulmakta zorlanabilirler.
Bölüm 7’de pub anahtar kelimesiyle öğeleri nasıl açık hâle getireceğimizi ve use anahtar kelimesiyle öğeleri nasıl kapsama dahil edeceğimizi ele almıştık. Ancak bir crate geliştirirken size mantıklı gelen iç yapı, kullanıcılarınız için pek kullanışlı olmayabilir. Struct’larınızı birden fazla katmandan oluşan bir hiyerarşi içinde düzenlemek isteyebilirsiniz; fakat bu durumda hiyerarşinin derinliklerinde tanımladığınız bir türü kullanmak isteyen kişiler, o türün var olduğunu keşfetmekte bile güçlük çekebilir. Ayrıca use benim_crate::FaydaliTur; yazmak yerine use benim_crate::bir_modul::baska_modul::FaydaliTur; yazmak zorunda kalmak da can sıkıcı olabilir.
İyi haber şu ki bu yapı, crate’inizi başka bir kütüphaneden kullanacak kişiler için kullanışlı değilse iç organizasyonunuzu değiştirmek zorunda değilsiniz: Bunun yerine pub use kullanarak öğeleri yeniden dışa aktarabilir ve gizli iç yapınızdan farklı bir açık API yapısı oluşturabilirsiniz. Yeniden dışa aktarma (re-exporting), bir konumdaki açık bir öğeyi alır ve sanki diğer konumda tanımlanmış gibi başka bir konumda da açık hâle getirir.
Örneğin sanatsal kavramları modellemek üzere sanat adında bir kütüphane oluşturduğumuzu varsayalım. Bu kütüphanenin içinde Liste 14-3’te gösterildiği gibi iki modül bulunuyor: BirincilRenk ve IkincilRenk adlı iki enum içeren bir turler modülü ile karistir adlı bir fonksiyon içeren bir yardimcilar modülü.
//! # Sanat
//!
//! Sanatsal kavramları modellemek için bir kütüphane.
pub mod turler {
/// RYB renk modeline göre birincil renkler.
pub enum BirincilRenk {
Kirmizi,
Sari,
Mavi,
}
/// RYB renk modeline göre ikincil renkler.
pub enum IkincilRenk {
Turuncu,
Yesil,
Mor,
}
}
pub mod yardimcilar {
use crate::turler::*;
/// İki birincil rengi eşit miktarda birleştirerek
/// bir ikincil renk oluşturur.
pub fn karistir(renk1: BirincilRenk, renk2: BirincilRenk) -> IkincilRenk {
// --snip--
let _ = (renk1, renk2);
unimplemented!();
}
}
turler ve yardimcilar modüllerinde düzenlenmiş bir sanat kütüphanesiŞekil 14-3, cargo doc tarafından bu crate için üretilen belgelendirmenin ana sayfasının nasıl görüneceğini göstermektedir.
Şekil 14-3: sanat (art) belgelendirmesinin turler
(kinds) ve yardimcilar (utils) modüllerini listeleyen ana sayfası
BirincilRenk ve IkincilRenk türlerinin ana sayfada listelenmediğine, aynı şekilde karistir fonksiyonunun da burada yer almadığına dikkat edin. Onları görebilmek için turler ve yardimcilar bağlantılarına tıklamamız gerekir.
Bu kütüphaneye bağımlı olan başka bir crate’in, sanat içindeki öğeleri kapsama dahil etmek için mevcut modül yapısını açıkça belirten use deyimlerine ihtiyacı olacaktır. Liste 14-4, sanat crate’inden BirincilRenk ve karistir öğelerini kullanan bir crate örneğini göstermektedir.
use sanat::turler::BirincilRenk;
use sanat::yardimcilar::karistir;
fn main() {
let kirmizi = BirincilRenk::Kirmizi;
let sari = BirincilRenk::Sari;
karistir(kirmizi, sari);
}
sanat crate’inin öğelerini kullanan bir cratesanat crate’ini kullanan Liste 14-4’teki kodun yazarı, BirincilRenk türünün turler modülünde ve karistir fonksiyonunun yardimcilar modülünde yer aldığını bulmak zorunda kalmıştır. sanat crate’inin modül yapısı, bu crate’i kullananlardan çok onun üzerinde çalışan geliştiricileri ilgilendirir. İç yapı, sanat crate’inin nasıl kullanılacağını anlamaya çalışan biri için yararlı bir bilgi taşımaz; aksine geliştiricilerin nereye bakacaklarını bulmak ve use deyimlerinde modül adlarını belirtmek zorunda kalmaları nedeniyle kafa karışıklığına yol açar.
İç organizasyonu açık API’den soyutlamak için Liste 14-3’teki sanat crate’i kodunu değiştirip Liste 14-5’te gösterildiği gibi öğeleri en üst düzeyde yeniden dışa aktaran pub use deyimleri ekleyebiliriz.
//! # Sanat
//!
//! Sanatsal kavramları modellemek için bir kütüphane.
pub use self::turler::BirincilRenk;
pub use self::turler::IkincilRenk;
pub use self::yardimcilar::karistir;
pub mod turler {
// --snip--
/// RYB renk modeline göre birincil renkler.
pub enum BirincilRenk {
Kirmizi,
Sari,
Mavi,
}
/// RYB renk modeline göre ikincil renkler.
pub enum IkincilRenk {
Turuncu,
Yesil,
Mor,
}
}
pub mod yardimcilar {
// --snip--
use crate::turler::*;
/// İki birincil rengi eşit miktarda birleştirerek
/// bir ikincil renk oluşturur.
pub fn karistir(renk1: BirincilRenk, renk2: BirincilRenk) -> IkincilRenk {
let _ = (renk1, renk2);
IkincilRenk::Turuncu
}
}
pub use deyimleri eklemecargo doc aracının bu crate için ürettiği API belgelendirmesi artık yeniden dışa aktarımları Şekil 14-4’te gösterildiği gibi ana sayfada listeleyecek ve bağlantılayacaktır; böylece BirincilRenk ve IkincilRenk türleri ile karistir fonksiyonunu bulmak çok daha kolay hâle gelecektir.
Şekil 14-4: Yeniden dışa aktarımları listeleyen sanat
(art) belgelendirmesinin ana sayfası
sanat crate’inin kullanıcıları isterlerse Liste 14-3’teki iç yapıyı Liste 14-4’te gösterildiği biçimde görmeye ve kullanmaya devam edebilir, isterlerse Liste 14-6’da gösterildiği gibi Liste 14-5’teki daha kullanışlı yapıyı tercih edebilirler.
use sanat::BirincilRenk;
use sanat::karistir;
fn main() {
// --snip--
let kirmizi = BirincilRenk::Kirmizi;
let sari = BirincilRenk::Sari;
karistir(kirmizi, sari);
}
sanat crate’inden yeniden dışa aktarılan öğeleri kullanan bir programÇok sayıda iç içe modülün bulunduğu durumlarda, türleri pub use ile en üst düzeyde yeniden dışa aktarmak crate’i kullanan kişilerin deneyiminde büyük fark yaratabilir. pub use deyiminin bir diğer yaygın kullanım alanı ise bir bağımlılığın tanımlarını mevcut crate içinde yeniden dışa aktararak söz konusu crate’in tanımlarını kendi crate’inizin açık API’sinin bir parçası hâline getirmektir.
Kullanışlı bir açık API yapısı oluşturmak bilimden çok bir sanattır ve kullanıcılarınız için en iyi çalışan API’yi bulmak üzere zaman içinde iyileştirmeler yapabilirsiniz. pub use kullanmak, crate’inizi içeride nasıl yapılandıracağınız konusunda size esneklik kazandırır ve bu iç yapıyı kullanıcılarınıza sunduğunuz arayüzden ayırır. İç yapılarının açık API’lerinden farklı olup olmadığını görmek için kurduğunuz bazı crate’lerin kaynak kodlarını inceleyebilirsiniz.
Crates.io Hesabı Oluşturma
Herhangi bir crate yayınlayabilmeniz için önce crates.io üzerinde bir hesap oluşturmanız ve bir API belirteci (API token) almanız gerekir. Bunun için crates.io ana sayfasını ziyaret edin ve bir GitHub hesabıyla oturum açın. (GitHub hesabı şu an için zorunludur, ancak site gelecekte hesap oluşturmak için başka yöntemleri de destekleyebilir.) Oturum açtıktan sonra https://crates.io/me/ adresindeki hesap ayarlarınıza gidin ve API belirtecinizi alın. Ardından cargo login komutunu çalıştırın ve istendiğinde API belirtecinizi şu şekilde yapıştırın:
$ cargo login
abcdefghijklmnopqrstuvwxyz012345
Bu komut Cargo’yu API belirtecinizden haberdar eder ve onu yerel olarak ~/.cargo/credentials.toml dosyasında saklar. Bu belirtecin gizli bir bilgi olduğunu unutmayın: Başka hiç kimseyle paylaşmayın. Herhangi bir nedenle birisiyle paylaşırsanız, belirteci derhal iptal etmeli ve crates.io üzerinde yeni bir belirteç oluşturmalısınız.
Yeni Bir Crate’e Meta Veri Ekleme
Yayınlamak istediğiniz bir crate’iniz olduğunu düşünelim. Yayınlamadan önce crate’in Cargo.toml dosyasındaki [package] bölümüne bazı meta veriler eklemeniz gerekir.
Crate’inizin benzersiz bir adı olmalıdır. Yerelde bir crate üzerinde çalışırken crate’e dilediğiniz adı verebilirsiniz. Ancak crates.io üzerindeki crate adları “ilk gelen alır” ilkesine göre tahsis edilir. Bir crate adı alındıktan sonra başka hiç kimse o adla bir crate yayınlayamaz. Bir crate yayınlamaya çalışmadan önce kullanmak istediğiniz adı sitede aratın. Ad daha önce kullanılmışsa başka bir ad bulmanız ve yayınlamak üzere yeni adı kullanmak için Cargo.toml dosyasındaki [package] bölümünün altında yer alan name alanını şu şekilde düzenlemeniz gerekir:
Dosya adı: Cargo.toml
[package]
name = "tahmin_oyunu"
Benzersiz bir ad seçmiş olsanız bile, bu aşamada crate’i yayınlamak için cargo publish komutunu çalıştırdığınızda önce bir uyarı, ardından bir hata alırsınız:
$ cargo publish
Updating crates.io index
warning: manifest has no description, license, license-file, documentation, homepage or repository.
See https://doc.rust-lang.org/cargo/reference/manifest.html#package-metadata for more info.
--snip--
error: failed to publish to registry at https://crates.io
Caused by:
the remote server responded with an error (status 400 Bad Request): missing or empty metadata fields: description, license. Please see https://doc.rust-lang.org/cargo/reference/manifest.html for more information on configuring these fields
Bazı kritik bilgiler eksik olduğu için bu işlem hatayla sonuçlanır: İnsanların crate’inizin ne işe yaradığını ve onu hangi koşullar altında kullanabileceklerini bilmeleri için bir açıklama (description) ve bir lisans (license) alanı zorunludur. Cargo.toml dosyasına, arama sonuçlarında crate’inizle birlikte görüneceği için yalnızca bir iki cümleden oluşan kısa bir açıklama ekleyin. license alanı için bir lisans tanımlayıcı değeri vermeniz gerekir. Linux Foundation’ın Software Package Data Exchange (SPDX) standardı, bu değer için kullanabileceğiniz tanımlayıcıları listeler. Örneğin crate’inizi MIT Lisansı ile lisansladığınızı belirtmek için MIT tanımlayıcısını ekleyin:
Dosya adı: Cargo.toml
[package]
name = "tahmin_oyunu"
license = "MIT"
SPDX listesinde yer almayan bir lisans kullanmak istiyorsanız, o lisansın metnini bir dosyaya yerleştirmeli, dosyayı projenize dahil etmeli ve license anahtarı yerine o dosyanın adını belirtmek üzere license-file alanını kullanmalısınız.
Projeniz için hangi lisansın uygun olduğuna dair rehberlik bu kitabın kapsamı dışındadır. Rust topluluğundaki pek çok kişi, projelerini Rust ile aynı şekilde MIT OR Apache-2.0 ikili lisansını kullanarak lisanslar. Bu uygulama, projenizde birden fazla lisansa sahip olmak için OR ile ayrılmış birden çok lisans tanımlayıcısı belirtebileceğinizi de gösterir.
Benzersiz bir ad, sürüm numarası, açıklama ve lisans eklendiğinde, yayınlanmaya hazır bir projenin Cargo.toml dosyası şöyle görünebilir:
Dosya adı: Cargo.toml
[package]
name = "tahmin_oyunu"
version = "0.1.0"
edition = "2024"
description = "Bilgisayarın seçtiği sayıyı tahmin ettiğiniz eğlenceli bir oyun."
license = "MIT OR Apache-2.0"
[dependencies]
Cargo belgeleri, başkalarının crate’inizi daha kolay keşfedip kullanabilmesini sağlamak için belirtebileceğiniz diğer meta verileri de ayrıntılı olarak açıklar.
Crates.io’da Yayınlama
Artık bir hesap oluşturduğunuza, API belirtecinizi kaydettiğinize, crate’iniz için bir ad seçtiğinize ve gerekli meta verileri belirttiğinize göre yayınlamaya hazırsınız! Bir crate yayınlamak, belirli bir sürümü başkalarının kullanabilmesi için crates.io üzerine yükler.
Dikkatli olun, çünkü yayınlama işlemi kalıcıdır. Bir sürümün üzerine asla yazılamaz ve belirli istisnai durumlar dışında kod silinemez. Crates.io’nun en önemli hedeflerinden biri, crates.io üzerindeki crate’lere bağımlı olan tüm projelerin derlemelerinin gelecekte de çalışmaya devam etmesi için kalıcı bir kod arşivi görevi görmektir. Sürümlerin silinmesine izin vermek bu hedefin gerçekleştirilmesini imkânsız kılardı. Öte yandan yayınlayabileceğiniz crate sürümlerinin sayısında herhangi bir üst sınır yoktur.
cargo publish komutunu tekrar çalıştırın. Bu kez başarılı olması gerekir:
$ cargo publish
Updating crates.io index
Packaging tahmin_oyunu v0.1.0 (file:///projects/tahmin_oyunu)
Packaged 6 files, 1.2KiB (895.0B compressed)
Verifying tahmin_oyunu v0.1.0 (file:///projects/tahmin_oyunu)
Compiling tahmin_oyunu v0.1.0
(file:///projects/tahmin_oyunu/target/package/tahmin_oyunu-0.1.0)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.19s
Uploading tahmin_oyunu v0.1.0 (file:///projects/tahmin_oyunu)
Uploaded tahmin_oyunu v0.1.0 to registry `crates-io`
note: waiting for `tahmin_oyunu v0.1.0` to be available at registry
`crates-io`.
You may press ctrl-c to skip waiting; the crate should be available shortly.
Published tahmin_oyunu v0.1.0 at registry `crates-io`
Tebrikler! Artık kodunuzu Rust topluluğuyla paylaştınız ve isteyen herkes crate’inizi kendi projesine kolayca bağımlılık olarak ekleyebilir.
Not: Yayınlanan sürümler kalıcı olduğundan, gerçek cargo publish komutunu
çalıştırmadan önce cargo publish --dry-run komutuyla yerel bir deneme
yapmanız iyi bir alışkanlıktır. Bu komut meta verileri denetler, kaynak
dosyalarını arşivler ve paketi target/package dizininde yalıtılmış bir
ortamda sıfırdan derleyerek doğrular; ancak Crates.io sunucusuna hiçbir şey
yüklemez. Pakete hangi dosyaların dahil edildiğini önceden listelemek için
cargo package --list komutunu da kullanabilir, test verileri veya büyük
görseller gibi gereksiz dosyaları dışarıda bırakmak üzere Cargo.toml
dosyasındaki [package] bölümünde exclude ya da include alanlarını
tanımlayabilirsiniz.
Var Olan Bir Crate’in Yeni Sürümünü Yayınlama
Crate’inizde değişiklikler yaptığınızda ve yeni bir sürüm yayınlamaya hazır olduğunuzda, Cargo.toml dosyanızda belirtilen version değerini değiştirip yeniden yayınlarsınız. Yaptığınız değişikliklerin türüne göre bir sonraki uygun sürüm numarasının ne olacağına karar vermek için Semantik Sürümleme kurallarını kullanın. Ardından yeni sürümü yüklemek için cargo publish komutunu çalıştırın.
Not: Semantik Sürümleme, sürüm numarasını BÜYÜK.KÜÇÜK.YAMA
(MAJOR.MINOR.PATCH) biçiminde üç parçaya ayırır: Geriye dönük uyumlu hata
düzeltmelerinde yalnızca yama numarasını (1.0.1’den 1.0.2’ye), geriye
dönük uyumlu yeni özelliklerde küçük numarayı (1.0.2’den 1.1.0’a), açık
API’de uyumsuz bir değişiklik yaptığınızda ise büyük numarayı (1.1.0’dan
2.0.0’a) artırırsınız. Cargo, Cargo.toml dosyasındaki rand = "0.8.5"
gibi sade bir sürüm gereksinimini ^0.8.5 olarak yorumlar ve cargo update
çalıştırıldığında yalnızca aynı uyumluluk aralığındaki sürümlere
(>=0.8.5, <0.9.0) geçilmesine izin verir. Bu kural 0.x sürümlerinde
farklı işler: Büyük numara 0 iken küçük numaradaki her değişiklik uyumsuz
kabul edilir; yani 0.8.5 ile 0.9.0 birbiriyle uyumlu sayılmaz. Bu yüzden
var olan açık bir fonksiyonun imzasını değiştirmek ya da bir öğeyi kaldırmak
gibi kırıcı değişiklikleri her zaman yeni bir büyük sürüme (0.x serisinde
ise yeni bir küçük sürüme) saklayın; aksi hâlde kullanıcılarınızın projeleri
cargo update sonrasında beklenmedik biçimde derlenmez hâle gelebilir.
Crates.io’da Sürümleri Kullanımdan Kaldırma
Bir crate’in önceki sürümlerini silemeseniz de gelecekteki projelerin onları yeni bir bağımlılık olarak eklemesini engelleyebilirsiniz. Bu özellik, bir crate sürümü herhangi bir nedenle kusurlu çıktığında oldukça işe yarar. Böyle durumlarda Cargo, bir crate sürümünü geri çekmeyi (yanking) destekler.
Bir sürümü geri çekmek (yank), mevcut bağımlı projelerin çalışmaya devam etmesine izin verirken yeni projelerin o sürüme bağımlı olmasını engeller. Özünde geri çekme işlemi, hâlihazırda bir Cargo.lock dosyasına sahip olan tüm projelerin bozulmayacağı, ancak gelecekte üretilecek yeni Cargo.lock dosyalarının geri çekilen bu sürümü kullanmayacağı anlamına gelir.
Bir crate’in belirli bir sürümünü geri çekmek için, daha önce yayınladığınız crate’in dizininde cargo yank komutunu çalıştırır ve hangi sürümü geri çekmek istediğinizi belirtirsiniz. Örneğin tahmin_oyunu adlı bir crate’in 1.0.1 sürümünü yayınladıysak ve onu geri çekmek istiyorsak, tahmin_oyunu proje dizininde şu komutu çalıştırırız:
$ cargo yank --vers 1.0.1
Updating crates.io index
Yank tahmin_oyunu@1.0.1
Komuta --undo seçeneğini ekleyerek bir geri çekme işlemini iptal edebilir ve projelerin ilgili sürüme yeniden bağımlı olmasına izin verebilirsiniz:
$ cargo yank --vers 1.0.1 --undo
Updating crates.io index
Unyank tahmin_oyunu@1.0.1
Geri çekme işlemi hiçbir kodu silmez. Örneğin yanlışlıkla yüklenmiş gizli anahtarları veya parolaları ortadan kaldıramaz. Böyle bir durum yaşanırsa söz konusu gizli bilgileri derhal sıfırlamanız ve yenilemeniz gerekir.
Cargo Çalışma Alanları (Workspaces)
Cargo Çalışma Alanları (Workspaces)
Bölüm 12’de hem bir ikili crate hem de bir kütüphane crate’i içeren bir paket oluşturmuştuk. Projeniz geliştikçe kütüphane crate’inin giderek büyüdüğünü ve paketinizi birden fazla kütüphane crate’ine bölmek istediğinizi fark edebilirsiniz. Cargo, eşzamanlı olarak birlikte geliştirilen birbiriyle ilişkili birden çok paketi yönetmeye yardımcı olan çalışma alanları (workspaces) adlı bir özellik sunar.
Çalışma Alanı Oluşturma
Çalışma alanı, aynı Cargo.lock dosyasını ve aynı çıktı dizinini paylaşan paketler kümesidir. Çalışma alanının yapısına odaklanabilmek için çok basit kod kullanan bir proje oluşturalım. Bir çalışma alanını yapılandırmanın birden fazla yolu vardır; biz burada yaygın kullanılan bir yöntemi göstereceğiz. Bir ikili crate ve iki kütüphane crate’i içeren bir çalışma alanımız olacak. Ana işlevselliği sunacak olan ikili crate, bu iki kütüphaneye bağımlı olacak. Kütüphanelerden biri bir_ekle fonksiyonunu, diğeri ise iki_ekle fonksiyonunu sağlayacak. Bu üç crate aynı çalışma alanının parçası olacak. İşe çalışma alanı için yeni bir dizin oluşturarak başlayalım:
$ mkdir topla
$ cd topla
Ardından topla dizininde, tüm çalışma alanını yapılandıracak Cargo.toml dosyasını oluşturuyoruz. Bu dosyada [package] bölümü yer almayacak; bunun yerine çalışma alanına üye paketler eklememizi sağlayan bir [workspace] bölümüyle başlayacak. Ayrıca çalışma alanımızda Cargo’nun bağımlılık çözümleyici algoritmasının en güncel ve gelişmiş sürümünü kullanmak için resolver değerini özellikle "3" olarak ayarlıyoruz:
Dosya adı: Cargo.toml
[workspace]
resolver = "3"
Not: Normalde tekli bir pakette edition = "2024" ayarı Cargo’nun 3. sürüm
bağımlılık çözümleyicisini otomatik olarak etkinleştirir. Ancak başında
[package] bölümü bulunmayan bu tür sanal çalışma alanı (virtual workspace)
Cargo.toml dosyalarında edition alanı yer almadığından, resolver = "3"
satırını açıkça belirtmezseniz Cargo geriye dönük uyumluluk amacıyla eski
çözümleyiciye ("1") döner ve derleme sırasında uyarı üretir.
Şimdi topla dizini içinde cargo new çalıştırarak toplayici ikili crate’ini oluşturalım:
$ cargo new toplayici
Created binary (application) `toplayici` package
Adding `toplayici` as member of workspace at `file:///projects/topla`
Bir çalışma alanı içinde cargo new çalıştırmak, yeni oluşturulan paketi çalışma alanının Cargo.toml dosyasındaki [workspace] tanımında yer alan members anahtarına da otomatik olarak ekler:
[workspace]
resolver = "3"
members = ["toplayici"]
Bu noktada cargo build çalıştırarak çalışma alanını derleyebiliriz. topla dizininizdeki dosyalar şu şekilde görünmelidir:
├── Cargo.lock
├── Cargo.toml
├── toplayici
│ ├── Cargo.toml
│ └── src
│ └── main.rs
└── target
Çalışma alanının en üst düzeyde derlenmiş çıktıların yerleştirileceği tek bir target dizini vardır; toplayici paketinin kendine ait ayrı bir target dizini yoktur. Hatta cargo build komutunu toplayici dizininin içinden çalıştırsak bile derlenmiş çıktılar topla/toplayici/target yerine yine topla/target dizinine yerleştirilir. Cargo bir çalışma alanındaki target dizinini bu şekilde yapılandırır çünkü çalışma alanındaki crate’lerin birbirine bağımlı olması amaçlanır. Her crate’in kendi target dizini olsaydı, her crate derlenmiş çıktıları kendi target dizinine yerleştirmek için çalışma alanındaki diğer crate’lerin her birini yeniden derlemek zorunda kalırdı. Tek bir target dizinini paylaşarak crate’ler gereksiz yeniden derlemelerin önüne geçer.
Çalışma Alanında İkinci Paketi Oluşturma
Şimdi çalışma alanında bir üye paket daha oluşturalım ve adını bir_ekle koyalım. bir_ekle adında yeni bir kütüphane crate’i üretin:
$ cargo new bir_ekle --lib
Created library `bir_ekle` package
Adding `bir_ekle` as member of workspace at `file:///projects/topla`
Üst düzey Cargo.toml dosyası artık members listesinde bir_ekle yolunu da içerecektir:
Dosya adı: Cargo.toml
[workspace]
resolver = "3"
members = ["toplayici", "bir_ekle"]
topla dizininizde artık şu dizin ve dosyalar bulunmalıdır:
├── Cargo.lock
├── Cargo.toml
├── bir_ekle
│ ├── Cargo.toml
│ └── src
│ └── lib.rs
├── toplayici
│ ├── Cargo.toml
│ └── src
│ └── main.rs
└── target
bir_ekle/src/lib.rs dosyasına bir bir_ekle fonksiyonu ekleyelim:
Dosya adı: bir_ekle/src/lib.rs
pub fn bir_ekle(x: i32) -> i32 {
x + 1
}
Artık ikili crate’imizi barındıran toplayici paketinin, kütüphanemizi içeren bir_ekle paketine bağımlı olmasını sağlayabiliriz. Öncelikle toplayici/Cargo.toml dosyasına bir_ekle için bir yol bağımlılığı (path dependency) eklememiz gerekecek.
Dosya adı: toplayici/Cargo.toml
[dependencies]
bir_ekle = { path = "../bir_ekle" }
Cargo bir çalışma alanındaki crate’lerin birbirine bağımlı olacağını kendiliğinden varsaymaz; bu nedenle bağımlılık ilişkilerini açıkça belirtmemiz gerekir.
Şimdi (bir_ekle crate’inden gelen) bir_ekle fonksiyonunu toplayici crate’inde kullanalım. toplayici/src/main.rs dosyasını açın ve main fonksiyonunu Liste 14-7’deki gibi bir_ekle fonksiyonunu çağıracak şekilde değiştirin.
fn main() {
let sayi = 10;
println!(
"Merhaba, dünya! {sayi} artı bir {} eder!",
bir_ekle::bir_ekle(sayi)
);
}
bir_ekle kütüphane crate’ini toplayici crate’i içinden kullanmakÜst düzey topla dizininde cargo build çalıştırarak çalışma alanını derleyelim!
$ cargo build
Compiling bir_ekle v0.1.0 (file:///projects/topla/bir_ekle)
Compiling toplayici v0.1.0 (file:///projects/topla/toplayici)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.22s
İkili crate’i topla dizininden çalıştırmak için cargo run komutuyla birlikte -p argümanını ve paket adını kullanarak çalışma alanındaki hangi paketi çalıştırmak istediğimizi belirtebiliriz:
$ cargo run -p toplayici
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
Running `target/debug/toplayici`
Merhaba, dünya! 10 artı bir 11 eder!
Bu komut, toplayici/src/main.rs içindeki ve bir_ekle crate’ine bağımlı olan kodu çalıştırır.
Not: Kök Cargo.toml dosyasında [package] bölümü bulunmayan böyle bir
sanal çalışma alanında (virtual workspace) kök dizinden çalıştırdığınız
cargo build, cargo check ve cargo test gibi komutlar varsayılan olarak
tüm üye paketlere uygulanır. cargo run ise hangi ikili dosyayı
çalıştıracağını bilmek zorunda olduğundan, çalışma alanında birden fazla
ikili crate bulunduğunda -p bayrağı verilmezse hata verir. Üye paketlerden
birinin dizinine girip orada komut çalıştırdığınızda yalnızca o paket
hedeflenir; o konumdan tüm çalışma alanını derlemek için
cargo build --workspace kullanabilirsiniz. Kök dizinde varsayılan olarak
hangi paketlerin hedefleneceğini ise [workspace] bölümündeki
default-members anahtarıyla belirleyebilirsiniz.
Harici Bir Pakete Bağımlı Olma
Çalışma alanında her crate’in kendi dizininde ayrı bir Cargo.lock bulunması yerine, en üst düzeyde yalnızca tek bir Cargo.lock dosyası bulunduğuna dikkat edin. Bu yapı, tüm crate’lerin bütün bağımlılıkların aynı sürümünü kullanmasını güvence altına alır. rand paketini hem toplayici/Cargo.toml hem de bir_ekle/Cargo.toml dosyalarına eklersek, Cargo her ikisini de tek bir rand sürümüne çözümler ve bunu tek Cargo.lock dosyasına kaydeder. Çalışma alanındaki tüm crate’lerin aynı bağımlılıkları kullanmasını sağlamak, bu crate’lerin birbirleriyle her zaman uyumlu olacağı anlamına gelir. Şimdi rand crate’ini bir_ekle crate’inde kullanabilmek için bir_ekle/Cargo.toml dosyasındaki [dependencies] bölümüne ekleyelim:
Dosya adı: bir_ekle/Cargo.toml
[dependencies]
rand = "0.10.1"
Artık bir_ekle/src/lib.rs dosyasına use rand; ekleyebiliriz; topla dizininde cargo build çalıştırarak tüm çalışma alanını derlemek rand crate’ini indirip derleyecektir. Kapsama dahil ettiğimiz rand crate’ine kodda başvurmadığımız için bir uyarı alırız:
$ cargo build
Updating crates.io index
Downloaded rand v0.10.1
--snip--
Compiling rand v0.10.1
Compiling bir_ekle v0.1.0 (file:///projects/topla/bir_ekle)
warning: unused import: `rand`
--> bir_ekle/src/lib.rs:1:5
|
1 | use rand;
| ^^^^
|
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
warning: `bir_ekle` (lib) generated 1 warning (run `cargo fix --lib -p bir_ekle` to apply 1 suggestion)
Compiling toplayici v0.1.0 (file:///projects/topla/toplayici)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.95s
Üst düzey Cargo.lock dosyası artık bir_ekle crate’inin rand bağımlılığına ilişkin bilgileri içerir. Bununla birlikte, rand çalışma alanının bir yerinde kullanılıyor olsa bile, diğer crate’lerin Cargo.toml dosyalarına da rand eklemediğimiz sürece onu çalışma alanındaki diğer crate’lerde kullanamayız. Örneğin toplayici paketine ait toplayici/src/main.rs dosyasına use rand; eklersek bir hata alırız:
$ cargo build
--snip--
Compiling toplayici v0.1.0 (file:///projects/topla/toplayici)
error[E0432]: unresolved import `rand`
--> toplayici/src/main.rs:2:5
|
2 | use rand;
| ^^^^ no external crate `rand`
Bunu düzeltmek için toplayici paketinin Cargo.toml dosyasını düzenleyip rand paketinin onun için de bir bağımlılık olduğunu belirtin. toplayici paketini derlemek, Cargo.lock dosyasında toplayici paketinin bağımlılık listesine rand paketini ekleyecektir; ancak rand paketinin ek bir kopyası indirilmeyecektir. Paketler birbiriyle uyumlu rand sürümleri belirttiği sürece Cargo, çalışma alanında rand kullanan tüm crate’lerin aynı sürümü paylaşmasını sağlar; bu hem disk alanından tasarruf ettirir hem de çalışma alanındaki crate’lerin birbiriyle uyumlu kalmasını güvence altına alır.
Çalışma alanındaki crate’ler aynı bağımlılığın birbiriyle uyumsuz sürümlerini belirtirse Cargo bunların her birini ayrı ayrı çözümler, ancak yine de mümkün olan en az sayıda sürümü çözümlemeye çalışır.
Not: Çok sayıda paketten oluşan projelerde aynı bağımlılık sürümünü veya ortak
paket meta verilerini (version, edition, license vb.) her üye paketin
Cargo.toml dosyasında tekrar tekrar yazmak ve güncel tutmak sürüm
uyuşmazlıklarına yol açabilir. Cargo, bunu tek merkezden yönetmek için
çalışma alanı mirası (workspace inheritance) özelliğini sunar. Üst düzey
Cargo.toml dosyasında ortak paket alanlarını [workspace.package] altında,
ortak bağımlılıkları ise [workspace.dependencies] altında bir kez
tanımlayabilirsiniz:
[workspace]
resolver = "3"
members = ["toplayici", "bir_ekle"]
[workspace.package]
version = "0.1.0"
edition = "2024"
[workspace.dependencies]
rand = "0.10.1"
bir_ekle = { path = "bir_ekle" }
Ardından üye paketlerin kendi Cargo.toml dosyalarında (örneğin
toplayici/Cargo.toml) sürüm numarası veya dosya yolu tekrarlamak yerine
workspace = true anahtarını kullanması yeterlidir:
[package]
name = "toplayici"
version.workspace = true
edition.workspace = true
[dependencies]
rand.workspace = true
bir_ekle.workspace = true
Böylece bir bağımlılığı güncellemeniz gerektiğinde yalnızca kök Cargo.toml dosyasındaki tek bir satırı değiştirirsiniz ve çalışma alanındaki tüm crate’lerin aynı sürüm tanımını paylaşması garanti altına alınır.
Çalışma Alanına Test Ekleme
Bir iyileştirme daha yapalım ve bir_ekle crate’i içinde bir_ekle::bir_ekle fonksiyonunun bir testini ekleyelim:
Dosya adı: bir_ekle/src/lib.rs
pub fn bir_ekle(x: i32) -> i32 {
x + 1
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn calisir() {
assert_eq!(3, bir_ekle(2));
}
}
Şimdi üst düzey topla dizininde cargo test çalıştırın. Bu şekilde yapılandırılmış bir çalışma alanında cargo test çalıştırmak, çalışma alanındaki tüm crate’lerin testlerini çalıştıracaktır:
$ cargo test
Compiling bir_ekle v0.1.0 (file:///projects/topla/bir_ekle)
Compiling toplayici v0.1.0 (file:///projects/topla/toplayici)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.20s
Running unittests src/lib.rs (target/debug/deps/bir_ekle-93c49ee75dc46543)
running 1 test
test tests::calisir ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running unittests src/main.rs (target/debug/deps/toplayici-3a47283c568d2b6a)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests bir_ekle
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Çıktının ilk bölümü, bir_ekle crate’indeki calisir testinin geçtiğini gösterir. Bir sonraki bölüm toplayici crate’inde sıfır test bulunduğunu, son bölüm ise bir_ekle crate’inde sıfır belgelendirme testi bulunduğunu gösterir.
Üst düzey dizinden -p bayrağını kullanıp test etmek istediğimiz crate’in adını belirterek çalışma alanındaki belirli bir crate’in testlerini de çalıştırabiliriz:
$ cargo test -p bir_ekle
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.00s
Running unittests src/lib.rs (target/debug/deps/bir_ekle-93c49ee75dc46543)
running 1 test
test tests::calisir ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests bir_ekle
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Bu çıktı, cargo test komutunun yalnızca bir_ekle crate’i için testleri çalıştırdığını ve toplayici crate’inin testlerini çalıştırmadığını gösterir.
Çalışma alanındaki crate’leri crates.io üzerinde yayınlarsanız, çalışma alanındaki her crate’in ayrı ayrı yayınlanması gerekecektir. Tıpkı cargo test komutunda olduğu gibi, -p bayrağını kullanıp yayınlamak istediğimiz crate’in adını belirterek çalışma alanımızdaki belirli bir crate’i yayınlayabiliriz.
Ek pratik yapmak için bu çalışma alanına bir_ekle crate’ine benzer bir şekilde iki_ekle crate’ini ekleyin!
Projeniz büyüdükçe bir çalışma alanı kullanmayı değerlendirin: Tek parça büyük bir kod yığını yerine daha küçük ve anlaşılması daha kolay bileşenlerle çalışmanıza olanak tanır. Üstelik crate’ler sıklıkla aynı anda değiştiriliyorsa, onları bir çalışma alanında tutmak crate’ler arasındaki eşgüdümü de kolaylaştırabilir.
cargo install ile İkili Dosyaları Kurma
cargo install ile İkili Dosyaları Kurma
cargo install komutu, ikili crate’leri yerel olarak kurup kullanmanıza olanak tanır. Bu komut sistem paketlerinin yerini almak için değil; Rust geliştiricilerinin, başkaları tarafından crates.io üzerinde paylaşılan araçları pratik bir şekilde kurabilmesi için tasarlanmıştır. Yalnızca ikili hedefi olan paketleri kurabileceğinizi unutmayın. İkili hedef (binary target), crate içinde bir src/main.rs dosyası ya da ikili dosya olarak belirtilmiş başka bir dosya bulunduğunda oluşturulan çalıştırılabilir programdır; buna karşılık kütüphane hedefi tek başına çalıştırılamaz, ancak başka programlara dahil edilmeye uygundur. Crate’lerin README dosyalarında genellikle ilgili crate’in bir kütüphane mi olduğu, bir ikili hedefe mi sahip olduğu yoksa her ikisini birden mi sunduğu bilgisi yer alır.
cargo install ile kurulan tüm ikili dosyalar, kurulum kök dizininin bin klasöründe saklanır. Rust’ı rustup.rs kullanarak kurduysanız ve özel bir yapılandırma yapmadıysanız bu dizin $HOME/.cargo/bin olacaktır. cargo install ile kurduğunuz programları çalıştırabilmek için bu dizinin $PATH çevre değişkeninizde yer aldığından emin olun.
Örneğin Bölüm 12’de, dosyalarda arama yapmak için kullanılan grep aracının Rust ile yazılmış ripgrep adlı bir uygulaması olduğundan söz etmiştik. ripgrep’i kurmak için şu komutu çalıştırabiliriz:
$ cargo install ripgrep
Updating crates.io index
Downloaded ripgrep v14.1.1
Downloaded 1 crate (213.6 KB) in 0.40s
Installing ripgrep v14.1.1
--snip--
Compiling grep v0.3.2
Finished `release` profile [optimized + debuginfo] target(s) in 6.73s
Installing ~/.cargo/bin/rg
Installed package `ripgrep v14.1.1` (executable `rg`)
Çıktının sondan bir önceki satırı, kurulan ikili dosyanın konumunu ve adını gösterir; ripgrep örneğinde bu ad rg’dir. Daha önce de belirttiğimiz gibi, kurulum dizini $PATH içinde yer aldığı sürece rg --help komutunu çalıştırabilir ve dosyalarda arama yapmak için daha hızlı, daha “Rust usulü” bir araç kullanmaya başlayabilirsiniz!
Not: Günlük geliştirme sürecinde cargo install ile kurduğunuz araçları
yönetirken ve kendi projelerinizi yerel sisteminize kurarken şu pratik
komutlar ve bayraklar işinizi önemli ölçüde kolaylaştırır:
cargo install --list: Sisteminizecargo installile kurulmuş tüm paketleri, sürüm numaralarını ve her paketin sağladığı çalıştırılabilir ikili dosya adlarını (örneğinripgreppaketi altındarg) listeler.cargo uninstall <paket>: Belirtilen pakete ait ikili dosyaları kurulum dizininden kaldırır. Kaldırma işleminde çalıştırılabilir dosya adı (rg) yerine paket adını (cargo uninstall ripgrep) kullandığınıza dikkat edin.cargo install --path .: Kendi geliştirdiğiniz yerel bir ikili projeyi (örneğin Bölüm 12’de geliştirdiğimizminigreparacını) crates.io üzerinde yayınlamaya gerek kalmadanreleaseprofiliyle derleyip~/.cargo/bindizinine kurar; böylece aracınızı terminalin her yerinden doğrudan çalıştırabilirsiniz.cargo install --locked <paket>: Cargo varsayılan olarak paketin içinde gelen Cargo.lock dosyasını yok sayar ve bağımlılıkların kurulum anındaki en güncel uyumlu sürümlerini yeniden çözümler.--lockedbayrağı ise Cargo’yu, paket yayınlandığında yazarın test edip paketlediği Cargo.lock dosyasındaki bağımlılık sürümlerini birebir kullanmaya zorlar. Böylece hem tekrarlanabilir bir kurulum elde edersiniz hem de alt bağımlılıklarda sonradan yayınlanmış hatalı veya uyumsuz bir güncellemenin derlemeyi bozmasını önlersiniz.
Özel Komutlarla Cargo'yu Genişletme
Özel Komutlarla Cargo’yu Genişletme
Cargo, kendisinde değişiklik yapmanıza gerek kalmadan onu yeni alt komutlarla genişletebileceğiniz şekilde tasarlanmıştır. $PATH çevre değişkeninizdeki dizinlerde cargo-birsey adında bir ikili dosya varsa, cargo birsey yazarak onu sanki bir Cargo alt komutuymuş gibi çalıştırabilirsiniz. Bu tür özel komutlar, cargo --list komutunu çalıştırdığınızda da listelenir. Uzantıları cargo install ile kurup ardından tıpkı yerleşik Cargo araçları gibi çalıştırabilmek, Cargo tasarımının son derece kullanışlı bir avantajıdır!
Not: Rust ekosisteminde geliştiricilerin günlük iş akışlarını hızlandırmak
için cargo install ile kurup sıkça kullandığı pek çok özel Cargo alt komutu
bulunur:
cargo-edit(cargo upgradeve yerleşikcargo add): Cargo.toml dosyasını elle düzenlemeden komut satırından bağımlılık yönetmeyi sağlar; topluluk uzantısı olarak doğancargo addvecargo removekomutları yoğun ilgi üzerine Cargo’ya yerleşik olarak dahil edilirken, bağımlılıkları üst sürümlere yükseltencargo upgradekomutu bu paketle sunulmaya devam etmektedir.cargo-watch(cargo watch) vebacon: Projenizdeki kaynak dosyaları izleyerek her değişikliktecargo check,cargo testveyacargo rungibi komutları otomatik olarak yeniden çalıştırır.cargo-watchartık yalnızca bakım modunda olduğundan, bugün pek çok geliştirici aynı işi görenbaconaracını tercih etmektedir.cargo-nextest(cargo nextest): Her testi yalıtılmış ayrı bir süreçte paralel olarak yürüten yeni nesil test koşucusudur; özellikle büyük çalışma alanlarında standartcargo testkomutuna kıyasla çok daha hızlı çalışma süreleri ve okunaklı hata raporları sunar.cargo-auditvecargo-deny: Cargo.lock dosyasındaki bağımlılık ağacını RustSec güvenlik veri tabanına karşı tarayarak bilinen güvenlik açıklarını (cargo audit), ayrıca lisans uyumluluğunu, yasaklı paketleri ve mükerrer bağımlılık sürümlerini (cargo deny) denetler.cargo-expand(cargo expand):#[derive(...)]özniteliklerinin ve makroların derleyici tarafından açılmış (genişletilmiş) Rust kaynak kodunu göstererek makroların perde arkasında ne ürettiğini incelemenizi sağlar.cargo-bloat(cargo bloat): Derlenen çalıştırılabilir ikili dosyada hangi crate’lerin ve fonksiyonların en fazla yer kapladığını analiz ederek ikili dosya boyutunu iyileştirmenize yardımcı olur.
Özet
Cargo ve crates.io aracılığıyla kod paylaşmak, Rust ekosistemini pek çok farklı görev için kullanışlı kılan temel unsurlardan biridir. Rust’ın standart kütüphanesi küçük ve kararlıdır; ancak crate’leri paylaşmak, kullanmak ve dilin kendisinden bağımsız bir takvimde geliştirmek kolaydır. İşinize yarayan kodları crates.io üzerinde paylaşmaktan çekinmeyin; büyük olasılıkla başkalarının da işine yarayacaktır!
Akıllı İşaretçiler (Smart Pointers)
İşaretçi (pointer), bellekteki bir adresi barındıran değişkenler için kullanılan genel bir kavramdır. Bu adres başka bir veriye başvurur, diğer bir deyişle o veriyi “işaret eder”. Rust’taki en yaygın işaretçi türü, Bölüm 4’te öğrendiğiniz referanslardır. Referanslar & sembolüyle gösterilir ve işaret ettikleri değeri ödünç alırlar. Veriye başvurmak dışında özel bir yetenekleri yoktur ve herhangi bir ek maliyet getirmezler.
Akıllı işaretçiler ise bir işaretçi gibi davranan, ancak bunun yanı sıra ek meta veriler ve yetenekler de barındıran veri yapılarıdır. Akıllı işaretçi kavramı yalnızca Rust’a özgü değildir; kökeni C++ diline dayanır ve başka dillerde de bulunur. Rust’ın standart kütüphanesinde, referansların sunduğu işlevselliğin ötesine geçen çeşitli akıllı işaretçiler tanımlanmıştır. Genel kavramı keşfetmek için aralarında referans sayımlı (reference counting) bir akıllı işaretçi türünün de bulunduğu birkaç farklı örneği inceleyeceğiz. Bu işaretçi, sahiplerin sayısını takip ederek bir verinin birden fazla sahibi olmasına olanak tanır ve hiçbir sahip kalmadığında veriyi temizler.
Sahiplik ve ödünç alma kavramlarını barındıran Rust’ta, referanslar ile akıllı işaretçiler arasında bir fark daha vardır: Referanslar veriyi yalnızca ödünç alırken, akıllı işaretçiler çoğu durumda işaret ettikleri verinin sahibidir.
Akıllı işaretçiler genellikle struct’lar kullanılarak oluşturulur. Sıradan bir struct’tan farklı olarak akıllı işaretçiler Deref ve Drop trait’lerini uygular. Deref trait’i, akıllı işaretçi struct’ının bir örneğinin referans gibi davranmasını sağlar; böylece hem referanslarla hem de akıllı işaretçilerle çalışabilen kodlar yazabilirsiniz. Drop trait’i ise bir akıllı işaretçi örneği kapsam dışına çıktığında çalıştırılacak kodu özelleştirmenize olanak tanır. Bu bölümde her iki trait’i de ele alacak ve akıllı işaretçiler için neden önemli olduklarını göstereceğiz.
Akıllı işaretçi deseni Rust’ta sıkça kullanılan genel bir tasarım deseni olduğundan, bu bölüm mevcut tüm akıllı işaretçileri kapsamayacaktır. Pek çok kütüphanenin kendi akıllı işaretçileri vardır, hatta siz de kendinizinkini yazabilirsiniz. Biz burada standart kütüphanedeki en yaygın akıllı işaretçileri ele alacağız:
Box<T>: Değerleri öbekte (heap) ayırmak içinRc<T>: Çoklu sahipliği mümkün kılan referans sayımlı türRef<T>veRefMut<T>: Ödünç alma kurallarını derleme zamanı yerine çalışma zamanında uygulayanRefCell<T>türü üzerinden erişilen türler
Bunlara ek olarak, değiştirilemez bir türün kendi içindeki bir değeri değiştirmek üzere bir API sunduğu içsel değiştirilebilirlik (interior mutability) desenini inceleyeceğiz. Ayrıca referans döngülerini (reference cycles), bunların nasıl bellek sızıntısına yol açabileceğini ve nasıl önlenebileceğini de ele alacağız.
Not: Aslında Bölüm 8’de tanıştığınız String ve Vec<T> gibi türler de öbekteki belleğin sahibi oldukları, kapasite ve uzunluk gibi ek meta veriler tuttukları ve Deref ile Drop trait’lerini uyguladıkları için birer akıllı işaretçidir. Bu bölüme başlarken Rust’taki işaretçi ve akıllı işaretçi ailesini kuşbakışı olarak şöyle özetleyebiliriz:
&Tve&mut T(Normal referanslar): Verinin sahibi olmadan yalnızca ödünç alır; ek bellek veya çalışma zamanı maliyeti yoktur.Box<T>: Veriyi yığın (stack) yerine öbekte tutar ve derleme zamanında denetlenen tek sahiplik sağlar.Rc<T>(ve çok thread’li — çok iş parçacıklı — eşdeğeriArc<T>): Referans sayımıyla aynı veriye çoklu sahiplik olanağı tanır (Arc<T>türünü Bölüm 16’da göreceğiz).RefCell<T>(ve çok thread’li eşdeğeriMutex<T>): İçsel değiştirilebilirlik sağlar ve ödünç alma kurallarını derleme zamanı yerine çalışma zamanında denetler.DerefveDroptrait’leri: Bir struct’ı akıllı işaretçi yapan temel yapı taşlarıdır;Derefreferans benzeri başvuru çözme (*) ve deref zorlaması sağlarken,Dropdeğer kapsam dışına çıktığında bellek ve kaynak temizliğini otomatikleştirir.
Haydi başlayalım!
Öbekteki Veriyi İşaret Etmek İçin Box<T> Kullanımı
Öbekteki Veriyi İşaret Etmek İçin Box<T> Kullanımı
En yalın akıllı işaretçi, türü Box<T> biçiminde yazılan bir kutudur. Kutular (boxes), verileri yığın (stack) yerine öbekte (heap) saklamanıza olanak tanır. Yığında kalan şey ise öbekteki veriye işaret eden işaretçinin kendisidir. Yığın ile öbek arasındaki farkları yeniden gözden geçirmek için Bölüm 4’e bakabilirsiniz.
Kutuların, verilerini yığın yerine öbekte saklamak dışında ek bir performans maliyeti yoktur. Öte yandan çok fazla ekstra yetenek de sunmazlar. Kutuları en sık şu durumlarda kullanırsınız:
- Boyutu derleme zamanında bilinemeyen bir türünüz olduğunda ve bu türden bir değeri kesin boyut gerektiren bir bağlamda kullanmak istediğinizde
- Büyük miktarda veriniz olduğunda ve bu verinin sahipliğini aktarırken kopyalanmadığından emin olmak istediğinizde
- Bir değerin sahibi olmak istediğinizde ve bu değerin belirli bir somut türde olmasından ziyade yalnızca belirli bir trait’i uygulayan bir tür olmasını önemsediğinizde
İlk durumu “Kutularla Özyineli Türleri Mümkün Kılmak” alt bölümünde göstereceğiz. İkinci durumda, büyük miktarda verinin sahipliğini aktarmak uzun sürebilir; çünkü veri yığın üzerinde kopyalanarak taşınır. Bu durumda performansı artırmak için büyük miktardaki veriyi bir kutu içinde öbekte saklayabiliriz. Böylece yığın üzerinde yalnızca küçük boyutlu işaretçi verisi kopyalanırken, başvurulan asıl veri öbekte tek bir yerde sabit kalır. Üçüncü durum ise trait nesnesi (trait object) olarak bilinir ve Bölüm 18’deki “Ortak Davranışı Soyutlamak İçin Trait Nesnelerini Kullanma” bölümü tamamen bu konuya ayrılmıştır. Dolayısıyla burada öğrendiklerinizi o bölümde yeniden uygulayacaksınız!
Veriyi Öbekte Saklamak
Box<T> türünün öbekte veri saklama kullanım senaryosunu ele almadan önce, sözdizimini ve bir Box<T> içinde saklanan değerlerle nasıl etkileşim kurulduğunu inceleyelim.
Liste 15-1, bir i32 değerini öbekte saklamak için kutunun nasıl kullanıldığını göstermektedir.
fn main() {
let k = Box::new(5);
println!("k = {k}");
}
i32 değerini kutu kullanarak öbekte saklamakBurada k değişkenini, öbekte ayrılmış 5 değerine işaret eden bir Box değerine sahip olacak şekilde tanımlıyoruz. Bu program ekrana k = 5 yazdırır; bu örnekte kutudaki veriye, sanki bu veri yığındaymış gibi erişebiliriz. Sahip olunan her değerde olduğu gibi, bir kutu da kapsam dışına çıktığında — tıpkı k değişkeninin main fonksiyonunun sonunda çıktığı gibi — bellekten serbest bırakılır. Bellekten serbest bırakma işlemi hem (yığında saklanan) kutunun kendisi hem de (öbekte saklanan) işaret ettiği veri için gerçekleşir.
Tek bir değeri öbeğe koymak pek kullanışlı değildir; bu nedenle kutuları tek başlarına bu şekilde çok sık kullanmazsınız. Tek bir i32 gibi değerleri, varsayılan olarak zaten saklandıkları yer olan yığında tutmak çoğu durumda çok daha uygundur. Şimdi, kutular olmasaydı hiç tanımlayamayacağımız türleri kutular sayesinde nasıl tanımlayabildiğimize bakalım.
Kutularla Özyineli Türleri Mümkün Kılmak
Özyineli bir türün (recursive type) değeri, kendi parçası olarak aynı türden başka bir değere sahip olabilir. Özyineli türler bir sorun oluşturur; çünkü Rust’ın bir türün bellekte ne kadar yer kapladığını derleme zamanında bilmesi gerekir. Ne var ki özyineli türlerde değerlerin iç içe geçmesi kuramsal olarak sonsuza dek sürebileceğinden, Rust değerin ne kadar alana ihtiyaç duyduğunu bilemez. Kutular bilinen bir boyuta sahip olduğu için, özyineli tür tanımının içine bir kutu yerleştirerek özyineli türleri mümkün kılabiliriz.
Özyineli bir tür örneği olarak cons listesini (cons list) inceleyelim. Bu, fonksiyonel programlama dillerinde yaygın olarak karşılaşılan bir veri türüdür. Tanımlayacağımız cons listesi türü özyineleme dışında oldukça yalındır; dolayısıyla üzerinde çalışacağımız örnekteki kavramlar, özyineli türler içeren daha karmaşık durumlarla her karşılaştığınızda işinize yarayacaktır.
Cons Listesini Anlamak
Cons listesi, Lisp programlama dili ve lehçelerinden gelen, iç içe geçmiş çiftlerden oluşan ve bağlı listenin (linked list) Lisp dünyasındaki karşılığı olan bir veri yapısıdır. Adını, Lisp’te iki argümanından yeni bir çift oluşturan cons fonksiyonundan (construct function ifadesinin kısaltması) alır. Bir değer ve başka bir çiftten oluşan bir çift üzerinde cons fonksiyonunu çağırarak özyineli çiftlerden oluşan cons listeleri kurabiliriz.
Örneğin 1, 2, 3 listesini içeren bir cons listesinin her çiftin parantez içinde gösterildiği sözde kod gösterimi şöyledir:
(1, (2, (3, Nil)))
Bir cons listesindeki her öğe iki eleman içerir: mevcut öğenin değeri ve bir sonraki öğe. Listedeki son öğe ise bir sonraki öğe olmaksızın yalnızca Nil adlı bir değer içerir. Cons listesi, cons fonksiyonunun özyineli biçimde çağrılmasıyla üretilir. Özyinelemenin taban durumunu (base case) belirtmek için kullanılan geleneksel ad Nil’dir. Bunun, Bölüm 6’da ele aldığımız geçersiz veya mevcut olmayan bir değeri ifade eden “null” ya da “nil” kavramıyla aynı şey olmadığına dikkat edin.
Cons listesi Rust’ta sık kullanılan bir veri yapısı değildir. Rust’ta bir öğe listesine sahip olduğunuz çoğu durumda Vec<T> kullanmak daha iyi bir seçenektir. Diğer, daha karmaşık özyineli veri türleri çeşitli senaryolarda gerçekten kullanışlıdır; ancak bu bölümde cons listesiyle başlayarak, dikkat dağıtıcı ayrıntılara boğulmadan kutuların özyineli bir veri türünü tanımlamamıza nasıl olanak tanıdığını keşfedebiliriz.
Liste 15-2, bir cons listesi için enum tanımı içermektedir (burada Lisp’teki Cons çiftini Dugum, Nil taban durumunu ise Bos varyantıyla temsil ediyoruz). Bu kodun henüz derlenmeyeceğine dikkat edin; çünkü birazdan göstereceğimiz üzere Liste türünün bilinen bir boyutu yoktur.
enum Liste {
Dugum(i32, Liste),
Bos,
}
fn main() {}
i32 değerlerinden oluşan bir cons listesi veri yapısını temsil etmek için ilk enum tanımlama denemesiNot: Bu örneğin amacı doğrultusunda yalnızca i32 değerlerini tutan bir cons
listesi uyguluyoruz. Bölüm 10’da ele aldığımız gibi jenerikleri kullanarak her
türden değeri saklayabilen bir cons listesi türü de tanımlayabilirdik.
1, 2, 3 listesini saklamak için Liste türünü kullanmak, Liste 15-3’teki kod gibi görünürdü.
enum Liste {
Dugum(i32, Liste),
Bos,
}
// --snip--
use crate::Liste::{Bos, Dugum};
fn main() {
let liste = Dugum(1, Dugum(2, Dugum(3, Bos)));
}
1, 2, 3 listesini saklamak için Liste enum’ını kullanmakİlk Dugum değeri 1 sayısını ve başka bir Liste değerini tutar. Bu Liste değeri, 2 sayısını ve bir başka Liste değerini tutan yeni bir Dugum değeridir. Bu Liste değeri ise 3 sayısını ve bir Liste değeri daha tutan bir diğer Dugum değeridir; en içteki bu son Liste değeri de listenin bittiğini belirten, özyineli olmayan Bos varyantıdır.
Liste 15-3’teki kodu derlemeye çalışırsak Liste 15-4’te gösterilen hatayı alırız.
$ cargo run
Compiling kons-liste v0.1.0 (file:///projects/kons-liste)
error[E0072]: recursive type `Liste` has infinite size
--> src/main.rs:1:1
|
1 | enum Liste {
| ^^^^^^^^^^
2 | Dugum(i32, Liste),
| ----- recursive without indirection
|
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
|
2 | Dugum(i32, Box<Liste>),
| ++++ +
For more information about this error, try `rustc --explain E0072`.
error: could not compile `kons-liste` (bin "kons-liste") due to 1 previous error
Hata mesajı bu türün “sonsuz boyuta sahip olduğunu” (has infinite size) belirtir. Bunun nedeni, Liste türünü özyineli bir varyantla tanımlamış olmamızdır: Varyant, kendi türünden başka bir değeri doğrudan içinde tutar. Bu yüzden Rust, bir Liste değerini saklamak için ne kadar alana ihtiyaç duyduğunu hesaplayamaz. Bu hatayı neden aldığımızı adım adım inceleyelim. Öncelikle Rust’ın özyineli olmayan bir türün değerini saklamak için ne kadar alana ihtiyaç duyduğuna nasıl karar verdiğine bakalım.
Özyineli Olmayan Bir Türün Boyutunu Hesaplamak
Bölüm 6’da enum tanımlarını ele alırken Liste 6-2’de tanımladığımız Mesaj enum’ını hatırlayın:
enum Mesaj {
Cik,
Tasi { x: i32, y: i32 },
Yaz(String),
RenkDegistir(i32, i32, i32),
}
fn main() {}
Bir Mesaj değeri için ne kadar alan ayıracağını belirlemek amacıyla Rust, varyantların her birini tek tek inceler ve hangisinin en fazla alana ihtiyaç duyduğunu kontrol eder. Derleyici, Mesaj::Cik varyantının hiç alana ihtiyaç duymadığını, Mesaj::Tasi varyantının iki i32 değerini saklayacak kadar alana ihtiyaç duyduğunu ve diğer varyantların gereksinimlerini görür. Aynı anda yalnızca tek bir varyant kullanılacağı için, bir Mesaj değerinin ihtiyaç duyacağı en fazla alan, varyantlarının en büyüğünü saklamak için gereken alan kadardır.
Şimdi bunu, Rust derleyicisi Liste 15-2’deki Liste enum’ı gibi özyineli bir türün ne kadar alana ihtiyaç duyduğunu belirlemeye çalıştığında olanlarla karşılaştırın. Derleyici işe Dugum varyantına bakarak başlar; bu varyant i32 türünde bir değer ile Liste türünde bir değer tutmaktadır. Dolayısıyla Dugum, bir i32 boyutu artı bir Liste boyutu kadar alana ihtiyaç duyar. Liste türünün ne kadar belleğe ihtiyaç duyduğunu bulmak için derleyici varyantlara yeniden Dugum varyantından başlayarak bakar. Dugum varyantı yine i32 türünde bir değer ve Liste türünde bir değer tutar; bu süreç Şekil 15-1’de gösterildiği gibi sonsuza dek devam eder.
Şekil 15-1: Sonsuz Dugum (görselde Cons) varyantlarından oluşan sonsuz bir Liste
Boyutu Bilinen Özyineli Bir Tür Elde Etmek
Rust özyineli olarak tanımlanmış türler için ne kadar alan ayıracağını hesaplayamadığından, derleyici şu yararlı öneriyi içeren bir hata verir:
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
|
2 | Dugum(i32, Box<Liste>),
| ++++ +
Bu önerideki dolaylılık (indirection) terimi, bir değeri doğrudan kendi içinde saklamak yerine veri yapısını değiştirerek o değere işaret eden bir işaretçi saklamak ve böylece değeri dolaylı yoldan tutmak anlamına gelir.
Box<T> bir işaretçi olduğu için Rust bir Box<T> değerinin ne kadar alana ihtiyaç duyduğunu her zaman bilir: Bir işaretçinin boyutu, işaret ettiği verinin miktarına bağlı olarak değişmez. Bu da Dugum varyantının içine doğrudan başka bir Liste değeri koymak yerine bir Box<T> koyabileceğimiz anlamına gelir. Box<T>, Dugum varyantının içinde değil öbekte yer alacak bir sonraki Liste değerine işaret edecektir. Kavramsal olarak hâlâ diğer listeleri barındıran listelerden oluşturulmuş bir listemiz vardır; ancak bu uygulama artık öğeleri birbiri içine yerleştirmekten ziyade yan yana dizmeye benzer.
Liste 15-2’deki Liste enum’ı tanımını ve Liste 15-3’teki Liste kullanımını, sorunsuzca derlenecek olan Liste 15-5’teki koda dönüştürebiliriz.
enum Liste {
Dugum(i32, Box<Liste>),
Bos,
}
use crate::Liste::{Bos, Dugum};
fn main() {
let liste = Dugum(1, Box::new(Dugum(2, Box::new(Dugum(3, Box::new(Bos))))));
}
Box<T> kullanan Liste tanımıDugum varyantı, bir i32 boyutu artı kutunun işaretçi verisini saklamak için gereken alana ihtiyaç duyar. Bos varyantı hiçbir değer saklamaz; dolayısıyla yığında Dugum varyantından daha az alana ihtiyaç duyar. Artık herhangi bir Liste değerinin bir i32 boyutu artı bir kutunun işaretçi verisi kadar yer kaplayacağını biliyoruz. Kutu kullanarak sonsuz ve özyineli zinciri kırdık; böylece derleyici bir Liste değerini saklamak için ihtiyaç duyduğu boyutu hesaplayabilir. Şekil 15-2, Dugum varyantının artık nasıl göründüğünü göstermektedir.
Şekil 15-2: Dugum (görselde Cons) bir Box tuttuğu için boyutu sonsuz olmayan bir Liste
Not: Şekil 15-2’de Box kutusunun içinde gördüğünüz usize, hedef işlemci
mimarisinin bellek adresi genişliğini ifade eder. Bir Box<T> değeri, öbekte
işaret ettiği T verisi ister 4 bayt ister 4 megabayt olsun, yığında yalnızca
tek bir usize genişliğinde (64-bit mimaride 8 bayt, 32-bit mimaride
4 bayt) bir işaretçi olarak durur; kapasite veya referans sayacı gibi hiçbir
ek meta veri taşımaz.
Üstelik Rust derleyicisi, bir Box<T> işaretçisinin tuttuğu bellek adresinin
asla sıfır (null) olamayacağını garanti eder. Bu garanti sayesinde
derleyici boş işaretçi optimizasyonu (null-pointer optimization) uygular:
Örneğin Option<Box<T>> kullandığınızda None varyantını temsil etmek için
fazladan bayt harcamak yerine doğrudan 0 adresini kullanır; böylece 64-bit
bir sistemde hem Box<T> hem de Option<Box<T>> yığında ek bellek
tüketmeden tam olarak 8 bayt yer kaplar
(std::mem::size_of::<Option<Box<i32>>>() == 8). Aynı optimizasyon Liste
15-5’teki Liste enum’ı için de geçerlidir: Bos varyantı Box<Liste>
içindeki sıfır adresi boşluğundan yararlandığı için fazladan bir ayrım
etiketi eklenmez ve tüm Liste türü yığında yalnızca 16 bayt (4 bayt
i32 + 4 bayt hizalama dolgusu + 8 bayt Box işaretçisi) yer tutar.
Kutular yalnızca bu dolaylılığı ve öbekte bellek ayırmayı sağlar; diğer akıllı işaretçi türlerinde göreceğimiz gibi başka özel yeteneklere sahip değildir. Ayrıca bu özel yeteneklerin getirdiği ek performans maliyetini de taşımazlar; bu nedenle cons listesi gibi ihtiyaç duyduğumuz tek özelliğin dolaylılık olduğu durumlarda son derece kullanışlıdırlar. Kutuların diğer kullanım senaryolarını Bölüm 18’de inceleyeceğiz.
Box<T> türü bir akıllı işaretçidir; çünkü Box<T> değerlerine referanslar gibi davranılmasını sağlayan Deref trait’ini uygular. Bir Box<T> değeri kapsam dışına çıktığında, kutunun işaret ettiği öbek verisi de Drop trait’i uygulaması sayesinde temizlenir. Bu iki trait, bu bölümün geri kalanında ele alacağımız diğer akıllı işaretçi türlerinin sunduğu işlevsellik açısından daha da büyük önem taşıyacaktır. Gelin şimdi bu iki trait’i daha ayrıntılı olarak inceleyelim.
Akıllı İşaretçilere Normal Referanslar Gibi Davranma
Akıllı İşaretçilere Normal Referanslar Gibi Davranma
Deref trait’ini uygulamak, başvuru çözme operatörü (dereference operator) olan * sembolünün (çarpma veya glob operatörüyle karıştırılmamalıdır) davranışını özelleştirmenize olanak tanır. Deref trait’ini bir akıllı işaretçiye normal bir referans gibi davranılabilecek biçimde uygulayarak, referanslar üzerinde çalışan kodlar yazabilir ve aynı kodu akıllı işaretçilerle de kullanabilirsiniz.
Öncelikle başvuru çözme operatörünün normal referanslarla nasıl çalıştığına bakalım. Ardından Box<T> gibi davranan özel bir tür tanımlamayı deneyecek ve başvuru çözme operatörünün yeni tanımladığımız bu tür üzerinde neden bir referanstaki gibi çalışmadığını göreceğiz. Deref trait’ini uygulamanın, akıllı işaretçilerin referanslara benzer şekillerde çalışmasını nasıl mümkün kıldığını inceleyeceğiz. Son olarak da Rust’ın deref zorlaması (deref coercion) özelliğine ve bu özelliğin hem referanslarla hem de akıllı işaretçilerle çalışmamıza nasıl imkân tanıdığına göz atacağız.
Referansı İzleyerek Değere Ulaşmak
Normal bir referans bir işaretçi türüdür ve bir işaretçiyi düşünmenin yollarından biri, onu başka bir yerde saklanan bir değere uzanan bir ok olarak hayal etmektir. Liste 15-6’da bir i32 değerine referans oluşturuyor ve ardından referansı izleyip değere ulaşmak için başvuru çözme operatörünü kullanıyoruz.
fn main() {
let sayi = 5;
let referans = &sayi;
assert_eq!(5, sayi);
assert_eq!(5, *referans);
}
i32 değerine işaret eden referansı başvuru çözme operatörüyle izlemeksayi değişkeni 5 değerinde bir i32 tutar. referans değişkenini sayi değişkenine bir referans olacak şekilde ayarlarız. sayi değişkeninin 5’e eşit olduğunu doğrulayabiliriz. Ancak referans içindeki değer hakkında bir doğrulama yapmak istiyorsak, derleyicinin gerçek değeri karşılaştırabilmesi için *referans yazarak referansı işaret ettiği değere kadar izlememiz (bu işleme başvuru çözme denir) gerekir. referans üzerinde başvuru çözme yaptığımızda, referans’ın işaret ettiği tam sayı değerine erişir ve bu değeri 5 ile karşılaştırabiliriz.
Bunun yerine assert_eq!(5, referans); yazmayı deneseydik şu derleme hatasını alırdık:
$ cargo run
Compiling deref-ornegi v0.1.0 (file:///projects/deref-ornegi)
error[E0277]: can't compare `{integer}` with `&{integer}`
--> src/main.rs:6:5
|
6 | assert_eq!(5, referans);
| ^^^^^^^^^^^^^^^^^^^^^^^ no implementation for `{integer} == &{integer}`
|
= help: the trait `PartialEq<&{integer}>` is not implemented for `{integer}`
= help: the following other types implement trait `PartialEq<Rhs>`:
f128
f16
f32
f64
i128
i16
i32
i64
and 8 others
For more information about this error, try `rustc --explain E0277`.
error: could not compile `deref-ornegi` (bin "deref-ornegi") due to 1 previous error
Bir sayıyı ve bir sayıya referansı karşılaştırmaya izin verilmez; çünkü bunlar farklı türlerdir ({integer} ve &{integer}). Derleyicinin E0277 hatasında belirttiği üzere tam sayı türü için &{integer} ile eşitlik karşılaştırması (PartialEq<&{integer}>) tanımlı değildir. Referansın işaret ettiği değere ulaşmak için başvuru çözme operatörünü kullanmamız gerekir.
Box<T>’yi Referans Gibi Kullanmak
Liste 15-6’daki kodu referans yerine bir Box<T> kullanacak şekilde yeniden yazabiliriz; Liste 15-7’de Box<T> üzerinde kullanılan başvuru çözme operatörü, Liste 15-6’da referans üzerinde kullanılan başvuru çözme operatörüyle birebir aynı şekilde çalışır.
fn main() {
let sayi = 5;
let kutu = Box::new(sayi);
assert_eq!(5, sayi);
assert_eq!(5, *kutu);
}
Box<i32> üzerinde başvuru çözme operatörünü kullanmakListe 15-7 ile Liste 15-6 arasındaki temel fark, burada kutu değişkenini sayi değerine işaret eden bir referans yerine, sayi değerinin öbekteki bir kopyasına işaret eden bir kutu örneği olarak ayarlamamızdır. Son doğrulamada, Liste 15-6’da referans değişkeniyle yaptığımız gibi kutunun işaretçisini izlemek için başvuru çözme operatörünü (*kutu) kullanabiliriz. Şimdi kendi kutu türümüzü tanımlayarak, Box<T>’yi başvuru çözme operatörünü kullanmamıza olanak tanıyacak şekilde özel kılan şeyin ne olduğunu keşfedelim.
Kendi Akıllı İşaretçimizi Tanımlamak
Akıllı işaretçi türlerinin varsayılan olarak referanslardan nasıl farklı davrandığını deneyimlemek için standart kütüphanenin sunduğu Box<T> türüne benzer sarmalayıcı bir tür oluşturalım. Ardından başvuru çözme operatörünü kullanma yeteneğini nasıl ekleyeceğimize bakacağız.
Not: Birazdan oluşturacağımız BenimKutu<T> türü ile gerçek Box<T> arasında
büyük bir fark vardır: Bizim sürümümüz verisini öbekte saklamayacaktır. Bu
örnekte Deref trait’ine odaklandığımız için, verinin gerçekte nerede
saklandığı işaretçi benzeri davranıştan daha az önemlidir.
Box<T> türü özünde tek elemanlı bir demet struct’ı olarak tanımlanır; bu yüzden Liste 15-8 de BenimKutu<T> türünü aynı şekilde tanımlar. Ayrıca Box<T> üzerinde tanımlı new fonksiyonuna karşılık gelecek bir yeni fonksiyonu da tanımlayacağız.
struct BenimKutu<T>(T);
impl<T> BenimKutu<T> {
fn yeni(x: T) -> BenimKutu<T> {
BenimKutu(x)
}
}
fn main() {}
BenimKutu<T> türü tanımlamakTürümüzün her türden değeri tutabilmesini istediğimiz için BenimKutu adında bir struct tanımlıyor ve jenerik bir T parametresi bildiriyoruz. BenimKutu türü, T türünde tek bir elemana sahip bir demet struct’ıdır. BenimKutu::yeni fonksiyonu T türünde bir parametre alır ve aktarılan değeri tutan bir BenimKutu örneği döndürür.
Liste 15-7’deki main fonksiyonunu Liste 15-8’e eklemeyi ve Box<T> yerine tanımladığımız BenimKutu<T> türünü kullanacak şekilde değiştirmeyi deneyelim. Liste 15-9’daki kod derlenmeyecektir; çünkü Rust, BenimKutu üzerinde nasıl başvuru çözme yapacağını bilmez.
struct BenimKutu<T>(T);
impl<T> BenimKutu<T> {
fn yeni(x: T) -> BenimKutu<T> {
BenimKutu(x)
}
}
fn main() {
let sayi = 5;
let kutu = BenimKutu::yeni(sayi);
assert_eq!(5, sayi);
assert_eq!(5, *kutu);
}
BenimKutu<T> türünü referansları ve Box<T>’yi kullandığımız şekilde kullanmayı denemekOrtaya çıkan derleme hatası şöyledir:
$ cargo run
Compiling deref-ornegi v0.1.0 (file:///projects/deref-ornegi)
error[E0614]: type `BenimKutu<{integer}>` cannot be dereferenced
--> src/main.rs:14:19
|
14 | assert_eq!(5, *kutu);
| ^^^^^ can't be dereferenced
For more information about this error, try `rustc --explain E0614`.
error: could not compile `deref-ornegi` (bin "deref-ornegi") due to 1 previous error
BenimKutu<T> türümüz üzerinde bu yeteneği henüz uygulamadığımız için başvuru çözme işlemi yapılamaz (type 'BenimKutu<{integer}>' cannot be dereferenced). * operatörüyle başvuru çözmeyi etkinleştirmek için Deref trait’ini uygularız.
Deref Trait’ini Uygulamak
Bölüm 10’daki “Bir Tür Üzerinde Trait Uygulama” başlığında ele aldığımız gibi, bir trait’i uygulamak için trait’in zorunlu kıldığı metotların uygulamalarını sağlamamız gerekir. Standart kütüphane tarafından sunulan Deref trait’i, self’i ödünç alan ve içteki veriye bir referans döndüren deref adında tek bir metodu uygulamamızı ister. Liste 15-10, BenimKutu<T> tanımına eklenecek Deref uygulamasını içerir.
use std::ops::Deref;
impl<T> Deref for BenimKutu<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.0
}
}
struct BenimKutu<T>(T);
impl<T> BenimKutu<T> {
fn yeni(x: T) -> BenimKutu<T> {
BenimKutu(x)
}
}
fn main() {
let sayi = 5;
let kutu = BenimKutu::yeni(sayi);
assert_eq!(5, sayi);
assert_eq!(5, *kutu);
}
BenimKutu<T> üzerinde Deref trait’ini uygulamaktype Target = T; sözdizimi, Deref trait’inin kullanması için bir ilişkili tür (associated type) tanımlar. İlişkili türler jenerik bir parametre bildirmenin biraz farklı bir yoludur, ancak şimdilik bunlar için endişelenmenize gerek yoktur; ilişkili türleri Bölüm 20’de daha ayrıntılı olarak ele alacağız.
deref metodunun * operatörüyle erişmek istediğimiz değere bir referans döndürmesi için metot gövdesini &self.0 ile dolduruyoruz; Bölüm 5’teki “Demet Struct’ları ile Farklı Türler Oluşturma” başlığından .0 sözdiziminin bir demet struct’ındaki ilk değere eriştiğini hatırlayın. Liste 15-9’da BenimKutu<T> değeri üzerinde * çağıran main fonksiyonu artık derlenir ve doğrulamalar başarıyla geçer!
Deref trait’i olmadan derleyici yalnızca & referanslarının başvurusunu çözebilir. deref metodu derleyiciye, Deref trait’ini uygulayan herhangi bir türdeki değeri alıp deref metodunu çağırarak nasıl çözeceğini bildiği bir & referansı elde etme yeteneği kazandırır.
Liste 15-9’da *kutu yazdığımızda, perde arkasında Rust aslında şu kodu çalıştırdı:
*(kutu.deref())
Rust, deref metodunu çağırmamız gerekip gerekmediğini düşünmek zorunda kalmamamız için * operatörünü önce bir deref metodu çağrısıyla, ardından da sıradan bir başvuru çözme işlemiyle değiştirir. Rust’ın bu özelliği, elimizde ister normal bir referans ister Deref uygulayan bir tür olsun, birebir aynı şekilde çalışan kodlar yazmamızı sağlar.
deref metodunun bir değere referans döndürmesinin ve *(kutu.deref()) ifadesinde parantez dışındaki sıradan başvuru çözme işleminin hâlâ gerekli olmasının nedeni sahiplik sistemiyle ilgilidir. Eğer deref metodu değere referans yerine doğrudan değerin kendisini döndürseydi, değer self’in dışına taşınmış (move) olurdu. Bu örnekte —ve başvuru çözme operatörünü kullandığımız çoğu durumda— BenimKutu<T> içindeki değerin sahipliğini devralmak istemeyiz.
Kodumuzda her * kullandığımızda, * operatörünün bir deref metodu çağrısı ve ardından yalnızca bir kez * operatörü çağrısı ile değiştirildiğine dikkat edin. * operatörünün bu dönüşümü sonsuza dek özyineli olarak tekrarlanmadığı için sonunda Liste 15-9’daki assert_eq! makrosunda yer alan 5 ile eşleşen i32 türünde veriyi elde ederiz.
Not: *(kutu.deref()) dönüşümünün sahiplik açısından sağladığı korumayı somut
olarak görmek için kutu değişkeninin Copy olmayan bir BenimKutu<String>
olduğunu düşünün. kutu.deref() çağrısı &self üzerinden ödünç alarak
&String döndürür; dıştaki * operatörü ise değeri taşımak yerine bellekteki
veriyi temsil eden bir konum ifadesi (place expression) üretir. Böylece
&*kutu ile referans alabilir veya içteki değerin metotlarını sahipliği
bozmadan çağırabilirsiniz. Buna karşılık let s = *kutu; yazarak içteki
String’i doğrudan dışarı taşımaya kalkışırsanız derleyici error[E0507]: cannot move out of dereference of BenimKutu<String> (BenimKutu<String>
başvuru çözmesinden dışarı taşıma yapılamaz) hatasıyla akıllı işaretçinin
bütünlüğünü korur. Liste 15-9’da *kutu ifadesinin doğrudan çalışmasının
nedeni ise i32 türünün Copy trait’ini uygulamasıdır; derleyici konumdan
değeri taşımak yerine, bit düzeyinde kopyalayarak okur.
Gerçek Box<T> ise bu kuralın derleyiciye yerleşik tek istisnasıdır: Kutu,
içindeki değerin tek sahibi olduğundan let s = *kutu; yazarak değeri
sahipliğiyle birlikte dışarı taşıyabilirsiniz. Deref uygulayan başka hiçbir
tür (Rc<T> dahil) bu olanağı sunmaz.
Fonksiyon ve Metotlarda Deref Zorlaması Kullanmak
Deref zorlaması, Deref trait’ini uygulayan bir türe ait referansı başka bir türe ait referansa dönüştürür. Örneğin String türü, &str döndürecek şekilde Deref trait’ini uyguladığı için deref zorlaması &String’i &str’ye dönüştürebilir. Deref zorlaması, Rust’ın fonksiyon ve metot argümanları üzerinde gerçekleştirdiği bir kolaylıktır ve yalnızca Deref trait’ini uygulayan türler üzerinde çalışır. Belirli bir türdeki değere ait referansı, fonksiyon veya metot tanımındaki parametre türüyle eşleşmeyen bir argüman olarak aktardığımızda otomatik olarak devreye girer. Arka arkaya yapılan bir dizi deref metodu çağrısı, sağladığımız türü parametrenin ihtiyaç duyduğu türe dönüştürür.
Deref zorlaması Rust’a, fonksiyon ve metot çağrıları yazan programcıların & ve * ile çok sayıda açık referans alma ve başvuru çözme işlemi eklemek zorunda kalmaması için dahil edilmiştir. Deref zorlaması özelliği ayrıca hem referanslarla hem de akıllı işaretçilerle çalışabilen daha fazla kod yazmamıza olanak tanır.
Deref zorlamasını çalışırken görmek için Liste 15-8’de tanımladığımız BenimKutu<T> türünü ve Liste 15-10’da eklediğimiz Deref uygulamasını kullanalım. Liste 15-11, string dilimi parametresine sahip bir fonksiyonun tanımını göstermektedir.
fn merhaba(isim: &str) {
println!("Merhaba, {isim}!");
}
fn main() {}
&str türünde isim parametresine sahip bir merhaba fonksiyonumerhaba fonksiyonunu argüman olarak örneğin merhaba("Rust"); şeklinde bir string dilimiyle çağırabiliriz. Deref zorlaması ise Liste 15-12’de gösterildiği gibi merhaba fonksiyonunu BenimKutu<String> türündeki bir değere referansla çağırmayı mümkün kılar.
use std::ops::Deref;
impl<T> Deref for BenimKutu<T> {
type Target = T;
fn deref(&self) -> &T {
&self.0
}
}
struct BenimKutu<T>(T);
impl<T> BenimKutu<T> {
fn yeni(x: T) -> BenimKutu<T> {
BenimKutu(x)
}
}
fn merhaba(isim: &str) {
println!("Merhaba, {isim}!");
}
fn main() {
let kutu = BenimKutu::yeni(String::from("Rust"));
merhaba(&kutu);
}
merhaba fonksiyonunu bir BenimKutu<String> değerine referansla çağırmakBurada merhaba fonksiyonunu, bir BenimKutu<String> değerine referans olan &kutu argümanıyla çağırıyoruz. Liste 15-10’da BenimKutu<T> üzerinde Deref trait’ini uyguladığımız için Rust, deref metodunu çağırarak &BenimKutu<String> türünü &String türüne dönüştürebilir. Standart kütüphane, String üzerinde string dilimi döndüren bir Deref uygulaması sunar ve bu durum Deref’in API belgelerinde yer alır. Rust, &String’i &str’ye dönüştürmek için deref metodunu bir kez daha çağırır ve böylece merhaba fonksiyonunun tanımıyla eşleşme sağlanır.
Eğer Rust deref zorlamasını uygulamasaydı, merhaba fonksiyonunu &BenimKutu<String> türünde bir değerle çağırmak için Liste 15-12’deki kod yerine Liste 15-13’teki kodu yazmamız gerekirdi.
use std::ops::Deref;
impl<T> Deref for BenimKutu<T> {
type Target = T;
fn deref(&self) -> &T {
&self.0
}
}
struct BenimKutu<T>(T);
impl<T> BenimKutu<T> {
fn yeni(x: T) -> BenimKutu<T> {
BenimKutu(x)
}
}
fn merhaba(isim: &str) {
println!("Merhaba, {isim}!");
}
fn main() {
let kutu = BenimKutu::yeni(String::from("Rust"));
merhaba(&(*kutu)[..]);
}
(*kutu) ifadesi BenimKutu<String> değerinin başvurusunu çözerek bir String elde eder. Ardından & ve [..], merhaba fonksiyonunun imzasıyla eşleşmek üzere String’in tamamına eşit bir string dilimi alır. Deref zorlaması olmadan yazılan bu kodu tüm bu sembollerle okumak, yazmak ve anlamak daha güçtür. Deref zorlaması, Rust’ın bu dönüşümleri bizim adımıza otomatik olarak ele almasını sağlar.
İlgili türler için Deref trait’i tanımlandığında Rust, türleri analiz eder ve parametrenin türüyle eşleşecek bir referans elde etmek için gerektiği kadar Deref::deref kullanır. Kaç kez Deref::deref çağrısı ekleneceği derleme zamanında çözümlenir; dolayısıyla deref zorlamasından yararlanmanın hiçbir çalışma zamanı maliyeti yoktur!
Not: Deref zorlaması yalnızca fonksiyon argümanlarında değil, metot çağrısı
sözdiziminde de iş başındadır. kutu.len() yazdığınızda Rust, len adında
bir metot bulana dek BenimKutu<String>, String ve str türlerini sırayla
dener; bu yüzden değiştirilebilir bir Box<Vec<i32>> üzerinde doğrudan
.push(1) çağırabilir ya da bir Rc<String> üzerinde .len()
kullanabilirsiniz. Bölüm 5’te gördüğünüz otomatik referans alma ve başvuru
çözme mekanizması, perde arkasında işte bu Deref zincirini izler.
Değiştirilebilir Referanslarla Deref Zorlamasını Ele Almak
Değiştirilemez referanslar üzerinde * operatörünü geçersiz kılmak için Deref trait’ini kullandığınız gibi, değiştirilebilir referanslar üzerinde * operatörünü geçersiz kılmak için de DerefMut trait’ini kullanabilirsiniz.
Rust, aşağıdaki üç durumda uygun türleri ve trait uygulamalarını bulduğunda deref zorlaması gerçekleştirir:
T: Deref<Target=U>olduğunda&T’den&U’yaT: DerefMut<Target=U>olduğunda&mut T’den&mut U’yaT: Deref<Target=U>olduğunda&mut T’den&U’ya
İlk iki durum, ikincisinin değiştirilebilirlik içermesi dışında birbiriyle aynıdır. İlk durum, elinizde bir &T varsa ve T türü bir U türüne Deref uyguluyorsa şeffaf biçimde bir &U elde edebileceğinizi belirtir. İkinci durum, aynı deref zorlamasının değiştirilebilir referanslar için de gerçekleştiğini ifade eder.
Üçüncü durum ise daha inceliklidir: Rust, değiştirilebilir bir referansı da değiştirilemez bir referansa zorlayarak dönüştürebilir. Ancak bunun tersi mümkün değildir: Değiştirilemez referanslar asla değiştirilebilir referanslara dönüştürülemez. Ödünç alma kuralları gereği, elinizde değiştirilebilir bir referans varsa bu referans söz konusu veriye olan tek referans olmak zorundadır (aksi takdirde program derlenmezdi). Tek bir değiştirilebilir referansı tek bir değiştirilemez referansa dönüştürmek ödünç alma kurallarını asla ihlal etmez. Buna karşılık değiştirilemez bir referansı değiştirilebilir bir referansa dönüştürmek, başlangıçtaki değiştirilemez referansın o veriye olan tek değiştirilemez referans olmasını gerektirirdi; oysa ödünç alma kuralları bunu garanti etmez. Bu nedenle Rust, değiştirilemez bir referansı değiştirilebilir bir referansa dönüştürmenin mümkün olduğu varsayımında bulunamaz.
Not: Derleme zamanında sıfır çalışma zamanı maliyetiyle çözümlenen referans
dönüşümü kurallarını aşağıdaki tabloyla özetleyebiliriz (DerefMut trait’i,
üst trait olarak Deref’i zorunlu kılar; yani bir tür üzerinde DerefMut
uygulamak için önce Deref uygulanmalıdır):
| Kaynak Referans | Hedef Referans | Gerekli Trait Sınırı | İzin Durumu ve Nedeni |
|---|---|---|---|
&T | &U | T: Deref<Target = U> | Geçerli — paylaşımlı referanstan paylaşımlı referansa dönüşüm |
&mut T | &mut U | T: DerefMut<Target = U> | Geçerli — tekil (münhasır) erişim korunur |
&mut T | &U | T: Deref<Target = U> | Geçerli — tekil referans güvenle paylaşımlı referansa daraltılabilir |
&T | &mut U | — | Geçersiz — aynı anda başka &T referansları da bulunabileceğinden ödünç alma kurallarını bozar |
Drop Trait'i ile Temizlik Sırasında Kod Çalıştırma
Drop Trait’i ile Temizlik Sırasında Kod Çalıştırma
Akıllı işaretçi deseni açısından önemli ikinci trait, bir değer kapsam dışına çıkmak üzereyken ne olacağını özelleştirmenize olanak tanıyan Drop trait’idir. Herhangi bir tür üzerinde Drop trait’i için bir uygulama sağlayabilirsiniz ve bu kod, dosyalar veya ağ bağlantıları gibi kaynakları serbest bırakmak amacıyla kullanılabilir.
Drop trait’ini akıllı işaretçiler bağlamında tanıtıyoruz; çünkü Drop trait’inin sunduğu işlevsellik, bir akıllı işaretçi uygulanırken neredeyse her zaman kullanılır. Örneğin bir Box<T> düşürüldüğünde, kutunun işaret ettiği öbek (heap) alanını serbest bırakır.
Bazı dillerde, belirli türlerin örnekleriyle işiniz her bittiğinde belleği veya kaynakları serbest bırakacak kodu programcının elle çağırması gerekir. Dosya tanıtıcıları (file handle), soketler ve kilitler buna örnektir. Programcı bunu unutursa sistem aşırı yüklenebilir ve çökebilir. Rust’ta ise bir değer her kapsam dışına çıktığında belirli bir kod parçasının çalıştırılmasını belirtebilirsiniz ve derleyici bu kodu otomatik olarak ekler. Sonuç olarak, bir programda belirli bir türün örneğiyle işinizin bittiği her yere temizlik kodu yerleştirme konusunda titizlenmeniz gerekmez; yine de kaynak sızıntısı yaşamazsınız!
Bir değer kapsam dışına çıktığında çalışacak kodu Drop trait’ini uygulayarak belirtirsiniz. Drop trait’i, self’e değiştirilebilir bir referans (&mut self) alan drop adında tek bir metodu uygulamanızı zorunlu kılar. Rust’ın drop metodunu ne zaman çağırdığını görmek için şimdilik drop metodunu println! deyimleriyle uygulayalım.
Liste 15-14, Rust’ın drop metodunu ne zaman çalıştırdığını göstermek amacıyla, tek özel işlevi örnek kapsam dışına çıktığında ekrana `{}` verisine sahip OzelAkilliIsaretci bırakılıyor! yazdırmak olan bir OzelAkilliIsaretci struct’ını göstermektedir.
struct OzelAkilliIsaretci {
veri: String,
}
impl Drop for OzelAkilliIsaretci {
fn drop(&mut self) {
println!(
"`{}` verisine sahip OzelAkilliIsaretci bırakılıyor!",
self.veri
);
}
}
fn main() {
let c = OzelAkilliIsaretci {
veri: String::from("benim şeylerim"),
};
let d = OzelAkilliIsaretci {
veri: String::from("diğer şeyler"),
};
println!("OzelAkilliIsaretciler oluşturuldu");
}
Drop trait’ini uygulayan bir OzelAkilliIsaretci struct’ıDrop trait’i başlangıç bildirimlerinde (prelude) yer aldığı için onu ayrıca kapsama dahil etmemiz gerekmez. OzelAkilliIsaretci üzerinde Drop trait’ini uyguluyor ve println! makrosunu çağıran drop metodu için bir uygulama sağlıyoruz. drop metodunun gövdesi, türünüzün bir örneği kapsam dışına çıktığında çalışmasını istediğiniz her türlü mantığı yerleştireceğiniz yerdir. Burada Rust’ın drop metodunu ne zaman çağıracağını görsel olarak göstermek için ekrana metin yazdırıyoruz.
main fonksiyonunda iki OzelAkilliIsaretci örneği oluşturuyor ve ardından OzelAkilliIsaretciler oluşturuldu metnini yazdırıyoruz. main’in sonunda OzelAkilliIsaretci örneklerimiz kapsam dışına çıkacak ve Rust, drop metoduna koyduğumuz kodu çağırarak son mesajlarımızı yazdıracaktır. drop metodunu açıkça çağırmamız gerekmediğine dikkat edin.
Bu programı çalıştırdığımızda aşağıdaki çıktıyı görürüz:
$ cargo run
Compiling birak-ornegi v0.1.0 (file:///projects/birak-ornegi)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.60s
Running `target/debug/birak-ornegi`
OzelAkilliIsaretciler oluşturuldu
`diğer şeyler` verisine sahip OzelAkilliIsaretci bırakılıyor!
`benim şeylerim` verisine sahip OzelAkilliIsaretci bırakılıyor!
Örneklerimiz kapsam dışına çıktığında Rust bizim yerimize otomatik olarak drop metodunu çağırdı ve belirttiğimiz kodu çalıştırdı. Değişkenler, yığın (stack) üzerindeki son giren ilk çıkar düzenine uygun biçimde oluşturuldukları sıranın tersine düşürülür; bu yüzden d değişkeni c’den önce düşürülmüştür. Bu örneğin amacı drop metodunun nasıl çalıştığına dair size görsel bir rehber sunmaktır; normalde bir yazdırma mesajı yerine türünüzün çalıştırması gereken temizlik kodunu belirtirsiniz.
Not: Düşürme sırası yalnızca yerel değişkenler için değil, bileşik değerlerin parçaları için de kesin olarak belirlidir ve bu kurallara güvenebilirsiniz:
- Aynı kapsamdaki yerel değişkenler, bildirildikleri sıranın tersine düşürülür.
- Bir struct ya da enum değeri düşürülürken önce (varsa) türün kendi
dropmetodu çalışır, ardından alanları bildirim sırasıyla düşürülür. Demetlerde, dizilerde veVec<T>gibi koleksiyonlarda da öğeler baştan sona sırayla düşürülür. - Bir değişkene yeni bir değer atandığında eski değer, Bölüm 4’te gördüğümüz gibi atama anında hemen düşürülür.
drop metodunun gövdesinde self.veri gibi alanlara hâlâ erişebilmenizin
nedeni de budur: Alanlar, türün kendi drop metodu tamamlandıktan sonra
düşürülür.
Ne yazık ki otomatik drop işlevini devre dışı bırakmak doğrudan mümkün değildir. Aslında drop işlevini devre dışı bırakmak genellikle gerekli de değildir; Drop trait’inin tüm amacı bu işlemin otomatik olarak halledilmesidir. Bununla birlikte zaman zaman bir değeri erkenden temizlemek isteyebilirsiniz. Bunun bir örneği kilitleri yöneten akıllı işaretçilerin kullanımıdır: Aynı kapsam içindeki başka bir kodun kilidi edinebilmesi için kilidi serbest bırakan drop metodunu çalışmaya zorlamak isteyebilirsiniz. Rust, Drop trait’inin drop metodunu elle çağırmanıza izin vermez; bunun yerine bir değerin kendi kapsamının sonundan önce düşürülmesini zorlamak istiyorsanız standart kütüphane tarafından sunulan std::mem::drop fonksiyonunu çağırmanız gerekir.
Liste 15-14’teki main fonksiyonunu değiştirerek Drop trait’inin drop metodunu elle çağırmayı denemek, Liste 15-15’te gösterildiği gibi çalışmayacaktır.
struct OzelAkilliIsaretci {
veri: String,
}
impl Drop for OzelAkilliIsaretci {
fn drop(&mut self) {
println!(
"`{}` verisine sahip OzelAkilliIsaretci bırakılıyor!",
self.veri
);
}
}
fn main() {
let c = OzelAkilliIsaretci {
veri: String::from("bir miktar veri"),
};
println!("OzelAkilliIsaretci oluşturuldu");
c.drop();
println!("OzelAkilliIsaretci, main bitmeden önce bırakıldı");
}
Drop trait’indeki drop metodunu elle çağırmayı denemekBu kodu derlemeye çalıştığımızda şu hatayı alırız:
$ cargo run
Compiling birak-ornegi v0.1.0 (file:///projects/birak-ornegi)
error[E0040]: explicit use of destructor method
--> src/main.rs:19:7
|
19 | c.drop();
| ^^^^ explicit destructor calls not allowed
|
help: consider using `drop` function
|
19 - c.drop();
19 + drop(c);
|
For more information about this error, try `rustc --explain E0040`.
error: could not compile `birak-ornegi` (bin "birak-ornegi") due to 1 previous error
Bu hata mesajı (explicit use of destructor method — yıkıcı metodun açık kullanımı), drop metodunu açıkça çağırmamıza izin verilmediğini belirtir. Hata mesajında geçen yıkıcı (destructor) ifadesi, bir örneği temizleyen fonksiyonlar için kullanılan genel programlama terimidir. Bir yıkıcı, bir örnek oluşturan yapıcı (constructor) kavramının karşıtı niteliğindedir. Rust’taki drop metodu da bu tür yıkıcılardan biridir.
Rust, drop metodunu açıkça çağırmamıza izin vermez; çünkü main fonksiyonunun sonunda değer üzerinde drop metodunu yine otomatik olarak çağıracaktır. Bu durum, Rust aynı değeri iki kez temizlemeye çalışacağı için bir çift serbest bırakma (double free) hatasına yol açardı.
Bir değer kapsam dışına çıktığında drop çağrısının otomatik olarak eklenmesini devre dışı bırakamayız ve drop metodunu açıkça çağıramayız. Dolayısıyla bir değerin erkenden temizlenmesini zorlamamız gerekiyorsa std::mem::drop fonksiyonunu kullanırız.
std::mem::drop fonksiyonu, Drop trait’indeki drop metodundan farklıdır. Bu fonksiyonu, düşürülmesini zorlamak istediğimiz değeri argüman olarak aktararak çağırırız. Fonksiyon başlangıç bildirimlerinde yer aldığı için Liste 15-15’teki main fonksiyonunu Liste 15-16’da gösterildiği gibi drop fonksiyonunu çağıracak şekilde değiştirebiliriz.
struct OzelAkilliIsaretci {
veri: String,
}
impl Drop for OzelAkilliIsaretci {
fn drop(&mut self) {
println!(
"`{}` verisine sahip OzelAkilliIsaretci bırakılıyor!",
self.veri
);
}
}
fn main() {
let c = OzelAkilliIsaretci {
veri: String::from("bir miktar veri"),
};
println!("OzelAkilliIsaretci oluşturuldu");
drop(c);
println!("OzelAkilliIsaretci, main bitmeden önce bırakıldı");
}
std::mem::drop fonksiyonunu çağırmakBu kodu çalıştırmak aşağıdaki çıktıyı üretecektir:
$ cargo run
Compiling birak-ornegi v0.1.0 (file:///projects/birak-ornegi)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.73s
Running `target/debug/birak-ornegi`
OzelAkilliIsaretci oluşturuldu
`bir miktar veri` verisine sahip OzelAkilliIsaretci bırakılıyor!
OzelAkilliIsaretci, main bitmeden önce bırakıldı
`bir miktar veri` verisine sahip OzelAkilliIsaretci bırakılıyor! metni, OzelAkilliIsaretci oluşturuldu ile OzelAkilliIsaretci, main bitmeden önce bırakıldı metinleri arasında yazdırılır; bu da c değerini düşürmek için drop metodu kodunun tam o noktada çağrıldığını gösterir.
Not: Standart kütüphanedeki std::mem::drop fonksiyonunun arkasında özel bir
derleyici sihri yoktur; tanımı kelimenin tam anlamıyla gövdesi tamamen boş bir
fonksiyondur:
#![allow(unused)]
fn main() {
pub fn drop<T>(_x: T) {}
}
Buradaki bütün incelik sahiplik sistemindedir: Drop::drop(&mut self) metodu
değeri yalnızca değiştirilebilir referansla ödünç alırken, std::mem::drop
fonksiyonu _x: T parametresiyle değerin sahipliğini devralır (move).
drop(c) çağrıldığında c değişkeni fonksiyonun içine taşınır ve fonksiyonun
kapanış süslü parantezi } hizasında kapsam dışına çıkarak Rust’ın normal
otomatik düşürme mekanizmasını (C++ dünyasında RAII — Resource Acquisition Is
Initialization olarak bilinen deterministik kaynak yönetimini) tetikler.
Sahiplik taşındığı için derleyici c değişkeninin main içinde tekrar
kullanılmasını engeller ve main sonunda ikinci kez düşürülmemesini garanti
eder.
Aynı sahiplik kuralı, bir türün neden hem Copy hem de Drop trait’ini aynı
anda uygulayamayacağını da açıklar (denerseniz derleyici error[E0184]: the trait 'Copy' cannot be implemented for this type; the type has a destructor —
bu tür bir yıkıcıya sahip olduğundan Copy trait’i uygulanamaz hatasını
verir). Copy türleri atama ve fonksiyon çağrılarında sahipliği taşımak
yerine örtük olarak bit düzeyinde kopyalandığından, bir tür hem Copy hem
Drop olsaydı drop(x) çağrısı yalnızca bir kopyayı düşürür, asıl x
değişkeni yaşamaya devam eder ve her kopya kapsam dışına çıktığında aynı
kaynak üzerinde yıkıcı tekrar tekrar çalışarak çift serbest bırakma hatasına
yol açardı.
Temizlik işlemlerini kullanışlı ve güvenli hâle getirmek için bir Drop trait’i uygulamasında belirttiğiniz kodu pek çok farklı biçimde kullanabilirsiniz: Örneğin bu sayede kendi bellek ayırıcınızı oluşturabilirsiniz! Drop trait’i ve Rust’ın sahiplik sistemi sayesinde temizlik yapmayı hatırlamak zorunda kalmazsınız; çünkü Rust bunu otomatik olarak gerçekleştirir.
Ayrıca hâlâ kullanımda olan değerleri yanlışlıkla temizlemekten kaynaklanacak sorunlar için de endişelenmeniz gerekmez: Referansların her zaman geçerli olmasını sağlayan sahiplik sistemi, drop metodunun yalnızca değer artık kullanılmadığında ve tek bir kez çağrılmasını da garanti eder.
Box<T> türünü ve akıllı işaretçilerin bazı karakteristik özelliklerini incelediğimize göre, şimdi standart kütüphanede tanımlı diğer birkaç akıllı işaretçiye göz atalım.
Rc<T>, Referans Sayılı Akıllı İşaretçi
Rc<T>, Referans Sayılı Akıllı İşaretçi
Çoğu durumda sahiplik nettir: Belirli bir değerin sahibinin hangi değişken olduğunu tam olarak bilirsiniz. Bununla birlikte, tek bir değerin birden fazla sahibi olabileceği durumlar da vardır. Örneğin graf veri yapılarında birden fazla kenar (edge) aynı düğüme (node) işaret edebilir ve bu düğüm kavramsal olarak ona işaret eden kenarların tümü tarafından sahiplenilir. Bir düğüm, kendisine işaret eden hiçbir kenar kalmadıkça ve dolayısıyla hiçbir sahibi olmadıkça bellekten temizlenmemelidir.
Çoklu sahipliği, referans sayımı (reference counting) ifadesinin kısaltması olan Rust’ın Rc<T> türünü kullanarak açıkça etkinleştirmeniz gerekir. Rc<T> türü, bir değerin hâlâ kullanımda olup olmadığını belirlemek için o değere olan referansların sayısını takip eder. Bir değere olan referans sayısı sıfırsa, herhangi bir referans geçersiz hâle gelmeden değer bellekten temizlenebilir.
Rc<T> türünü oturma odasındaki bir televizyon gibi hayal edin. Bir kişi televizyon izlemek için odaya girdiğinde televizyonu açar. Başkaları da odaya gelip televizyonu izleyebilir. Son kişi odadan ayrıldığında, artık kullanılmadığı için televizyonu kapatır. Başkaları hâlâ izlerken biri televizyonu kapatacak olursa, kalan izleyicilerden büyük bir itiraz yükselir!
Programımızın birden fazla bölümünün okuması için öbekte (heap) bir miktar veri ayırmak istediğimizde ve veriyi kullanmayı en son hangi bölümün bitireceğini derleme zamanında belirleyemediğimizde Rc<T> türünü kullanırız. Hangi bölümün en son bitireceğini bilseydik, doğrudan o bölümü verinin sahibi yapabilirdik ve derleme zamanında uygulanan normal sahiplik kuralları devreye girerdi.
Rc<T> türünün yalnızca tek thread’li (single-threaded) senaryolarda kullanıma yönelik olduğuna dikkat edin. Bölüm 16’da eşzamanlılığı ele aldığımızda, çok thread’li (multithreaded) programlarda referans sayımının nasıl yapılacağını inceleyeceğiz.
Veri Paylaşmak
Liste 15-5’teki cons listesi örneğimize geri dönelim. Onu Box<T> kullanarak tanımladığımızı hatırlayın. Bu kez üçüncü bir listenin sahipliğini paylaşan iki liste oluşturacağız. Kavramsal olarak bu yapı Şekil 15-3’e benzer.
Şekil 15-3: Üçüncü bir liste olan a’nın sahipliğini
paylaşan b ve c adlı iki liste
Önce 5 ve ardından 10 değerlerini içeren a listesini oluşturacağız. Sonra iki liste daha yapacağız: 3 ile başlayan b ve 4 ile başlayan c. Hem b hem de c listeleri, sonrasında 5 ve 10 değerlerini içeren ilk a listesiyle devam edecek. Başka bir deyişle, her iki liste de 5 ve 10’u içeren ilk listeyi paylaşacak.
Bu senaryoyu Box<T> kullanan Liste tanımımızla uygulamaya çalışmak, Liste 15-17’de gösterildiği gibi işe yaramayacaktır.
enum Liste {
Dugum(i32, Box<Liste>),
Bos,
}
use crate::Liste::{Bos, Dugum};
fn main() {
let a = Dugum(5, Box::new(Dugum(10, Box::new(Bos))));
let b = Dugum(3, Box::new(a));
let c = Dugum(4, Box::new(a));
}
Box<T> kullanan iki listenin üçüncü bir listenin sahipliğini paylaşmaya çalışmasına izin verilmediğini göstermekBu kodu derlediğimizde şu hatayı alırız:
$ cargo run
Compiling kons-liste v0.1.0 (file:///projects/kons-liste)
error[E0382]: use of moved value: `a`
--> src/main.rs:11:31
|
9 | let a = Dugum(5, Box::new(Dugum(10, Box::new(Bos))));
| - move occurs because `a` has type `Liste`, which does not implement the `Copy` trait
10 | let b = Dugum(3, Box::new(a));
| - value moved here
11 | let c = Dugum(4, Box::new(a));
| ^ value used here after move
|
note: if `Liste` implemented `Clone`, you could clone the value
--> src/main.rs:1:1
|
1 | enum Liste {
| ^^^^^^^^^^ consider implementing `Clone` for this type
...
10 | let b = Dugum(3, Box::new(a));
| - you could clone this value
For more information about this error, try `rustc --explain E0382`.
error: could not compile `kons-liste` (bin "kons-liste") due to 1 previous error
Dugum varyantları tuttukları verinin sahibidir; bu nedenle b listesini oluşturduğumuzda a değeri b’nin içine taşınır ve a’nın sahibi artık b olur. Ardından c’yi oluştururken a’yı yeniden kullanmaya çalıştığımızda, a daha önce taşındığı için buna izin verilmez.
Dugum tanımını bunun yerine referans tutacak şekilde değiştirebilirdik; ancak o zaman ömür (lifetime) parametreleri belirtmemiz gerekirdi. Ömür parametreleri belirterek, listedeki her öğenin en az tüm listenin kendisi kadar uzun yaşayacağını şart koşmuş olurduk. Liste 15-17’deki öğeler ve listeler için bu durum geçerlidir, ancak her senaryoda böyle olmayabilir.
Bunun yerine Liste tanımımızı Liste 15-18’de gösterildiği gibi Box<T> yerine Rc<T> kullanacak şekilde değiştireceğiz. Her Dugum varyantı artık bir değer ve bir Liste’ye işaret eden bir Rc<T> tutacaktır. b’yi oluştururken a’nın sahipliğini almak yerine, a’nın tuttuğu Rc<Liste> değerini klonlayacağız; böylece referans sayısını birden ikiye çıkaracak ve a ile b’nin o Rc<Liste> içindeki verinin sahipliğini paylaşmasını sağlayacağız. c’yi oluştururken de a’yı klonlayarak referans sayısını ikiden üçe çıkaracağız. Rc::clone fonksiyonunu her çağırdığımızda Rc<Liste> içindeki veriye olan referans sayısı artacak ve kendisine yönelik referans sayısı sıfıra düşmedikçe bu veri bellekten temizlenmeyecektir.
enum Liste {
Dugum(i32, Rc<Liste>),
Bos,
}
use crate::Liste::{Bos, Dugum};
use std::rc::Rc;
fn main() {
let a = Rc::new(Dugum(5, Rc::new(Dugum(10, Rc::new(Bos)))));
let b = Dugum(3, Rc::clone(&a));
let c = Dugum(4, Rc::clone(&a));
}
Rc<T> kullanan bir Liste tanımıRc<T> başlangıç bildirimlerinde (prelude) yer almadığı için onu kapsama dahil etmek üzere bir use deyimi eklememiz gerekir. main içinde 5 ve 10 değerlerini tutan listeyi oluşturup a değişkenindeki yeni bir Rc<Liste> içinde saklıyoruz. Ardından b ve c’yi oluştururken Rc::clone fonksiyonunu çağırıyor ve argüman olarak a içindeki Rc<Liste> değerine bir referans geçiriyoruz.
Rc::clone(&a) yerine a.clone() çağrısı da yapabilirdik; ancak bu durumda Rust’ın genel kabul gören kuralı Rc::clone kullanmaktır. Rc::clone uygulaması, çoğu türün clone uygulamasının yaptığı gibi tüm verinin derin kopyasını (deep copy) çıkarmaz. Rc::clone çağrısı yalnızca referans sayısını artırır ve bu işlem çok az zaman alır. Verilerin derin kopyalarını çıkarmak ise oldukça uzun sürebilir. Referans sayımı için Rc::clone kullanarak, derin kopyalama yapan klonlamalar ile referans sayısını artıran klonlamaları görsel olarak birbirinden ayırt edebiliriz. Kodda performans sorunlarını ararken yalnızca derin kopya oluşturan klonlamaları dikkate almamız yeterli olur; Rc::clone çağrılarını göz ardı edebiliriz.
Not: Rc<T> türünün perde arkasındaki çalışma biçimine dair iki önemli
noktayı aklınızda bulundurmak faydalıdır:
- Neden
a.clone()yerineRc::clone(&a)yazılır?Rc::newçağrıldığında öbekte tek bir bellek bloğu ayrılır ve bu blokta asıl veriyle birlikte güçlü (strong) ve zayıf (weak) referans sayılarını tutan iki adetusizesayaç saklanır.Rc::clone(&a)çağrısı öbekte yeni bir bellek tahsisi yapmaz ve içteki veriyi derinlemesine kopyalamaz (hatta içtekiTtürününClonetrait’ini uygulaması bile gerekmez); yalnızca öbekteki mevcutusizesayacını1artırır ve aynı bellek adresine işaret eden yeni birRc<T>işaretçisi döndürür. Metot sözdizimi olana.clone()yerineRc::clone(&a)yazma geleneği, kod okunurken burada maliyetli bir derin kopya değil yalnızca hafif bir sayaç artırımı yapıldığını görsel olarak vurgular. Rc<T>neden!Sendolup yalnızca tek thread’li senaryolarda kullanılır?Rc<T>, tek thread’li kodda ek maliyet yaratmamak için referans sayacını sıradan (atomik olmayan) işlemci komutlarıyla günceller. İki farklı thread aynıRc<T>sayacını eşzamanlı olarak artırıp azaltmaya çalışsaydı sayaç üzerinde veri yarışı (data race) oluşabilir, bu da bellek sızıntısına veya geçersiz bellek erişimine yol açabilirdi. Bunu derleme zamanında kesin olarak engellemek içinRc<T>türüSendtrait’ini uygulamaz (!Send); dolayısıyla birRc<T>değerini başka bir thread’e aktarmaya çalıştığınızda Rust derleyicisi hata verir. Çok thread’li senaryolarda paylaşımlı sahiplik için Bölüm 16’da inceleyeceğimiz, sayaç güncellemelerini atomik komutlarla güvenle yapanArc<T>(Atomically Reference Counted) türü kullanılır.
Klonlayarak Referans Sayısını Artırmak
Liste 15-18’deki çalışan örneğimizi, a içindeki Rc<Liste> değerine yönelik referanslar oluşturup düşürdükçe referans sayılarının nasıl değiştiğini görebileceğimiz şekilde değiştirelim.
Liste 15-19’da main fonksiyonunu, c listesinin etrafında bir iç kapsam olacak biçimde değiştireceğiz; böylece c kapsam dışına çıktığında referans sayısının nasıl değiştiğini görebiliriz.
enum Liste {
Dugum(i32, Rc<Liste>),
Bos,
}
use crate::Liste::{Bos, Dugum};
use std::rc::Rc;
// --snip--
fn main() {
let a = Rc::new(Dugum(5, Rc::new(Dugum(10, Rc::new(Bos)))));
println!("a oluşturulduktan sonraki sayı = {}", Rc::strong_count(&a));
let b = Dugum(3, Rc::clone(&a));
println!("b oluşturulduktan sonraki sayı = {}", Rc::strong_count(&a));
{
let c = Dugum(4, Rc::clone(&a));
println!("c oluşturulduktan sonraki sayı = {}", Rc::strong_count(&a));
}
println!("c kapsamdan çıkınca sayı = {}", Rc::strong_count(&a));
}
Programda referans sayısının değiştiği her noktada, Rc::strong_count fonksiyonunu çağırarak elde ettiğimiz referans sayısını yazdırıyoruz. Rc<T> türünün bir de weak_count değeri bulunduğu için bu fonksiyona count yerine strong_count adı verilmiştir; weak_count değerinin ne işe yaradığını “Weak<T> Kullanarak Referans Döngülerini Önlemek” bölümünde göreceğiz.
Bu kod aşağıdakileri yazdırır:
$ cargo run
Compiling kons-liste v0.1.0 (file:///projects/kons-liste)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.45s
Running `target/debug/kons-liste`
a oluşturulduktan sonraki sayı = 1
b oluşturulduktan sonraki sayı = 2
c oluşturulduktan sonraki sayı = 3
c kapsamdan çıkınca sayı = 2
a içindeki Rc<Liste> değerinin başlangıçta 1 referans sayısına sahip olduğunu, ardından clone fonksiyonunu her çağırdığımızda bu sayının 1 arttığını görebiliriz. c kapsam dışına çıktığında ise sayı 1 azalır. Referans sayısını artırmak için Rc::clone çağırmamız gerekirken, sayıyı azaltmak için herhangi bir fonksiyon çağırmamız gerekmez: Bir Rc<T> değeri kapsam dışına çıktığında Drop trait’inin uygulaması referans sayısını otomatik olarak azaltır.
Bu örnekte göremediğimiz şey ise main fonksiyonunun sonunda önce b, ardından da a kapsam dışına çıktığında sayının 0 olması ve Rc<Liste> değerinin bellekten tamamen temizlenmesidir. Rc<T> kullanmak tek bir değerin birden fazla sahibi olmasına olanak tanır ve referans sayacı, sahiplerden herhangi biri var olduğu sürece değerin geçerli kalmasını güvence altına alır.
Rc<T>, değiştirilemez referanslar aracılığıyla programınızın birden fazla bölümü arasında veriyi yalnızca okuma amacıyla paylaşmanıza olanak tanır. Eğer Rc<T> birden fazla değiştirilebilir referansa da sahip olmanıza izin verseydi, Bölüm 4’te ele aldığımız ödünç alma kurallarından birini ihlal edebilirdiniz: Aynı yere yönelik birden fazla değiştirilebilir ödünç alma, veri yarışlarına ve tutarsızlıklara yol açabilir. Öte yandan veriyi değiştirebilmek son derece kullanışlıdır! Bir sonraki bölümde, bu değiştirilemezlik kısıtlamasıyla başa çıkmak üzere bir Rc<T> ile birlikte kullanabileceğiniz içsel değiştirilebilirlik (interior mutability) desenini ve RefCell<T> türünü ele alacağız.
RefCell<T> ve İçsel Değiştirilebilirlik Deseni
RefCell<T> ve İçsel Değiştirilebilirlik Deseni
İçsel değiştirilebilirlik (interior mutability), bir veriye yönelik değiştirilemez referanslar varken bile o veriyi değiştirmenize olanak tanıyan bir Rust tasarım desenidir; normalde ödünç alma kuralları bu işleme izin vermez. Veriyi değiştirmek için bu desen, bir veri yapısının içinde unsafe (güvenli olmayan) kod kullanarak Rust’ın değiştirme ve ödünç almayı yöneten olağan kurallarını esnetir. Güvenli olmayan kod, kuralları bizim adımıza derleyicinin denetlemesine güvenmek yerine onları elle denetlediğimizi derleyiciye bildirir; güvenli olmayan kodu Bölüm 20’de daha ayrıntılı ele alacağız.
İçsel değiştirilebilirlik desenini kullanan türleri, derleyici bunu garanti edemese bile ödünç alma kurallarına çalışma zamanında uyulacağından emin olabildiğimiz durumlarda kullanabiliriz. İşin içindeki unsafe kod güvenli bir API ile sarmalanır ve dıştaki tür yine değiştirilemez kalır.
Bu kavramı, içsel değiştirilebilirlik desenini izleyen RefCell<T> türünü inceleyerek keşfedelim.
Ödünç Alma Kurallarını Çalışma Zamanında Uygulamak
Rc<T> türünden farklı olarak RefCell<T> türü, tuttuğu veri üzerinde tek sahipliği temsil eder. Peki RefCell<T> türünü Box<T> gibi bir türden farklı kılan nedir? Bölüm 4’te öğrendiğiniz ödünç alma kurallarını hatırlayın:
- Herhangi bir anda ya tek bir değiştirilebilir referansa ya da istediğiniz sayıda değiştirilemez referansa sahip olabilirsiniz (ancak ikisine birden sahip olamazsınız).
- Referanslar her zaman geçerli olmalıdır.
Referanslar ve Box<T> ile ödünç alma kurallarının değişmezleri (invariants) derleme zamanında uygulanır. RefCell<T> ile bu değişmezler çalışma zamanında uygulanır. Referanslarda bu kuralları çiğnerseniz bir derleyici hatası alırsınız. RefCell<T> ile bu kuralları çiğnerseniz programınız panikler ve sonlanır.
Ödünç alma kurallarını derleme zamanında denetlemenin avantajları, hataların geliştirme sürecinde daha erken yakalanması ve tüm analiz önceden tamamlandığı için çalışma zamanı performansı üzerinde hiçbir etkisinin olmamasıdır. Bu nedenlerle ödünç alma kurallarını derleme zamanında denetlemek vakaların büyük çoğunluğunda en iyi seçimdir; Rust’ta varsayılan yaklaşımın bu olmasının nedeni de budur.
Ödünç alma kurallarını bunun yerine çalışma zamanında denetlemenin avantajı ise derleme zamanı denetimlerinin izin vermeyeceği bazı bellek açısından güvenli senaryolara olanak tanımasıdır. Rust derleyicisi gibi statik analiz araçları doğaları gereği temkinlidir (conservative). Kodun bazı özelliklerini yalnızca kodu analiz ederek tespit etmek imkânsızdır: Bunun en ünlü örneği, bu kitabın kapsamı dışında kalan ancak araştırmak için ilginç bir konu olan Durma Problemi’dir (Halting Problem).
Bazı analizler imkânsız olduğundan, Rust derleyicisi kodun sahiplik kurallarına uyduğundan emin olamazsa doğru bir programı bile reddedebilir; işte bu açıdan temkinlidir. Rust hatalı bir programı kabul etseydi, kullanıcılar Rust’ın sunduğu güvencelere güvenemezdi. Buna karşılık Rust doğru bir programı reddederse programcı biraz zahmete girer, ancak felaketle sonuçlanacak hiçbir şey meydana gelmez. RefCell<T> türü, kodunuzun ödünç alma kurallarına uyduğundan emin olduğunuz, ancak derleyicinin bunu anlayıp garanti edemediği durumlarda kullanışlıdır.
RefCell<T> aynı anda birden fazla thread arasında paylaşılamaz; çünkü Sync trait’ini uygulamaz. Ancak T: Send olduğunda bir RefCell<T> değerinin sahipliği başka bir thread’e taşınabilir. Bu yönüyle ne Send ne de Sync olan Rc<T> türünden farklıdır. Thread’ler arasında paylaşılan veriyi değiştirmek için kullanabileceğimiz Mutex<T> gibi araçları Bölüm 16’da ele alacağız.
Box<T>, Rc<T> veya RefCell<T> türlerinden birini seçme nedenlerinin özeti şöyledir:
Rc<T>aynı verinin birden fazla sahibi olmasını sağlar;Box<T>veRefCell<T>tek sahiplidir.Box<T>derleme zamanında denetlenen değiştirilemez veya değiştirilebilir ödünç almalara izin verir;Rc<T>yalnızca derleme zamanında denetlenen değiştirilemez ödünç almalara izin verir;RefCell<T>ise çalışma zamanında denetlenen değiştirilemez veya değiştirilebilir ödünç almalara izin verir.RefCell<T>çalışma zamanında denetlenen değiştirilebilir ödünç almalara izin verdiği için,RefCell<T>değerinin kendisi değiştirilemez olsa bileRefCell<T>içindeki değeri değiştirebilirsiniz.
Değiştirilemez bir değerin içindeki değeri değiştirmek, içsel değiştirilebilirlik desenidir. Şimdi içsel değiştirilebilirliğin yararlı olduğu bir duruma bakalım ve bunun nasıl mümkün olduğunu inceleyelim.
Not: Bölüm boyunca incelediğimiz üç temel akıllı işaretçi türünü (ve sıkça
birlikte kullanılan Rc<RefCell<T>> bileşimini) tek bakışta şöyle
karşılaştırabilirsiniz. Box<T> ve Rc<T> öbekte sahip olunan veriye
erişim sağlarken, RefCell<T> tek başına ek bir öbek tahsisi yapmaz; içindeki T değerini ve
ödünç sayacını doğrudan bulunduğu yerde (örneğin yığında veya bir struct/enum
alanının içinde) sarmalar:
| Tür | Sahiplik Sayısı | İzin Verilen Ödünç Alma Türü | Kural Denetim Zamanı | Kural İhlali Sonucu |
|---|---|---|---|---|
Box<T> | Tek sahip | Değiştirilemez (&T) veya değiştirilebilir (&mut T) | Derleme zamanı | Derleme hatası |
Rc<T> | Çoklu sahip | Yalnızca değiştirilemez (&T) | Derleme zamanı | Derleme hatası |
RefCell<T> | Tek sahip | Değiştirilemez (Ref<T>) veya değiştirilebilir (RefMut<T>) | Çalışma zamanı | Çalışma zamanında panic! |
Rc<RefCell<T>> | Çoklu sahip | Değiştirilemez veya değiştirilebilir (içteki RefCell<T> üzerinden) | Çalışma zamanı | Çalışma zamanında panic! |
Copy olan küçük değerler (i32, bool gibi) için standart kütüphane,
RefCell<T> yerine daha hafif olan Cell<T> türünü de sunar: Cell<T>,
içteki değere referans vermek yerine get ile bir kopyasını döndürür ve set
ile değeri yenisiyle değiştirir; dolayısıyla çalışma zamanında ödünç takibi
yapmasına gerek kalmaz. Bu, Cell<T> üzerindeki bütün işlemlerin hiçbir
koşulda paniklemeyeceği anlamına gelmez; örneğin eski bir değerin
düşürülmesi o türün kendi Drop kodunu çalıştırabilir.
İçsel Değiştirilebilirlik Kullanmak
Ödünç alma kurallarının bir sonucu olarak, değiştirilemez bir değeriniz olduğunda onu değiştirilebilir biçimde ödünç alamazsınız. Örneğin şu kod derlenmeyecektir:
fn main() {
let sayi = 5;
let degistirilebilir_referans = &mut sayi;
}
Bu kodu derlemeye çalışırsanız aşağıdaki hatayı alırsınız:
$ cargo run
Compiling odunc-alma v0.1.0 (file:///projects/odunc-alma)
error[E0596]: cannot borrow `sayi` as mutable, as it is not declared as mutable
--> src/main.rs:3:37
|
3 | let degistirilebilir_referans = &mut sayi;
| ^^^^^^^^^ cannot borrow as mutable
|
help: consider changing this to be mutable
|
2 | let mut sayi = 5;
| +++
For more information about this error, try `rustc --explain E0596`.
error: could not compile `odunc-alma` (bin "odunc-alma") due to 1 previous error
Bununla birlikte, bir değerin kendi metotları içinde kendisini değiştirmesinin ancak diğer kodlara değiştirilemez görünmesinin yararlı olacağı durumlar vardır. Değerin kendi metotları dışındaki kodlar değeri değiştiremeyecektir. RefCell<T> kullanmak içsel değiştirilebilirlik yeteneği kazanmanın bir yoludur; ancak RefCell<T> ödünç alma kurallarını tamamen aşmaz: Derleyicideki ödünç alma denetleyicisi (borrow checker) bu içsel değiştirilebilirliğe izin verir ve ödünç alma kuralları bunun yerine çalışma zamanında denetlenir. Kuralları ihlal ederseniz derleyici hatası yerine bir panic! alırsınız.
Değiştirilemez bir değeri değiştirmek için RefCell<T> kullanabileceğimiz pratik bir örnek üzerinde çalışalım ve bunun neden yararlı olduğunu görelim.
Taklit Nesnelerle Test Yazmak
Test sırasında bir programcı bazen belirli bir davranışı gözlemlemek ve doğru uygulanıp uygulanmadığını doğrulamak amacıyla bir türün yerine başka bir tür kullanır. Bu yer tutucu türe test dublörü (test double) denir. Bunu film yapımcılığındaki dublör (stunt double) kavramı gibi düşünebilirsiniz: Özellikle zorlu bir sahneyi çekmek için bir kişinin oyuncunun yerine geçmesi gibi, test dublörleri de testleri çalıştırırken diğer türlerin yerine geçer. Taklit nesneler (mock objects) ise test sırasında neler olup bittiğini kaydeden özel test dublörü türleridir; böylece doğru eylemlerin gerçekleştiğini doğrulayabilirsiniz.
Rust’ta diğer dillerdeki anlamıyla nesneler yoktur ve bazı dillerin aksine Rust’ın standart kütüphanesinde yerleşik bir taklit nesne işlevselliği bulunmaz. Ancak taklit nesneyle aynı amaçlara hizmet edecek bir struct’ı rahatlıkla oluşturabilirsiniz.
Test edeceğimiz senaryo şudur: Bir değeri bir üst sınıra karşı izleyen ve mevcut değerin üst sınıra ne kadar yaklaştığına bağlı olarak iletiler gönderen bir kütüphane oluşturacağız. Bu kütüphane, örneğin bir kullanıcının yapmasına izin verilen API çağrısı sayısına ilişkin kotasını takip etmek için kullanılabilir.
Kütüphanemiz yalnızca bir değerin üst sınıra ne kadar yakın olduğunu takip etme ve hangi durumlarda hangi iletilerin gönderilmesi gerektiğini belirleme işlevselliğini sunacaktır. Kütüphanemizi kullanan uygulamaların ise iletileri gönderme mekanizmasını sağlaması beklenecektir: Uygulama iletiyi doğrudan kullanıcıya gösterebilir, e-posta gönderebilir, kısa mesaj atabilir veya başka bir şey yapabilir. Kütüphanenin bu ayrıntıyı bilmesine gerek yoktur. Tek ihtiyacı olan, bizim sağlayacağımız Iletici adlı trait’i uygulayan bir türdür. Liste 15-20 bu kütüphane kodunu göstermektedir.
pub trait Iletici {
fn gonder(&self, ileti: &str);
}
pub struct SinirIzleyici<'a, T: Iletici> {
iletici: &'a T,
deger: usize,
en_buyuk: usize,
}
impl<'a, T> SinirIzleyici<'a, T>
where
T: Iletici,
{
pub fn yeni(iletici: &'a T, en_buyuk: usize) -> SinirIzleyici<'a, T> {
SinirIzleyici {
iletici,
deger: 0,
en_buyuk,
}
}
pub fn deger_ata(&mut self, deger: usize) {
self.deger = deger;
let en_buyugun_yuzdesi = self.deger as f64 / self.en_buyuk as f64;
if en_buyugun_yuzdesi >= 1.0 {
self.iletici.gonder("Hata: Kotanızı aştınız!");
} else if en_buyugun_yuzdesi >= 0.9 {
self.iletici
.gonder("Acil uyarı: Kotanızın %90'ını geçtiniz!");
} else if en_buyugun_yuzdesi >= 0.75 {
self.iletici.gonder("Uyarı: Kotanızın %75'ini geçtiniz!");
}
}
}
Bu kodun önemli bir parçası, Iletici trait’inin self’e değiştirilemez bir referans ve iletinin metnini alan gonder adında tek bir metoda sahip olmasıdır. Bu trait, taklit nesnemizin gerçek bir nesneyle aynı şekilde kullanılabilmesi için uygulaması gereken arayüzdür. Diğer önemli parça ise SinirIzleyici üzerindeki deger_ata metodunun davranışını test etmek istememizdir. deger parametresi için geçirdiğimiz sayıyı değiştirebiliriz, ancak deger_ata üzerinde doğrulama (assertion) yapabileceğimiz hiçbir şey döndürmez. Iletici trait’ini uygulayan bir tür ve belirli bir en_buyuk değeriyle bir SinirIzleyici oluşturduğumuzda, deger için farklı sayılar geçirdiğimizde ileticiye uygun iletileri göndermesinin söylendiğini doğrulayabilmek istiyoruz.
gonder metodunu çağırdığımızda e-posta veya kısa mesaj göndermek yerine yalnızca göndermesi söylenen iletilerin kaydını tutacak bir taklit nesneye ihtiyacımız var. Taklit nesnenin yeni bir örneğini oluşturabilir, bu taklit nesneyi kullanan bir SinirIzleyici yaratabilir, SinirIzleyici üzerinde deger_ata metodunu çağırabilir ve ardından taklit nesnenin beklediğimiz iletilere sahip olup olmadığını kontrol edebiliriz. Liste 15-21 tam olarak bunu yapacak bir taklit nesne uygulama girişimini göstermektedir, ancak ödünç alma denetleyicisi buna izin vermez.
pub trait Iletici {
fn gonder(&self, ileti: &str);
}
pub struct SinirIzleyici<'a, T: Iletici> {
iletici: &'a T,
deger: usize,
en_buyuk: usize,
}
impl<'a, T> SinirIzleyici<'a, T>
where
T: Iletici,
{
pub fn yeni(iletici: &'a T, en_buyuk: usize) -> SinirIzleyici<'a, T> {
SinirIzleyici {
iletici,
deger: 0,
en_buyuk,
}
}
pub fn deger_ata(&mut self, deger: usize) {
self.deger = deger;
let en_buyugun_yuzdesi = self.deger as f64 / self.en_buyuk as f64;
if en_buyugun_yuzdesi >= 1.0 {
self.iletici.gonder("Hata: Kotanızı aştınız!");
} else if en_buyugun_yuzdesi >= 0.9 {
self.iletici
.gonder("Acil uyarı: Kotanızın %90'ını geçtiniz!");
} else if en_buyugun_yuzdesi >= 0.75 {
self.iletici.gonder("Uyarı: Kotanızın %75'ini geçtiniz!");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
struct SahteIletici {
gonderilen_iletiler: Vec<String>,
}
impl SahteIletici {
fn yeni() -> SahteIletici {
SahteIletici {
gonderilen_iletiler: vec![],
}
}
}
impl Iletici for SahteIletici {
fn gonder(&self, ileti: &str) {
self.gonderilen_iletiler.push(String::from(ileti));
}
}
#[test]
fn yuzde_75_ustu_uyari_iletisi_gonderir() {
let sahte_iletici = SahteIletici::yeni();
let mut sinir_izleyici = SinirIzleyici::yeni(&sahte_iletici, 100);
sinir_izleyici.deger_ata(80);
assert_eq!(sahte_iletici.gonderilen_iletiler.len(), 1);
}
}
SahteIletici uygulaması denemesiBu test kodu, göndermesi söylenen iletileri takip etmek üzere String değerlerinden oluşan bir Vec tutan gonderilen_iletiler alanına sahip bir SahteIletici struct’ı tanımlar. Ayrıca boş bir ileti listesiyle başlayan yeni SahteIletici değerleri oluşturmayı kolaylaştırmak için yeni adında bir ilişkili fonksiyon tanımlarız. Ardından bir SinirIzleyici’ye SahteIletici verebilmek için SahteIletici üzerinde Iletici trait’ini uygularız. gonder metodunun tanımında ise parametre olarak geçirilen iletiyi alır ve SahteIletici içindeki gonderilen_iletiler listesinde saklarız.
Testte, SinirIzleyici’ye deger alanını en_buyuk değerinin yüzde 75’inden büyük bir sayıya ayarlaması söylendiğinde ne olduğunu sınıyoruz. İlk olarak, boş bir ileti listesiyle başlayacak yeni bir SahteIletici oluşturuyoruz. Ardından yeni bir SinirIzleyici oluşturuyor ve ona yeni SahteIletici’ye bir referans ile en_buyuk değeri olarak 100 veriyoruz. SinirIzleyici üzerindeki deger_ata metodunu, 100’ün yüzde 75’inden fazla olan 80 değeriyle çağırıyoruz. Sonrasında SahteIletici’nin kaydını tuttuğu ileti listesinde artık bir adet ileti bulunması gerektiğini doğruluyoruz.
Ancak aşağıda gösterildiği gibi bu testte bir sorun vardır:
$ cargo test
Compiling sinir-izleyici v0.1.0 (file:///projects/sinir-izleyici)
error[E0596]: cannot borrow `self.gonderilen_iletiler` as mutable, as it is behind a `&` reference
--> src/lib.rs:57:13
|
57 | self.gonderilen_iletiler.push(String::from(ileti));
| ^^^^^^^^^^^^^^^^^^^^^^^^ `self` is a `&` reference, so it cannot be borrowed as mutable
|
help: consider changing this to be a mutable reference in the `impl` method and the `trait` definition
|
2 ~ fn gonder(&mut self, ileti: &str);
3 | }
...
55 | impl Iletici for SahteIletici {
56 ~ fn gonder(&mut self, ileti: &str) {
|
For more information about this error, try `rustc --explain E0596`.
error: could not compile `sinir-izleyici` (lib test) due to 1 previous error
gonder metodu self’e değiştirilemez bir referans aldığı için iletileri kaydetmek üzere SahteIletici’yi değiştiremiyoruz. Hata metnindeki hem impl metodunda hem de trait tanımında &mut self kullanma önerisini de uygulayamayız. Yalnızca test uğruna Iletici trait’ini değiştirmek istemiyoruz. Bunun yerine, test kodumuzun mevcut tasarımımızla doğru biçimde çalışmasını sağlayacak bir yol bulmamız gerekir.
İşte bu, içsel değiştirilebilirliğin yardımcı olabileceği bir durumdur! gonderilen_iletiler alanını bir RefCell<T> içinde saklayacağız; böylece gonder metodu gördüğümüz iletileri saklamak üzere gonderilen_iletiler üzerinde değişiklik yapabilecektir. Liste 15-22 bunun nasıl göründüğünü göstermektedir.
pub trait Iletici {
fn gonder(&self, ileti: &str);
}
pub struct SinirIzleyici<'a, T: Iletici> {
iletici: &'a T,
deger: usize,
en_buyuk: usize,
}
impl<'a, T> SinirIzleyici<'a, T>
where
T: Iletici,
{
pub fn yeni(iletici: &'a T, en_buyuk: usize) -> SinirIzleyici<'a, T> {
SinirIzleyici {
iletici,
deger: 0,
en_buyuk,
}
}
pub fn deger_ata(&mut self, deger: usize) {
self.deger = deger;
let en_buyugun_yuzdesi = self.deger as f64 / self.en_buyuk as f64;
if en_buyugun_yuzdesi >= 1.0 {
self.iletici.gonder("Hata: Kotanızı aştınız!");
} else if en_buyugun_yuzdesi >= 0.9 {
self.iletici
.gonder("Acil uyarı: Kotanızın %90'ını geçtiniz!");
} else if en_buyugun_yuzdesi >= 0.75 {
self.iletici.gonder("Uyarı: Kotanızın %75'ini geçtiniz!");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
struct SahteIletici {
gonderilen_iletiler: RefCell<Vec<String>>,
}
impl SahteIletici {
fn yeni() -> SahteIletici {
SahteIletici {
gonderilen_iletiler: RefCell::new(vec![]),
}
}
}
impl Iletici for SahteIletici {
fn gonder(&self, ileti: &str) {
self.gonderilen_iletiler
.borrow_mut()
.push(String::from(ileti));
}
}
#[test]
fn yuzde_75_ustu_uyari_iletisi_gonderir() {
// --snip--
let sahte_iletici = SahteIletici::yeni();
let mut sinir_izleyici = SinirIzleyici::yeni(&sahte_iletici, 100);
sinir_izleyici.deger_ata(80);
assert_eq!(sahte_iletici.gonderilen_iletiler.borrow().len(), 1);
}
}
RefCell<T> kullanmakgonderilen_iletiler alanı artık Vec<String> yerine RefCell<Vec<String>> türündedir. yeni fonksiyonunda boş vektörün etrafında yeni bir RefCell<Vec<String>> örneği oluşturuyoruz.
gonder metodunun uygulamasında ilk parametre hâlâ self’in değiştirilemez ödünç alımıdır; bu da trait tanımıyla eşleşir. self.gonderilen_iletiler içindeki RefCell<Vec<String>> üzerinde borrow_mut çağırarak RefCell<Vec<String>> içindeki değere — yani vektöre — değiştirilebilir bir referans elde ederiz. Ardından test sırasında gönderilen iletilerin kaydını tutmak için vektöre olan değiştirilebilir referans üzerinde push çağırabiliriz.
Yapmamız gereken son değişiklik doğrulama satırındadır: İçteki vektörde kaç öğe bulunduğunu görmek için RefCell<Vec<String>> üzerinde borrow çağırarak vektöre değiştirilemez bir referans alırız.
RefCell<T> türünün nasıl kullanıldığını gördüğünüze göre, şimdi nasıl çalıştığının ayrıntılarına inelim!
Ödünç Almaları Çalışma Zamanında İzlemek
Değiştirilemez ve değiştirilebilir referanslar oluştururken sırasıyla & ve &mut sözdizimini kullanırız. RefCell<T> ile ise RefCell<T> türüne ait güvenli API’nin bir parçası olan borrow ve borrow_mut metotlarını kullanırız. borrow metodu Ref<T> akıllı işaretçi türünü, borrow_mut metodu ise RefMut<T> akıllı işaretçi türünü döndürür. Her iki tür de Deref trait’ini uyguladığından onlara normal referanslar gibi davranabiliriz.
RefCell<T>, o anda kaç tane Ref<T> ve RefMut<T> akıllı işaretçisinin etkin olduğunu takip eder. borrow metodunu her çağırdığımızda RefCell<T>, etkin olan değiştirilemez ödünç almaların sayısını artırır. Bir Ref<T> değeri kapsam dışına çıktığında değiştirilemez ödünç almaların sayısı 1 azalır. Tıpkı derleme zamanı ödünç alma kuralları gibi, RefCell<T> de herhangi bir anda çok sayıda değiştirilemez ödünç almaya veya tek bir değiştirilebilir ödünç almaya sahip olmamıza izin verir.
Bu kuralları ihlal etmeye çalışırsak, referanslarda olacağı gibi bir derleyici hatası almak yerine RefCell<T> uygulaması çalışma zamanında panikleyecektir. Liste 15-23, Liste 15-22’deki gonder uygulamasının değiştirilmiş bir hâlini göstermektedir. RefCell<T> türünün çalışma zamanında bunu yapmamızı engellediğini göstermek amacıyla aynı kapsamda etkin iki değiştirilebilir ödünç almayı bilerek oluşturmaya çalışıyoruz.
pub trait Iletici {
fn gonder(&self, ileti: &str);
}
pub struct SinirIzleyici<'a, T: Iletici> {
iletici: &'a T,
deger: usize,
en_buyuk: usize,
}
impl<'a, T> SinirIzleyici<'a, T>
where
T: Iletici,
{
pub fn yeni(iletici: &'a T, en_buyuk: usize) -> SinirIzleyici<'a, T> {
SinirIzleyici {
iletici,
deger: 0,
en_buyuk,
}
}
pub fn deger_ata(&mut self, deger: usize) {
self.deger = deger;
let en_buyugun_yuzdesi = self.deger as f64 / self.en_buyuk as f64;
if en_buyugun_yuzdesi >= 1.0 {
self.iletici.gonder("Hata: Kotanızı aştınız!");
} else if en_buyugun_yuzdesi >= 0.9 {
self.iletici
.gonder("Acil uyarı: Kotanızın %90'ını geçtiniz!");
} else if en_buyugun_yuzdesi >= 0.75 {
self.iletici.gonder("Uyarı: Kotanızın %75'ini geçtiniz!");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
struct SahteIletici {
gonderilen_iletiler: RefCell<Vec<String>>,
}
impl SahteIletici {
fn yeni() -> SahteIletici {
SahteIletici {
gonderilen_iletiler: RefCell::new(vec![]),
}
}
}
impl Iletici for SahteIletici {
fn gonder(&self, ileti: &str) {
let mut ilk_odunc = self.gonderilen_iletiler.borrow_mut();
let mut ikinci_odunc = self.gonderilen_iletiler.borrow_mut();
ilk_odunc.push(String::from(ileti));
ikinci_odunc.push(String::from(ileti));
}
}
#[test]
fn yuzde_75_ustu_uyari_iletisi_gonderir() {
let sahte_iletici = SahteIletici::yeni();
let mut sinir_izleyici = SinirIzleyici::yeni(&sahte_iletici, 100);
sinir_izleyici.deger_ata(80);
assert_eq!(sahte_iletici.gonderilen_iletiler.borrow().len(), 1);
}
}
RefCell<T> türünün panikleyeceğini görmek için aynı kapsamda iki değiştirilebilir referans oluşturmakborrow_mut çağrısından dönen RefMut<T> akıllı işaretçisi için ilk_odunc değişkenini oluşturuyoruz. Ardından ikinci_odunc değişkeninde aynı şekilde başka bir değiştirilebilir ödünç alma daha oluşturuyoruz. Bu durum aynı kapsamda iki değiştirilebilir referans oluşturur ki buna izin verilmez. Kütüphanemiz için testleri çalıştırdığımızda Liste 15-23’teki kod hiçbir hata vermeden derlenecek, ancak test başarısız olacaktır:
$ cargo test
Compiling sinir-izleyici v0.1.0 (file:///projects/sinir-izleyici)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.91s
Running unittests src/lib.rs (target/debug/deps/sinir_izleyici-e599811fa246dbde)
running 1 test
test tests::yuzde_75_ustu_uyari_iletisi_gonderir ... FAILED
failures:
---- tests::yuzde_75_ustu_uyari_iletisi_gonderir stdout ----
thread 'tests::yuzde_75_ustu_uyari_iletisi_gonderir' (6028024) panicked at src/lib.rs:59:61:
RefCell already borrowed
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::yuzde_75_ustu_uyari_iletisi_gonderir
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
Kodun RefCell already borrowed (önceki Rust sürümlerinde already borrowed: BorrowMutError) mesajıyla paniklediğine dikkat edin. RefCell<T>, ödünç alma kurallarının ihlallerini çalışma zamanında işte bu şekilde ele alır.
Burada yaptığımız gibi ödünç alma hatalarını derleme zamanı yerine çalışma zamanında yakalamayı seçmek, kodunuzdaki hataları geliştirme sürecinin daha ileriki aşamalarında — belki de kodunuz canlı ortama (production) alınana dek — fark edemeyebileceğiniz anlamına gelir. Ayrıca ödünç almaları derleme zamanı yerine çalışma zamanında takip etmenin bir sonucu olarak kodunuz küçük bir çalışma zamanı performans maliyeti üstlenir. Bununla birlikte RefCell<T> kullanmak, yalnızca değiştirilemez değerlere izin verilen bir bağlamda kullanılırken bile gördüğü iletilerin kaydını tutmak üzere kendisini değiştirebilen bir taklit nesne yazmayı mümkün kılar. Bu ödünleşimlere rağmen, normal referansların sunduğundan daha fazla işlevsellik elde etmek için RefCell<T> kullanabilirsiniz.
Not: Çalışma zamanında ödünç çakışması ihtimali bulunan durumlarda programın
panic! ile çökmesini istemiyorsanız, RefCell<T> türünün paniklemeyen
alternatifleri olan try_borrow() ve try_borrow_mut() metotlarını
kullanabilirsiniz:
try_borrow(), değer zaten değiştirilebilir olarak ödünç alınmışsa paniklemek yerineErr(BorrowError)döndüren birResult<Ref<'_, T>, BorrowError>üretir.try_borrow_mut(), değer o anda değiştirilemez veya değiştirilebilir olarak ödünç alınmışsaErr(BorrowMutError)döndüren birResult<RefMut<'_, T>, BorrowMutError>üretir.
Ayrıca Ref<T> ve RefMut<T> akıllı işaretçileri ödünç kilidini kapsam sonuna
kadar tuttuğu için, aynı fonksiyon içinde art arda ödünç almanız gerektiğinde
ilk ödüncü bir iç blok ({ ... }) içinde sınırlayabilir ya da bu bölümde daha
önce gördüğünüz drop(ilk_odunc) çağrısıyla erken bırakabilirsiniz.
Değiştirilebilir Verinin Birden Fazla Sahibi Olmasına İzin Vermek
RefCell<T> kullanmanın yaygın bir yolu, onu Rc<T> ile birleştirmektir. Rc<T> türünün bir verinin birden fazla sahibi olmasına izin verdiğini, ancak o veriye yalnızca değiştirilemez erişim sağladığını hatırlayın. İçinde bir RefCell<T> tutan bir Rc<T> değeriniz varsa, hem birden fazla sahibi olabilen hem de değiştirebileceğiniz bir değer elde edersiniz!
Örneğin, birden fazla listenin başka bir listenin sahipliğini paylaşmasına olanak tanımak için Rc<T> kullandığımız Liste 15-18’deki cons listesi örneğini hatırlayın. Rc<T> yalnızca değiştirilemez değerler tuttuğu için, listeyi oluşturduktan sonra içindeki değerlerin hiçbirini değiştiremeyiz. Listelerdeki değerleri değiştirebilme yeteneği için işin içine RefCell<T> ekleyelim. Liste 15-24, Dugum tanımında bir RefCell<T> kullanarak tüm listelerde saklanan değeri değiştirebileceğimizi göstermektedir.
#[derive(Debug)]
enum Liste {
Dugum(Rc<RefCell<i32>>, Rc<Liste>),
Bos,
}
use crate::Liste::{Bos, Dugum};
use std::cell::RefCell;
use std::rc::Rc;
fn main() {
let deger = Rc::new(RefCell::new(5));
let a = Rc::new(Dugum(Rc::clone(°er), Rc::new(Bos)));
let b = Dugum(Rc::new(RefCell::new(3)), Rc::clone(&a));
let c = Dugum(Rc::new(RefCell::new(4)), Rc::clone(&a));
*deger.borrow_mut() += 10;
println!("a sonrası = {a:?}");
println!("b sonrası = {b:?}");
println!("c sonrası = {c:?}");
}
Liste oluşturmak için Rc<RefCell<i32>> kullanmakRc<RefCell<i32>> örneği olan bir değer oluşturuyor ve daha sonra doğrudan erişebilmek için onu deger adlı bir değişkende saklıyoruz. Ardından a içinde deger’i tutan bir Dugum varyantıyla bir Liste oluşturuyoruz. Sahipliği deger’den a’ya devretmek ya da a’nın deger’den ödünç almasını sağlamak yerine hem a’nın hem de deger’in içteki 5 değerinin sahipliğine sahip olması için deger’i klonlamamız gerekir.
Sonrasında b ve c listelerini oluşturduğumuzda her ikisinin de a’ya başvurabilmesi için a listesini de bir Rc<T> içine sarıyoruz; bu, tıpkı Liste 15-18’de yaptığımız işlemdir.
a, b ve c içindeki listeleri oluşturduktan sonra deger içindeki sayıya 10 eklemek istiyoruz. Bunu deger üzerinde borrow_mut çağırarak yapıyoruz; bu çağrı, Rc<T> türünün başvurusunu içteki RefCell<T> değerine çözmek için Bölüm 5’teki “-> Operatörü Nerede?” başlığında ele aldığımız otomatik başvuru çözme özelliğini kullanır. borrow_mut metodu bir RefMut<T> akıllı işaretçisi döndürür; biz de onun üzerinde başvuru çözme operatörünü (*) kullanarak içteki değeri değiştiririz.
a, b ve c’yi yazdırdığımızda hepsinin 5 yerine değiştirilmiş 15 değerine sahip olduğunu görebiliriz:
$ cargo run
Compiling kons-liste v0.1.0 (file:///projects/kons-liste)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.63s
Running `target/debug/kons-liste`
a sonrası = Dugum(RefCell { value: 15 }, Bos)
b sonrası = Dugum(RefCell { value: 3 }, Dugum(RefCell { value: 15 }, Bos))
c sonrası = Dugum(RefCell { value: 4 }, Dugum(RefCell { value: 15 }, Bos))
Bu teknik oldukça zariftir! RefCell<T> kullanarak dışarıdan bakıldığında değiştirilemez bir Liste değerine sahip oluruz. Ancak verimizi değiştirmemiz gerektiğinde içsel değiştirilebilirliğine erişim sağlayan RefCell<T> metotlarını kullanabiliriz. Ödünç alma kurallarının çalışma zamanı denetimleri bizi veri yarışlarından (data race) korur ve veri yapılarımızda bu esnekliği kazanmak için bazen bir miktar hızdan feragat etmeye değer. RefCell<T> türünün çok thread’li kodlarda çalışmadığını unutmayın! Mutex<T>, RefCell<T> türünün thread güvenli (thread-safe) karşılığıdır ve Mutex<T> türünü Bölüm 16’da ele alacağız.
Referans Döngüleri Bellek Sızıntısına Neden Olabilir
Referans Döngüleri Bellek Sızıntısına Neden Olabilir
Rust’ın bellek güvenliği garantileri, yanlışlıkla asla temizlenmeyen bellek alanları oluşturmayı (bellek sızıntısı (memory leak) olarak bilinir) zorlaştırır, ancak imkânsız kılmaz. Bellek sızıntılarını tamamen önlemek Rust’ın sunduğu garantilerden biri değildir; başka bir deyişle, Rust’ta bellek sızıntıları bellek açısından güvenlidir. Rc<T> ve RefCell<T> kullanarak Rust’ın bellek sızıntılarına izin verdiğini görebiliriz: Öğelerin döngü oluşturacak biçimde birbirine başvuruda bulunduğu referanslar oluşturmak mümkündür. Döngüdeki her bir öğenin referans sayısı hiçbir zaman 0’a ulaşmayacağı ve değerler asla düşürülmeyeceği için bu durum bellek sızıntılarına yol açar.
Referans Döngüsü Oluşturmak
Liste 15-25’teki Liste enum’ının ve kuyruk metodunun tanımıyla başlayarak bir referans döngüsünün nasıl oluşabileceğine ve bunun nasıl önlenebileceğine bakalım.
use crate::Liste::{Bos, Dugum};
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Debug)]
enum Liste {
Dugum(i32, RefCell<Rc<Liste>>),
Bos,
}
impl Liste {
fn kuyruk(&self) -> Option<&RefCell<Rc<Liste>>> {
match self {
Dugum(_, oge) => Some(oge),
Bos => None,
}
}
}
fn main() {}
Dugum varyantının neye başvurduğunu değiştirebilmemiz için bir RefCell<T> tutan cons listesi tanımıBurada Liste 15-5’teki Liste tanımının bir başka çeşidini kullanıyoruz. Dugum varyantındaki ikinci eleman artık RefCell<Rc<Liste>> türündedir; bu da Liste 15-24’te yaptığımız gibi i32 değerini değiştirebilmek yerine, bir Dugum varyantının işaret ettiği Liste değerini değiştirmek istediğimiz anlamına gelir. Ayrıca elimizde bir Dugum varyantı olduğunda ikinci öğeye erişmemizi kolaylaştırmak için bir kuyruk metodu ekliyoruz.
Liste 15-26’da, Liste 15-25’teki tanımları kullanan bir main fonksiyonu ekliyoruz. Bu kod, a içinde bir liste ve a içindeki listeye işaret eden b içinde başka bir liste oluşturur. Ardından a içindeki listeyi b’ye işaret edecek şekilde değiştirerek bir referans döngüsü oluşturur. Bu sürecin çeşitli noktalarında referans sayılarının kaç olduğunu göstermek için yol boyunca println! deyimleri yer almaktadır.
use crate::Liste::{Bos, Dugum};
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Debug)]
enum Liste {
Dugum(i32, RefCell<Rc<Liste>>),
Bos,
}
impl Liste {
fn kuyruk(&self) -> Option<&RefCell<Rc<Liste>>> {
match self {
Dugum(_, oge) => Some(oge),
Bos => None,
}
}
}
fn main() {
let a = Rc::new(Dugum(5, RefCell::new(Rc::new(Bos))));
println!("a için ilk rc sayısı = {}", Rc::strong_count(&a));
println!("a sonraki öğe = {:?}", a.kuyruk());
let b = Rc::new(Dugum(10, RefCell::new(Rc::clone(&a))));
println!("b oluşturulduktan sonra a rc sayısı = {}", Rc::strong_count(&a));
println!("b için ilk rc sayısı = {}", Rc::strong_count(&b));
println!("b sonraki öğe = {:?}", b.kuyruk());
if let Some(baglanti) = a.kuyruk() {
*baglanti.borrow_mut() = Rc::clone(&b);
}
println!("a değiştirildikten sonra b rc sayısı = {}", Rc::strong_count(&b));
println!("a değiştirildikten sonra a rc sayısı = {}", Rc::strong_count(&a));
// Bir döngü oluştuğunu görmek için sonraki satırın yorumunu kaldırın;
// bu, yığın taşmasına yol açacaktır.
// println!("a sonraki öğe = {:?}", a.kuyruk());
}
Liste değerinden oluşan bir referans döngüsü oluşturmaka değişkeninde başlangıçta 5, Bos listesine sahip bir Liste değeri tutan bir Rc<Liste> örneği oluşturuyoruz. Ardından b değişkeninde, 10 değerini içeren ve a içindeki listeye işaret eden başka bir Liste değeri tutan bir Rc<Liste> örneği oluşturuyoruz.
Bir döngü oluşturmak üzere a’yı, Bos yerine b’ye işaret edecek şekilde değiştiriyoruz. Bunu, a içindeki RefCell<Rc<Liste>> değerine bir referans almak için kuyruk metodunu kullanarak yapıyor ve bu referansı baglanti değişkenine koyuyoruz. Ardından RefCell<Rc<Liste>> üzerinde borrow_mut metodunu kullanarak içerideki değeri, Bos değeri tutan bir Rc<Liste> örneğinden b içindeki Rc<Liste> örneğine çeviriyoruz.
Son println! satırını şimdilik yorum satırı olarak bırakıp bu kodu çalıştırdığımızda şu çıktıyı alırız:
$ cargo run
Compiling kons-liste v0.1.0 (file:///projects/kons-liste)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.53s
Running `target/debug/kons-liste`
a için ilk rc sayısı = 1
a sonraki öğe = Some(RefCell { value: Bos })
b oluşturulduktan sonra a rc sayısı = 2
b için ilk rc sayısı = 1
b sonraki öğe = Some(RefCell { value: Dugum(5, RefCell { value: Bos }) })
a değiştirildikten sonra b rc sayısı = 2
a değiştirildikten sonra a rc sayısı = 2
a içindeki listeyi b’ye işaret edecek şekilde değiştirdikten sonra hem a hem de b içindeki Rc<Liste> örneklerinin referans sayısı 2 olur. main fonksiyonunun sonunda Rust önce b değişkenini düşürür; bu işlem b’deki Rc<Liste> örneğinin referans sayısını 2’den 1’e indirir. Referans sayısı 0 değil 1 olduğu için bu noktada Rc<Liste> örneğinin öbekte sahip olduğu bellek serbest bırakılmaz. Ardından Rust a değişkenini düşürür; bu da a’daki Rc<Liste> örneğinin referans sayısını aynı şekilde 2’den 1’e indirir. Diğer Rc<Liste> örneği hâlâ ona başvurduğu için bu örneğin belleği de serbest bırakılamaz. Listeye ayrılan bellek bir daha asla serbest bırakılmayacaktır. Bu referans döngüsünü görselleştirmek için Şekil 15-4’teki diyagramı oluşturduk.
Şekil 15-4: Birbirine işaret eden a ve b listelerinin oluşturduğu referans döngüsü
Son println! satırını yorumdan çıkarıp programı çalıştırırsanız Rust, a’nın b’ye, b’nin tekrar a’ya işaret ettiği bu döngüyü yığın taşması (stack overflow) gerçekleşene dek yazdırmaya çalışacaktır.
Gerçek dünyadaki bir programla karşılaştırıldığında, bu örnekte bir referans döngüsü oluşturmanın sonuçları çok vahim değildir: Referans döngüsünü oluşturduktan hemen sonra program sona erer. Ancak daha karmaşık bir program bir döngü içinde çok miktarda bellek ayırıp bunu uzun süre elinde tutarsa, ihtiyaç duyduğundan daha fazla bellek kullanır ve sistemi zorlayarak kullanılabilir belleğin tükenmesine neden olabilir.
Referans döngüleri oluşturmak kolayca yapılan bir şey değildir, ama imkânsız da değildir. İçinde Rc<T> değerleri barındıran RefCell<T> değerleriniz ya da içsel değiştirilebilirlik ile referans sayımına sahip türlerin benzer iç içe bileşimleri varsa, döngü oluşturmadığınızdan emin olmanız gerekir; bunları yakalaması için Rust’a güvenemezsiniz. Bir referans döngüsü oluşturmak programınızda bir mantık hatası olur; bu tür hataları en aza indirmek için otomatik testlerden, kod incelemelerinden ve diğer yazılım geliştirme pratiklerinden yararlanmalısınız.
Referans döngülerinden kaçınmanın bir diğer çözümü de veri yapılarınızı, bazı referanslar sahipliği ifade ederken bazıları etmeyecek biçimde yeniden düzenlemektir. Sonuç olarak, bir kısmı sahiplik ilişkilerinden ve bir kısmı sahiplik dışı ilişkilerden oluşan döngüler kurabilirsiniz; böylece bir değerin düşürülüp düşürülemeyeceğini yalnızca sahiplik ilişkileri etkiler. Liste 15-25’te Dugum varyantlarının her zaman kendi listelerinin sahibi olmasını istediğimizden veri yapısını yeniden düzenlemek mümkün değildir. Sahiplik dışı ilişkilerin referans döngülerini önlemek için ne zaman uygun bir yöntem olduğunu görmek üzere ebeveyn düğümler ve çocuk düğümlerden oluşan grafları kullanan bir örneğe bakalım.
Weak<T> Kullanarak Referans Döngülerini Önlemek
Şimdiye dek Rc::clone çağrısının bir Rc<T> örneğinin strong_count değerini artırdığını ve bir Rc<T> örneğinin yalnızca strong_count değeri 0 olduğunda temizlendiğini gösterdik. Ayrıca Rc::downgrade fonksiyonunu çağırıp argüman olarak Rc<T> örneğine bir referans geçirerek Rc<T> örneği içindeki değere zayıf bir referans da oluşturabilirsiniz. Güçlü referanslar (strong references), bir Rc<T> örneğinin sahipliğini paylaşma yönteminizdir. Zayıf referanslar (weak references) ise bir sahiplik ilişkisi ifade etmez ve sayıları bir Rc<T> örneğinin ne zaman temizleneceğini etkilemez. Zayıf referanslar bir referans döngüsüne yol açmaz; çünkü zayıf referanslar içeren herhangi bir döngü, ilgili değerlerin güçlü referans sayısı 0 olduğunda kırılacaktır.
Rc::downgrade çağırdığınızda Weak<T> türünde bir akıllı işaretçi elde edersiniz. Rc::downgrade çağrısı, Rc<T> örneğindeki strong_count değerini 1 artırmak yerine weak_count değerini 1 artırır. Rc<T> türü, tıpkı strong_count gibi kaç tane Weak<T> referansının var olduğunu takip etmek için weak_count sayacını kullanır. Aradaki fark, Rc<T> örneğinin temizlenmesi için weak_count değerinin 0 olması gerekmemesidir.
Weak<T> örneğinin başvurduğu değer düşürülmüş olabileceğinden, bir Weak<T> işaretçisinin gösterdiği değerle herhangi bir işlem yapabilmek için değerin hâlâ var olduğundan emin olmanız gerekir. Bunu, bir Weak<T> örneği üzerinde Option<Rc<T>> döndüren upgrade metodunu çağırarak yaparsınız. Rc<T> değeri henüz düşürülmemişse Some, düşürülmüşse None sonucu alırsınız. upgrade metodu bir Option<Rc<T>> döndürdüğü için Rust hem Some durumunun hem de None durumunun ele alınmasını güvence altına alır ve geçersiz bir işaretçi oluşmaz.
Örnek olarak, öğeleri yalnızca bir sonraki öğeden haberdar olan bir liste kullanmak yerine, öğeleri hem çocuk öğelerinden hem de ebeveyn öğelerinden haberdar olan bir ağaç oluşturacağız.
Ağaç Veri Yapısı Oluşturmak
Başlangıç olarak, çocuk düğümlerinden haberdar olan düğümlere sahip bir ağaç oluşturacağız. Kendi i32 değerinin yanı sıra çocuk Dugum değerlerine yönelik referansları da tutan Dugum adında bir struct oluşturacağız:
Dosya adı: src/main.rs
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Debug)]
struct Dugum {
deger: i32,
cocuklar: RefCell<Vec<Rc<Dugum>>>,
}
fn main() {
let yaprak = Rc::new(Dugum {
deger: 3,
cocuklar: RefCell::new(vec![]),
});
let dal = Rc::new(Dugum {
deger: 5,
cocuklar: RefCell::new(vec![Rc::clone(&yaprak)]),
});
}
Bir Dugum örneğinin kendi çocuklarının sahibi olmasını ve ağaçtaki her bir Dugum örneğine doğrudan erişebilmemiz için bu sahipliği değişkenlerle paylaşmayı istiyoruz. Bunu sağlamak adına Vec<T> öğelerini Rc<Dugum> türündeki değerler olarak tanımlıyoruz. Ayrıca hangi düğümlerin başka bir düğümün çocuğu olduğunu değiştirebilmek istediğimizden cocuklar alanında Vec<Rc<Dugum>> etrafında bir RefCell<T> bulunduruyoruz.
Ardından Liste 15-27’de gösterildiği gibi struct tanımımızı kullanarak 3 değerine sahip ve çocuğu olmayan yaprak adında bir Dugum örneği ile 5 değerine sahip ve çocuklarından biri olarak yaprak düğümünü barındıran dal adında başka bir örnek oluşturacağız.
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Debug)]
struct Dugum {
deger: i32,
cocuklar: RefCell<Vec<Rc<Dugum>>>,
}
fn main() {
let yaprak = Rc::new(Dugum {
deger: 3,
cocuklar: RefCell::new(vec![]),
});
let dal = Rc::new(Dugum {
deger: 5,
cocuklar: RefCell::new(vec![Rc::clone(&yaprak)]),
});
}
yaprak düğümü ve çocuklarından biri yaprak olan bir dal düğümü oluşturmakyaprak içindeki Rc<Dugum> örneğini klonlayıp dal içinde saklıyoruz; bu da yaprak içindeki Dugum örneğinin artık yaprak ve dal olmak üzere iki sahibi olduğu anlamına gelir. dal.cocuklar aracılığıyla dal’dan yaprak’a ulaşabiliriz, ancak yaprak’tan dal’a ulaşmanın hiçbir yolu yoktur. Bunun nedeni, yaprak düğümünün dal düğümüne bir referansı olmaması ve aralarında bir ilişki bulunduğunu bilmemesidir. yaprak düğümünün, dal düğümünün kendi ebeveyni olduğunu bilmesini istiyoruz. Şimdi bunu yapacağız.
Çocuktan Ebeveyne Referans Eklemek
Çocuk düğümün ebeveyninden haberdar olmasını sağlamak için Dugum struct tanımımıza bir ebeveyn alanı eklememiz gerekir. Buradaki güçlük, ebeveyn alanının türünün ne olması gerektiğine karar vermektir. Bunun bir Rc<T> içeremeyeceğini biliyoruz; çünkü öyle olsaydı yaprak.ebeveyn alanının dal’a, dal.cocuklar alanının da yaprak’a işaret etmesiyle bir referans döngüsü oluşur ve bu durum her ikisinin strong_count değerlerinin hiçbir zaman 0 olmamasına yol açardı.
İlişkileri başka bir açıdan düşünürsek, bir ebeveyn düğüm çocuklarının sahibi olmalıdır: Bir ebeveyn düğüm düşürülürse çocuk düğümleri de onunla birlikte düşürülmelidir. Ancak bir çocuk düğüm ebeveyninin sahibi olmamalıdır: Bir çocuk düğümü düşürürsek ebeveyn düğüm var olmaya devam etmelidir. Bu tam olarak zayıf referanslara uygun bir durumdur!
Bu nedenle Rc<T> yerine ebeveyn alanının türünü Weak<T> kullanan bir tür, daha açık bir ifadeyle RefCell<Weak<Dugum>> yapacağız. Artık Dugum struct tanımımız şöyle görünür:
Dosya adı: src/main.rs
use std::cell::RefCell;
use std::rc::{Rc, Weak};
#[derive(Debug)]
struct Dugum {
deger: i32,
ebeveyn: RefCell<Weak<Dugum>>,
cocuklar: RefCell<Vec<Rc<Dugum>>>,
}
fn main() {
let yaprak = Rc::new(Dugum {
deger: 3,
ebeveyn: RefCell::new(Weak::new()),
cocuklar: RefCell::new(vec![]),
});
println!("yaprak ebeveyni = {:?}", yaprak.ebeveyn.borrow().upgrade());
let dal = Rc::new(Dugum {
deger: 5,
ebeveyn: RefCell::new(Weak::new()),
cocuklar: RefCell::new(vec![Rc::clone(&yaprak)]),
});
*yaprak.ebeveyn.borrow_mut() = Rc::downgrade(&dal);
println!("yaprak ebeveyni = {:?}", yaprak.ebeveyn.borrow().upgrade());
}
Böylece bir düğüm kendi ebeveyn düğümüne başvurabilecek, ancak ebeveyninin sahibi olmayacaktır. Liste 15-28’de, yaprak düğümünün ebeveyni olan dal düğümüne başvurabilmesi için main fonksiyonunu bu yeni tanımı kullanacak şekilde güncelliyoruz.
use std::cell::RefCell;
use std::rc::{Rc, Weak};
#[derive(Debug)]
struct Dugum {
deger: i32,
ebeveyn: RefCell<Weak<Dugum>>,
cocuklar: RefCell<Vec<Rc<Dugum>>>,
}
fn main() {
let yaprak = Rc::new(Dugum {
deger: 3,
ebeveyn: RefCell::new(Weak::new()),
cocuklar: RefCell::new(vec![]),
});
println!("yaprak ebeveyni = {:?}", yaprak.ebeveyn.borrow().upgrade());
let dal = Rc::new(Dugum {
deger: 5,
ebeveyn: RefCell::new(Weak::new()),
cocuklar: RefCell::new(vec![Rc::clone(&yaprak)]),
});
*yaprak.ebeveyn.borrow_mut() = Rc::downgrade(&dal);
println!("yaprak ebeveyni = {:?}", yaprak.ebeveyn.borrow().upgrade());
}
dal’a zayıf bir referansı bulunan yaprak düğümüyaprak düğümünü oluşturmak, ebeveyn alanı dışında Liste 15-27’dekine benzer görünür: yaprak başlangıçta ebeveynsiz olduğundan yeni ve boş bir Weak<Dugum> referans örneği oluştururuz.
Bu noktada upgrade metodunu kullanarak yaprak düğümünün ebeveynine bir referans almaya çalıştığımızda None değeri elde ederiz. Bunu ilk println! deyiminin çıktısında görürüz:
yaprak ebeveyni = None
dal düğümünü oluşturduğumuzda, dal düğümünün bir ebeveyn düğümü olmadığı için onun ebeveyn alanında da yeni bir Weak<Dugum> referansı yer alacaktır. yaprak yine dal düğümünün çocuklarından biridir. dal içindeki Dugum örneğini elde ettikten sonra, yaprak düğümünü ebeveynine yönelik bir Weak<Dugum> referansı tutacak biçimde değiştirebiliriz. yaprak düğümünün ebeveyn alanındaki RefCell<Weak<Dugum>> üzerinde borrow_mut metodunu kullanır, ardından dal içindeki Rc<Dugum> örneğinden dal’a işaret eden bir Weak<Dugum> referansı oluşturmak için Rc::downgrade fonksiyonunu çağırırız.
yaprak düğümünün ebeveynini yeniden yazdırdığımızda bu kez dal’ı tutan bir Some varyantı elde ederiz: Artık yaprak kendi ebeveynine erişebilmektedir! yaprak düğümünü yazdırırken, Liste 15-26’da sonunda yığın taşmasıyla sonuçlanan döngüden de kaçınmış oluruz; Weak<Dugum> referansları (Weak) olarak yazdırılır:
yaprak ebeveyni = Some(Dugum { deger: 5, ebeveyn: RefCell { value: (Weak) },
cocuklar: RefCell { value: [Dugum { deger: 3, ebeveyn: RefCell { value: (Weak) },
cocuklar: RefCell { value: [] } }] } })
Sonsuz bir çıktının oluşmaması, bu kodun bir referans döngüsü oluşturmadığını gösterir. Bunu Rc::strong_count ve Rc::weak_count çağrılarından elde ettiğimiz değerlere bakarak da anlayabiliriz.
strong_count ve weak_count Değişimlerini Görselleştirmek
Yeni bir iç kapsam oluşturup dal düğümünün oluşturulmasını bu kapsama taşıyarak Rc<Dugum> örneklerinin strong_count ve weak_count değerlerinin nasıl değiştiğine bakalım. Bunu yaparak dal oluşturulduğunda ve ardından kapsam dışına çıkıp düşürüldüğünde neler olduğunu görebiliriz. Değişiklikler Liste 15-29’da gösterilmektedir.
use std::cell::RefCell;
use std::rc::{Rc, Weak};
#[derive(Debug)]
struct Dugum {
deger: i32,
ebeveyn: RefCell<Weak<Dugum>>,
cocuklar: RefCell<Vec<Rc<Dugum>>>,
}
fn main() {
let yaprak = Rc::new(Dugum {
deger: 3,
ebeveyn: RefCell::new(Weak::new()),
cocuklar: RefCell::new(vec![]),
});
println!(
"yaprak güçlü = {}, zayıf = {}",
Rc::strong_count(&yaprak),
Rc::weak_count(&yaprak),
);
{
let dal = Rc::new(Dugum {
deger: 5,
ebeveyn: RefCell::new(Weak::new()),
cocuklar: RefCell::new(vec![Rc::clone(&yaprak)]),
});
*yaprak.ebeveyn.borrow_mut() = Rc::downgrade(&dal);
println!(
"dal güçlü = {}, zayıf = {}",
Rc::strong_count(&dal),
Rc::weak_count(&dal),
);
println!(
"yaprak güçlü = {}, zayıf = {}",
Rc::strong_count(&yaprak),
Rc::weak_count(&yaprak),
);
}
println!("yaprak ebeveyni = {:?}", yaprak.ebeveyn.borrow().upgrade());
println!(
"yaprak güçlü = {}, zayıf = {}",
Rc::strong_count(&yaprak),
Rc::weak_count(&yaprak),
);
}
dal düğümünü bir iç kapsamda oluşturmak ve güçlü ile zayıf referans sayılarını incelemekyaprak oluşturulduktan sonra onun Rc<Dugum> örneğinin güçlü referans sayısı 1, zayıf referans sayısı ise 0 olur. İç kapsamda dal düğümünü oluşturup yaprak ile ilişkilendirdiğimizde ve sayıları yazdırdığımızda, dal içindeki Rc<Dugum> örneğinin güçlü referans sayısı 1, zayıf referans sayısı ise (yaprak.ebeveyn alanının bir Weak<Dugum> ile dal’a işaret etmesinden dolayı) 1 olacaktır. yaprak içindeki sayıları yazdırdığımızda, dal artık yaprak düğümünün Rc<Dugum> örneğinin bir klonunu dal.cocuklar içinde sakladığı için güçlü referans sayısının 2 olduğunu, ancak zayıf referans sayısının hâlâ 0 kaldığını görürüz.
İç kapsam sona erdiğinde dal kapsam dışına çıkar ve Rc<Dugum> örneğinin güçlü referans sayısı 0’a düşer; böylece içindeki Dugum düşürülür. yaprak.ebeveyn kaynaklı 1 olan zayıf referans sayısının Dugum değerinin düşürülüp düşürülmeyeceği üzerinde hiçbir etkisi yoktur; dolayısıyla herhangi bir bellek sızıntısı yaşamayız!
Kapsam sona erdikten sonra yaprak düğümünün ebeveynine erişmeye çalışırsak yeniden None elde ederiz. Programın sonunda yaprak değişkeni artık Rc<Dugum> örneğine yönelik tek referans olarak kaldığından, yaprak içindeki Rc<Dugum> örneğinin güçlü referans sayısı 1 ve zayıf referans sayısı 0 olur.
Sayaçları ve değerlerin düşürülmesini yöneten mantığın tamamı Rc<T> ve Weak<T> türlerinin içine ve bunların Drop trait’i uygulamalarına yerleşiktir. Dugum tanımında çocuktan ebeveyne olan ilişkinin bir Weak<T> referansı olması gerektiğini belirterek, bir referans döngüsü ve bellek sızıntısı oluşturmadan ebeveyn düğümlerin çocuk düğümlere ve çocuk düğümlerin de ebeveyn düğümlere işaret etmesini sağlayabilirsiniz.
Not: Bu bölümdeki iki temel kavramı kendi veri yapılarınızı tasarlarken şu pratik kurallarla özetleyebilirsiniz:
- Bellek sızıntısı Rust’ta neden
unsafedeğildir? Rust’ta bellek güvenliği, serbest bırakılmış belleğe erişim (use-after-free), çift serbest bırakma (double free), sarkan işaretçi ve veri yarışı gibi tanımsız davranışları (undefined behavior) engellemek anlamına gelir. Öbekte ayrılan bir belleğin hiç serbest bırakılmaması kaynak tüketimini artırsa da geçersiz bir bellek erişimine yol açmaz. Referans döngülerini her durumda derleme zamanında yakalamak mümkün olmadığından, Rust dil modelinde bir değerinDrop::dropyıkıcısının çalışmaması bellek güvenliğini ihlal etmez; bu nedenle standart kütüphanedeki bir değeri düşürmeden unutanstd::mem::forgetfonksiyonu ve öbekteki bir kutuyu'staticreferansa dönüştürenBox::leakmetoduunsafegerektirmeyen, tamamen güvenli API’lerdir. - Kim
Rc<T>, kimWeak<T>tutmalı? Çift yönlü yapılarda yönü belirlemek için şu soruyu sorun: “A nesnesi, B nesnesini hayatta tutmaktan sorumlu mu?” Yanıt evet ise A’dan B’ye güçlü sahiplik (Rc<T>), yanıt hayır ise A’dan B’ye sahiplik iddia etmeyen gözlemci bağlantısı (Weak<T>) kullanın. A’nın düşürülmesi B’yi ancak başka güçlü sahibi kalmamışsa düşürür. Döngüdeki sahiplik iddia etmeyen geri bağlantılar için deWeak<T>tercih edilir:- Ağaç ve arayüz (DOM/UI) hiyerarşileri: Ebeveynden çocuğa
Rc<T>, çocuktan ebeveyneWeak<T>. - Çift yönlü bağlı listeler (doubly linked list): İleri bağlantı
(
sonraki)Rc<T>, geri bağlantı (onceki)Weak<T>. - Gözlemci (observer) ve önbellek (cache) desenleri: Verinin asıl
sahipleri
Rc<T>tutarken, yalnızca veri hâlâ yaşıyorsa ona bildirim göndermek veya onu önbellekten sunmak isteyen kayıt tablosuWeak<T>tutar (upgrade()çağrısıNonedöndürdüğünde süresi dolan kaydı temizler). - Aralarında doğal bir üst-alt sahiplik yönü bulunmayan genel döngüsel
graflarda ise
Rc<T>/Weak<T>yerine tüm düğümleri tek birVec<Dugum>(veya arena) içinde tutup kenarları vektör indisleriyle (usize) ifade etmek, döngü kaynaklı bellek sızıntılarını tamamen ortadan kaldıran yaygın bir Rust desenidir.
- Ağaç ve arayüz (DOM/UI) hiyerarşileri: Ebeveynden çocuğa
Özet
Bu bölümde, Rust’ın normal referanslarla varsayılan olarak sunduğundan farklı güvenceler ve ödünleşimler (trade-offs) elde etmek için akıllı işaretçilerin nasıl kullanılacağını ele aldık. Box<T> türü bilinen bir boyuta sahiptir ve öbekte ayrılmış veriye işaret eder. Rc<T> türü, öbekteki verinin birden fazla sahibi olabilmesi için o veriye olan referansların sayısını takip eder. İçsel değiştirilebilirliğiyle RefCell<T> türü ise değiştirilemez bir türe ihtiyaç duyduğumuz ama o türün içindeki bir değeri değiştirmemiz gereken durumlarda kullanabileceğimiz bir yapı sunar; ayrıca ödünç alma kurallarını derleme zamanı yerine çalışma zamanında uygular.
Ayrıca akıllı işaretçilerin işlevselliğinin büyük bir kısmını mümkün kılan Deref ve Drop trait’lerini de ele aldık. Bellek sızıntılarına neden olabilen referans döngülerini ve bunları Weak<T> kullanarak nasıl önleyebileceğimizi inceledik.
Bu bölüm ilginizi çektiyse ve kendi akıllı işaretçilerinizi uygulamak istiyorsanız daha fazla yararlı bilgi için “The Rustonomicon” kaynağına göz atabilirsiniz.
Sırada Rust’ta eşzamanlılık (concurrency) konusu var. Hatta orada birkaç yeni akıllı işaretçi daha öğreneceksiniz.
Korkusuz Eşzamanlılık (Fearless Concurrency)
Eşzamanlı programlamayı güvenli ve verimli bir şekilde ele almak, Rust’ın temel hedeflerinden bir diğeridir. Bir programın farklı bölümlerinin birbirinden bağımsız olarak yürütüldüğü eşzamanlı programlama (concurrent programming) ve bir programın farklı bölümlerinin aynı anda yürütüldüğü paralel programlama (parallel programming), giderek daha fazla bilgisayarın birden çok işlemcisinden yararlanmasıyla birlikte her geçen gün daha büyük önem kazanmaktadır. Geçmişten bugüne bu bağlamlarda program yazmak hep zor ve hataya açık olmuştur. Rust bu durumu değiştirmeyi hedefliyor.
Başlangıçta Rust ekibi, bellek güvenliğini sağlamanın ve eşzamanlılık sorunlarını önlemenin farklı yöntemlerle çözülmesi gereken iki ayrı zorluk olduğunu düşünüyordu. Zamanla ekip, sahiplik ve tür sistemlerinin hem bellek güvenliğini hem de eşzamanlılık sorunlarını yönetmeye yardımcı olan çok güçlü bir araç seti olduğunu keşfetti! Sahiplik ve tür denetimi sayesinde Rust’ta pek çok eşzamanlılık hatası, çalışma zamanı hatası yerine derleme zamanı hatası hâline gelir. Böylece çalışma zamanında ortaya çıkan bir eşzamanlılık hatasının oluştuğu koşulları birebir yeniden üretmeye çalışarak uzun zaman harcamak zorunda kalmazsınız; hatalı kod derlenmez ve derleyici sorunu açıklayan bir hata mesajı gösterir. Sonuç olarak kodunuzu canlı ortama (üretime) aldıktan sonra değil, henüz üzerinde çalışırken düzeltebilirsiniz. Rust’ın bu yönüne korkusuz eşzamanlılık adını verdik. Korkusuz eşzamanlılık, fark edilmesi güç sinsi hatalardan arınmış ve yeni hatalara yol açmadan kolayca yeniden düzenlenebilen kodlar yazmanıza olanak tanır.
Not: Anlatımı sade tutmak adına, pek çok sorundan söz ederken daha kesin bir
ifadeyle eşzamanlı ve/veya paralel demek yerine kısaca eşzamanlı
diyeceğiz. Bu bölüm boyunca eşzamanlı ifadesini gördüğünüz her yerde
zihninizde bunu eşzamanlı ve/veya paralel olarak düşünebilirsiniz. Bu
ayrımın daha büyük önem taşıdığı bir sonraki bölümde (Bölüm 17’de async ve
await yapısını incelerken) daha kesin bir dil kullanacağız.
Pek çok dil, eşzamanlılık sorunlarını ele almak için sunduğu çözümler konusunda katı (dogmatik) bir yaklaşım benimser. Örneğin Erlang, mesaj iletimine (message passing) dayalı eşzamanlılık için zarif olanaklar sunarken thread’ler (iş parçacıkları) arasında durum paylaşmak için yalnızca dolaylı ve anlaşılması güç yollar barındırır. Olası çözümlerin yalnızca bir alt kümesini desteklemek üst düzey diller için makul bir stratejidir; çünkü üst düzey bir dil, soyutlama kazanmak uğruna denetimin bir kısmından vazgeçmenin getireceği yararları vaat eder. Buna karşılık alt düzey dillerden, her durumda en yüksek performansı sağlayan çözümü sunmaları ve donanım üzerinde daha az soyutlama katmanı bulundurmaları beklenir. Bu nedenle Rust, problemleri kendi durumunuza ve gereksinimlerinize en uygun biçimde modelleyebilmeniz için çeşitli araçlar sunar.
Bu bölümde ele alacağımız konular şunlardır:
- Aynı anda birden fazla kod parçasını çalıştırmak için thread’lerin nasıl oluşturulacağı
- Kanalların thread’ler arasında mesaj gönderdiği mesaj iletimli eşzamanlılık
- Birden fazla thread’in aynı veriye erişebildiği paylaşımlı durum (shared-state) eşzamanlılığı
- Rust’ın eşzamanlılık güvencelerini, standart kütüphanenin sunduğu türlerin
yanı sıra kullanıcı tanımlı türlere de genişleten
SyncveSendtrait’leri
Not: Bilgisayar biliminde sıkça birbiriyle karıştırılan eşzamanlılık (concurrency) ve paralellik (parallelism) kavramları arasındaki farkı ve Rust’ın yaklaşımını şu iki temel noktayla özetleyebiliriz:
- Eşzamanlılık yapı ile, paralellik yürütme ile ilgilidir: Eşzamanlılık,
birden fazla bağımsız işi birinin tamamlanmasını beklemeden diğerinde de
ilerleme kaydedebilecek şekilde yapısal olarak yönetmektir; tek
çekirdekli bir işlemci bile görevler arasında hızla geçiş yaparak eşzamanlı
ilerleme sağlayabilir (Bölüm 17’de göreceğimiz
async/awaitmodelinin tek bir thread üzerinde çok sayıda G/Ç görevini nöbetleşe yürütmesi gibi). Paralellik ise birden fazla işin çok çekirdekli donanım üzerinde fiziksel olarak kelimenin tam anlamıyla aynı anda çalışmasıdır (birden fazla işletim sistemi thread’inin farklı CPU çekirdeklerinde eş anlı hesaplama yapması gibi). - Veri yarışlarının (data races) derleme zamanında önlenmesi: İki veya
daha fazla thread’in aynı bellek alanına senkronizasyon olmaksızın erişip en
az birinin yazma işlemi yapması veri yarışı doğurur. Rust’ın Bölüm 4’te
gördüğümüz temel ödünç alma kuralı —aynı anda ya birden fazla değiştirilemez
referans (
&T) ya da yalnızca tek bir değiştirilebilir referans (&mut T) bulunabilmesi—SendveSynctrait’leri aracılığıyla thread sınırları boyunca da zorunlu tutulur. Böylece başka dillerde yalnızca çalışma zamanında ve belirli yük koşullarında tesadüfen patlak veren veri yarışları, Rust’ta henüz kod derlenirken yakalanıp engellenir.
Kodu Aynı Anda Çalıştırmak İçin Thread'leri Kullanma
Kodu Aynı Anda Çalıştırmak İçin Thread’leri Kullanma
Günümüzdeki çoğu işletim sisteminde çalıştırılan bir programın kodu bir süreç (process) içinde yürütülür ve işletim sistemi aynı anda birden fazla süreci yönetir. Bir programın kendi içinde de aynı anda çalışan bağımsız bölümler bulunabilir. Bu bağımsız bölümleri çalıştıran yapılara thread (iş parçacığı) denir. Örneğin bir web sunucusu, aynı anda birden fazla isteğe yanıt verebilmek için birden çok thread kullanabilir.
Programınızdaki hesaplamayı birden fazla görevi aynı anda yürütecek şekilde birden çok thread’e bölmek performansı artırabilir, ancak beraberinde karmaşıklık da getirir. Thread’ler aynı anda çalışabildiği için, farklı thread’lerdeki kod parçalarının hangi sırayla çalışacağına dair doğal bir garanti yoktur. Bu durum aşağıdaki gibi sorunlara yol açabilir:
- Thread’lerin verilere veya kaynaklara tutarsız bir sırayla eriştiği yarış koşulları (race conditions)
- İki thread’in birbirini beklediği ve her ikisinin de devam etmesini engelleyen kilitlenmeler (deadlocks)
- Yalnızca belirli durumlarda ortaya çıkan, yeniden üretilmesi ve güvenilir biçimde düzeltilmesi zor hatalar
Rust, thread kullanmanın olumsuz etkilerini azaltmaya çalışır; ancak çok thread’li bir bağlamda programlama yapmak yine de dikkatli düşünmeyi ve tek bir thread üzerinde çalışan programlardan farklı bir kod yapısını gerektirir.
Programlama dilleri thread’leri birkaç farklı şekilde uygular ve pek çok işletim sistemi, programlama dilinin yeni thread’ler oluşturmak için çağırabileceği bir API sunar. Rust standart kütüphanesi 1:1 thread uygulama modelini kullanır; yani program, dil düzeyindeki her bir thread için bir işletim sistemi thread’i kullanır. 1:1 modeline kıyasla farklı ödünleşimler sunan başka thread modellerini uygulayan crate’ler de mevcuttur. (Bir sonraki bölümde göreceğimiz üzere, Rust’ın async sistemi de eşzamanlılığa farklı bir yaklaşım sunar.)
spawn ile Yeni Bir Thread Oluşturma
Yeni bir thread oluşturmak için thread::spawn fonksiyonunu çağırır ve ona yeni thread içinde çalıştırmak istediğimiz kodu içeren bir kapanış (closure) (kapanışları Bölüm 13’te ele almıştık) aktarırız. Liste 16-1’deki örnek, ana thread’den bazı metinler ve yeni bir thread’den başka metinler yazdırır.
use std::thread;
use std::time::Duration;
fn main() {
thread::spawn(|| {
for sira in 1..10 {
println!("oluşturulan iş parçacığından merhaba sayı {sira}!");
thread::sleep(Duration::from_millis(1));
}
});
for sira in 1..5 {
println!("ana iş parçacığından merhaba sayı {sira}!");
thread::sleep(Duration::from_millis(1));
}
}
Bir Rust programının ana thread’i tamamlandığında, oluşturulan tüm thread’lerin çalışmaları bitmiş olsun ya da olmasın kapatıldığına dikkat edin. Bu programın çıktısı her çalıştırmada biraz farklı olabilir, ancak aşağıdakine benzer görünecektir:
ana iş parçacığından merhaba sayı 1!
oluşturulan iş parçacığından merhaba sayı 1!
ana iş parçacığından merhaba sayı 2!
oluşturulan iş parçacığından merhaba sayı 2!
ana iş parçacığından merhaba sayı 3!
oluşturulan iş parçacığından merhaba sayı 3!
ana iş parçacığından merhaba sayı 4!
oluşturulan iş parçacığından merhaba sayı 4!
oluşturulan iş parçacığından merhaba sayı 5!
thread::sleep çağrıları, bir thread’i yürütmesini kısa bir süreliğine durdurmaya zorlayarak farklı bir thread’in çalışmasına olanak tanır. Thread’ler muhtemelen sırayla çalışacaktır ancak bunun bir garantisi yoktur: bu durum tamamen işletim sisteminizin thread’leri nasıl zamanladığına bağlıdır. Bu çalıştırmada, oluşturulan thread’deki yazdırma ifadesi kodda daha önce yer almasına rağmen ilk çıktıyı ana thread verdi. Ayrıca oluşturulan thread’e sira değeri 9 olana kadar yazdırmasını söylemiş olmamıza rağmen, ana thread kapanmadan önce yalnızca 5 değerine kadar gelebildi.
Bu kodu çalıştırdığınızda yalnızca ana thread’den gelen çıktıları görürseniz veya çıktılar arasında hiçbir örtüşme göremezseniz, işletim sisteminin thread’ler arasında geçiş yapmasına daha fazla fırsat tanımak için aralıklardaki sayıları artırmayı deneyin.
Tüm Thread’lerin Bitmesini Bekleme
Liste 16-1’deki kod, ana thread sona erdiği için çoğu zaman oluşturulan thread’i erken durdurmakla kalmaz; thread’lerin hangi sırayla çalışacağına dair bir garanti olmadığından, oluşturulan thread’in hiç çalışacağını bile garanti edemeyiz!
Oluşturulan thread’in hiç çalışmaması ya da erken sonlanması sorununu, thread::spawn fonksiyonunun dönüş değerini bir değişkende saklayarak çözebiliriz. thread::spawn fonksiyonunun dönüş türü JoinHandle<T>’dir. JoinHandle<T>, üzerinde join metodunu çağırdığımızda ilgili thread’in bitmesini bekleyen sahipli bir değerdir. Liste 16-2, Liste 16-1’de oluşturduğumuz thread’in JoinHandle<T> değerinin nasıl kullanılacağını ve main sonlanmadan önce oluşturulan thread’in tamamlandığından emin olmak için join metodunun nasıl çağrılacağını göstermektedir.
use std::thread;
use std::time::Duration;
fn main() {
let tutamac = thread::spawn(|| {
for sira in 1..10 {
println!("oluşturulan iş parçacığından merhaba sayı {sira}!");
thread::sleep(Duration::from_millis(1));
}
});
for sira in 1..5 {
println!("ana iş parçacığından merhaba sayı {sira}!");
thread::sleep(Duration::from_millis(1));
}
tutamac.join().unwrap();
}
thread::spawn çağrısından dönen JoinHandle<T> değerini saklamatutamac üzerinde join çağırmak, tutamacın temsil ettiği thread sonlanana kadar o sırada çalışmakta olan thread’i bloklar. Bir thread’i bloklamak (blocking), o thread’in iş yapmasının veya sonlanmasının engellenmesi anlamına gelir. join çağrısını ana thread’in for döngüsünden sonraya koyduğumuz için, Liste 16-2’yi çalıştırmak şuna benzer bir çıktı üretecektir:
ana iş parçacığından merhaba sayı 1!
ana iş parçacığından merhaba sayı 2!
oluşturulan iş parçacığından merhaba sayı 1!
ana iş parçacığından merhaba sayı 3!
oluşturulan iş parçacığından merhaba sayı 2!
ana iş parçacığından merhaba sayı 4!
oluşturulan iş parçacığından merhaba sayı 3!
oluşturulan iş parçacığından merhaba sayı 4!
oluşturulan iş parçacığından merhaba sayı 5!
oluşturulan iş parçacığından merhaba sayı 6!
oluşturulan iş parçacığından merhaba sayı 7!
oluşturulan iş parçacığından merhaba sayı 8!
oluşturulan iş parçacığından merhaba sayı 9!
İki thread dönüşümlü çalışmaya devam eder, ancak tutamac.join() çağrısı sayesinde ana thread bekler ve oluşturulan thread işini bitirene kadar sonlanmaz.
Peki bunun yerine tutamac.join() çağrısını main içindeki for döngüsünden önceye taşırsak ne olacağına bakalım:
use std::thread;
use std::time::Duration;
fn main() {
let tutamac = thread::spawn(|| {
for sira in 1..10 {
println!("oluşturulan iş parçacığından merhaba sayı {sira}!");
thread::sleep(Duration::from_millis(1));
}
});
tutamac.join().unwrap();
for sira in 1..5 {
println!("ana iş parçacığından merhaba sayı {sira}!");
thread::sleep(Duration::from_millis(1));
}
}
Ana thread, oluşturulan thread’in bitmesini bekleyecek ve ardından kendi for döngüsünü çalıştıracaktır; dolayısıyla çıktı burada gösterildiği gibi artık iç içe geçmiş olmayacaktır:
oluşturulan iş parçacığından merhaba sayı 1!
oluşturulan iş parçacığından merhaba sayı 2!
oluşturulan iş parçacığından merhaba sayı 3!
oluşturulan iş parçacığından merhaba sayı 4!
oluşturulan iş parçacığından merhaba sayı 5!
oluşturulan iş parçacığından merhaba sayı 6!
oluşturulan iş parçacığından merhaba sayı 7!
oluşturulan iş parçacığından merhaba sayı 8!
oluşturulan iş parçacığından merhaba sayı 9!
ana iş parçacığından merhaba sayı 1!
ana iş parçacığından merhaba sayı 2!
ana iş parçacığından merhaba sayı 3!
ana iş parçacığından merhaba sayı 4!
join metodunun nerede çağrıldığı gibi küçük ayrıntılar, thread’lerinizin aynı anda çalışıp çalışmayacağını etkileyebilir.
Not: JoinHandle<T> türündeki <T> parametresi, thread::spawn içine
verdiğiniz kapanışın dönüş değerinin türünü temsil eder (yukarıdaki
örneklerde kapanış bir değer döndürmediği için bu tür JoinHandle<()> olur).
join metodu ise std::thread::Result<T> döndürür: oluşturulan thread
sorunsuz tamamlandığında kapanışın ürettiği değer Ok(deger) içinde döner;
ancak oluşturulan thread çalışırken paniklerse (panic!), bu panik ana
thread’i kendiliğinden çökertmez ve join çağrısı Err döndürür. Örneklerde
tutamac.join().unwrap() yazmamızın nedeni budur: alt thread’de bir panik
meydana gelmişse unwrap() çağrısı hatayı yakalayıp ana thread’i de
panikletir; böylece arka plandaki çökmelerin sessizce gözden kaçması önlenir.
Thread’lerle move Kapanışlarını Kullanma
thread::spawn fonksiyonuna aktarılan kapanışlarla birlikte sıklıkla move anahtar kelimesini kullanırız; çünkü bu sayede kapanış, çevresinden kullandığı değerlerin sahipliğini alır ve böylece bu değerlerin sahipliğini bir thread’den diğerine devretmiş olur. Bölüm 13’teki “Referansları Yakalamak veya Sahipliği Taşımak” başlığında move anahtar kelimesini kapanışlar bağlamında ele almıştık. Şimdi ise move ile thread::spawn arasındaki etkileşime daha yakından odaklanacağız.
Liste 16-1’de thread::spawn fonksiyonuna aktardığımız kapanışın hiçbir argüman almadığına dikkat edin: oluşturulan thread’in kodunda ana thread’den hiçbir veri kullanmıyoruz. Ana thread’deki verileri oluşturulan thread içinde kullanabilmek için, oluşturulan thread’in kapanışının ihtiyaç duyduğu değerleri yakalaması gerekir. Liste 16-3, ana thread’de bir vektör oluşturup onu oluşturulan thread’de kullanma girişimini göstermektedir. Ancak birazdan göreceğiniz üzere bu kod henüz çalışmayacaktır.
use std::thread;
fn main() {
let vektor = vec![1, 2, 3];
let tutamac = thread::spawn(|| {
println!("İşte bir vektör: {vektor:?}");
});
tutamac.join().unwrap();
}
Kapanış vektor değişkenini kullandığı için vektor’ü yakalar ve kapanışın çevresinin bir parçası hâline getirir. thread::spawn bu kapanışı yeni bir thread içinde çalıştırdığından, yeni thread’in içinden vektor değerine erişebilmemiz gerekir. Ancak bu örneği derlediğimizde aşağıdaki hatayı alırız:
$ cargo run
Compiling is-parcaciklari v0.1.0 (file:///projects/is-parcaciklari)
error[E0373]: closure may outlive the current function, but it borrows `vektor`, which is owned by the current function
--> src/main.rs:6:33
|
6 | let tutamac = thread::spawn(|| {
| ^^ may outlive borrowed value `vektor`
7 | println!("İşte bir vektör: {vektor:?}");
| ------ `vektor` is borrowed here
|
note: function requires argument type to outlive `'static`
--> src/main.rs:6:19
|
6 | let tutamac = thread::spawn(|| {
| ___________________^
7 | | println!("İşte bir vektör: {vektor:?}");
8 | | });
| |______^
help: to force the closure to take ownership of `vektor` (and any other referenced variables), use the `move` keyword
|
6 | let tutamac = thread::spawn(move || {
| ++++
For more information about this error, try `rustc --explain E0373`.
error: could not compile `is-parcaciklari` (bin "is-parcaciklari") due to 1 previous error
Rust, vektor değişkenini nasıl yakalayacağını çıkarsar (infer) ve println! yalnızca vektor’e ait bir referansa ihtiyaç duyduğu için kapanış vektor’ü ödünç almaya çalışır. Ancak ortada bir sorun vardır: Rust, oluşturulan thread’in ne kadar süre çalışacağını bilemez; dolayısıyla vektor referansının her zaman geçerli kalıp kalmayacağını da bilemez.
Liste 16-4, vektor referansının geçersiz kalma olasılığının çok daha yüksek olduğu bir senaryoyu göstermektedir.
use std::thread;
fn main() {
let vektor = vec![1, 2, 3];
let tutamac = thread::spawn(|| {
println!("İşte bir vektör: {vektor:?}");
});
drop(vektor); // eyvah!
tutamac.join().unwrap();
}
vektor değerini düşüren ana thread’den vektor referansı yakalamaya çalışan bir kapanışa sahip threadRust bu kodu çalıştırmamıza izin verseydi, oluşturulan thread hiç çalışmadan anında arka plana alınabilirdi. Oluşturulan thread kendi içinde vektor için bir referans tutar, ancak ana thread Bölüm 15’te ele aldığımız drop fonksiyonunu kullanarak vektor’ü hemen bellekten düşürür. Ardından oluşturulan thread çalışmaya başladığında vektor artık geçerli değildir; dolayısıyla ona işaret eden referans da geçersizdir. Eyvah!
Liste 16-3’teki derleyici hatasını düzeltmek için hata mesajındaki tavsiyeyi uygulayabiliriz:
help: to force the closure to take ownership of `vektor` (and any other referenced variables), use the `move` keyword
|
6 | let tutamac = thread::spawn(move || {
| ++++
Kapanıştan önce move anahtar kelimesini ekleyerek, Rust’ın değerleri ödünç alması gerektiğini çıkarsamasına izin vermek yerine kapanışı kullandığı değerlerin sahipliğini almaya zorlarız. Liste 16-3’teki kodda Liste 16-5’te gösterilen değişikliği yaptığımızda, kod amaçladığımız gibi derlenecek ve çalışacaktır.
use std::thread;
fn main() {
let vektor = vec![1, 2, 3];
let tutamac = thread::spawn(move || {
println!("İşte bir vektör: {vektor:?}");
});
tutamac.join().unwrap();
}
move anahtar kelimesini kullanmaAna thread’in drop çağırdığı Liste 16-4’teki kodu düzeltmek için de aynı şeyi yapmayı, yani bir move kapanışı kullanmayı denemek isteyebiliriz. Ancak bu düzeltme işe yaramayacaktır; çünkü Liste 16-4’ün yapmaya çalıştığı şeye farklı bir nedenden ötürü izin verilmez. Kapanışa move eklersek vektor değerini kapanışın çevresine taşımış oluruz ve artık ana thread içinde onun üzerinde drop çağıramayız. Bunun yerine şu derleyici hatasını alırız:
$ cargo run
Compiling is-parcaciklari v0.1.0 (file:///projects/is-parcaciklari)
error[E0382]: use of moved value: `vektor`
--> src/main.rs:10:10
|
4 | let vektor = vec![1, 2, 3];
| ------ move occurs because `vektor` has type `Vec<i32>`, which does not implement the `Copy` trait
5 |
6 | let tutamac = thread::spawn(move || {
| ------- value moved into closure here
7 | println!("İşte bir vektör: {vektor:?}");
| ------ variable moved due to use in closure
...
10 | drop(vektor); // eyvah!
| ^^^^^^ value used here after move
|
help: consider cloning the value before moving it into the closure
|
6 ~ let value = vektor.clone();
7 ~ let tutamac = thread::spawn(move || {
8 ~ println!("İşte bir vektör: {value:?}");
|
For more information about this error, try `rustc --explain E0382`.
error: could not compile `is-parcaciklari` (bin "is-parcaciklari") due to 1 previous error
Rust’ın sahiplik kuralları bizi bir kez daha kurtardı! Liste 16-3’teki koddan hata almamızın nedeni, Rust’ın temkinli davranarak thread için vektor değerini yalnızca ödünç almasıydı; bu da ana thread’in teorik olarak oluşturulan thread’in referansını geçersiz kılabileceği anlamına geliyordu. Rust’a vektor’ün sahipliğini oluşturulan thread’e taşımasını söyleyerek, ana thread’in vektor’ü bir daha kullanmayacağını Rust’a garanti etmiş oluruz. Liste 16-4’ü aynı şekilde değiştirdiğimizde ise vektor’ü ana thread’de kullanmaya çalıştığımız anda sahiplik kurallarını ihlal etmiş oluruz. move anahtar kelimesi, Rust’ın ödünç alma yönündeki temkinli varsayılan davranışını geçersiz kılar; sahiplik kurallarını çiğnememize izin vermez.
Not: Liste 16-3’ün derleyici çıktısındaki note: function requires argument type to outlive 'static (fonksiyon, argüman türünün 'static ömrü kadar
yaşamasını gerektiriyor) açıklamasına dikkat edin. std::thread::spawn
fonksiyonu, başlatılan thread’in kendisini çağıran fonksiyondan daha uzun
yaşayabileceği varsayımıyla F: Send + 'static sınırı koyar; bu yüzden kısa
ömürlü yerel değişkenlere referans alınmasına izin vermez.
Oysa oluşturacağınız thread’lerin belirli bir kod kapsamı bitmeden kesinlikle
tamamlanacağını biliyorsanız, verinin sahipliğini taşımak (move), klonlamak
veya Arc<T> kullanmak yerine Rust 1.63 ile standart kütüphaneye eklenen
std::thread::scope (kapsamlı thread’ler) API’sinden yararlanabilirsiniz.
thread::scope, kendi kapsam bloğu sona ermeden önce içeride başlatılan tüm
thread’lerin tamamlanmasını otomatik olarak bekler (join eder). Böylece
ödünç alma denetleyicisi, yerel değişken referanslarının thread’ler çalıştığı
sürece geçerli kalacağını derleme zamanında kanıtlayabilir:
use std::thread;
fn main() {
let vektor = vec![1, 2, 3];
thread::scope(|kapsam| {
kapsam.spawn(|| {
println!("1. thread vektörü ödünç aldı: {vektor:?}");
});
kapsam.spawn(|| {
println!("2. thread vektörü ödünç aldı: {vektor:?}");
});
}); // Tüm kapsamlı thread'ler bu noktada otomatik olarak beklenir (join edilir).
// Sahiplik taşınmadığı için `vektor` ana thread'de hâlâ kullanılabilir:
println!("Ana thread vektörü kullanmaya devam ediyor: {vektor:?}");
}
Thread’lerin ne olduğunu ve thread API’sinin sunduğu metotları ele aldığımıza göre, şimdi thread’leri kullanabileceğimiz bazı durumlara bakalım.
Mesaj İletimiyle Thread'ler Arası Veri Transferi
Mesaj İletimiyle Thread’ler Arası Veri Transferi
Güvenli eşzamanlılığı sağlamanın giderek popülerleşen yaklaşımlarından biri, thread’lerin veya aktörlerin birbirlerine veri içeren mesajlar göndererek iletişim kurduğu mesaj iletimi (message passing) yöntemidir. Go dili belgelerindeki bir slogan bu fikri şöyle özetler: “Belleği paylaşarak iletişim kurmayın; bunun yerine iletişim kurarak belleği paylaşın.”
Mesaj göndermeye dayalı eşzamanlılığı gerçekleştirmek için Rust’ın standart kütüphanesi bir kanal uygulaması sunar. Kanal (channel), verinin bir thread’den diğerine gönderilmesini sağlayan genel bir programlama kavramıdır.
Programlamadaki bir kanalı, bir dere veya nehir gibi tek yönlü bir su kanalı olarak hayal edebilirsiniz. Bir nehre lastik ördek gibi bir şey bırakırsanız, akıntı yönünde ilerleyerek su yolunun sonuna kadar ulaşacaktır.
Bir kanalın iki yarısı vardır: bir gönderici (transmitter) ve bir alıcı (receiver). Gönderici yarısı, lastik ördeği nehre bıraktığınız akıntının üst kısmındaki konumdur; alıcı yarısı ise lastik ördeğin akıntı aşağısında ulaştığı noktadır. Kodunuzun bir bölümü göndermek istediğiniz verilerle gönderici üzerindeki metotları çağırır; diğer bölümü ise gelen mesajlar için alıcı ucu kontrol eder. Gönderici veya alıcı yarılarından herhangi biri bellekten düşürüldüğünde kanalın kapandığı (closed) söylenir.
Burada, değerler üretip bunları bir kanal üzerinden gönderen bir thread’e ve bu değerleri alıp ekrana yazdıran başka bir thread’e sahip bir programı adım adım oluşturacağız. Bu özelliği göstermek amacıyla bir kanal kullanarak thread’ler arasında basit değerler göndereceğiz. Bu tekniğe aşina olduktan sonra kanalları, birbirleriyle iletişim kurması gereken her türlü thread yapısında kullanabilirsiniz: örneğin bir sohbet sisteminde ya da pek çok thread’in bir hesaplamanın parçalarını gerçekleştirip sonuçları birleştiren tek bir thread’e gönderdiği bir sistemde.
İlk olarak Liste 16-6’da bir kanal oluşturacağız ancak onunla henüz hiçbir şey yapmayacağız. Rust, kanal üzerinden ne tür değerler göndermek istediğimizi henüz anlayamadığı için bu kodun şimdilik derlenmeyeceğine dikkat edin.
use std::sync::mpsc;
fn main() {
let (gonderici, alici) = mpsc::channel();
}
gonderici ile alici değişkenlerine atamampsc::channel fonksiyonunu kullanarak yeni bir kanal oluşturuyoruz; mpsc kısaltması çoklu üretici, tek tüketici (multiple producer, single consumer) anlamına gelir. Kısacası Rust’ın standart kütüphanesinin kanalları uygulama biçimi, bir kanalın değer üreten birden fazla gönderme ucuna sahip olabileceği, ancak bu değerleri tüketen yalnızca tek bir alma ucuna sahip olabileceği anlamına gelir. Birden çok derenin birleşerek tek bir büyük nehre aktığını hayal edin: derelerin herhangi birinden aşağı gönderilen her şey, sonunda tek bir nehirde toplanacaktır. Şimdilik tek bir üreticiyle başlayacağız, ancak bu örneği çalışır hâle getirdiğimizde birden fazla üretici ekleyeceğiz.
mpsc::channel fonksiyonu bir demet (tuple) döndürür; bu demetin ilk elemanı gönderme ucu —yani gönderici—, ikinci elemanı ise alma ucu —yani alıcı— olur. Pek çok alanda transmitter (gönderici) ve receiver (alıcı) terimleri için geleneksel olarak sırasıyla tx ve rx kısaltmaları kullanılır; biz ise kodumuzda her bir ucu açıkça belirtmek üzere değişkenlerimizi gonderici ve alici olarak adlandırıyoruz. Burada demeti parçalarına ayıran (destructuring) bir desenle birlikte bir let deyimi kullanıyoruz; let deyimlerinde desen kullanımını ve parçalarına ayırma işlemini Bölüm 19’da ele alacağız. Şimdilik bir let deyimini bu şekilde kullanmanın, mpsc::channel tarafından döndürülen demetin parçalarını ayıklamak için elverişli bir yaklaşım olduğunu bilmeniz yeterlidir.
Liste 16-7’de gösterildiği gibi, gönderme ucunu oluşturulan bir thread’e taşıyalım ve oluşturulan thread’in ana thread ile iletişim kurması için bir string göndermesini sağlayalım. Bu, nehrin üst kısmından suya bir lastik ördek bırakmaya veya bir thread’den diğerine bir sohbet mesajı göndermeye benzer.
use std::sync::mpsc;
use std::thread;
fn main() {
let (gonderici, alici) = mpsc::channel();
thread::spawn(move || {
let deger = String::from("merhaba");
gonderici.send(deger).unwrap();
});
}
gonderici ucunu oluşturulan bir thread içine taşıma ve "merhaba" göndermeYine yeni bir thread oluşturmak için thread::spawn kullanıyor ve ardından oluşturulan thread’in gonderici değerine sahip olması için move kullanarak gonderici’yi kapanışın içine taşıyoruz. Oluşturulan thread’in kanal üzerinden mesaj gönderebilmesi için göndericiye sahip olması gerekir (ayrıca send çağrısıyla bir String gönderdiğimiz için Rust artık kanalın türünü (Sender<String>, Receiver<String>) olarak otomatik çıkarsayabilir).
Göndericinin, göndermek istediğimiz değeri alan bir send metodu vardır. send metodu bir Result<T, E> türü döndürür; dolayısıyla alıcı daha önce bellekten düşürülmüşse ve değeri gönderecek bir yer kalmamışsa, gönderme işlemi bir hata döndürür. Bu örnekte, bir hata durumunda paniklemek için unwrap çağırıyoruz. Ancak gerçek bir uygulamada bu hatayı uygun şekilde ele alırız: uygun hata yönetimi stratejilerini gözden geçirmek için Bölüm 9’a dönebilirsiniz.
Liste 16-8’de, ana thread’de alıcıdan değeri alacağız. Bu, nehrin sonunda lastik ördeği sudan çıkarmaya veya bir sohbet mesajını almaya benzer.
use std::sync::mpsc;
use std::thread;
fn main() {
let (gonderici, alici) = mpsc::channel();
thread::spawn(move || {
let deger = String::from("merhaba");
gonderici.send(deger).unwrap();
});
let alinan = alici.recv().unwrap();
println!("Alındı: {alinan}");
}
"merhaba" değerini alma ve ekrana yazdırmaAlıcının iki kullanışlı metodu vardır: recv ve try_recv. Burada, receive (almak) kelimesinin kısaltması olan recv metodunu kullanıyoruz; bu metot ana thread’in yürütmesini bloklar ve kanal üzerinden bir değer gönderilene kadar bekler. Bir değer gönderildiğinde recv, onu bir Result<T, E> içinde döndürür. Gönderici kapandığında ise recv, artık başka değer gelmeyeceğini bildirmek üzere bir hata döndürür.
try_recv metodu ise bloklamaz; bunun yerine hemen bir Result<T, E> döndürür: mevcut bir mesaj varsa onu tutan bir Ok değeri, o sırada hiç mesaj yoksa bir Err değeri döner. Bu thread’in mesajları beklerken yapacağı başka işler varsa try_recv kullanmak faydalıdır: belirli aralıklarla try_recv çağıran, mevcut bir mesaj varsa onu işleyen, aksi takdirde tekrar kontrol edene kadar bir süre başka işler yapan bir döngü yazabiliriz.
Bu örnekte sadelik adına recv kullandık; ana thread’in mesajları beklemek dışında yapacağı başka bir iş olmadığından ana thread’i bloklamak uygundur.
Liste 16-8’deki kodu çalıştırdığımızda, ana thread’den yazdırılan değeri görürüz:
Alındı: merhaba
Mükemmel!
Kanallar Üzerinden Sahiplik Aktarma
Sahiplik kuralları mesaj gönderiminde hayati bir rol oynar; çünkü güvenli ve eşzamanlı kod yazmanıza yardımcı olur. Eşzamanlı programlamada hataları önlemek, Rust programlarınız boyunca sahipliği hesaba katmanın sağladığı avantajdır. Kanalların ve sahipliğin sorunları önlemek üzere birlikte nasıl çalıştığını göstermek için bir deney yapalım: oluşturulan thread’de deger değişkenini kanaldan gönderdikten sonra kullanmaya çalışacağız. Bu koda neden izin verilmediğini görmek için Liste 16-9’daki kodu derlemeyi deneyin.
use std::sync::mpsc;
use std::thread;
fn main() {
let (gonderici, alici) = mpsc::channel();
thread::spawn(move || {
let deger = String::from("merhaba");
gonderici.send(deger).unwrap();
println!("değer şu: {deger}");
});
let alinan = alici.recv().unwrap();
println!("Alındı: {alinan}");
}
deger değişkenini kanaldan gönderdikten sonra kullanmaya çalışmaBurada, gonderici.send aracılığıyla kanaldan gönderdikten sonra deger değişkenini yazdırmaya çalışıyoruz. Buna izin vermek kötü bir fikir olurdu: değer başka bir thread’e gönderildikten sonra, biz değeri tekrar kullanmaya çalışmadan önce o thread onu değiştirebilir veya bellekten düşürebilirdi. Diğer thread’in yaptığı değişiklikler, tutarsız veya var olmayan veriler yüzünden potansiyel olarak hatalara ya da beklenmedik sonuçlara yol açabilirdi. Ancak Liste 16-9’daki kodu derlemeye çalıştığımızda Rust bize bir hata verir:
$ cargo run
Compiling mesaj-iletimi v0.1.0 (file:///projects/mesaj-iletimi)
error[E0382]: borrow of moved value: `deger`
--> src/main.rs:10:30
|
8 | let deger = String::from("merhaba");
| ----- move occurs because `deger` has type `String`, which does not implement the `Copy` trait
9 | gonderici.send(deger).unwrap();
| ----- value moved here
10 | println!("değer şu: {deger}");
| ^^^^^ value borrowed here after move
For more information about this error, try `rustc --explain E0382`.
error: could not compile `mesaj-iletimi` (bin "mesaj-iletimi") due to 1 previous error
Eşzamanlılık hatamız derleme zamanı hatasına neden oldu. send fonksiyonu parametresinin sahipliğini alır ve değer taşındığında alıcı onun sahipliğini devralır. Bu durum, değeri gönderdikten sonra yanlışlıkla tekrar kullanmamızı engeller; sahiplik sistemi her şeyin yolunda olduğunu denetler.
Not: Derleyicinin ürettiği error[E0382]: borrow of moved value: `deger`
(taşınmış değerin ödünç alınması) hatasına dikkat edin: String türü öbekte
(heap) veri tuttuğu ve Copy trait’ini uygulamadığı için
gonderici.send(deger) çağrısında sahiplik doğrudan kanala taşınır; bir alt
satırdaki println! makrosunun {deger}’i ödünç almaya çalışması bu yüzden
reddedilir. Kanala i32 veya bool gibi Copy trait’ini uygulayan bir değer
gönderseydiniz, değer taşınmak yerine bit düzeyinde kopyalanacağı için
gönderici thread kendi kopyasını kullanmaya devam edebilirdi; ancak iki thread
birbirinden tamamen bağımsız kopyalar üzerinde çalışacağından yine veri yarışı
oluşmazdı.
Birden Fazla Değer Gönderme
Liste 16-8’deki kod derlenip çalıştı, ancak iki ayrı thread’in kanal üzerinden birbiriyle konuştuğunu bize açıkça göstermedi.
Liste 16-10’da, Liste 16-8’deki kodun eşzamanlı olarak çalıştığını kanıtlayacak bazı değişiklikler yaptık: oluşturulan thread artık birden fazla mesaj gönderecek ve her bir mesaj arasında bir saniye duraklayacak.
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn main() {
let (gonderici, alici) = mpsc::channel();
thread::spawn(move || {
let degerler = vec![
String::from("merhaba"),
String::from("oluşturulan"),
String::from("iş"),
String::from("parçacığından"),
];
for deger in degerler {
gonderici.send(deger).unwrap();
thread::sleep(Duration::from_secs(1));
}
});
for alinan in alici {
println!("Alındı: {alinan}");
}
}
Bu kez oluşturulan thread’de, ana thread’e göndermek istediğimiz string’lerden oluşan bir vektör bulunuyor. Bunların üzerinde ilerliyor, her birini tek tek gönderiyor ve bir saniyelik Duration değeriyle thread::sleep fonksiyonunu çağırarak her gönderim arasında duraklıyoruz.
Ana thread’de ise artık recv fonksiyonunu açıkça çağırmıyoruz: bunun yerine alici değerini bir iteratör olarak ele alıyoruz. Alınan her değeri ekrana yazdırıyoruz. Kanal kapandığında iterasyon da sona erecektir.
Liste 16-10’daki kodu çalıştırdığınızda, her satır arasında bir saniyelik duraklamayla aşağıdaki çıktıyı görmelisiniz:
Alındı: merhaba
Alındı: oluşturulan
Alındı: iş
Alındı: parçacığından
Ana thread’deki for döngüsünde duraklayan veya gecikme yaratan herhangi bir kod bulunmadığından, ana thread’in oluşturulan thread’den değerleri almak için beklediğini anlayabiliriz.
Birden Fazla Üretici Oluşturma
Daha önce mpsc kısaltmasının çoklu üretici, tek tüketici anlamına geldiğini belirtmiştik. Şimdi mpsc’yi amacına uygun biçimde kullanalım ve Liste 16-10’daki kodu, tamamı aynı alıcıya değerler gönderen birden fazla thread oluşturacak şekilde genişletelim. Bunu Liste 16-11’de gösterildiği gibi göndericiyi klonlayarak yapabiliriz.
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn main() {
// --snip--
let (gonderici, alici) = mpsc::channel();
let gonderici1 = gonderici.clone();
thread::spawn(move || {
let degerler = vec![
String::from("merhaba"),
String::from("oluşturulan"),
String::from("iş"),
String::from("parçacığından"),
];
for deger in degerler {
gonderici1.send(deger).unwrap();
thread::sleep(Duration::from_secs(1));
}
});
thread::spawn(move || {
let degerler = vec![
String::from("daha"),
String::from("fazla"),
String::from("mesaj"),
String::from("sana"),
];
for deger in degerler {
gonderici.send(deger).unwrap();
thread::sleep(Duration::from_secs(1));
}
});
for alinan in alici {
println!("Alındı: {alinan}");
}
// --snip--
}
Bu kez, ilk thread’i oluşturmadan önce gönderici üzerinde clone metodunu çağırıyoruz. Bu bize oluşturulan ilk thread’e aktarabileceğimiz yeni bir gönderici verir. Orijinal göndericiyi ise oluşturulan ikinci bir thread’e aktarıyoruz. Böylece elimizde, her biri tek bir alıcıya farklı mesajlar gönderen iki thread olur.
Kodu çalıştırdığınızda çıktınız aşağıdakine benzer görünmelidir:
Alındı: merhaba
Alındı: daha
Alındı: oluşturulan
Alındı: fazla
Alındı: mesaj
Alındı: iş
Alındı: sana
Alındı: parçacığından
Sisteminize bağlı olarak değerleri farklı bir sırada görebilirsiniz. Eşzamanlılığı hem ilginç hem de zor kılan şey tam olarak budur. Farklı thread’lerde thread::sleep fonksiyonuna çeşitli değerler vererek denemeler yaparsanız, her çalıştırma daha belirlenimsiz (nondeterministic) olacak ve her seferinde farklı bir çıktı üretecektir.
Not: Gerçek dünya uygulamalarında kanallarla çalışırken şu üç önemli ayrıntıyı göz önünde bulundurmak faydalıdır:
- Kanalın Kapanması ve Askıda Kalma Tuzağı: Liste 16-11’de ana thread’deki
for alinan in alicidöngüsünün sonsuza dek beklemeyip zarifçe sonlanması, tümSender<T>kopyalarının (gonderici1ve orijinalgonderici) taşındıkları thread’ler bittiğinde bellekten düşürülmesi (drop) sayesindedir. Eğergonderici.clone()ile kopyalar üretip orijinalgondericideğişkenini hiçbir thread’e taşımadanmainkapsamında canlı bırakırsanız, kanalın gönderme ucu hiç kapanmaz vefordöngüsü yeni mesaj bekleyerek sonsuza dek bloklanır; böyle durumlarda kullanılmayan göndericiyi döngüden öncedrop(gonderici);ile açıkça düşürmeniz gerekir. - Sınırsız (
channel) ve Sınırlı (sync_channel) Kanallar:mpsc::channel()bellekte sınırsız (unbounded) bir kuyruk oluşturur; üreticiler alıcının işleyebileceğinden çok daha hızlı mesaj üretirsesendhiç bloklanmaz ancak bellek kullanımı kontrolsüzce büyüyebilir. Üreticileri yavaşlatarak geri basınç (backpressure) uygulamak istediğinizdempsc::sync_channel(kapasite)kullanabilirsiniz: bu fonksiyon en fazlakapasitekadar mesaj tutabilen sınırlı (bounded) bir kanal oluşturur ve tampon dolduğundasendçağrısı, alıcı yer açana kadar gönderici thread’i bloklar (kapasiteolarak0verildiğinde ise gönderici ve alıcının aynı anda buluşmasını zorunlu kılan buluşmalı / rendezvous bir kanal elde edilir). - Çoklu Tüketici (MPMC) Senaryoları: Standart
mpsckanalındaSender<T>klonlanabilirkenReceiver<T>klonlanamaz. Birden fazla işçi (worker) thread’in aynı kuyruktan görev çekmesi gerektiğinde; bir sonraki bölümde göreceğimiz araçlarla alıcıyıArc<Mutex<mpsc::Receiver<T>>>içine sarabilir (Bölüm 21’deki çok thread’li web sunucusunda tam olarak bu deseni kuracağız), ekosistemdekicrossbeam-channelveyaflumegibi çoklu üretici–çoklu tüketici crate’lerini kullanabilir ya da standart kütüphanede geliştirilmekte olan deneyselstd::sync::mpmcmodülünü (Nightly Rust’ta) değerlendirebilirsiniz.
Kanalların nasıl çalıştığını incelediğimize göre, şimdi farklı bir eşzamanlılık yöntemine bakalım.
Paylaşımlı Durum (Shared-State) Eşzamanlılığı
Paylaşımlı Durum (Shared-State) Eşzamanlılığı
Mesaj iletimi eşzamanlılığı ele almanın iyi bir yoludur, ancak tek yol değildir. Bir diğer yöntem de birden fazla thread’in aynı paylaşılan veriye erişmesidir. Go dili belgelerindeki sloganın şu kısmını yeniden düşünün: “Belleği paylaşarak iletişim kurmayın.”
Belleği paylaşarak iletişim kurmak neye benzerdi? Ayrıca, mesaj iletimini savunanlar neden bellek paylaşımını kullanmamamız konusunda uyarıda bulunur?
Bir bakıma, herhangi bir programlama dilindeki kanallar tekli sahipliğe benzer; çünkü bir değeri kanal üzerinden aktardığınızda o değeri artık kullanmamanız gerekir. Paylaşımlı bellek eşzamanlılığı ise çoklu sahipliğe benzer: birden fazla thread aynı anda aynı bellek konumuna erişebilir. Bölüm 15’te akıllı işaretçilerin çoklu sahipliği nasıl mümkün kıldığını gördüğünüz üzere, bu farklı sahiplerin yönetilmesi gerektiği için çoklu sahiplik beraberinde karmaşıklık getirebilir. Rust’ın tür sistemi ve sahiplik kuralları, bu yönetimi doğru biçimde gerçekleştirmenize büyük ölçüde yardımcı olur. Örnek olarak, paylaşımlı bellek için en yaygın eşzamanlılık ilkellerinden (concurrency primitives) biri olan mutex’lere bakalım.
Mutex’lerle Erişimi Kontrol Etme
Mutex, karşılıklı dışlama (mutual exclusion) ifadesinin kısaltmasıdır; yani bir mutex, herhangi bir anda yalnızca tek bir thread’in belirli bir veriye erişmesine izin verir. Bir mutex içindeki veriye erişmek için thread’in önce mutex’in kilidini (lock) edinmeyi talep ederek erişim istediğini bildirmesi gerekir. Kilit, mutex’in bir parçası olan ve o anda veriye kimin münhasır (özel) erişim hakkına sahip olduğunu takip eden bir veri yapısıdır. Dolayısıyla mutex’in, barındırdığı veriyi bu kilitleme sistemi aracılığıyla koruduğu (guarding) söylenir.
Mutex’ler, iki kuralı sürekli aklınızda tutmanız gerektiği için kullanımı zor yapılar olarak bilinir:
- Veriyi kullanmadan önce kilidi edinme girişiminde bulunmalısınız.
- Mutex’in koruduğu veriyle işiniz bittiğinde, diğer thread’lerin kilidi edinebilmesi için verinin kilidini açmalısınız.
Bir mutex için gerçek dünyadan bir benzetme yapmak gerekirse, bir konferansta yalnızca tek bir mikrofonun bulunduğu bir panel tartışmasını hayal edin. Bir panelist konuşmaya başlamadan önce mikrofonu kullanmak istediğini söylemeli veya işaret etmelidir. Mikrofonu aldığında istediği süre boyunca konuşabilir ve ardından mikrofonu konuşma talebinde bulunan bir sonraki paneliste devreder. Eğer bir panelist konuşmasını bitirdiğinde mikrofonu devretmeyi unutursa, başka hiç kimse konuşamaz. Paylaşılan mikrofonun yönetimi ters giderse panel planlandığı gibi yürümeyecektir!
Mutex’lerin yönetimini doğru kurgulamak son derece çetrefilli olabilir; pek çok kişinin kanalları bu kadar coşkuyla tercih etmesinin nedeni de budur. Ancak Rust’ın tür sistemi ve sahiplik kuralları sayesinde, kilitleme ve kilit açma işlemlerini yanlış yapamazsınız.
Mutex<T> API’si
Bir mutex’in nasıl kullanılacağına örnek olarak, Liste 16-12’de gösterildiği gibi öncelikle tek thread’li bir bağlamda mutex kullanarak başlayalım.
use std::sync::Mutex;
fn main() {
let kilit = Mutex::new(5);
{
let mut sayi = kilit.lock().unwrap();
*sayi = 6;
}
println!("kilit = {kilit:?}");
}
Mutex<T> API’sini tek thread’li bir bağlamda incelemePek çok türde olduğu gibi, bir Mutex<T> örneğini new ilişkili fonksiyonunu kullanarak oluştururuz. Mutex’in içindeki veriye erişmek için lock metodunu kullanarak kilidi ediniriz. Bu çağrı, kilidi alma sırası bize gelene kadar mevcut thread’i hiçbir iş yapamayacak şekilde bloklar.
Kilidi elinde tutan başka bir thread paniklediyse lock çağrısı başarısız olur. Böyle bir durumda kilidi bir daha hiç kimse alamayacağı için unwrap çağrısını tercih ettik ve bu durumla karşılaşırsak mevcut thread’in de paniklemesini sağladık.
Kilidi edindikten sonra, bu örnekte sayi adını verdiğimiz dönüş değerine içerideki verinin değiştirilebilir bir referansı gibi davranabiliriz. Tür sistemi, kilit içindeki değeri kullanmadan önce bir kilit edindiğimizi garanti altına alır. kilit değişkeninin türü i32 değil Mutex<i32> olduğundan, i32 değerini kullanabilmek için lock metodunu çağırmamız zorunludur. Bunu unutamayız; aksi takdirde tür sistemi içerideki i32 değerine erişmemize izin vermez.
lock çağrısı, unwrap çağrısıyla ele aldığımız bir LockResult içine sarılmış MutexGuard adlı bir tür döndürür. MutexGuard türü, içerideki verimize işaret etmek üzere Deref trait’ini uygular; ayrıca bu türün, bir MutexGuard kapsam dışına çıktığında —ki bu örnekte iç kapsamın sonunda gerçekleşir— kilidi otomatik olarak serbest bırakan bir Drop uygulaması vardır. Sonuç olarak, kilidi bırakmayı unutup mutex’in diğer thread’ler tarafından kullanılmasını engelleme riskine girmeyiz; çünkü kilidin serbest bırakılması otomatik olarak gerçekleşir.
Kilit düşürüldükten sonra mutex değerini ekrana yazdırabilir ve içerideki i32 değerini 6 olarak değiştirebildiğimizi görebiliriz.
Not: MutexGuard ve lock() kullanımıyla ilgili iki pratik ayrıntıyı
aklınızda bulundurmak gerçek dünya projelerinde çok işinize yarayacaktır:
- Kritik Bölgeyi Dar Tutma (
{ ... }veyadrop): Kilit,MutexGuarddeğeri kapsamda kaldığı sürece tutulur. Kilidi aldıktan sonra aynı kapsamda ağ isteği, dosya okuma/yazma gibi yavaş G/Ç (I/O) işlemleri veya uzun hesaplamalar yaparsanız, kilidi bekleyen diğer tüm thread’ler gereksiz yere bloklanır. Bunu önlemek için Liste 16-12’deki gibi{ let mut sayi = kilit.lock().unwrap(); *sayi = 6; }biçiminde dar bir iç kapsam açabilir ya da paylaşılan veriyle işiniz biter bitmezdrop(sayi);çağırarak kilidi erkenden serbest bırakabilirsiniz. - Kilit Zehirlenmesi (Lock Poisoning): Liste 16-12’yi çalıştırdığınızda
ekrana
kilit = Mutex { data: 6, poisoned: false, .. }yazdırılır. Eğer bir thread kilidi elinde tutarken paniklerse, içerideki veriyi yarım kalmış ve tutarsız bir durumda bırakmış olabileceğinden Rust bu mutex’i zehirlenmiş (poisoned) olarak işaretler ve sonrakilock()çağrılarındaErr(PoisonError)döndürür. Çoğu senaryodaunwrap()ile panik yaymak en güvenli yoldur; ancak veriyi onararak kurtarmak isterseniz hata değeri üzerindeinto_inner()metodunu çağırarak alttakiMutexGuard’a yine de erişebilirsiniz.
Mutex<T>’ye Paylaşımlı Erişim
Şimdi Mutex<T> kullanarak bir değeri birden fazla thread arasında paylaşmayı deneyelim. 10 adet thread başlatacağız ve her birinin bir sayaç değerini 1 artırmasını sağlayacağız; böylece sayaç 0’dan 10’a çıkacak. Liste 16-13’teki örnek bir derleyici hatası verecek ve bu hatayı Mutex<T> kullanımı ile Rust’ın onu doğru kullanmamıza nasıl yardımcı olduğu hakkında daha fazla şey öğrenmek için değerlendireceğiz.
use std::sync::Mutex;
use std::thread;
fn main() {
let sayac = Mutex::new(0);
let mut tutamaclar = vec![];
for _ in 0..10 {
let tutamac = thread::spawn(move || {
let mut sayi = sayac.lock().unwrap();
*sayi += 1;
});
tutamaclar.push(tutamac);
}
for tutamac in tutamaclar {
tutamac.join().unwrap();
}
println!("Sonuç: {}", *sayac.lock().unwrap());
}
Mutex<T> tarafından korunan sayacı artıran 10 threadListe 16-12’de yaptığımız gibi, bir Mutex<T> içinde i32 tutmak üzere sayac değişkenini oluşturuyoruz. Ardından bir sayı aralığı üzerinde yineleme yaparak 10 thread oluşturuyoruz. thread::spawn fonksiyonunu kullanıyor ve tüm thread’lere aynı kapanışı veriyoruz: sayacı thread içine taşıyan, lock metodunu çağırarak Mutex<T> üzerindeki kilidi edinen ve ardından mutex içindeki değere 1 ekleyen bir kapanış. Bir thread kapanışını çalıştırmayı bitirdiğinde sayi kapsam dışına çıkar ve kilidi serbest bırakır; böylece başka bir thread kilidi edinebilir.
Ana thread’de tüm birleştirme tutamaçlarını (tutamaclar) topluyoruz. Ardından, Liste 16-2’de yaptığımız gibi tüm thread’lerin tamamlandığından emin olmak için her bir tutamac üzerinde join metodunu çağırıyoruz. Bu noktada ana thread kilidi edinecek ve programın sonucunu ekrana yazdıracaktır.
Bu örneğin derlenmeyeceğini çıtlatmıştık. Şimdi nedenini öğrenelim!
$ cargo run
Compiling paylasimli-durum v0.1.0 (file:///projects/paylasimli-durum)
error[E0382]: borrow of moved value: `sayac`
--> src/main.rs:21:28
|
5 | let sayac = Mutex::new(0);
| ----- move occurs because `sayac` has type `std::sync::Mutex<i32>`, which does not implement the `Copy` trait
...
8 | for _ in 0..10 {
| -------------- inside of this loop
9 | let tutamac = thread::spawn(move || {
| ------- value moved into closure here, in previous iteration of loop
...
21 | println!("Sonuç: {}", *sayac.lock().unwrap());
| ^^^^^ value borrowed here after move
For more information about this error, try `rustc --explain E0382`.
error: could not compile `paylasimli-durum` (bin "paylasimli-durum") due to 1 previous error
Hata mesajı, sayac değerinin döngünün bir önceki adımında taşındığını (value moved into closure here, in previous iteration of loop) belirtir. Rust bize sayac kilidinin sahipliğini birden fazla thread içine taşıyamayacağımızı söylüyor. Derleyici hatasını, Bölüm 15’te ele aldığımız çoklu sahiplik yöntemiyle düzeltelim.
Birden Fazla Thread ile Çoklu Sahiplik
Bölüm 15’te, referans sayımlı bir değer oluşturmak üzere Rc<T> akıllı işaretçisini kullanarak bir değere birden fazla sahip vermiştik. Burada da aynısını yapalım ve ne olacağını görelim. Liste 16-14’te Mutex<T> değerini Rc<T> içine saracağız ve sahipliği thread’e taşımadan önce Rc<T> örneğini klonlayacağız.
use std::rc::Rc;
use std::sync::Mutex;
use std::thread;
fn main() {
let sayac = Rc::new(Mutex::new(0));
let mut tutamaclar = vec![];
for _ in 0..10 {
let sayac = Rc::clone(&sayac);
let tutamac = thread::spawn(move || {
let mut sayi = sayac.lock().unwrap();
*sayi += 1;
});
tutamaclar.push(tutamac);
}
for tutamac in tutamaclar {
tutamac.join().unwrap();
}
println!("Sonuç: {}", *sayac.lock().unwrap());
}
Mutex<T> değerine sahip olabilmesi için Rc<T> kullanmayı denemeYeniden derliyoruz ve… farklı hatalar alıyoruz! Derleyici bize pek çok şey öğretiyor:
$ cargo run
Compiling paylasimli-durum v0.1.0 (file:///projects/paylasimli-durum)
error[E0277]: `Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely
--> src/main.rs:11:37
|
11 | let tutamac = thread::spawn(move || {
| ------------- ^------
| | |
| _______________________|_____________within this `{closure@src/main.rs:11:37: 11:44}`
| | |
| | required by a bound introduced by this call
12 | | let mut sayi = sayac.lock().unwrap();
13 | |
14 | | *sayi += 1;
15 | | });
| |_________^ `Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely
|
= help: within `{closure@src/main.rs:11:37: 11:44}`, the trait `Send` is not implemented for `Rc<std::sync::Mutex<i32>>`
note: required because it's used within this closure
--> src/main.rs:11:37
|
11 | let tutamac = thread::spawn(move || {
| ^^^^^^^
note: required by a bound in `spawn`
--> /rustc/58cf8f9c35b355199727627d07ae34a4e2846d67/library/std/src/thread/functions.rs:125:0
For more information about this error, try `rustc --explain E0277`.
error: could not compile `paylasimli-durum` (bin "paylasimli-durum") due to 1 previous error
Vay canına, bu hata mesajı oldukça ayrıntılı! Odaklanmamız gereken önemli kısım şurasıdır: `Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely (Rc<Mutex<i32>> thread’ler arasında güvenli bir şekilde gönderilemez). Derleyici bize bunun nedenini de söylüyor: the trait `Send` is not implemented for `Rc<std::sync::Mutex<i32>>` (Send trait’i Rc<Mutex<i32>> için uygulanmamıştır). Send hakkında bir sonraki bölümde konuşacağız: thread’lerle birlikte kullandığımız türlerin eşzamanlı durumlarda kullanıma uygun olmasını sağlayan trait’lerden biridir.
Ne yazık ki Rc<T>, thread’ler arasında paylaşılmak için güvenli değildir. Rc<T> referans sayısını yönetirken her clone çağrısında sayaca ekleme, her klon bellekten düşürüldüğünde ise sayaçtan çıkarma yapar. Ancak sayaca yapılan değişikliklerin başka bir thread tarafından kesintiye uğratılamayacağından emin olmak için herhangi bir eşzamanlılık ilkeli kullanmaz. Bu durum yanlış sayımlara —bellek sızıntılarına veya bir değerin henüz onunla işimiz bitmeden düşürülmesine neden olabilecek sinsi hatalara— yol açabilir. İhtiyacımız olan şey, tıpkı Rc<T> gibi çalışan ancak referans sayısındaki değişiklikleri thread güvenli (thread-safe) bir biçimde yapan bir türdür.
Arc<T> ile Atomik Referans Sayımı
Neyse ki Arc<T>, eşzamanlı durumlarda güvenle kullanılabilen Rc<T> benzeri bir türdür. Başındaki a harfi atomik (atomic) kelimesinden gelir; yani bu bir atomik referans sayımlı (atomically reference-counted) türdür. Atomikler, burada ayrıntılı olarak ele almayacağımız ek bir eşzamanlılık ilkeli türüdür: daha fazla ayrıntı için std::sync::atomic standart kütüphane belgelerine bakabilirsiniz. Bu noktada yalnızca atomiklerin ilkel türler gibi çalıştığını, ancak thread’ler arasında paylaşılmak için güvenli olduğunu bilmeniz yeterlidir.
Bu noktada neden tüm ilkel türlerin atomik olmadığını ve standart kütüphane türlerinin neden varsayılan olarak Arc<T> kullanacak şekilde uygulanmadığını merak edebilirsiniz. Bunun nedeni, thread güvenliğinin yalnızca gerçekten ihtiyaç duyduğunuzda ödemek isteyeceğiniz bir performans maliyeti getirmesidir. Değerler üzerinde yalnızca tek bir thread içinde işlemler gerçekleştiriyorsanız, kodunuz atomiklerin sağladığı güvenceleri zorunlu kılmak zorunda kalmadığında daha hızlı çalışabilir.
Örneğimize geri dönelim: Arc<T> ve Rc<T> aynı API’ye sahiptir; dolayısıyla use satırını, new çağrısını ve clone çağrısını değiştirerek programımızı düzeltiriz. Liste 16-15’teki kod nihayet derlenecek ve çalışacaktır.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let sayac = Arc::new(Mutex::new(0));
let mut tutamaclar = vec![];
for _ in 0..10 {
let sayac = Arc::clone(&sayac);
let tutamac = thread::spawn(move || {
let mut sayi = sayac.lock().unwrap();
*sayi += 1;
});
tutamaclar.push(tutamac);
}
for tutamac in tutamaclar {
tutamac.join().unwrap();
}
println!("Sonuç: {}", *sayac.lock().unwrap());
}
Mutex<T> değerini bir Arc<T> ile sarmalamaBu kod aşağıdaki çıktıyı yazdıracaktır:
Sonuç: 10
Başardık! 0’dan 10’a kadar saydık; bu ilk bakışta çok etkileyici görünmeyebilir, ancak bize Mutex<T> ve thread güvenliği hakkında pek çok şey öğretti. Bu programın yapısını yalnızca bir sayacı artırmaktan daha karmaşık işlemler yapmak için de kullanabilirsiniz. Bu stratejiyi kullanarak bir hesaplamayı bağımsız parçalara bölebilir, bu parçaları thread’lere dağıtabilir ve ardından her bir thread’in nihai sonucu kendi parçasıyla güncellemesi için bir Mutex<T> kullanabilirsiniz.
Basit sayısal işlemler yapıyorsanız, standart kütüphanenin std::sync::atomic modülü tarafından sunulan ve Mutex<T> türlerinden daha basit olan türler bulunduğunu unutmayın. Bu türler ilkel türlere güvenli, eşzamanlı ve atomik erişim sağlar. Biz bu örnekte Mutex<T> türünün nasıl çalıştığına odaklanabilmek için Mutex<T> yapısını ilkel bir türle birlikte kullanmayı tercih ettik.
RefCell<T>/Rc<T> ile Mutex<T>/Arc<T> Karşılaştırması
sayac değişkeninin değiştirilemez olmasına rağmen içindeki değere değiştirilebilir bir referans alabildiğimizi fark etmiş olabilirsiniz; bu durum, tıpkı Cell ailesinin yaptığı gibi Mutex<T> türünün de içsel değiştirilebilirlik (interior mutability) sağladığı anlamına gelir. Bölüm 15’te bir Rc<T> içindeki içeriği değiştirebilmek için RefCell<T> kullandığımız gibi, bir Arc<T> içindeki içeriği değiştirmek için de Mutex<T> kullanırız.
Dikkat edilmesi gereken bir diğer ayrıntı, Mutex<T> kullandığınızda Rust’ın sizi her türlü mantık hatasından koruyamayacağıdır. Bölüm 15’ten hatırlayacağınız üzere Rc<T> kullanmak, iki Rc<T> değerinin birbirine atıfta bulunarak bellek sızıntılarına yol açtığı referans döngüleri oluşturma riskini taşıyordu. Benzer şekilde Mutex<T> de kilitlenme (deadlock) oluşturma riskini beraberinde getirir. Bu durum, bir işlemin iki kaynağı birden kilitlemesi gerektiğinde ve iki farklı thread bu kilitlerden birer tanesini edinip sonsuza dek birbirini beklemeye başladığında meydana gelir. Kilitlenmeler ilginizi çekiyorsa, kilitlenme içeren bir Rust programı oluşturmayı deneyin; ardından herhangi bir dilde mutex’ler için uygulanan kilitlenme önleme stratejilerini araştırıp bunları Rust’ta uygulamaya çalışın. Mutex<T> ve MutexGuard için hazırlanan standart kütüphane API belgeleri bu konuda faydalı bilgiler sunar.
Not: Kilitlenme için iki thread bile gerekmez. std::sync::Mutex
yeniden girişli (reentrant) değildir; yani kilidi elinde tutan bir thread
aynı mutex üzerinde ikinci kez lock çağırırsa kendi kendini bekler:
let veri = Mutex::new(0);
let koruyucu = veri.lock().unwrap();
// `koruyucu` hâlâ kapsamda olduğu için kilit serbest bırakılmadı;
// bu ikinci çağrı sonsuza dek bekler (ya da platforma göre panikler).
let ikinci = veri.lock().unwrap();
Bu tuzak, bir MutexGuard kapsamdayken aynı mutex’i yeniden kilitleyen bir
yardımcı fonksiyon çağırdığınızda kolayca ortaya çıkar. İki mutex söz konusu
olduğunda ise klasik çözüm, tüm thread’lerin kilitleri her zaman aynı sırayla
(örneğin önce a sonra b) edinmesini sağlamaktır; böylece iki thread’in
birbirinin elindeki kilidi beklediği çapraz bekleme durumu hiç oluşmaz.
Not: Bölüm 15’te gördüğünüz tek thread’li (single-threaded) akıllı işaretçi ve içsel değiştirilebilirlik araçları ile bu bölümde tanıştığınız çok thread’li (multithreaded) eşleniklerini tek bakışta şöyle eşleştirebilirsiniz:
| Amaç | Tek Thread’de Kullanılan Araç | Thread’ler Arası Paylaşım İçin Araç | Temel Fark ve Davranış |
|---|---|---|---|
| Çoklu sahiplik | Rc<T> | Arc<T> | Rc<T> sıradan sayaç kullanır (daha hızlıdır); Arc<T> sayacı atomik işlemlerle günceller. |
| İçsel değiştirilebilirlik | RefCell<T> | Mutex<T> / RwLock<T> | RefCell<T> ödünç çakışmasında panic! üretir; Mutex<T> tek erişim için, RwLock<T> ise çoklu okuyucu (read) veya tek yazıcı (write) için bekleyen thread’i bloklar. |
| İlkel/kopyalanabilir içsel değişim | Cell<T> | Atomic* (AtomicUsize, AtomicBool vb.) | Cell<T> referans vermeden değeri kopyalar/değiştirir; std::sync::atomic türleri donanım düzeyinde kilitsiz (lock-free) atomik erişim sunar. |
| Paylaşımlı değiştirilebilir durum | Rc<RefCell<T>> | Arc<Mutex<T>> / Arc<RwLock<T>> | Tek thread’de paylaşımlı değişim için Rc<RefCell<T>>, thread’ler arasında paylaşımlı değişim için Arc<Mutex<T>> (okuma ağırlıklı iş yüklerinde Arc<RwLock<T>>) kullanılır. |
Sağ sütundaki jenerik türlerin Send ve Sync olması, içerdikleri T
türüne bağlıdır. Örneğin Arc<T>, ancak T: Send + Sync iken bu iki
trait’i uygular; Arc<RefCell<T>> kullanmak, RefCell<T> türünü thread
güvenli hâle getirmez. Arc<Mutex<T>> için ise T: Send yeterlidir.
Bu bölümü Send ve Sync trait’lerinden ve bunları özel türlerle nasıl kullanabileceğimizden bahsederek tamamlayacağız.
Send ve Sync ile Genişletilebilir Eşzamanlılık
Send ve Sync ile Genişletilebilir Eşzamanlılık
İlginç bir şekilde, bu bölümde şimdiye dek ele aldığımız eşzamanlılık özelliklerinin neredeyse tamamı dilin kendisinin değil, standart kütüphanenin bir parçasıydı. Eşzamanlılığı yönetme seçenekleriniz dil veya standart kütüphaneyle sınırlı değildir; kendi eşzamanlılık özelliklerinizi yazabilir veya başkalarının yazdıklarını kullanabilirsiniz.
Bununla birlikte, standart kütüphaneden ziyade doğrudan dilin içine yerleşik olan temel eşzamanlılık kavramları arasında std::marker modülündeki Send ve Sync trait’leri yer alır.
Thread’ler Arasında Sahiplik Aktarma
Send işaretleyici trait’i (marker trait), Send uygulayan türe ait değerlerin sahipliğinin thread’ler arasında aktarılabileceğini belirtir. Rust’taki neredeyse her tür Send uygular; ancak aralarında Rc<T> türünün de bulunduğu bazı istisnalar vardır: Rc<T> bu trait’i uygulayamaz, çünkü bir Rc<T> değerini klonlayıp klonun sahipliğini başka bir thread’e aktarmayı deneseydiniz, her iki thread de referans sayısını aynı anda güncelleyebilirdi. Bu nedenle Rc<T>, thread güvenliğinin getirdiği performans maliyetini ödemek istemediğiniz tek thread’li durumlarda kullanılmak üzere uygulanmıştır.
Dolayısıyla Rust’ın tür sistemi ve trait sınırları, bir Rc<T> değerini thread’ler arasında yanlışlıkla güvensiz bir biçimde göndermenizi kesin olarak engeller. Liste 16-14’te bunu yapmayı denediğimizde the trait `Send` is not implemented for `Rc<std::sync::Mutex<i32>>` (Send trait’i Rc<Mutex<i32>> için uygulanmamıştır) hatasını almıştık. Send trait’ini uygulayan Arc<T> türüne geçtiğimizde ise kod derlenmişti.
Tamamen Send türlerinden oluşan her tür de otomatik olarak Send olarak işaretlenir. Bölüm 20’de ele alacağımız ham işaretçiler (raw pointers) dışında neredeyse tüm ilkel türler Send’dir.
Birden Fazla Thread’den Erişim
Sync işaretleyici trait’i, Sync uygulayan bir türe birden fazla thread’den referans verilmesinin güvenli olduğunu belirtir. Başka bir deyişle, herhangi bir T türü, &T (T’ye ait değiştirilemez referans) Send trait’ini uyguluyorsa —yani bu referans başka bir thread’e güvenle gönderilebiliyorsa— Sync uygular. Send’e benzer şekilde, ham işaretçiler dışındaki tüm ilkel türler Sync uygular ve tamamen Sync uygulayan türlerden oluşan türler de Sync uygular.
Rc<T> akıllı işaretçisi, Send trait’ini uygulamamasıyla aynı gerekçelerle Sync trait’ini de uygulamaz. Bölüm 15’te ele aldığımız RefCell<T> türü ve onunla ilişkili Cell<T> türleri ailesi de Sync uygulamaz. RefCell<T>’nin çalışma zamanında yürüttüğü ödünç alma denetimi thread güvenli değildir. Buna karşılık Mutex<T> akıllı işaretçisi Sync uygular ve “Mutex<T>’ye Paylaşımlı Erişim” bölümünde gördüğünüz gibi erişimi birden fazla thread ile paylaşmak için kullanılabilir.
Not: Sync trait’inin kavramsal tanımı tek bir özlü kurala dayanır:
T: Sync olması, ancak ve ancak &T: Send olması demektir. Yani bir
değere ait değiştirilemez referansı (&T) başka bir thread’e güvenle
gönderebiliyorsanız T türü Sync’tir. Peki ölçüt neden &mut T değil de
&T’dir? Çünkü &mut T derleme zamanında zaten tekil (münhasır) erişimi
garanti eder; ortada aynı veriye erişen ikinci bir thread olamaz. Birden fazla
thread’in aynı veriye eşzamanlı erişimi yalnızca paylaşımlı &T referansları
üzerinden gerçekleşir ve tehlike, &T üzerinden senkronizasyonsuz içsel
değiştirilebilirlik sunulduğunda doğar. Standart kütüphanedeki türler bu dört
olasılığı somut biçimde sergiler:
- Hem
SendhemSync(i32,String,Vec<i32>,Arc<i32>,Mutex<i32>): Hem sahipliği başka bir thread’e taşınabilir hem de&Treferansı birden fazla thread arasında güvenle paylaşılabilir. (Mutex<T>erişimi kilitlerle sıraya soktuğundan,T: Sendolduğu süreceMutex<T>hemSendhem deSyncolur.) Sendiken!Sync(Cell<T>veRefCell<T>,T: Sendiken): Bu türlerin tek bir sahibi vardır; değerin tamamını bir thread’den diğerine taşımak (Send) güvenlidir, çünkü eski thread artık ona erişemez. Ancak&Cell<T>veya&RefCell<T>başka bir thread’e gönderilebilseydi (Sync), iki thread atomik olmayan iç durumu veya ödünç alma sayacını aynı anda değiştirerek veri yarışına yol açardı; bu yüzden!Sync’tirler.- Hem
!Sendhem!Sync(Rc<T>, ham işaretçiler*const T/*mut T):Rc<T>klonlandığında aynı atomik olmayan referans sayacını paylaşan ikinci bir sahip oluşturur. Klonu başka bir thread’e taşımak (Send) da,&Rc<T>referansını paylaşıp karşı thread’de.clone()çağrılmasına olanak tanımak (Sync) da sayaç üzerinde veri yarışı yaratacağındanRc<T>neSendne deSync’tir. !SendikenSync(MutexGuard<'_, T>,T: Synciken): İşletim sistemi düzeyindeki pek çok kilit mekanizması, kilidi edinen thread ile serbest bırakan thread’in aynı thread olmasını şart koşar.MutexGuarddüşürüldüğünde (Drop) kilidi açtığı için başka bir thread’e taşınamaz (!Send); aksi hâlde kilit yanlış thread üzerinde açılırdı. Buna karşılık kilidi tutan thread’in&MutexGuard<'_, T>referansını (örneğinstd::thread::scopeile) diğer thread’lere ödünç vermesi güvenlidir (Sync); çünkü diğer thread’ler yalnızca&Tüzerinden okuma yapar ve kilidin açılması yine asıl sahibi olan thread’de gerçekleşir.
Send ve Sync’i Elle Uygulamak Güvensizdir
Tamamen Send ve Sync trait’lerini uygulayan başka türlerden oluşan türler de Send ve Sync’i otomatik olarak uyguladığından, bu trait’leri elle uygulamamız gerekmez. İşaretleyici trait’ler oldukları için uygulanacak herhangi bir metotları bile yoktur. Yalnızca eşzamanlılıkla ilgili değişmezleri (invariants) zorunlu kılmak için yararlıdırlar.
Bu trait’leri elle uygulamak, güvenli olmayan (unsafe) Rust kodu yazmayı gerektirir. Güvenli olmayan Rust kodunun kullanımını Bölüm 20’de ele alacağız; şimdilik bilmeniz gereken önemli nokta, Send ve Sync parçalardan oluşmayan yeni eşzamanlı türler oluşturmanın güvenlik güvencelerini koruyabilmek adına çok dikkatli düşünmeyi gerektirdiğidir. “The Rustonomicon”, bu güvenceler ve bunların nasıl korunacağı hakkında daha fazla bilgi içerir.
Not: Send ve Sync, #[derive(...)] özniteliğiyle değil derleyicinin
otomatik trait (auto trait) mekanizmasıyla uygulanır: bir struct veya enum’ın
tüm alanları Send (veya Sync) ise türün kendisi de bu trait’i kendiliğinden
kazanır. Buna karşılık kendi veri yapınızın içinde ham işaretçi (*const T /
*mut T) kullandığınızda —örneğin standart kütüphanedeki Vec<T>, Arc<T>
veya Mutex<T> gibi alt düzey yapılar tasarlarken— ham işaretçiler Send ve
Sync olmadığı için bu alanı içeren tür de kendiliğinden !Send / !Sync
olur. Türünüzün gerçekten thread güvenli olduğunu bizzat doğruladıktan sonra
bunu derleyiciye unsafe impl Send for ... / unsafe impl Sync for ...
satırlarıyla açıkça bildirmeniz gerekir.
Özet
Bu kitapta eşzamanlılık konusunu son kez görmüyorsunuz: Bir sonraki bölüm asenkron (async) programlamaya odaklanacak ve Bölüm 21’deki proje, bu bölümdeki kavramları burada ele aldığımız daha küçük örneklerden daha gerçekçi bir senaryoda kullanacak.
Daha önce de belirttiğimiz gibi, Rust’ın eşzamanlılığı ele alış biçiminin çok küçük bir kısmı dilin kendi parçası olduğundan, pek çok eşzamanlılık çözümü crate’ler olarak uygulanmıştır. Bu crate’ler standart kütüphaneden daha hızlı evrilir; dolayısıyla çok thread’li durumlarda kullanabileceğiniz güncel ve en gelişmiş crate’ler için çevrim içi araştırma yapmayı unutmayın.
Rust standart kütüphanesi, mesaj iletimi için kanallar ve eşzamanlı bağlamlarda güvenle kullanılabilen Mutex<T> ile Arc<T> gibi akıllı işaretçi türleri sunar. Tür sistemi ve ödünç alma denetleyicisi, bu çözümleri kullanan kodların veri yarışlarına veya geçersiz referanslara yol açmamasını güvence altına alır. Kodunuzu derlemeyi başardığınızda, diğer dillerde sıkça rastlanan ve izini sürmesi güç hatalarla karşılaşmadan birden fazla thread üzerinde sorunsuzca çalışacağından emin olabilirsiniz. Eşzamanlı programlama artık korkulacak bir kavram değil: İlerleyin ve programlarınızı korkusuzca eşzamanlı hâle getirin!
Asenkron Programlamanın Temelleri: Async, Await, Futures ve Streams
Bilgisayardan gerçekleştirmesini istediğimiz pek çok işlemin tamamlanması biraz zaman alabilir. Uzun süren bu işlemlerin bitmesini beklerken başka bir şeyler de yapabilsek harika olurdu. Modern bilgisayarlar aynı anda birden fazla işlem üzerinde çalışmak için iki teknik sunar: paralellik (parallelism) ve eşzamanlılık (concurrency). Buna karşılık, programlarımızın mantığı çoğunlukla doğrusal bir akışla yazılır. Programın gerçekleştirmesi gereken işlemleri ve bir fonksiyonun duraklayıp onun yerine programın başka bir bölümünün çalışabileceği noktaları, her kod parçasının tam olarak hangi sırayla ve nasıl çalışacağını en baştan belirlemek zorunda kalmadan ifade edebilmek isteriz. Asenkron programlama (asynchronous programming), kodumuzu olası duraklama noktaları ve nihai sonuçlar cinsinden ifade etmemizi sağlayan ve koordinasyon ayrıntılarını bizim yerimize üstlenen bir soyutlamadır.
Bu bölüm, Bölüm 16’da paralellik ve eşzamanlılık için thread’leri kullanışımızın üzerine inşa edilir ve kod yazmaya alternatif bir yaklaşım sunar: işlemlerin nasıl asenkron olabileceğini ifade etmemizi sağlayan Rust future’ları ile akışları (streams), bunları destekleyen async ve await sözdizimi ve asenkron çalışma zamanlarını (asynchronous runtimes) —yani asenkron işlemlerin yürütülmesini yönetip koordine eden kodu— sağlayan üçüncü taraf crate’ler.
Bir örnek ele alalım. Diyelim ki bir aile kutlamasından hazırladığınız videoyu dışa aktarıyorsunuz; bu işlem dakikalardan saatlere kadar uzayabilir. Video dışa aktarma işlemi, kullanabildiği kadar çok CPU ve GPU gücü tüketecektir. Yalnızca tek bir CPU çekirdeğiniz olsaydı ve işletim sisteminiz bu dışa aktarmayı tamamlanana kadar hiç duraklatmasaydı —yani dışa aktarma işlemini senkron olarak yürütseydi— bu görev çalışırken bilgisayarınızda başka hiçbir şey yapamazdınız. Bu oldukça can sıkıcı bir deneyim olurdu. Neyse ki bilgisayarınızın işletim sistemi, aynı anda başka işlerinizi de halledebilmeniz için dışa aktarma işlemini görünmez biçimde yeterince sık kesintiye uğratabilir ve gerçekten de uğratır.
Şimdi de bir başkasının paylaştığı bir videoyu indirdiğinizi düşünelim; bu işlem de biraz zaman alabilir ama o kadar fazla CPU süresi tüketmez. Bu durumda CPU, ağdan verinin gelmesini beklemek zorundadır. Veri gelmeye başlar başlamaz okumaya başlayabilseniz de tamamının ulaşması biraz vakit alabilir. Verinin tamamı ulaştıktan sonra bile, video oldukça büyükse hepsini belleğe yüklemek en az bir iki saniye sürebilir. Bu kulağa pek uzun gelmeyebilir, ancak her saniye milyarlarca işlem gerçekleştirebilen modern bir işlemci için çok uzun bir süredir. İşletim sisteminiz yine ağ çağrısının tamamlanmasını beklerken CPU’nun başka işler yapabilmesine olanak tanımak için programınızı görünmez biçimde kesintiye uğratacaktır.
Video dışa aktarma, CPU bağımlı (CPU-bound) veya hesaplama bağımlı (compute-bound) bir işlem örneğidir. Böyle bir işlem, bilgisayarın CPU veya GPU içinde veri işleyebileceği azami hızla ve bu hızın ne kadarını söz konusu işleme ayırabildiğiyle sınırlıdır. Video indirme ise G/Ç bağımlı (I/O-bound) bir işlem örneğidir; çünkü bilgisayarın girdi ve çıktı hızıyla sınırlıdır ve yalnızca verinin ağ üzerinden gönderilebildiği hızda ilerleyebilir.
Bu örneklerin her ikisinde de işletim sisteminin görünmez kesmeleri bir tür eşzamanlılık sağlar. Ne var ki bu eşzamanlılık yalnızca programın bütünü düzeyinde gerçekleşir: işletim sistemi diğer programların iş yapabilmesi için bir programı kesintiye uğratır. Pek çok durumda, programlarımızı işletim sisteminin anlayabileceğinden çok daha ayrıntılı bir düzeyde anladığımız için, işletim sisteminin göremediği eşzamanlılık fırsatlarını biz yakalayabiliriz.
Örneğin, dosya indirmelerini yöneten bir araç geliştiriyorsak, programımızı bir indirme başlatmanın kullanıcı arayüzünü kilitlemeyeceği şekilde yazabilmeliyiz ve kullanıcılar aynı anda birden fazla indirme başlatabilmelidir. Ne var ki ağ ile etkileşim kurmaya yönelik pek çok işletim sistemi API’si bloklayıcıdır (blocking); yani işledikleri veri tamamen hazır olana dek programın ilerleyişini durdurur.
Not: Üzerinde düşünürseniz, çoğu fonksiyon çağrısı zaten bu şekilde çalışır. Ancak bloklayıcı terimi genellikle dosyalarla, ağla veya bilgisayardaki diğer kaynaklarla etkileşime giren fonksiyon çağrıları için kullanılır; çünkü bir programın, işlemin bloklamayan (non-blocking) olmasından fayda göreceği durumlar asıl bunlardır.
Her dosyayı indirmek üzere ayrı bir thread başlatarak ana thread’imizi bloklamaktan kaçınabilirdik. Ancak bu thread’lerin tükettiği sistem kaynaklarının ek maliyeti bir noktadan sonra sorun hâline gelirdi. Çağrının en baştan hiç bloklamaması çok daha iyi olurdu: Bunun yerine programımızın tamamlamasını istediğimiz bir dizi görev (task) tanımlayabilir, bunları çalıştırmak için en uygun sırayı ve yöntemi seçmeyi de çalışma zamanına bırakabilirdik.
Rust’ın async (asynchronous yani asenkron kelimesinin kısaltması) soyutlamasının bize sunduğu şey tam olarak budur. Bu bölümde şu konuları ele alırken async yapısını tüm yönleriyle öğreneceksiniz:
- Rust’ın
asyncveawaitsözdiziminin nasıl kullanılacağı ve asenkron fonksiyonların bir çalışma zamanı ile nasıl yürütüleceği - Bölüm 16’da incelediğimiz zorluklardan bazılarını çözmek için async modelinin nasıl kullanılacağı
- Çoklu thread kullanımı (multithreading) ile async’in pek çok durumda bir arada kullanabileceğiniz tamamlayıcı çözümleri nasıl sunduğu
Ancak async’in pratikte nasıl çalıştığını görmeden önce, paralellik ile eşzamanlılık arasındaki farkları ele almak için kısa bir parantez açmamız gerekiyor.
Paralellik ve Eşzamanlılık
Şimdiye kadar paralellik ve eşzamanlılığı çoğunlukla birbirinin yerine geçebilen kavramlar gibi ele aldık. Artık aralarındaki ayrımı daha kesin biçimde yapmamız gerekiyor; çünkü çalışmaya başladığımızda bu farklar karşımıza çıkacak.
Bir ekibin bir yazılım projesindeki işleri paylaşabileceği farklı yolları düşünün. Tek bir ekip üyesine birden fazla görev verebilir, her üyeye tek bir görev atayabilir ya da bu iki yaklaşımın bir karışımını kullanabilirsiniz.
Bir kişinin, elindeki görevlerden herhangi biri tamamlanmadan önce birkaç farklı görev üzerinde çalışması eşzamanlılıktır. Eşzamanlılığı uygulamanın bir yolu, bilgisayarınızda iki farklı projeyi hazır bulundurup bir projeden sıkıldığınızda ya da bir noktada takıldığınızda diğerine geçmenize benzer. Yalnızca tek bir kişisinizdir; dolayısıyla her iki görevde kelimenin tam anlamıyla aynı anda ilerleme kaydedemezsiniz, ancak aralarında geçiş yapıp her seferinde biri üzerinde ilerleyerek çoklu görev yürütebilirsiniz (bkz. Şekil 17-1).
Ekip, bir grup görevi her üyenin tek bir görev alıp onun üzerinde tek başına çalışacağı şekilde paylaştırdığında bu paralelliktir. Ekipteki her kişi kelimenin tam anlamıyla aynı anda ilerleme kaydedebilir (bkz. Şekil 17-2).
Bu iş akışlarının her ikisinde de farklı görevler arasında koordinasyon sağlamanız gerekebilir. Belki bir kişiye atanan görevin diğer herkesin işinden tamamen bağımsız olduğunu düşünmüşsünüzdür, ancak gerçekte ekipteki başka bir kişinin önce kendi görevini bitirmesini gerektiriyordur. İşin bir kısmı paralel olarak yapılabilse de bir kısmı aslında seridir (serial): Şekil 17-3’te olduğu gibi yalnızca sırayla, bir görev bittikten sonra diğeri başlayacak biçimde gerçekleşebilir.
Benzer şekilde, kendi görevlerinizden birinin yine kendi görevlerinizden bir diğerine bağımlı olduğunu fark edebilirsiniz. Artık sizin eşzamanlı çalışmanız da seri hâle gelmiştir.
Paralellik ve eşzamanlılık birbiriyle kesişebilir de. Bir iş arkadaşınızın, siz görevlerinizden birini bitirene kadar takılıp kaldığını öğrenirseniz, muhtemelen iş arkadaşınızın “blokajını kaldırmak” (önünü açmak) için tüm çabanızı o göreve odaklarsınız. Artık iş arkadaşınızla paralel çalışamazsınız ve kendi görevleriniz üzerinde de eşzamanlı çalışamaz hâle gelirsiniz.
Aynı temel dinamikler yazılımda ve donanımda da devreye girer. Tek CPU çekirdekli bir makinede CPU her seferinde yalnızca tek bir işlem gerçekleştirebilir, ancak yine de eşzamanlı olarak çalışabilir. Thread’ler, süreçler (processes) ve async gibi araçları kullanan bilgisayar, bir etkinliği duraklatıp diğerlerine geçebilir ve sonunda döngüyü tamamlayarak yeniden o ilk etkinliğe geri dönebilir. Birden fazla CPU çekirdeğine sahip bir makinede ise işleri paralel olarak da yürütebilir. Bir çekirdek bir görevi yerine getirirken başka bir çekirdek onunla hiçbir ilgisi olmayan bambaşka bir görevi yürütebilir ve bu işlemler kelimenin tam anlamıyla aynı anda gerçekleşir.
Rust’ta async kod çalıştırmak genellikle eşzamanlı olarak gerçekleşir. Donanıma, işletim sistemine ve kullandığımız asenkron çalışma zamanına (asenkron çalışma zamanlarına birazdan daha ayrıntılı değineceğiz) bağlı olarak, bu eşzamanlılık perde arkasında paralellikten de yararlanabilir.
Not: Rust’ın asenkron tasarım felsefesi, başka dillerden gelen geliştiricilerin bilmesinde büyük fayda olan iki temel mimari karara dayanır:
- Çalışma zamanının (async runtime) ekosisteme bırakılması: Rust
standart kütüphanesi
Future,Poll,ContextveWakergibi temel türleri sağlar;async/awaitsözdizimini ise derleyici destekler. Rust, görevleri çizelgeleyip yürüten bir asenkron çalışma zamanını bünyesinde barındırmaz. Çünkü yüksek trafikli bir web sunucusunun ihtiyaç duyduğu çok thread’li iş çalmalı (work-stealing) çalışma zamanı ile birkaç kilobayt belleğe sahip bir mikrodenetleyicinin gereksinimleri birbirinden tamamen farklıdır. Standart kütüphaneye tek tip bir çalışma zamanı dayatmamak (Rust’ın “kullanmadığınız şeyin bedelini ödemezsiniz” ilkesinin bir uzantısı) sayesinde; ağ ve sunucu uygulamalarındatokio, hafif ve yalın projelerdesmol, işletim sistemsiz (bare-metal) gömülü sistemlerde iseembassygibi hedefe özel çalışma zamanları seçilebilir. Bu bölümde de öğrenme sürecini sadeleştirmek amacıyla, perde arkasındatokiovefuturescrate’lerini sarmalayan eğitim amaçlıtrplcrate’ini kullanacağız. - Tembel (lazy) durum makineleri olarak future’lar: JavaScript’teki
Promiseve C# dilindekiasyncmetotların döndürdüğüTaskdeğerleriyle kullanılan yaygın modelde, işlem bir bekleme çağrısı olmadan da başlayabilir. Bu, işlemin mutlaka ayrı bir thread’de yürütüldüğü anlamına gelmez. Rust’ta ise birasyncfonksiyonu çağırmak tek başına hiçbir iş yürütmez; yalnızca derleyicinin ürettiği tembel bir durum makinesi olan ve ek öbek (heap) ayırması gerektirmeden oluşturulabilen birFuturedeğeri döndürür. Bir future, yoklanmadıkça (poll) —yani üzerinde.awaitkullanılmadıkça ya da bir çalışma zamanına teslim edilmedikçe— hiçbir adım ilerlemez. Bu tasarım, future’ları çalıştırmadan önce birleştirmeyi ve henüz tamamlanmamış bir future’ın yürütülmesini düşürerek (drop) bırakmayı mümkün kılar. Ancak başlatılmış bir ağ isteği veya ayrı bir göreve devredilmiş iş, o future’ın düşürülmesiyle mutlaka durmaz; iptal davranışı kullanılan API’ye bağlıdır.
Şimdi, Rust’ta asenkron programlamanın gerçekte nasıl çalıştığına derinlemesine bakalım.
Futures ve Async Sözdizimi
Futures ve Async Sözdizimi
Rust’ta asenkron programlamanın temel unsurları future’lar ile Rust’ın async ve await anahtar kelimeleridir.
Bir future, şu anda hazır olmayabilen ancak gelecekte bir noktada hazır hâle gelecek bir değerdir. (Aynı kavram pek çok dilde bazen task veya promise gibi farklı adlarla da karşımıza çıkar.) Rust, farklı asenkron işlemlerin farklı veri yapılarıyla ancak ortak bir arayüz üzerinden uygulanabilmesi için yapı taşı olarak bir Future trait’i sunar. Rust’ta future’lar, Future trait’ini uygulayan türlerdir. Her future, kaydedilen ilerleme ve “hazır” olmanın ne anlama geldiği hakkında kendi bilgisini barındırır.
Kesintiye uğratılıp kaldıkları yerden devam ettirilebileceklerini belirtmek için bloklara ve fonksiyonlara async anahtar kelimesini uygulayabilirsiniz. Bir async blok veya async fonksiyon içinde ise bir future’ı beklemek (yani hazır hâle gelmesini beklemek) için await anahtar kelimesini kullanabilirsiniz. Bir async blok veya fonksiyon içinde bir future’ı beklediğiniz her nokta, o bloğun veya fonksiyonun duraklayıp yeniden devam edebileceği potansiyel bir noktadır. Bir future’ın değerinin henüz hazır olup olmadığını kontrol etme sürecine yoklama (polling) denir.
C# ve JavaScript gibi başka bazı diller de asenkron programlama için async ve await anahtar kelimelerini kullanır. Bu dillere aşinaysanız, Rust’ın sözdizimini ele alış biçiminde bazı önemli farklılıklar olduğunu fark edebilirsiniz. Birazdan göreceğimiz üzere bunun çok geçerli nedenleri var!
Asenkron Rust kodu yazarken çoğu zaman async ve await anahtar kelimelerini kullanırız. Rust bunları, tıpkı for döngülerini Iterator trait’ini kullanan eşdeğer koda derlediği gibi, Future trait’ini kullanan eşdeğer koda derler. Ancak Rust Future trait’ini doğrudan sunduğu için, ihtiyaç duyduğunuzda bu trait’i kendi veri türleriniz üzerinde de uygulayabilirsiniz. Bu bölüm boyunca göreceğimiz pek çok fonksiyon, kendi Future uygulamalarına sahip türler döndürür. Bölümün sonunda trait tanımına geri dönecek ve nasıl çalıştığını daha derinlemesine inceleyeceğiz, ancak şimdilik ilerlememiz için bu kadar ayrıntı yeterlidir.
Tüm bunlar biraz soyut gelmiş olabilir; gelin ilk asenkron programımızı yazalım: küçük bir web kazıyıcı (web scraper). Komut satırından iki URL geçirecek, her ikisini de eşzamanlı olarak getirecek ve hangisi önce tamamlanırsa onun sonucunu döndüreceğiz. Bu örnekte epeyce yeni sözdizimi yer alacak ama endişelenmeyin; ilerledikçe bilmeniz gereken her şeyi açıklayacağız.
İlk Async Programımız
Bu bölümün odağını ekosistemin farklı parçalarıyla uğraşmak yerine asenkron programlamayı öğrenmekte tutmak için trpl crate’ini oluşturduk (trpl, “The Rust Programming Language” ifadesinin kısaltmasıdır). Bu crate, başta futures ve tokio crate’leri olmak üzere ihtiyaç duyacağınız tüm türleri, trait’leri ve fonksiyonları yeniden dışa aktarır (re-export). futures crate’i, Rust’ta asenkron kod deneyleri için resmî bir yuvadır ve Future trait’inin ilk kez tasarlandığı yerdir. Tokio ise günümüzde Rust ekosisteminde, özellikle web uygulamaları için en yaygın kullanılan asenkron çalışma zamanıdır (async runtime). Piyasada başka harika çalışma zamanları da vardır ve kendi amaçlarınıza daha uygun olabilirler. İyi test edilmiş ve yaygın olarak kullanılıyor olması nedeniyle trpl altında perde arkasında tokio crate’ini kullanıyoruz.
Bazı durumlarda trpl, yalnızca bu bölümle ilgili ayrıntılara odaklanmanızı sağlamak için orijinal API’leri yeniden adlandırır veya sarmalar. Crate’in ne yaptığını anlamak isterseniz kaynak koduna göz atmanızı tavsiye ederiz. Her bir dışa aktarımın hangi crate’ten geldiğini görebilir ve crate’in ne yaptığını açıklayan kapsamlı yorumları inceleyebilirsiniz.
hello-async adında yeni bir ikili (binary) proje oluşturun ve trpl crate’ini bağımlılık olarak ekleyin:
$ cargo new hello-async
$ cd hello-async
$ cargo add trpl
Artık trpl tarafından sağlanan çeşitli parçaları kullanarak ilk asenkron programımızı yazabiliriz. İki web sayfasını getiren, her birinden <title> öğesini çeken ve tüm bu süreci hangisi önce tamamlarsa o sayfanın başlığını ekrana yazdıran küçük bir komut satırı aracı geliştireceğiz.
sayfa_basligi Fonksiyonunu Tanımlama
Parametre olarak tek bir sayfa URL’si alan, bu adrese istek yapan ve <title> öğesinin metnini döndüren bir fonksiyon yazarak başlayalım (bkz. Liste 17-1).
extern crate trpl; // required for mdbook test
fn main() {
// TODO: we'll add this next!
}
use trpl::Html;
async fn sayfa_basligi(url: &str) -> Option<String> {
let yanit = trpl::get(url).await;
let yanit_metni = yanit.text().await;
Html::parse(&yanit_metni)
.select_first("title")
.map(|title| title.inner_html())
}
İlk olarak sayfa_basligi adında bir fonksiyon tanımlıyor ve onu async anahtar kelimesiyle işaretliyoruz. Ardından, fonksiyona verilen URL’yi getirmek için trpl::get fonksiyonunu kullanıyor ve yanıtı beklemek üzere await anahtar kelimesini ekliyoruz. yanit değerinin metnini almak için üzerinde text metodunu çağırıyor ve onu da bir kez daha await anahtar kelimesiyle bekliyoruz. Bu adımların her ikisi de asenkrondur. get fonksiyonu için sunucunun yanıtının ilk bölümünü —HTTP üstbilgilerini (headers), çerezleri vb. içeren ve yanıt gövdesinden ayrı olarak iletilebilen kısmı— geri göndermesini beklememiz gerekir. Özellikle gövde çok büyükse tamamının ulaşması biraz zaman alabilir. Yanıtın tamamının gelmesini beklememiz gerektiği için text metodu da asenkrondur.
Bu iki future’ı da açıkça beklemek zorundayız; çünkü Rust’ta future’lar tembeldir (lazy): await anahtar kelimesiyle onlardan istemediğiniz sürece hiçbir şey yapmazlar. (Hatta bir future’ı kullanmazsanız Rust bir derleyici uyarısı gösterir.) Bu durum size Bölüm 13’teki “İteratörlerle Bir Dizi Öğeyi İşleme” bölümünde yer alan iteratör tartışmasını hatırlatabilir. İteratörler, ister doğrudan ister for döngüleri ya da perde arkasında next kullanan map gibi metotlar aracılığıyla olsun, next metotlarını çağırmadığınız sürece hiçbir şey yapmaz. Benzer şekilde, future’lar da siz açıkça istemedikçe hiçbir şey yapmaz. Bu tembellik, Rust’ın asenkron kodu gerçekten ihtiyaç duyulana kadar çalıştırmaktan kaçınmasına olanak tanır.
Not: Bu davranış, Bölüm 16’daki “spawn ile Yeni Bir Thread
Oluşturma” bölümünde thread::spawn
kullanırken gördüğümüz davranıştan farklıdır; orada başka bir thread’e
aktardığımız kapanış hemen çalışmaya başlamıştı. Aynı zamanda pek çok başka
dilin asenkron programlamaya yaklaşımından da farklıdır. Ancak tıpkı
iteratörlerde olduğu gibi, Rust’ın kendi performans güvencelerini
sunabilmesi açısından bu özellik kritik öneme sahiptir.
yanit_metni değerini elde ettikten sonra, Html::parse kullanarak onu Html türünün bir örneğine ayrıştırabiliriz. Artık ham bir string yerine, HTML ile daha zengin bir veri yapısı olarak çalışmamızı sağlayan bir veri türümüz var. Özellikle, verilen bir CSS seçicisiyle eşleşen ilk öğeyi bulmak için select_first metodunu kullanabiliriz. "title" string’ini geçirerek belgede varsa ilk <title> öğesini alırız. Eşleşen hiçbir öğe bulunmayabileceği için select_first bir Option<ElementRef> döndürür. Son olarak, Option içinde öğe mevcutsa onunla çalışmamızı, yoksa hiçbir şey yapmamamızı sağlayan Option::map metodunu kullanırız. (Burada bir match ifadesi de kullanabilirdik ancak map daha deyimseldir.) map metoduna verdiğimiz fonksiyonun gövdesinde, title üzerinde inner_html çağırarak String türündeki içeriğini alırız. Her şey tamamlandığında elimizde bir Option<String> olur.
Rust’ın await anahtar kelimesinin beklediğiniz ifadenin öncesine değil, sonrasına geldiğine dikkat edin. Başka bir deyişle, bu bir sonek (postfix) anahtar kelimedir. Başka dillerde async kullandıysanız bu durum alışkın olduğunuzdan farklı gelebilir; ancak Rust’ta metot zincirleriyle çalışmayı çok daha keyifli hâle getirir. Sonuç olarak, Liste 17-2’de gösterildiği gibi sayfa_basligi fonksiyonunun gövdesini trpl::get ve text çağrılarını aralarında await ile birbirine zincirleyecek şekilde değiştirebiliriz.
extern crate trpl; // required for mdbook test
use trpl::Html;
fn main() {
// TODO: we'll add this next!
}
async fn sayfa_basligi(url: &str) -> Option<String> {
let yanit_metni = trpl::get(url).await.text().await;
Html::parse(&yanit_metni)
.select_first("title")
.map(|title| title.inner_html())
}
await anahtar kelimesiyle zincirleme çağrı yapmaNot: Rust’ta await anahtar kelimesinin await foo() yerine sonek olarak
foo().await biçiminde tasarlanmasının en önemli nedenlerinden biri, Bölüm
9’da gördüğümüz hata yayma operatörü ? ile kusursuz biçimde
birleşebilmesidir. Gerçek dünya kodlarında ağ ve dosya işlemleri çoğunlukla
Result döndüren future’lar üretir. Önek sözdizimi kullanılsaydı, iç içe
parantezler gerektiren (await (await istek())?.metin())? gibi okunması güç
ifadeler yazmak zorunda kalırdık; sonek .await sayesinde aynı akış soldan
sağa doğal okuma sırasıyla istek().await?.metin().await? şeklinde
parantezsiz zincirlenebilir.
Böylece ilk asenkron fonksiyonumuzu başarıyla yazmış olduk! Bu fonksiyonu çağırmak üzere main içine biraz kod eklemeden önce, ne yazdığımız ve bunun ne anlama geldiği hakkında biraz daha konuşalım.
Rust, async anahtar kelimesiyle işaretlenmiş bir blok gördüğünde onu Future trait’ini uygulayan benzersiz ve isimsiz bir veri türüne derler. async ile işaretlenmiş bir fonksiyon gördüğünde ise onu, gövdesi asenkron bir blok olan asenkron olmayan bir fonksiyona derler. Bir asenkron fonksiyonun dönüş türü, derleyicinin bu asenkron blok için oluşturduğu isimsiz veri türünün türüdür.
Dolayısıyla async fn yazmak, dönüş türünün bir future’ını döndüren bir fonksiyon yazmakla eşdeğerdir. Derleyici açısından, Liste 17-1’deki async fn sayfa_basligi gibi bir fonksiyon tanımı kabaca şöyle tanımlanmış asenkron olmayan bir fonksiyona karşılık gelir:
#![allow(unused)]
fn main() {
extern crate trpl; // required for mdbook test
use std::future::Future;
use trpl::Html;
fn sayfa_basligi(url: &str) -> impl Future<Output = Option<String>> {
async move {
let metin = trpl::get(url).await.text().await;
Html::parse(&metin)
.select_first("title")
.map(|title| title.inner_html())
}
}
}
Dönüştürülmüş bu sürümün her bir parçasını adım adım inceleyelim:
- Bölüm 10’daki “Trait’leri Parametre Olarak Kullanma”
bölümünde ele aldığımız
impl Traitsözdizimini kullanır. - Döndürülen değer,
Outputadlı ilişkili türe sahipFuturetrait’ini uygular.OutputtürününOption<String>olduğuna, yanisayfa_basligifonksiyonununasync fnsürümündeki orijinal dönüş türüyle birebir aynı olduğuna dikkat edin. - Orijinal fonksiyonun gövdesinde çağrılan tüm kod bir
async movebloğu içine sarılmıştır. Blokların birer ifade (expression) olduğunu hatırlayın. Bu bloğun tamamı fonksiyondan döndürülen ifadedir. - Bu asenkron blok, az önce açıklandığı gibi
Option<String>türünde bir değer üretir. Bu değer, dönüş türündekiOutputtürüyle eşleşir. Bu durum daha önce gördüğünüz diğer bloklarla tamamen aynıdır. - Yeni fonksiyon gövdesi,
urlparametresini kullanma biçimi nedeniyle birasync movebloğudur. (asyncileasync movearasındaki farkları bölümün ilerleyen kısımlarında çok daha ayrıntılı ele alacağız.)
Artık sayfa_basligi fonksiyonunu main içinde çağırabiliriz.
Bir Async Fonksiyonu Çalışma Zamanıyla Yürütme
Başlangıç olarak Liste 17-3’te gösterildiği gibi tek bir sayfanın başlığını alacağız. Ne yazık ki bu kod henüz derlenmez.
extern crate trpl; // required for mdbook test
use trpl::Html;
async fn main() {
let args: Vec<String> = std::env::args().collect();
let url = &args[1];
match sayfa_basligi(url).await {
Some(title) => println!("{url} için başlık {title} idi"),
None => println!("{url} için başlık yoktu"),
}
}
async fn sayfa_basligi(url: &str) -> Option<String> {
let yanit_metni = trpl::get(url).await.text().await;
Html::parse(&yanit_metni)
.select_first("title")
.map(|title| title.inner_html())
}
main içinden sayfa_basligi fonksiyonunu çağırmaBölüm 12’deki “Komut Satırı Argümanlarını Kabul Etme” bölümünde komut satırı argümanlarını almak için kullandığımız kalıbın aynısını izliyoruz. Ardından URL argümanını sayfa_basligi fonksiyonuna geçiriyor ve sonucu bekliyoruz. Future tarafından üretilen değer bir Option<String> olduğu için, sayfanın bir <title> öğesine sahip olup olmamasına göre farklı mesajlar yazdırmak üzere bir match ifadesi kullanıyoruz.
await anahtar kelimesini kullanabileceğimiz tek yer asenkron fonksiyonlar veya bloklardır ve Rust özel main fonksiyonunu async olarak işaretlememize izin vermez.
error[E0752]: `main` function is not allowed to be `async`
--> src/main.rs:6:1
|
6 | async fn main() {
| ^^^^^^^^^^^^^^^ `main` function is not allowed to be `async`
main fonksiyonunun async olarak işaretlenememesinin nedeni, asenkron kodun bir çalışma zamanına (runtime) —yani asenkron kodun yürütülme ayrıntılarını yöneten bir Rust crate’ine— ihtiyaç duymasıdır. Bir programın main fonksiyonu bir çalışma zamanını başlatabilir, ancak kendisi bir çalışma zamanı değildir. (Bunun neden böyle olduğunu birazdan daha ayrıntılı göreceğiz.) Asenkron kod yürüten her Rust programında, future’ları çalıştıran bir çalışma zamanının kurulduğu en az bir yer bulunur.
Asenkron programlamayı destekleyen çoğu dil bir çalışma zamanını dilin içinde yerleşik olarak sunar, ancak Rust bunu yapmaz. Bunun yerine, her biri hedeflediği kullanım senaryosuna uygun farklı ödünleşimler yapan pek çok farklı asenkron çalışma zamanı mevcuttur. Örneğin çok sayıda CPU çekirdeğine ve büyük miktarda RAM’e sahip yüksek verimli bir web sunucusunun ihtiyaçları; tek çekirdekli, az miktarda RAM’e sahip ve öbek (heap) ayırma yeteneği bulunmayan bir mikrodenetleyicinin ihtiyaçlarından çok farklıdır. Bu çalışma zamanlarını sağlayan crate’ler çoğunlukla dosya veya ağ G/Ç (I/O) işlemleri gibi yaygın işlevlerin asenkron sürümlerini de beraberinde sunar.
Burada ve bölümün geri kalanı boyunca, trpl crate’indeki block_on fonksiyonunu kullanacağız; bu fonksiyon argüman olarak bir future alır ve bu future tamamlanana kadar mevcut thread’i bloklar. Perde arkasında block_on çağrısı, kendisine geçirilen future’ı çalıştırmak için tokio crate’ini kullanarak bir çalışma zamanı kurar (trpl crate’inin block_on davranışı, diğer çalışma zamanı crate’lerinin block_on fonksiyonlarına benzer). Future tamamlandığında block_on, future hangi değeri ürettiyse onu döndürür.
sayfa_basligi tarafından döndürülen future’ı doğrudan block_on fonksiyonuna geçirebilir ve tamamlandığında Liste 17-3’te yapmaya çalıştığımız gibi ortaya çıkan Option<String> değeri üzerinde match kullanabilirdik. Ancak bölümdeki örneklerin çoğunda (ve gerçek dünyadaki çoğu asenkron kodda) tek bir asenkron fonksiyon çağrısından daha fazlasını yapacağız; bu yüzden bunun yerine Liste 17-4’teki gibi bir async blok geçirecek ve sayfa_basligi çağrısının sonucunu açıkça bekleyeceğiz.
extern crate trpl; // required for mdbook test
use trpl::Html;
fn main() {
let args: Vec<String> = std::env::args().collect();
trpl::block_on(async {
let url = &args[1];
match sayfa_basligi(url).await {
Some(title) => println!("{url} için başlık {title} idi"),
None => println!("{url} için başlık yoktu"),
}
})
}
async fn sayfa_basligi(url: &str) -> Option<String> {
let yanit_metni = trpl::get(url).await.text().await;
Html::parse(&yanit_metni)
.select_first("title")
.map(|title| title.inner_html())
}
trpl::block_on ile bir asenkron bloğu beklemeBu kodu çalıştırdığımızda başlangıçta beklediğimiz davranışı elde ederiz:
$ cargo run -- "https://www.rust-lang.org"
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.05s
Running `target/debug/asenkron_bekleme 'https://www.rust-lang.org'`
https://www.rust-lang.org için başlık
Rust Programming Language idi
Oh be, sonunda çalışan bir asenkron kodumuz oldu! Ancak iki siteyi birbiriyle yarıştıracak kodu eklemeden önce, dikkatimizi kısaca future’ların nasıl çalıştığına çevirelim.
Her bekleme noktası (await point) —yani kodun await anahtar kelimesini kullandığı her yer— denetimin çalışma zamanına geri devredildiği bir noktayı temsil eder. Bunun çalışabilmesi için Rust’ın asenkron blokta yer alan durumu takip etmesi gerekir; böylece çalışma zamanı başka bir işi başlatabilir ve ilk işi tekrar ilerletmeyi denemeye hazır olduğunda geri dönebilir. Bu, her bekleme noktasındaki mevcut durumu kaydetmek için aşağıdaki gibi bir enum yazmışsınız gibi işleyen görünmez bir durum makinesidir (state machine):
#![allow(unused)]
fn main() {
extern crate trpl; // required for mdbook test
enum SayfaBasligiFuture<'a> {
Baslangic { url: &'a str },
GetBeklemeNoktasi { url: &'a str },
MetinBeklemeNoktasi { yanit: trpl::Response },
}
}
Ne var ki her bir durum arasındaki geçiş kodunu elle yazmak, özellikle de daha sonra koda yeni işlevler ve daha fazla durum eklemeniz gerektiğinde hem yorucu hem de hataya açık olurdu. Neyse ki Rust derleyicisi asenkron kod için durum makinesi veri yapılarını otomatik olarak oluşturur ve yönetir. Veri yapıları etrafındaki normal ödünç alma ve sahiplik kurallarının tamamı burada da geçerliliğini korur ve sevindirici biçimde derleyici bunları bizim için kontrol edip yararlı hata mesajları sunar. Bölümün ilerleyen kısımlarında bunlardan birkaçının üzerinden geçeceğiz.
Nihayetinde bir şeyin bu durum makinesini yürütmesi gerekir ve o şey bir çalışma zamanıdır. (Çalışma zamanlarını araştırırken yürütücülerden (executors) söz edildiğini görmenizin nedeni budur: yürütücü, bir çalışma zamanının asenkron kodu yürütmekten sorumlu parçasıdır.)
Artık Liste 17-3’te derleyicinin main fonksiyonunun kendisini asenkron bir fonksiyon yapmamızı neden engellediğini görebilirsiniz. Eğer main asenkron bir fonksiyon olsaydı, main’in döndürdüğü future her ne ise onun durum makinesini başka bir şeyin yönetmesi gerekirdi; oysa main programın başlangıç noktasıdır! Bunun yerine, bir çalışma zamanı kurmak ve async blok tarafından döndürülen future’ı tamamlanana kadar çalıştırmak için main içinde trpl::block_on fonksiyonunu çağırdık.
Not: Bazı çalışma zamanları, bir async main fonksiyonu yazabilmeniz için
makrolar sunar. Bu makrolar async fn main() { ... } tanımını normal bir fn main fonksiyonuna yeniden yazar ve Liste 17-4’te elle yaptığımız şeyin
aynısını gerçekleştirir: bir future’ı tıpkı trpl::block_on gibi tamamlanana
kadar çalıştıran bir fonksiyon çağırır. Örneğin gerçek dünya projelerinde
sıkça karşılaşacağınız #[tokio::main] öznitelik makrosu, yazdığınız async fn main() fonksiyonunu derleme zamanında kabaca fn main() { tokio::runtime::Runtime::new().unwrap().block_on(async { ... }) } yapısına
dönüştürür.
Şimdi bu parçaları bir araya getirelim ve eşzamanlı kodu nasıl yazabileceğimizi görelim.
İki URL’yi Eşzamanlı Olarak Birbiriyle Yarıştırma
Liste 17-5’te komut satırından geçirilen iki farklı URL ile sayfa_basligi fonksiyonunu çağırıyor ve hangi future önce biterse onu seçerek ikisini yarıştırıyoruz.
extern crate trpl; // required for mdbook test
use trpl::{Either, Html};
fn main() {
let args: Vec<String> = std::env::args().collect();
trpl::block_on(async {
let baslik_gelecegi_1 = sayfa_basligi(&args[1]);
let baslik_gelecegi_2 = sayfa_basligi(&args[2]);
let (url, olasi_baslik) =
match trpl::select(baslik_gelecegi_1, baslik_gelecegi_2).await {
Either::Left(left) => left,
Either::Right(right) => right,
};
println!("{url} ilk döndü");
match olasi_baslik {
Some(title) => println!("Sayfa başlığı şuydu: '{title}'"),
None => println!("Başlığı yoktu."),
}
})
}
async fn sayfa_basligi(url: &str) -> (&str, Option<String>) {
let yanit_metni = trpl::get(url).await.text().await;
let title = Html::parse(&yanit_metni)
.select_first("title")
.map(|title| title.inner_html());
(url, title)
}
sayfa_basligi çağırmaKullanıcının verdiği URL’lerin her biri için sayfa_basligi fonksiyonunu çağırarak başlıyoruz. Ortaya çıkan future’ları baslik_gelecegi_1 ve baslik_gelecegi_2 olarak kaydediyoruz. Unutmayın, bunlar henüz hiçbir şey yapmaz; çünkü future’lar tembeldir ve onları henüz await ile beklemedik. Ardından bu future’ları, kendisine geçirilen future’lardan hangisinin önce tamamlandığını belirten bir değer döndüren trpl::select fonksiyonuna aktarıyoruz.
Not: Perde arkasında trpl::select, futures crate’inde tanımlanan daha
genel bir select fonksiyonu üzerine inşa edilmiştir. futures crate’inin
select fonksiyonu trpl::select fonksiyonunun yapamadığı pek çok şeyi
yapabilir, ancak şimdilik atlayabileceğimiz bazı ek karmaşıklıklara da
sahiptir.
Her iki future da geçerli bir biçimde “kazanabileceği” için burada bir Result döndürmek mantıklı olmaz. Bunun yerine trpl::select, daha önce görmediğimiz bir tür olan trpl::Either türünü döndürür. Either türü, iki durumu olması bakımından Result türüne biraz benzer. Ancak Result’tan farklı olarak Either içinde yerleşik bir başarı veya başarısızlık kavramı yoktur. Bunun yerine “biri ya da diğeri” anlamını ifade etmek üzere Left (sol) ve Right (sağ) varyantlarını kullanır:
#![allow(unused)]
fn main() {
enum Either<A, B> {
Left(A),
Right(B),
}
}
select fonksiyonu, ilk argüman kazanırsa o future’ın çıktısıyla birlikte Left varyantını, ikinci future argümanı kazanırsa da onun çıktısıyla birlikte Right varyantını döndürür. Bu sıralama, fonksiyonu çağırırken argümanların görünme sırasıyla eşleşir: ilk argüman ikinci argümanın solundadır.
Ayrıca sayfa_basligi fonksiyonunu, kendisine geçirilen aynı URL’yi de döndürecek şekilde güncelliyoruz. Bu sayede, ilk dönen sayfanın çözümleyebileceğimiz bir <title> öğesi olmasa bile anlamlı bir mesaj yazdırabiliriz. Bu bilgi de elimizde olduğuna göre, hem hangi URL’nin önce tamamlandığını hem de varsa o URL’deki web sayfasının <title> metninin ne olduğunu gösterecek şekilde println! çıktımızı güncelleyerek programı tamamlıyoruz.
Artık çalışan küçük bir web kazıyıcı geliştirdiniz! Birkaç URL seçin ve komut satırı aracını çalıştırın. Bazı sitelerin diğerlerinden tutarlı biçimde daha hızlı olduğunu, bazı durumlarda ise daha hızlı olan sitenin çalıştırmadan çalıştırmaya değiştiğini keşfedebilirsiniz. Daha da önemlisi, future’larla çalışmanın temellerini öğrendiniz; artık async ile neler yapabileceğimizin daha derinlerine inebiliriz.
Async ile Eşzamanlılık Uygulama
Async ile Eşzamanlılık Uygulama
Bu bölümde async yaklaşımını, Bölüm 16’da thread’lerle ele aldığımız eşzamanlılık zorluklarının bazılarına uygulayacağız. Temel fikirlerin pek çoğunu orada zaten tartıştığımız için, bu bölümde thread’ler ile future’lar arasındaki farklara odaklanacağız.
Pek çok durumda async kullanarak eşzamanlılıkla çalışmaya yönelik API’ler, thread kullanmaya yönelik API’lere çok benzer. Bazı durumlarda ise oldukça farklı olurlar. API’ler thread’ler ile async arasında birbirine benzer göründüğünde bile çoğu zaman farklı davranışlar sergiler — ve neredeyse her zaman farklı performans özelliklerine sahiptir.
spawn_task ile Yeni Bir Görev Oluşturma
Bölüm 16’daki “spawn ile Yeni Bir Thread Oluşturma” başlığında ele aldığımız ilk işlem, iki ayrı thread üzerinde sayıları artırarak saymaktı. Gelin aynısını async kullanarak yapalım. trpl crate’i, thread::spawn API’sine çok benzeyen bir spawn_task fonksiyonu ve thread::sleep API’sinin asenkron sürümü olan bir sleep fonksiyonu sunar. Liste 17-6’da gösterildiği gibi, sayma örneğini uygulamak için bunları bir arada kullanabiliriz.
extern crate trpl; // required for mdbook test
use std::time::Duration;
fn main() {
trpl::block_on(async {
trpl::spawn_task(async {
for i in 1..10 {
println!("birinci görevden merhaba sayı {i}!");
trpl::sleep(Duration::from_millis(500)).await;
}
});
for i in 1..5 {
println!("ikinci görevden merhaba sayı {i}!");
trpl::sleep(Duration::from_millis(500)).await;
}
});
}
Başlangıç noktası olarak, en üst düzey fonksiyonumuzun asenkron olabilmesi için main fonksiyonumuzu trpl::block_on ile kuruyoruz.
Not: Bölümün bu noktasından itibaren her örnek main içinde trpl::block_on
içeren bu sarmalayıcı kodun birebir aynısını barındıracaktır; bu nedenle tıpkı
main fonksiyonunu çoğu örnekte göstermediğimiz gibi bunu da çoğu zaman
atlayacağız. Kendi kodunuza eklemeyi unutmayın!
Ardından bu bloğun içinde, her biri bir sonraki mesajı yazdırmadan önce yarım saniye (500 milisaniye) bekleyen bir trpl::sleep çağrısı içeren iki döngü yazıyoruz. Döngülerden birini trpl::spawn_task gövdesine koyuyor, diğerini ise en üst düzeyde bir for döngüsü olarak bırakıyoruz. Ayrıca sleep çağrılarının sonuna birer await ekliyoruz.
Not: Asenkron kod yazarken duraklama işlemleri için standart kütüphanedeki
senkron std::thread::sleep yerine mutlaka trpl::sleep(...).await (gerçek
projelerde örneğin tokio::time::sleep(...).await) gibi işbirlikçi
(cooperative) asenkron bekleme fonksiyonlarını kullanmalısınız.
std::thread::sleep çağrısı, görevi duraklatıp çalışma zamanına devretmek
yerine üzerinde çalıştığı işletim sistemi thread’ini doğrudan uyutarak
bloklar. Asenkron çalışma zamanları çok sayıda görevi az sayıda thread
üzerinde dönüşümlü olarak yürüttüğünden, işletim sistemi thread’ini bloklamak
aynı thread üzerinde sırasını bekleyen diğer tüm asenkron görevlerin de
donmasına neden olur. Asenkron sleep ise bir zamanlayıcı kurup .await
noktasında denetimi hemen çalışma zamanına geri verir; böylece bekleme süresi
dolana dek aynı thread başka görevleri kesintisiz çalıştırmaya devam edebilir.
Bu kod, kendi terminalinizde çalıştırdığınızda mesajların farklı bir sırayla görünebilmesi de dahil olmak üzere thread tabanlı uygulamaya benzer biçimde davranır:
ikinci görevden merhaba sayı 1!
birinci görevden merhaba sayı 1!
birinci görevden merhaba sayı 2!
ikinci görevden merhaba sayı 2!
birinci görevden merhaba sayı 3!
ikinci görevden merhaba sayı 3!
birinci görevden merhaba sayı 4!
ikinci görevden merhaba sayı 4!
birinci görevden merhaba sayı 5!
Bu sürüm, ana async bloğun gövdesindeki for döngüsü biter bitmez durur; çünkü spawn_task tarafından başlatılan görev, main fonksiyonu sona erdiğinde kapatılır. Görevin sonuna kadar çalışıp tamamlanmasını istiyorsanız, birinci görevin bitmesini beklemek için bir birleştirme tutamacı (join handle) kullanmanız gerekir. Thread’lerle çalışırken, thread çalışmasını bitirene kadar “bloklamak” için join metodunu kullanmıştık. Liste 17-7’de ise aynı şeyi yapmak için await kullanabiliriz; çünkü görev tutamacının (tutamac) kendisi de bir future’dır. Output türü bir Result olduğu için, onu bekledikten sonra üzerinde unwrap da çağırıyoruz.
extern crate trpl; // required for mdbook test
use std::time::Duration;
fn main() {
trpl::block_on(async {
let tutamac = trpl::spawn_task(async {
for i in 1..10 {
println!("birinci görevden merhaba sayı {i}!");
trpl::sleep(Duration::from_millis(500)).await;
}
});
for i in 1..5 {
println!("ikinci görevden merhaba sayı {i}!");
trpl::sleep(Duration::from_millis(500)).await;
}
tutamac.await.unwrap();
});
}
await kullanmaGüncellenen bu sürüm her iki döngü de bitene kadar çalışır:
ikinci görevden merhaba sayı 1!
birinci görevden merhaba sayı 1!
birinci görevden merhaba sayı 2!
ikinci görevden merhaba sayı 2!
birinci görevden merhaba sayı 3!
ikinci görevden merhaba sayı 3!
birinci görevden merhaba sayı 4!
ikinci görevden merhaba sayı 4!
birinci görevden merhaba sayı 5!
birinci görevden merhaba sayı 6!
birinci görevden merhaba sayı 7!
birinci görevden merhaba sayı 8!
birinci görevden merhaba sayı 9!
Şimdiye kadar async ve thread’ler bize yalnızca farklı bir sözdizimiyle benzer sonuçlar veriyor gibi görünüyor: birleştirme tutamacı üzerinde join çağırmak yerine await kullanmak ve sleep çağrılarını beklemek.
Asıl büyük fark, bunu yapmak için başka bir işletim sistemi thread’i başlatmamıza gerek kalmamasıdır. Hatta burada yeni bir görev başlatmamıza bile gerek yoktur. Async bloklar anonim future’lara derlendiği için, her döngüyü bir async bloğa koyabilir ve trpl::join fonksiyonunu kullanarak çalışma zamanının her ikisini de tamamlanana kadar çalıştırmasını sağlayabiliriz.
Not: trpl::spawn_task ile trpl::join arasındaki fark yalnızca sözdizimsel
değildir. spawn_task, verdiğiniz future’ı çalışma zamanına bağımsız bir
görev olarak teslim eder; çalışma zamanı bu görevi uygun gördüğü anda (çok
thread’li bir çalışma zamanında başka bir işletim sistemi thread’i üzerinde
bile) yürütebilir. Bu yüzden tıpkı thread::spawn gibi spawn_task de
future’ın Send + 'static olmasını ister; yerel değişkenlere referans tutan
bir future’ı spawn_task’a veremezsiniz. trpl::join ise yeni bir görev
oluşturmaz: verdiğiniz future’ları, içinde bulunduğunuz görevin bir parçası
olarak dönüşümlü biçimde yoklar. Future’lar aynı görevde, aynı thread üzerinde
kaldığı için 'static ömür şartı yoktur ve Liste 17-8’deki gibi çevredeki
değişkenleri rahatça ödünç alabilirler. Kural olarak, birbirinden bağımsız ve
uzun süre arka planda çalışacak işler için spawn_task; aynı kapsamda
başlayıp birlikte bitmesini istediğiniz işler için join tercih edilir.
Bölüm 16’daki “Tüm Thread’lerin Bitmesini Bekleme” başlığında, std::thread::spawn çağırdığınızda döndürülen JoinHandle türü üzerinde join metodunun nasıl kullanılacağını göstermiştik. trpl::join fonksiyonu da buna benzer, ancak future’lar içindir. Ona iki future verdiğinizde, her ikisi de tamamlandığında geçirdiğiniz her bir future’ın çıktısını içeren bir demet (tuple) üreten tek bir yeni future oluşturur. Böylece Liste 17-8’de, hem fut1 hem de fut2 değerlerinin bitmesini beklemek için trpl::join kullanıyoruz. fut1 ve fut2 değerlerini ayrı ayrı değil, bunun yerine trpl::join tarafından üretilen yeni future’ı bekliyoruz. Çıktıyı ise yalnızca iki birim değer (()) içeren bir demet olduğu için yoksayıyoruz.
extern crate trpl; // required for mdbook test
use std::time::Duration;
fn main() {
trpl::block_on(async {
let fut1 = async {
for i in 1..10 {
println!("birinci görevden merhaba sayı {i}!");
trpl::sleep(Duration::from_millis(500)).await;
}
};
let fut2 = async {
for i in 1..5 {
println!("ikinci görevden merhaba sayı {i}!");
trpl::sleep(Duration::from_millis(500)).await;
}
};
trpl::join(fut1, fut2).await;
});
}
trpl::join kullanmaBunu çalıştırdığımızda her iki future’ın da tamamlanana kadar çalıştığını görürüz:
birinci görevden merhaba sayı 1!
ikinci görevden merhaba sayı 1!
birinci görevden merhaba sayı 2!
ikinci görevden merhaba sayı 2!
birinci görevden merhaba sayı 3!
ikinci görevden merhaba sayı 3!
birinci görevden merhaba sayı 4!
ikinci görevden merhaba sayı 4!
birinci görevden merhaba sayı 5!
birinci görevden merhaba sayı 6!
birinci görevden merhaba sayı 7!
birinci görevden merhaba sayı 8!
birinci görevden merhaba sayı 9!
Artık her seferinde birebir aynı sırayı görürsünüz; bu durum thread’lerle ve Liste 17-7’deki trpl::spawn_task ile gördüğümüzden çok farklıdır. Bunun nedeni trpl::join fonksiyonunun adil (fair) olmasıdır; yani her bir future’ı eşit sıklıkta, aralarında sırayla geçiş yaparak kontrol eder ve diğeri hazırken birinin hızla öne geçip fark açmasına asla izin vermez. Thread’lerde hangi thread’in kontrol edileceğine ve ne kadar süre çalışmasına izin verileceğine işletim sistemi karar verir. Async Rust’ta ise hangi görevin kontrol edileceğine çalışma zamanı karar verir. (Pratikte ayrıntılar karmaşıklaşır; çünkü bir asenkron çalışma zamanı eşzamanlılığı yönetme biçiminin bir parçası olarak perde arkasında işletim sistemi thread’lerini kullanabilir, dolayısıyla adilliği garanti etmek bir çalışma zamanı için daha fazla iş anlamına gelebilir — ama yine de mümkündür!) Çalışma zamanları herhangi bir işlem için adilliği garanti etmek zorunda değildir ve adillik isteyip istemediğinizi seçebilmeniz için çoğu zaman farklı API’ler sunarlar.
Future’ları beklemeye yönelik şu çeşitlemelerden bazılarını deneyip ne yaptıklarını görün:
- Döngülerden birinin veya her ikisinin etrafındaki async bloğu kaldırın.
- Her bir async bloğu tanımladıktan hemen sonra
awaitile bekleyin. - Yalnızca ilk döngüyü bir async bloğa sarın ve ortaya çıkan future’ı ikinci döngünün gövdesinden sonra bekleyin.
Ek bir meydan okuma olarak, kodu çalıştırmadan önce her bir durumda çıktının ne olacağını tahmin edip edemeyeceğinize bakın!
Mesaj İletimiyle İki Görev Arasında Veri Gönderme
Future’lar arasında veri paylaşmak da tanıdık gelecektir: yine mesaj iletimini kullanacağız, ancak bu kez türlerin ve fonksiyonların asenkron sürümleriyle. Thread tabanlı ve future tabanlı eşzamanlılık arasındaki bazı temel farkları göstermek için Bölüm 16’daki “Mesaj İletimiyle Thread’ler Arası Veri Transferi” başlığında izlediğimizden biraz farklı bir yol izleyeceğiz. Orada ayrı bir thread başlatmıştık; Liste 17-9’da ise ayrı bir görev bile başlatmadan, yalnızca tek bir async blokla başlayacağız.
extern crate trpl; // required for mdbook test
fn main() {
trpl::block_on(async {
let (gonderici, mut alici) = trpl::channel();
let deger = String::from("merhaba");
gonderici.send(deger).unwrap();
let alinan = alici.recv().await.unwrap();
println!("alındı '{alinan}'");
});
}
gonderici ile alici değişkenlerine atamaBurada, Bölüm 16’da thread’lerle kullandığımız çoklu üretici, tek tüketici kanal API’sinin asenkron bir sürümü olan trpl::channel fonksiyonunu kullanıyoruz. API’nin asenkron sürümü thread tabanlı sürümden yalnızca biraz farklıdır: değiştirilemez yerine değiştirilebilir bir alici (mut alici) kullanır ve recv metodu değeri doğrudan üretmek yerine beklememiz gereken bir future üretir. Artık göndericiden alıcıya mesaj gönderebiliriz. Ayrı bir thread veya hatta ayrı bir görev başlatmamız gerekmediğine dikkat edin; yalnızca alici.recv çağrısını beklememiz yeterlidir.
std::sync::mpsc modülündeki senkron Receiver::recv metodu bir mesaj alana kadar bloklar. trpl::Receiver::recv metodu ise asenkron olduğu için bloklamaz. Bloklamak yerine, bir mesaj alınana ya da kanalın gönderici tarafı kapanana kadar denetimi çalışma zamanına geri verir. Buna karşılık send çağrısını beklemeyiz, çünkü bloklamaz. Bloklamasına da gerek yoktur; çünkü içine gönderim yaptığımız kanal sınırsızdır (unbounded).
Not: Tüm bu asenkron kod bir trpl::block_on çağrısındaki async blok içinde
çalıştığından, içindeki her şey bloklamaktan kaçınabilir. Ancak onun
dışındaki kod, block_on fonksiyonunun dönmesini bekleyerek bloklanacaktır.
trpl::block_on fonksiyonunun bütün amacı da budur: bir grup asenkron kod
üzerinde nerede bloklama yapacağınızı ve dolayısıyla senkron ile asenkron kod
arasında nerede geçiş yapacağınızı seçmenize olanak tanır.
Bu örnekle ilgili iki şeye dikkat edin. Birincisi, mesaj anında ulaşacaktır. İkincisi, burada bir future kullanmamıza rağmen henüz eşzamanlılık yoktur. Listedeki her şey, tıpkı işin içinde hiç future yokmuş gibi sırayla gerçekleşir.
İlk kısmı, Liste 17-10’da gösterildiği gibi bir dizi mesaj gönderip aralarında uyuyarak ele alalım.
extern crate trpl; // required for mdbook test
use std::time::Duration;
fn main() {
trpl::block_on(async {
let (gonderici, mut alici) = trpl::channel();
let degerler = vec![
String::from("merhaba"),
String::from("gelecekten"),
String::from("selam"),
String::from("var"),
];
for deger in degerler {
gonderici.send(deger).unwrap();
trpl::sleep(Duration::from_millis(500)).await;
}
while let Some(alinan) = alici.recv().await {
println!("alındı '{alinan}'");
}
});
}
await ile uyumaMesajları göndermenin yanı sıra onları almamız da gerekir. Bu örnekte kaç mesaj geleceğini bildiğimiz için, alici.recv().await çağrısını dört kez yazarak bunu elle yapabilirdik. Ancak gerçek dünyada genellikle bilinmeyen sayıda mesajı bekleriz; bu nedenle daha fazla mesaj kalmadığını belirleyene kadar beklemeye devam etmemiz gerekir.
Liste 16-10’da, senkron bir kanaldan alınan tüm öğeleri işlemek için bir for döngüsü kullanmıştık. Ne var ki Rust’ta henüz asenkron olarak üretilen bir öğe serisiyle for döngüsü kullanmanın bir yolu yoktur; bu yüzden daha önce görmediğimiz bir döngü kullanmamız gerekir: while let koşullu döngüsü. Bu yapı, Bölüm 6’daki “if let ve let...else ile Özlü Kontrol Akışı” başlığında gördüğümüz if let yapısının döngü sürümüdür. Döngü, belirttiği desen değerle eşleşmeye devam ettiği sürece çalışmayı sürdürür.
alici.recv çağrısı bir future üretir ve biz de onu bekleriz. Çalışma zamanı, hazır olana dek future’ı duraklatacaktır. Future, her mesaj ulaştığında Some(alinan) olarak çözülecektir. Kanal kapandığında ise, herhangi bir mesaj ulaşıp ulaşmadığına bakılmaksızın, artık başka değer kalmadığını ve dolayısıyla yoklamayı —yani beklemeyi— durdurmamız gerektiğini belirtmek üzere future bunun yerine None olarak çözülecektir.
while let döngüsü tüm bunları bir araya getirir. alici.recv().await çağrısının sonucu Some(alinan) ise mesaja erişim sağlarız ve tıpkı if let ile yapabildiğimiz gibi onu döngü gövdesinde kullanabiliriz. Sonuç None ise döngü sona erer. Döngü her tamamlandığında yeniden bekleme noktasına ulaşır; böylece çalışma zamanı bir başka mesaj gelene kadar onu tekrar duraklatır.
Kod artık tüm mesajları başarıyla gönderip alır. Ne yazık ki hâlâ birkaç sorun vardır. Birincisi, mesajlar yarım saniyelik aralıklarla gelmez. Programı başlattıktan 2 saniye (2.000 milisaniye) sonra hepsi bir anda gelir. İkincisi, bu program hiçbir zaman sonlanmaz! Bunun yerine sonsuza dek yeni mesajlar bekler. Programı ctrl-C kullanarak kapatmanız gerekecektir.
Tek Bir Async Blok İçindeki Kod Doğrusal Çalışır
Mesajların neden her birinin arasında gecikmelerle gelmek yerine, toplam gecikmenin sonunda hep birlikte bir anda geldiğini inceleyerek başlayalım. Belirli bir async blok içinde, await anahtar kelimelerinin kodda göründüğü sıra, aynı zamanda program çalışırken yürütüldükleri sıradır.
Liste 17-10’da yalnızca tek bir async blok vardır; dolayısıyla içindeki her şey doğrusal olarak çalışır. Hâlâ eşzamanlılık yoktur. Tüm gonderici.send çağrıları, aralarına serpiştirilmiş tüm trpl::sleep çağrıları ve onlara bağlı bekleme noktalarıyla birlikte sırayla gerçekleşir. while let döngüsü ancak ondan sonra recv çağrıları üzerindeki bekleme noktalarından herhangi birinden geçebilir.
Uyku gecikmesinin her mesaj arasında gerçekleştiği istediğimiz davranışı elde etmek için, Liste 17-11’de gösterildiği gibi gonderici ve alici işlemlerini kendi async bloklarına koymamız gerekir. Böylece çalışma zamanı, tıpkı Liste 17-8’de olduğu gibi trpl::join kullanarak bunların her birini ayrı ayrı yürütebilir. Bir kez daha, tekil future’ları değil, trpl::join çağrısının sonucunu bekliyoruz. Tekil future’ları sırayla bekleseydik, yine sıralı bir akışa geri dönerdik — ki bu tam olarak yapmamaya çalıştığımız şeydir.
extern crate trpl; // required for mdbook test
use std::time::Duration;
fn main() {
trpl::block_on(async {
let (gonderici, mut alici) = trpl::channel();
let gonderici_gelecegi = async {
let degerler = vec![
String::from("merhaba"),
String::from("gelecekten"),
String::from("selam"),
String::from("var"),
];
for deger in degerler {
gonderici.send(deger).unwrap();
trpl::sleep(Duration::from_millis(500)).await;
}
};
let alici_gelecegi = async {
while let Some(alinan) = alici.recv().await {
println!("alındı '{alinan}'");
}
};
trpl::join(gonderici_gelecegi, alici_gelecegi).await;
});
}
send ve recv işlemlerini kendi async bloklarına ayırma ve bu bloklara ait future’ları beklemeListe 17-11’deki güncellenmiş kodla birlikte mesajlar, 2 saniye sonra bir çırpıda topluca gelmek yerine 500 milisaniyelik aralıklarla yazdırılır.
Sahipliği Bir Async Bloğa Taşıma
Yine de program, while let döngüsünün trpl::join ile etkileşime girme biçimi yüzünden hâlâ hiçbir zaman sonlanmaz:
trpl::joinçağrısından dönen future, ancak kendisine geçirilen her iki future da tamamlandığında tamamlanır.gonderici_gelecegifuture’ı,degerleriçindeki son mesajı gönderdikten sonraki uykusunu bitirince tamamlanır.alici_gelecegifuture’ı,while letdöngüsü sona erene kadar tamamlanmaz.while letdöngüsü,alici.recvçağrısını beklemekNoneüretene kadar sona ermez.alici.recvçağrısını beklemek, yalnızca kanalın diğer ucu kapandığındaNonedöndürür.- Kanal yalnızca
alici.closeçağırırsak ya da gönderici taraf olangondericidüşürüldüğünde kapanır. - Hiçbir yerde
alici.closeçağırmıyoruz vegonderici,trpl::block_onfonksiyonuna geçirilen en dıştaki async blok sona erene kadar düşürülmeyecektir. - En dıştaki blok sona eremez; çünkü
trpl::joinçağrısının tamamlanmasını bekleyerek bloklanmıştır — bu da bizi bu listenin en başına geri götürür.
Şu anda mesajları gönderdiğimiz async blok, mesaj göndermek sahiplik gerektirmediği için gonderici değişkenini yalnızca ödünç alır; ancak gonderici değişkenini bu async bloğun içine taşıyabilseydik, söz konusu blok sona erdiğinde düşürülürdü. Bölüm 13’teki “Referansları Yakalamak veya Sahipliği Taşımak” başlığında kapanışlarla move anahtar kelimesinin nasıl kullanılacağını öğrenmiştiniz ve Bölüm 16’daki “Thread’lerle move Kapanışlarını Kullanma” başlığında ele alındığı üzere, thread’lerle çalışırken verileri çoğu zaman kapanışların içine taşımamız gerekir. Aynı temel dinamikler async bloklar için de geçerlidir; dolayısıyla move anahtar kelimesi async bloklarla da tıpkı kapanışlarda olduğu gibi çalışır.
Liste 17-12’de, mesaj göndermek için kullanılan bloğu async yerine async move olarak değiştiriyoruz.
extern crate trpl; // required for mdbook test
use std::time::Duration;
fn main() {
trpl::block_on(async {
let (gonderici, mut alici) = trpl::channel();
let gonderici_gelecegi = async move {
// --snip--
let degerler = vec![
String::from("merhaba"),
String::from("gelecekten"),
String::from("selam"),
String::from("var"),
];
for deger in degerler {
gonderici.send(deger).unwrap();
trpl::sleep(Duration::from_millis(500)).await;
}
};
let alici_gelecegi = async {
while let Some(alinan) = alici.recv().await {
println!("alındı '{alinan}'");
}
};
trpl::join(gonderici_gelecegi, alici_gelecegi).await;
});
}
Kodun bu sürümünü çalıştırdığımızda, son mesaj gönderilip alındıktan sonra düzgün bir şekilde kapanır.
Not: Bölüm 16’da thread::spawn kullanırken move yazmayı unuttuğumuzda kod
hiç derlenmiyordu; çünkü yeni başlatılan bir thread ana fonksiyondan daha uzun
yaşayabilirdi. Liste 17-11’de ise gonderici_gelecegi ayrı bir göreve
aktarılmayıp aynı kapsam içinde trpl::join ile beklendiğinden, derleyici
&gonderici referansının kapsam boyunca geçerli kalacağını kanıtlayabilmiş ve
kodu derlemişti. Burada async move kullanmamızın nedeni ödünç alma
denetleyicisini tatmin etmek değil, gonderici değerinin ömrünü bilerek o
bloğun kapsamıyla sınırlayıp blok biter bitmez düşürülmesini (drop) (ve
böylece kanalın otomatik olarak kapanmasını) sağlamaktır.
Şimdi, birden fazla future’dan veri göndermek için nelerin değişmesi gerekeceğine bakalım.
join! Makrosuyla Birden Fazla Future’ı Birleştirme
Bu asenkron kanal aynı zamanda çoklu üreticili bir kanal olduğundan, Liste 17-13’te gösterildiği gibi birden fazla future’dan mesaj göndermek istiyorsak gonderici üzerinde clone çağırabiliriz.
extern crate trpl; // required for mdbook test
use std::time::Duration;
fn main() {
trpl::block_on(async {
let (gonderici, mut alici) = trpl::channel();
let gonderici1 = gonderici.clone();
let gonderici1_gelecegi = async move {
let degerler = vec![
String::from("merhaba"),
String::from("gelecekten"),
String::from("selam"),
String::from("var"),
];
for deger in degerler {
gonderici1.send(deger).unwrap();
trpl::sleep(Duration::from_millis(500)).await;
}
};
let alici_gelecegi = async {
while let Some(alinan) = alici.recv().await {
println!("alındı '{alinan}'");
}
};
let gonderici_gelecegi = async move {
let degerler = vec![
String::from("daha"),
String::from("fazla"),
String::from("mesaj"),
String::from("sana"),
];
for deger in degerler {
gonderici.send(deger).unwrap();
trpl::sleep(Duration::from_millis(1500)).await;
}
};
trpl::join!(gonderici1_gelecegi, gonderici_gelecegi, alici_gelecegi);
});
}
İlk olarak, birinci async bloğun dışında gonderici değişkenini klonlayarak gonderici1 oluşturuyoruz. Tıpkı daha önce gonderici ile yaptığımız gibi gonderici1 değerini bu bloğun içine taşıyoruz. Ardından orijinal gonderici değerini yeni bir async bloğa taşıyor ve orada biraz daha yavaş bir gecikmeyle daha fazla mesaj gönderiyoruz. Bu yeni async bloğu tesadüfen mesajları alan async bloktan sonraya koyduk, ancak ondan önceye de koyabilirdik. Burada kilit nokta future’ların hangi sırayla oluşturulduğu değil, hangi sırayla beklendiğidir.
Mesaj gönderen async blokların her ikisinin de async move bloğu olması gerekir ki bu bloklar bittiğinde hem gonderici hem de gonderici1 düşürülsün. Aksi takdirde, başladığımız aynı sonsuz döngünün içine geri döneriz.
Son olarak, ek future’ı ele almak için trpl::join yerine trpl::join! makrosuna geçiyoruz: join! makrosu, future sayısını derleme zamanında bildiğimiz durumlarda keyfî sayıda future’ı bekler. Sayısı bilinmeyen bir future koleksiyonunu beklemeyi bu bölümün ilerleyen kısımlarında ele alacağız.
Artık her iki gönderici future’dan gelen tüm mesajları görürüz ve gönderici future’lar gönderim sonrasında biraz farklı gecikmeler kullandığından, mesajlar da bu farklı aralıklarla alınır:
alındı 'merhaba'
alındı 'daha'
alındı 'gelecekten'
alındı 'selam'
alındı 'fazla'
alındı 'var'
alındı 'mesaj'
alındı 'sana'
Future’lar arasında veri göndermek için mesaj iletiminin nasıl kullanılacağını, bir async blok içindeki kodun nasıl sıralı çalıştığını, sahipliğin bir async bloğa nasıl taşınacağını ve birden fazla future’ın nasıl birleştirileceğini inceledik. Şimdi, çalışma zamanına başka bir göreve geçebileceğini nasıl ve neden söyleyeceğimizi ele alalım.
Herhangi Bir Sayıda Future ile Çalışma
Herhangi Bir Sayıda Future ile Çalışma
Denetimi Çalışma Zamanına Geri Vermek
“İlk Async Programımız” bölümünden hatırlayacağınız üzere Rust, her await noktasında beklenen future henüz hazır değilse çalışma zamanına görevi duraklatıp başka bir göreve geçme fırsatı verir. Bunun tersi de doğrudur: Rust, async blokları yalnızca bir await noktasında duraklatır ve denetimi çalışma zamanına geri verir. await noktaları arasındaki her şey senkrondur.
Yani bir async blok içinde herhangi bir await noktası olmadan çok sayıda iş yaparsanız, o future diğer tüm future’ların ilerleme kaydetmesini engeller. Bu durumun zaman zaman bir future’ın diğer future’ları aç bırakması (starving) olarak adlandırıldığını duyabilirsiniz. Bazı durumlarda bu önemli bir sorun olmayabilir. Ancak maliyetli bir kurulum ya da uzun süren bir iş yapıyorsanız veya belirli bir görevi süresiz olarak yürütmeye devam edecek bir future’ınız varsa, denetimi çalışma zamanına ne zaman ve nerede geri vereceğinizi düşünmeniz gerekir.
Gelin aç bırakma problemini göstermek ve ardından bu sorunun nasıl çözüleceğini incelemek üzere uzun süren bir işlemi taklit edelim. Liste 17-14, bir slow fonksiyonu tanıtır.
extern crate trpl; // required for mdbook test
use std::{thread, time::Duration};
fn main() {
trpl::block_on(async {
// Daha sonra burada `slow` fonksiyonunu çağıracağız
});
}
fn slow(isim: &str, ms: u64) {
thread::sleep(Duration::from_millis(ms));
println!("'{isim}' {ms}ms çalıştı");
}
thread::sleep kullanmakBu kod, trpl::sleep yerine std::thread::sleep kullanır; böylece slow çağrısı mevcut thread’i belirtilen milisaniye süresince bloklar. Gerçek dünyadaki hem uzun süren hem de bloklayıcı işlemleri temsil etmesi için slow fonksiyonunu kullanabiliriz.
Liste 17-15’te, bir çift future içinde bu türden CPU bağımlı (CPU-bound) işler yapmayı taklit etmek için slow fonksiyonunu kullanıyoruz.
extern crate trpl; // required for mdbook test
use std::{thread, time::Duration};
fn main() {
trpl::block_on(async {
let a = async {
println!("'a' başladı.");
slow("a", 30);
slow("a", 10);
slow("a", 20);
trpl::sleep(Duration::from_millis(50)).await;
println!("'a' bitti.");
};
let b = async {
println!("'b' başladı.");
slow("b", 75);
slow("b", 10);
slow("b", 15);
slow("b", 350);
trpl::sleep(Duration::from_millis(50)).await;
println!("'b' bitti.");
};
trpl::select(a, b).await;
});
}
fn slow(isim: &str, ms: u64) {
thread::sleep(Duration::from_millis(ms));
println!("'{isim}' {ms}ms çalıştı");
}
slow fonksiyonunu çağırmakHer bir future, denetimi çalışma zamanına ancak bir dizi yavaş işlemi tamamladıktan sonra geri verir. Bu kodu çalıştırdığınızda şu çıktıyı görürsünüz:
'a' başladı.
'a' 30ms çalıştı
'a' 10ms çalıştı
'a' 20ms çalıştı
'b' başladı.
'b' 75ms çalıştı
'b' 10ms çalıştı
'b' 15ms çalıştı
'b' 350ms çalıştı
'a' bitti.
İki URL’yi getiren future’ları yarıştırmak için trpl::select kullandığımız Liste 17-5’te olduğu gibi, burada da select yine a biter bitmez sonlanır. Ne var ki iki future içindeki slow çağrıları arasında hiçbir nöbetleşe çalışma (interleaving) yoktur. a future’ı, trpl::sleep çağrısı beklenene (await) kadar tüm işini tek seferde yapar; ardından b future’ı kendi trpl::sleep çağrısı beklenene kadar tüm işini yapar ve son olarak a future’ı tamamlanır. Her iki future’ın da yavaş görevleri arasında ilerleme kaydedebilmesine olanak tanımak için denetimi çalışma zamanına geri verebileceğimiz await noktalarına ihtiyacımız vardır. Bu da await edebileceğimiz bir şeye ihtiyacımız olduğu anlamına gelir!
Bu tür bir denetim devrinin Liste 17-15’te şimdiden gerçekleştiğini görebiliriz: a future’ının sonundaki trpl::sleep çağrısını kaldırsaydık, b future’ı hiç çalışmadan a tamamlanırdı. Gelin Liste 17-16’da gösterildiği gibi, işlemlerin sırayla ilerleme kaydetmesini sağlamak üzere başlangıç noktası olarak trpl::sleep fonksiyonunu kullanmayı deneyelim.
extern crate trpl; // required for mdbook test
use std::{thread, time::Duration};
fn main() {
trpl::block_on(async {
let bir_ms = Duration::from_millis(1);
let a = async {
println!("'a' başladı.");
slow("a", 30);
trpl::sleep(bir_ms).await;
slow("a", 10);
trpl::sleep(bir_ms).await;
slow("a", 20);
trpl::sleep(bir_ms).await;
println!("'a' bitti.");
};
let b = async {
println!("'b' başladı.");
slow("b", 75);
trpl::sleep(bir_ms).await;
slow("b", 10);
trpl::sleep(bir_ms).await;
slow("b", 15);
trpl::sleep(bir_ms).await;
slow("b", 350);
trpl::sleep(bir_ms).await;
println!("'b' bitti.");
};
trpl::select(a, b).await;
});
}
fn slow(isim: &str, ms: u64) {
thread::sleep(Duration::from_millis(ms));
println!("'{isim}' {ms}ms çalıştı");
}
trpl::sleep kullanmakHer slow çağrısının arasına await noktaları içeren trpl::sleep çağrıları ekledik. Artık iki future’ın yaptığı iş nöbetleşe biçimde iç içe geçer:
'a' başladı.
'a' 30ms çalıştı
'b' başladı.
'b' 75ms çalıştı
'a' 10ms çalıştı
'b' 10ms çalıştı
'a' 20ms çalıştı
'b' 15ms çalıştı
'a' bitti.
a future’ı, trpl::sleep çağırmadan önce slow fonksiyonunu çağırdığı için denetimi b’ye devretmeden önce yine bir süre çalışır; ancak bundan sonra future’lar, içlerinden biri her await noktasına ulaştığında sırayı birbirine devreder. Bu örnekte bunu her slow çağrısından sonra yaptık, ancak işi bize en mantıklı gelen herhangi bir biçimde de parçalara bölebilirdik.
Ancak burada gerçekte uyumak istemiyoruz: olabildiğince hızlı ilerleme kaydetmek istiyoruz. Tek ihtiyacımız denetimi çalışma zamanına geri vermektir. Bunu trpl::yield_now fonksiyonunu kullanarak doğrudan yapabiliriz. Liste 17-17’de, tüm trpl::sleep çağrılarını trpl::yield_now ile değiştiriyoruz.
extern crate trpl; // required for mdbook test
use std::{thread, time::Duration};
fn main() {
trpl::block_on(async {
let a = async {
println!("'a' başladı.");
slow("a", 30);
trpl::yield_now().await;
slow("a", 10);
trpl::yield_now().await;
slow("a", 20);
trpl::yield_now().await;
println!("'a' bitti.");
};
let b = async {
println!("'b' başladı.");
slow("b", 75);
trpl::yield_now().await;
slow("b", 10);
trpl::yield_now().await;
slow("b", 15);
trpl::yield_now().await;
slow("b", 350);
trpl::yield_now().await;
println!("'b' bitti.");
};
trpl::select(a, b).await;
});
}
fn slow(isim: &str, ms: u64) {
thread::sleep(Duration::from_millis(ms));
println!("'{isim}' {ms}ms çalıştı");
}
yield_now kullanmakBu kod hem gerçek niyetimizi daha açık biçimde ortaya koyar hem de sleep kullanmaktan belirgin ölçüde daha hızlı olabilir; çünkü sleep’in kullandığı türden zamanlayıcıların ne kadar hassas olabileceği konusunda çoğu zaman sınırlar vardır. Örneğin kullandığımız sleep sürümü, ona bir nanosaniyelik Duration geçirsek bile her zaman en az bir milisaniye uyur. Yine hatırlatalım, modern bilgisayarlar hızlıdır: bir milisaniye içinde pek çok iş yapabilirler!
Bu durum, programınızın başka neler yaptığına bağlı olarak async’in hesaplama bağımlı (compute-bound) görevler için bile faydalı olabileceği anlamına gelir; çünkü programın farklı bölümleri arasındaki ilişkileri yapılandırmak için kullanışlı bir araç sunar (ancak bunun karşılığında async durum makinesinin getirdiği ek yük maliyeti vardır). Bu bir işbirlikli çoklu görev (cooperative multitasking) biçimidir: her bir future, await noktaları aracılığıyla denetimi ne zaman devredeceğini belirleme gücüne sahiptir. Dolayısıyla her future, çok uzun süre bloklamaktan kaçınma sorumluluğunu da taşır. Rust tabanlı bazı gömülü işletim sistemlerinde bu, tek çoklu görev türüdür!
Gerçek dünya kodlarında elbette genellikle her satırda fonksiyon çağrıları ile await noktalarını ardışık olarak sıralamazsınız. Denetimi bu şekilde devretmek nispeten ucuz olsa da bedelsiz değildir. Pek çok durumda, hesaplama bağımlı bir görevi parçalara bölmeye çalışmak onu belirgin biçimde yavaşlatabilir; dolayısıyla bazen genel performans açısından bir işlemin kısa süreliğine bloklamasına izin vermek daha iyidir. Kodunuzun gerçek performans darboğazlarının neler olduğunu görmek için her zaman ölçüm yapın. Yine de eşzamanlı gerçekleşmesini beklediğiniz pek çok işin seri hâlde yürütüldüğünü görüyorsanız, arka plandaki bu dinamiği akılda tutmak son derece önemlidir!
Not: Bir async blok içinde senkron G/Ç çağrıları (std::fs,
std::thread::sleep vb.) veya uzun süren ağır CPU hesaplamaları çalıştırmak,
yalnızca o future’ı değil, çalışma zamanının o anki yürütücü thread’inde sıra
bekleyen diğer görevleri de bekletir. Gerçek dünya projelerinde, yield_now
ile parçalara bölünemeyen bloklayıcı işlemler için çalışma zamanlarının
sunduğu spawn_blocking gibi fonksiyonlar (bloklayıcı işi ayrı bir thread
havuzuna devreder; bu fonksiyonun adı bölümün sonundaki “Future’lar,
Görevler ve Thread’ler” alt bölümünde de
geçecek) veya yoğun paralel veri işleme yükleri için rayon gibi özel thread
havuzları kullanılır.
Kendi Async Soyutlamalarımızı Kurmak
Yeni desenler oluşturmak üzere future’ları birbiriyle birleştirebiliriz de. Örneğin elimizde hâlihazırda bulunan async yapı taşlarıyla bir timeout (zaman aşımı) fonksiyonu inşa edebiliriz. İşimiz bittiğinde ortaya çıkan sonuç, daha da fazla async soyutlama oluşturmak için kullanabileceğimiz bir başka yapı taşı olacaktır.
Liste 17-18, bu timeout fonksiyonunun yavaş bir future ile nasıl çalışmasını beklediğimizi gösterir.
extern crate trpl; // required for mdbook test
use std::time::Duration;
fn main() {
trpl::block_on(async {
let yavas = async {
trpl::sleep(Duration::from_secs(5)).await;
"Sonunda bitti"
};
match timeout(yavas, Duration::from_secs(2)).await {
Ok(mesaj) => println!("'{mesaj}' ile başarılı oldu"),
Err(sure) => {
println!("{} saniye sonra başarısız oldu", sure.as_secs())
}
}
});
}
timeout fonksiyonumuzu kullanmakGelin bunu uygulayalım! Başlamak için timeout fonksiyonunun API’sini düşünelim:
- Onu
awaitedebilmemiz için kendisinin de bir async fonksiyon olması gerekir. - İlk parametresi çalıştırılacak bir future olmalıdır. Herhangi bir future ile çalışmasına olanak tanımak için onu jenerik yapabiliriz.
- İkinci parametresi beklenecek azami süre olacaktır. Bir
Durationkullanırsak, bu değeritrpl::sleepfonksiyonuna iletmek kolaylaşır. - Bir
Resultdöndürmelidir. Future başarıyla tamamlanırsaResult, future’ın ürettiği değeri içerenOkolur. Zaman aşımı süresi daha önce dolarsaResult, zaman aşımının beklediği süreyi içerenErrolur.
Liste 17-19 bu bildirimi gösterir.
extern crate trpl; // required for mdbook test
use std::time::Duration;
fn main() {
trpl::block_on(async {
let yavas = async {
trpl::sleep(Duration::from_secs(5)).await;
"Sonunda bitti"
};
match timeout(yavas, Duration::from_secs(2)).await {
Ok(mesaj) => println!("'{mesaj}' ile başarılı oldu"),
Err(sure) => {
println!("{} saniye sonra başarısız oldu", sure.as_secs())
}
}
});
}
async fn timeout<F: Future>(
denenecek_future: F,
azami_sure: Duration,
) -> Result<F::Output, Duration> {
// Uygulamamız buraya gelecek!
}
timeout imzasını tanımlamakBu imza, türlere ilişkin hedeflerimizi karşılar. Şimdi ihtiyacımız olan davranışı düşünelim: içeriye geçirilen future’ı süreye karşı yarıştırmak istiyoruz. Süreden bir zamanlayıcı future oluşturmak için trpl::sleep fonksiyonunu, bu zamanlayıcıyı çağıranın geçirdiği future ile birlikte çalıştırmak için de trpl::select fonksiyonunu kullanabiliriz.
Liste 17-20’de, trpl::select çağrısını beklemenin (await) sonucu üzerinde desen eşleştirerek timeout fonksiyonunu uyguluyoruz.
extern crate trpl; // required for mdbook test
use std::time::Duration;
use trpl::Either;
// --snip--
fn main() {
trpl::block_on(async {
let yavas = async {
trpl::sleep(Duration::from_secs(5)).await;
"Sonunda bitti"
};
match timeout(yavas, Duration::from_secs(2)).await {
Ok(mesaj) => println!("'{mesaj}' ile başarılı oldu"),
Err(sure) => {
println!("{} saniye sonra başarısız oldu", sure.as_secs())
}
}
});
}
async fn timeout<F: Future>(
denenecek_future: F,
azami_sure: Duration,
) -> Result<F::Output, Duration> {
match trpl::select(denenecek_future, trpl::sleep(azami_sure)).await {
Either::Left(cikti) => Ok(cikti),
Either::Right(_) => Err(azami_sure),
}
}
select ve sleep ile timeout tanımlamaktrpl::select uygulaması adil (fair) değildir: argümanları her zaman geçirildikleri sırayla yoklar (diğer select uygulamaları ilk olarak hangi argümanı yoklayacağını rastgele seçer). Bu nedenle select fonksiyonuna ilk olarak denenecek_future argümanını geçiriyoruz; böylece azami_sure çok kısa bir süre olsa bile denenecek_future tamamlanma şansı elde eder. denenecek_future önce biterse select, denenecek_future çıktısını içeren Left varyantını döndürür. Zamanlayıcı önce biterse select, zamanlayıcının çıktısı olan () değerini içeren Right varyantını döndürür.
denenecek_future başarılı olur ve bir Left(cikti) elde edersek Ok(cikti) döndürürüz. Uyku zamanlayıcısının süresi önce dolar ve bir Right(()) elde edersek, () değerini _ ile yok sayıp Err(azami_sure) döndürürüz.
Not: Rust’ta future’lar pasif durum makineleridir ve yalnızca yoklandıklarında
ilerlerler. trpl::select içinde yarışan iki future’dan biri tamamlandığında
select sonlanır ve yarışı kaybeden diğer future kapsamdan çıkarak düşürülür
(Drop). Bu düşürülme yoluyla iptal (cancellation on drop) mekanizması
sayesinde, denenecek_future erken biterse zamanlayıcı, zaman aşımı önce
dolarsa da denenecek_future fazladan bir iptal kodu yazmanıza gerek kalmadan
artık yoklanmaz ve tuttuğu yerel kaynaklar düşürülür. Ancak daha önce
gerçekleşmiş yan etkiler geri alınmaz; ayrı bir görevde veya işletim
sisteminde devam eden işlemin iptal edilip edilmediği kullanılan API’nin
sözleşmesine bağlıdır.
Böylece diğer iki async yardımcı üzerine inşa edilmiş, çalışan bir timeout fonksiyonuna sahip olduk. Kodumuzu çalıştırdığımızda zaman aşımının ardından başarısızlık durumunu ekrana yazdırır:
2 saniye sonra başarısız oldu
Future’lar diğer future’larla birleştirilebildiğinden, daha küçük async yapı taşlarını kullanarak gerçekten güçlü araçlar inşa edebilirsiniz. Örneğin aynı yaklaşımı zaman aşımlarını yeniden denemelerle (retries) birleştirmek için kullanabilir ve bunları da ağ çağrıları gibi işlemlerle (Liste 17-5’tekiler gibi) birlikte çalıştırabilirsiniz.
Pratikte çoğunlukla doğrudan async ve await ile, ikincil olarak da en dıştaki future’ların nasıl yürütüleceğini denetlemek üzere select gibi fonksiyonlar ve join! makrosu gibi makrolarla çalışırsınız.
Şimdiye kadar aynı anda birden fazla future ile çalışmanın çeşitli yollarını gördük. Sırada, zaman içinde art arda gelen birden fazla future ile akışlar (streams) aracılığıyla nasıl çalışabileceğimize bakacağız.
Akışlar (Streams): Sıralı Future'lar
Akışlar (Streams): Sıralı Future’lar
Bu bölümün başlarında, “Mesaj İletimiyle İki Görev Arasında Veri Gönderme” başlığında asenkron kanalımızın alıcı tarafını nasıl kullandığımızı hatırlayın. Asenkron recv metodu zaman içinde bir öğe dizisi üretir. Bu, akış (stream) olarak bilinen çok daha genel bir desenin bir örneğidir. Pek çok kavram doğal olarak akış şeklinde temsil edilir: bir kuyrukta öğelerin hazır hâle gelmesi, tüm veri kümesi bilgisayarın belleğine sığmayacak kadar büyük olduğunda verilerin dosya sisteminden artımlı olarak parça parça çekilmesi veya zaman içinde ağ üzerinden veri gelmesi. Akışlar her adımda bir future ürettiği için onları diğer her türlü future ile birlikte kullanabilir ve ilginç şekillerde birleştirebiliriz. Örneğin, çok fazla ağ çağrısı tetiklememek için olayları gruplayabilir, uzun süren işlem dizilerine zaman aşımı tanımlayabilir ya da gereksiz iş yapmamak için kullanıcı arayüzü olaylarının sıklığını sınırlandırabiliriz.
Bölüm 13’te, “Iterator Trait’i ve next Metodu” başlığında Iterator trait’ini incelerken de bir öğe dizisi görmüştük; ancak iteratörler ile asenkron kanal alıcısı arasında iki temel fark vardır. İlk fark zamandır: iteratörler senkronken kanal alıcısı asenkrondur. İkinci fark ise API’dir. Doğrudan Iterator ile çalışırken onun senkron next metodunu çağırırız. Özel olarak trpl::Receiver akışında ise bunun yerine asenkron bir recv metodu çağırmıştık. Bunların dışında bu API’ler kullanım hissi açısından birbirine çok benzer ve bu benzerlik bir tesadüf değildir. Akış, yinelemenin asenkron bir biçimi gibidir. Bununla birlikte trpl::Receiver özel olarak mesaj almayı beklerken, genel amaçlı akış API’si çok daha kapsamlıdır: tıpkı Iterator’ın yaptığı gibi sıradaki öğeyi sunar, ancak bunu asenkron olarak yapar.
Rust’ta iteratörler ile akışlar arasındaki bu benzerlik, aslında herhangi bir iteratörden bir akış oluşturabileceğimiz anlamına gelir. Bir iteratörde olduğu gibi, bir akışla da next metodunu çağırıp ardından çıktısını await ile bekleyerek çalışabiliriz; henüz derlenmeyecek olan Liste 17-21’de bu kullanım gösterilmektedir.
extern crate trpl; // required for mdbook test
fn main() {
trpl::block_on(async {
let degerler = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
let iter = degerler.iter().map(|n| n * 2);
let mut akis = trpl::stream_from_iter(iter);
while let Some(deger) = akis.next().await {
println!("Değer şuydu: {deger}");
}
});
}
Bir sayı dizisiyle başlıyor, bunu bir iteratöre dönüştürüyor ve ardından tüm değerleri iki katına çıkarmak için üzerinde map çağırıyoruz. Daha sonra trpl::stream_from_iter fonksiyonunu kullanarak iteratörü bir akışa dönüştürüyoruz. Ardından bir while let döngüsüyle akıştaki öğeler üzerinde geldikçe dönüyoruz.
Ne yazık ki kodu çalıştırmayı denediğimizde derlenmez ve bunun yerine kullanılabilir bir next metodu olmadığını bildirir:
error[E0599]: no method named `next` found for struct `tokio_stream::iter::Iter` in the current scope
--> src/main.rs:10:38
|
10 | while let Some(deger) = akis.next().await {
| ^^^^
|
= help: items from traits can only be used if the trait is in scope
help: the following traits which provide `next` are implemented but not in scope; perhaps you want to import one of them
|
1 + use crate::trpl::StreamExt;
|
1 + use futures_util::stream::stream::StreamExt;
|
1 + use std::iter::Iterator;
|
1 + use std::str::pattern::Searcher;
|
help: there is a method `try_next` with a similar name
|
10 | while let Some(deger) = akis.try_next().await {
| ~~~~~~~~
Bu çıktının da açıkladığı gibi, derleyici hatasının nedeni next metodunu kullanabilmek için doğru trait’in kapsamda olması gerekliliğidir. Şimdiye kadarki tartışmamıza bakarak bu trait’in Stream olmasını makul bir şekilde bekleyebilirsiniz, ancak gereken trait aslında StreamExt’tir. Extension (uzantı/genişletme) kelimesinin kısaltması olan Ext, Rust topluluğunda bir trait’i başka bir trait ile genişletmek için yaygın olarak kullanılan bir desendir.
Stream trait’i, Iterator ve Future trait’lerini etkili bir biçimde birleştiren düşük seviyeli bir arayüz tanımlar. StreamExt ise Stream üzerine, next metodunun yanı sıra Iterator trait’inin sunduklarına benzer diğer yardımcı metotları da içeren daha üst seviyeli bir API kümesi sağlar. Stream ve StreamExt henüz Rust’ın standart kütüphanesinin bir parçası değildir, ancak ekosistemdeki çoğu crate benzer tanımları kullanır.
Not: Neden *Ext (Uzantı Trait’i) Deseni Kullanılır?
Rust ekosisteminde Stream ve StreamExt (veya futures::FutureExt,
itertools::Itertools) gibi ikili trait tasarımları iki temel nedenden dolayı
çok yaygındır:
- Uygulama sadeliği ve örtü uygulaması (blanket implementation): Çekirdek
Streamtrait’i yalnızca tek bir düşük seviyeli yoklama metodu gerektirir (bunu bir sonraki bölümde inceleyeceğiz).StreamExtiseimpl<T: Stream + ?Sized> StreamExt for Tşeklinde genel bir uygulamaya sahiptir; böyleceStreamuygulayan her tür, tek satır ek kod yazmadanStreamExtiçindeki onlarca yardımcı metodu otomatik olarak kazanır. - Genişletilebilirlik ve geriye dönük uyumluluk: Başka bir crate’te
tanımlanmış bir trait’in tanımına dışarıdan yeni metot ekleyemezsiniz.
Yardımcı metotları ayrı bir
*Exttrait’inde toplamak; farklı crate’lerin (örneğinfuturesiletokio-stream’in) aynı çekirdekStreamtrait’ini bozmadan kendi üst seviye adaptörlerini sunmasına ve bu adaptörlerin çekirdek trait’e dokunulmadan zaman içinde geliştirilmesine olanak tanır.
Bu satırlar yazılırken standart kütüphaneye benzer bir trait’in
(AsyncIterator) eklenmesi ve senkron for döngüsünün asenkron karşılığı
olacak bir sözdizimi üzerindeki tasarım çalışmaları hâlâ sürmektedir; bu
özellikler yalnızca nightly Rust’ta deneysel olarak mevcuttur.
Derleyici hatasını düzeltmenin yolu, Liste 17-22’deki gibi trpl::StreamExt için bir use deyimi eklemektir.
extern crate trpl; // required for mdbook test
use trpl::StreamExt;
fn main() {
trpl::block_on(async {
let degerler = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
// --snip--
let iter = degerler.iter().map(|n| n * 2);
let mut akis = trpl::stream_from_iter(iter);
while let Some(deger) = akis.next().await {
println!("Değer şuydu: {deger}");
}
});
}
Tüm bu parçalar bir araya geldiğinde kod tam istediğimiz gibi çalışır! Dahası, artık StreamExt kapsamda olduğuna göre tıpkı iteratörlerde olduğu gibi sunduğu tüm yardımcı metotları kullanabiliriz.
Not: StreamExt kapsamda olduğunda iteratörlerden tanıdığınız map, filter,
take ve fold gibi adaptörlerin yanı sıra, trpl’nin yeniden dışa
aktardığı tokio_stream::StreamExt sürümünde zamana duyarlı asenkron akışlara
özgü timeout (belirli sürede öğe gelmezse zaman aşımı üretme), throttle
(öğelerin geliş hızını sınırlandırma) ve merge (birden fazla akışı tek bir
akışta birleştirme) gibi metotları da zincirleyebilirsiniz. Hangi yardımcı
metotların bulunduğu kullandığınız StreamExt sürümüne göre değişir; örneğin
futures crate’indeki sürümde timeout ve throttle yoktur. Ayrıca tıpkı
Bölüm 13’te Iterator::next(&mut self) çağrısının iteratörün iç durumunu
ilerletmesi gibi, akis.next().await çağrısı da akışın durumunu değiştirdiği
için akis değişkeninin let mut akis olarak tanımlandığına dikkat edin.
Async Trait'lerine Daha Yakından Bakış
Async Trait’lerine Daha Yakından Bakış
Bölüm boyunca Future, Stream ve StreamExt trait’lerini çeşitli biçimlerde kullandık. Ancak şimdiye dek bunların nasıl çalıştığına ya da birbiriyle nasıl bütünleştiğine dair ayrıntılara fazla girmekten kaçındık; günlük Rust çalışmalarınızda çoğu zaman bu kadarı fazlasıyla yeterlidir. Yine de zaman zaman Pin türü ve Unpin trait’i ile birlikte bu trait’lerin birkaç ayrıntısını daha anlamanız gereken durumlarla karşılaşırsınız. Bu alt bölümde, gerçekten derinlemesine incelemeyi diğer belgelendirmelere bırakarak tam da bu tür senaryolarda size yardımcı olacak kadar derine ineceğiz.
Future Trait’i
İşe Future trait’inin nasıl çalıştığına daha yakından bakarak başlayalım. Rust onu şöyle tanımlar:
#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
}
Bu trait tanımı bir dizi yeni tür ve daha önce görmediğimiz bazı sözdizimleri içeriyor; gelin tanımı parça parça inceleyelim.
İlk olarak, Future trait’inin ilişkili türü olan Output, future’ın hangi değere çözüleceğini belirtir. Bu, Iterator trait’indeki Item ilişkili türüne benzer. İkinci olarak Future, self parametresi için özel bir Pin referansı ve Context türüne değiştirilebilir bir referans alan, karşılığında da bir Poll<Self::Output> döndüren poll metoduna sahiptir. Pin ve Context hakkında birazdan daha fazla konuşacağız. Şimdilik metodun ne döndürdüğüne, yani Poll türüne odaklanalım:
#![allow(unused)]
fn main() {
pub enum Poll<T> {
Ready(T),
Pending,
}
}
Bu Poll türü bir Option türüne benzer: değer taşıyan bir varyantı (Ready(T)) ve değer taşımayan bir varyantı (Pending) vardır. Ancak Poll, Option’dan oldukça farklı bir anlama gelir! Pending varyantı, future’ın hâlâ yapacak işi olduğunu, dolayısıyla çağıran kodun daha sonra tekrar kontrol etmesi gerektiğini belirtir. Ready varyantı ise Future’ın işini bitirdiğini ve T değerinin kullanıma hazır olduğunu gösterir.
Not: Doğrudan poll çağırmanız nadiren gerekir; ancak çağırmanız gerekirse
çoğu future için, future Ready döndürdükten sonra çağıran kodun poll
metodunu tekrar çağırmaması gerektiğini unutmayın. Pek çok future, hazır hâle
geldikten sonra yeniden yoklanırsa panikler. Tekrar yoklanması güvenli olan
future’lar bunu belgelendirmelerinde açıkça belirtir. Bu davranış,
Iterator::next metodunun davranışına benzer.
await kullanan bir kod gördüğünüzde, Rust bunu perde arkasında poll çağıran bir koda derler. Çözüldüğünde tek bir URL için sayfa başlığını yazdırdığımız Liste 17-4’e geri dönüp bakarsanız, Rust onu kabaca (birebir aynı olmasa da) şuna benzer bir yapıya derler:
match sayfa_basligi(url).poll() {
Ready(sayfa_basligi) => match sayfa_basligi {
Some(title) => println!("{url} için başlık {title} idi"),
None => println!("{url} için başlık yoktu"),
}
Pending => {
// Peki buraya ne gelecek?
}
}
Future hâlâ Pending durumundaysa ne yapmalıyız? Future nihayet hazır olana dek tekrar, tekrar ve tekrar denemenin bir yoluna ihtiyacımız vardır. Başka bir deyişle, bir döngüye ihtiyacımız vardır:
let mut sayfa_basligi_fut = sayfa_basligi(url);
loop {
match sayfa_basligi_fut.poll() {
Ready(deger) => match deger {
Some(title) => println!("{url} için başlık {title} idi"),
None => println!("{url} için başlık yoktu"),
}
Pending => {
// devam et
}
}
}
Ancak Rust kodu tam olarak buna derleseydi, her await bloklayıcı olurdu — ki bu da ulaşmak istediğimiz hedefin tam tersidir! Bunun yerine Rust, bu döngünün denetimi başka bir yapıya devredebilmesini sağlar: Bu yapı, söz konusu future üzerindeki çalışmayı duraklatıp diğer future’larla ilgilenebilir ve daha sonra bu future’ı yeniden kontrol edebilir. Gördüğümüz üzere bu yapı bir asenkron çalışma zamanıdır ve bu zamanlama ile eşgüdüm işi onun temel görevlerinden biridir.
“Mesaj İletimiyle İki Görev Arasında Veri Gönderme” bölümünde alici.recv çağrısını beklemekten bahsetmiştik. recv çağrısı bir future döndürür ve future’ı await ile beklemek onu yoklar. Çalışma zamanının, future ya Some(mesaj) ile ya da kanal kapandığında None ile hazır olana dek onu duraklatacağını belirtmiştik. Future trait’ine ve özellikle Future::poll metoduna dair edindiğimiz bu daha derin kavrayışla bunun nasıl çalıştığını artık görebiliriz. Çalışma zamanı, Poll::Pending döndürdüğünde future’ın henüz hazır olmadığını anlar. Tersine, poll çağrısı Poll::Ready(Some(mesaj)) veya Poll::Ready(None) döndürdüğünde ise future’ın hazır olduğunu bilir ve onu ilerletir.
Bir çalışma zamanının bunu tam olarak nasıl yaptığının ayrıntıları bu kitabın kapsamı dışındadır; ancak asıl önemli olan future’ların temel mekaniğini görmektir: çalışma zamanı sorumlu olduğu her bir future’ı yoklar ve henüz hazır olmadığında onu tekrar uykuya yatırır.
Not: Peki çalışma zamanı, uyuttuğu bir future’ı yeniden yoklamanın zamanının
geldiğini nasıl anlar? Bu sorunun yanıtı, poll metodunun ikinci parametresi
olan cx: &mut Context<'_> değerinde gizlidir. Context, özünde bir
Waker (uyandırıcı) taşır. Bir future Pending döndürdüğünde, ilerleme
kaydedebileceği an geldiğinde (örneğin ağdan veri ulaştığında ya da bir
zamanlayıcı dolduğunda) bu uyandırıcının wake metodunun çağrılmasını
ayarlamakla yükümlüdür; trpl::sleep veya kanal alıcısı gibi hazır
future’lar bu işi sizin yerinize yapar. Çalışma zamanı da future’ları
körlemesine ve sürekli yoklamak yerine yalnızca uyandırıldıklarında yeniden
yoklar. Bu sözleşme sayesinde binlerce uyuyan future, işlemciyi meşgul etmeden
sırasını bekleyebilir.
Pin Türü ve Unpin Trait’i
Liste 17-13’te üç future’ı beklemek için trpl::join! makrosunu kullanmıştık. Ancak sayısı çalışma zamanına kadar bilinmeyecek birtakım future’lar barındıran vektör gibi bir koleksiyona sahip olmak oldukça yaygındır. Gelin Liste 17-13’ü, üç future’ı bir vektöre koyan ve bunun yerine trpl::join_all fonksiyonunu çağıran Liste 17-23’teki koda dönüştürelim; bu kod henüz derlenmeyecektir.
extern crate trpl; // required for mdbook test
use std::time::Duration;
fn main() {
trpl::block_on(async {
let (gonderici, mut alici) = trpl::channel();
let gonderici1 = gonderici.clone();
let gonderici1_gelecegi = async move {
let degerler = vec![
String::from("merhaba"),
String::from("gelecekten"),
String::from("selam"),
String::from("var"),
];
for deger in degerler {
gonderici1.send(deger).unwrap();
trpl::sleep(Duration::from_secs(1)).await;
}
};
let alici_gelecegi = async {
while let Some(alinan) = alici.recv().await {
println!("alındı '{alinan}'");
}
};
let gonderici_gelecegi = async move {
// --snip--
let degerler = vec![
String::from("daha"),
String::from("fazla"),
String::from("mesaj"),
String::from("sana"),
];
for deger in degerler {
gonderici.send(deger).unwrap();
trpl::sleep(Duration::from_secs(1)).await;
}
};
let gelecekler: Vec<Box<dyn Future<Output = ()>>> =
vec![Box::new(gonderici1_gelecegi), Box::new(alici_gelecegi), Box::new(gonderici_gelecegi)];
trpl::join_all(gelecekler).await;
});
}
Tıpkı Bölüm 12’deki “calistir Fonksiyonundan Hata Döndürme” başlığında yaptığımız gibi, her bir future’ı trait nesnelerine dönüştürmek için bir Box içine koyuyoruz. (Trait nesnelerini Bölüm 18’de ayrıntılı olarak ele alacağız.) Trait nesneleri kullanmak, bu ifadelerin ürettiği anonim future’ların tamamı Future trait’ini uyguladığı için hepsine aynı türmüş gibi davranabilmemizi sağlar.
Bu durum ilk bakışta şaşırtıcı gelebilir. Sonuçta async blokların hiçbiri bir değer döndürmez, dolayısıyla her biri bir Future<Output = ()> üretir. Ancak Future’ın bir trait olduğunu ve derleyicinin çıktı türleri birebir aynı olsa bile her bir async blok için benzersiz bir enum oluşturduğunu unutmayın. Tıpkı kendi yazdığınız iki farklı struct’ı aynı Vec içine koyamayacağınız gibi, derleyicinin ürettiği farklı enum’ları da bir arada karıştıramazsınız.
Ardından future koleksiyonunu trpl::join_all fonksiyonuna geçiriyor ve sonucu bekliyoruz. Ancak bu kod derlenmez; hata mesajlarının ilgili kısmı aşağıdadır.
error[E0277]: `dyn Future<Output = ()>` cannot be unpinned
--> src/main.rs:48:36
|
48 | trpl::join_all(gelecekler).await;
| ^^^^^ the trait `Unpin` is not implemented for `dyn Future<Output = ()>`
|
= note: consider using the `pin!` macro
consider using `Box::pin` if you need to access the pinned value outside of the current scope
= note: required for `Box<dyn Future<Output = ()>>` to implement `Future`
note: required by a bound in `futures_util::future::join_all::JoinAll`
--> file:///home/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.30/src/future/join_all.rs:29:8
|
27 | pub struct JoinAll<F>
| ------- required by a bound in this struct
28 | where
29 | F: Future,
| ^^^^^^ required by this bound in `JoinAll`
Bu hata mesajındaki not, değerleri bellekte taşınmayacaklarını garanti eden Pin türünün içine koymak —yani değerleri sabitlemek (pin)— için pin! makrosunu kullanmamız gerektiğini söyler. Hata mesajı, dyn Future<Output = ()> türünün Unpin trait’ini uygulaması gerektiği hâlde şu anda uygulamadığını, bu yüzden de sabitlemenin zorunlu olduğunu belirtir.
trpl::join_all fonksiyonu JoinAll adında bir struct döndürür. Bu struct, Future trait’ini uygulamakla sınırlandırılmış bir F türü üzerinden jeneriktir. Bir future’ı await ile doğrudan beklemek, o future’ı örtük olarak sabitler. Future beklemek istediğimiz her yerde pin! kullanmak zorunda kalmamamızın nedeni budur.
Ancak burada bir future’ı doğrudan beklemiyoruz. Bunun yerine, join_all fonksiyonuna bir future koleksiyonu geçirerek yeni bir future olan JoinAll struct’ını oluşturuyoruz. join_all fonksiyonunun imzası, koleksiyondaki öğelerin türlerinin tamamının Future trait’ini uygulamasını gerektirir ve Box<T>, yalnızca sarmaladığı T türü Unpin trait’ini uygulayan bir future olduğunda Future trait’ini uygular.
Sindirmesi gereken epey bilgi var! Bunu gerçekten anlayabilmek için Future trait’inin özellikle sabitleme etrafında nasıl çalıştığına biraz daha derinden bakalım. Future trait’inin tanımına tekrar göz atın:
#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};
pub trait Future {
type Output;
// Gerekli metot
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
}
cx parametresi ve onun Context türü, bir çalışma zamanının tembelliğini korurken belirli bir future’ı ne zaman yeniden kontrol etmesi gerektiğini nasıl bildiğinin anahtarıdır (yukarıdaki Waker notuna bakın). Yine bunun nasıl çalıştığının ayrıntıları bu bölümün kapsamı dışındadır ve genellikle bu konuyu yalnızca özel bir Future uygulaması yazarken düşünmeniz gerekir. Biz bunun yerine self parametresinin türüne odaklanacağız; çünkü self parametresinin tür bildirimine sahip olduğu bir metodu ilk kez görüyoruz. self için yazılan tür bildirimi diğer fonksiyon parametrelerindeki tür bildirimleri gibi çalışır, ancak iki temel farkı vardır:
- Metodun çağrılabilmesi için
selfdeğerinin hangi türde olması gerektiğini Rust’a bildirir. - Herhangi bir tür olamaz. Metodun üzerinde uygulandığı türle, o türe yönelik
bir referans ya da akıllı işaretçiyle veya o türe yönelik bir referansı (ya da
akıllı işaretçiyi) sarmalayan bir
Pinile sınırlandırılmıştır.
Bu sözdizimi hakkında daha fazlasını Bölüm 18’de göreceğiz. Şimdilik bir future’ın Pending mi yoksa Ready(Output) mu olduğunu kontrol etmek üzere onu yoklamak istiyorsak, türe yönelik Pin ile sarmalanmış değiştirilebilir bir referansa ihtiyacımız olduğunu bilmek yeterlidir.
Pin; &, &mut, Box ve Rc gibi işaretçi benzeri türler için bir sarmalayıcıdır. (Teknik olarak Pin, Deref veya DerefMut trait’lerini uygulayan türlerle çalışır; ancak bu, pratikte yalnızca referanslar ve akıllı işaretçilerle çalışmakla eşdeğerdir.) Pin başlı başına bir işaretçi değildir ve Rc ile Arc’ın referans sayımıyla yaptığı gibi kendine özgü bir davranış barındırmaz; tamamen derleyicinin işaretçi kullanımı üzerindeki kısıtlamaları zorunlu kılmak için kullanabileceği bir araçtır.
await ifadesinin poll çağrıları cinsinden uygulandığını hatırlamak daha önce gördüğümüz hata mesajını açıklamaya başlar; ancak o mesaj Pin değil Unpin üzerindendi. Peki Pin tam olarak Unpin ile nasıl ilişkilidir ve Future, poll çağrısı yapabilmek için self değerinin neden bir Pin türü içinde olmasına ihtiyaç duyar?
Bu bölümün önceki kısımlarından hatırlayacağınız üzere, bir future içindeki bir dizi await noktası bir durum makinesine derlenir ve derleyici bu durum makinesinin ödünç alma ve sahiplik dahil olmak üzere Rust’ın güvenlikle ilgili tüm normal kurallarına uymasını sağlar. Bunu gerçekleştirmek için Rust, bir await noktası ile bir sonraki await noktası ya da async bloğun sonu arasında hangi verilere ihtiyaç duyulduğuna bakar. Ardından derlenmiş durum makinesinde buna karşılık gelen bir varyant oluşturur. Her varyant, ister verinin sahipliğini devralarak ister ona değiştirilebilir ya da değiştirilemez bir referans alarak kaynak kodun o bölümünde kullanılacak verilere ihtiyaç duyduğu erişimi elde eder.
Buraya kadar her şey yolunda: belirli bir async blokta sahiplik veya referanslar konusunda bir hata yaparsak ödünç alma denetleyicisi bize bildirir. Ancak o bloğa karşılık gelen future’ı taşımak istediğimizde —örneğin join_all fonksiyonuna geçirmek üzere bir Vec içine taşıdığımızda— işler biraz daha çetrefilleşir.
Bir future’ı taşıdığımızda —ister join_all ile iteratör olarak kullanmak üzere bir veri yapısına ekleyerek ister bir fonksiyondan döndürerek olsun— bu aslında Rust’ın bizim için oluşturduğu durum makinesini taşımak anlamına gelir. Ve Rust’taki diğer çoğu türün aksine, Rust’ın async bloklar için oluşturduğu future’lar, Şekil 17-4’teki basitleştirilmiş çizimde gösterildiği gibi herhangi bir varyantın alanlarında kendilerine yönelik referanslar barındırabilir.
Ne var ki kendisine referans barındıran herhangi bir nesneyi taşımak varsayılan olarak güvenli değildir; çünkü referanslar her zaman başvurdukları şeyin gerçek bellek adresine işaret eder (bkz. Şekil 17-5). Veri yapısının kendisini taşırsanız, bu içsel referanslar eski konuma işaret eder hâlde kalır. Ancak o bellek konumu artık geçersizdir. Birincisi, veri yapısında değişiklik yaptığınızda oradaki değer güncellenmeyecektir. İkincisi —ve daha da önemlisi— bilgisayar artık o belleği başka amaçlar için yeniden kullanmakta özgürdür! Daha sonra tamamen alakasız veriler okuyabilirsiniz.
Kuramsal olarak Rust derleyicisi bir nesne her taşındığında ona yönelik tüm referansları güncellemeyi deneyebilirdi; ancak özellikle koca bir referans ağının güncellenmesi gerekiyorsa bu durum ciddi bir performans yükü ekleyebilirdi. Bunun yerine söz konusu veri yapısının bellekte taşınmadığından emin olabilirsek hiçbir referansı güncellememiz gerekmez. Rust’ın ödünç alma denetleyicisi tam olarak bunun içindir: güvenli kodda, kendisine etkin bir referans bulunan herhangi bir öğeyi taşımanızı engeller.
Pin, tam da ihtiyacımız olan güvenceyi bize sunmak için bu temelin üzerine inşa edilir. Bir değere yönelik işaretçiyi Pin içine sararak o değeri sabitlediğimizde, değer artık taşınamaz. Dolayısıyla elinizde Pin<Box<BirTur>> varsa, Box işaretçisini değil, gerçekte BirTur değerini sabitlersiniz. Şekil 17-6 bu süreci göstermektedir.
Aslına bakarsanız Box işaretçisi hâlâ serbestçe taşınabilir. Unutmayın: bizim önemsediğimiz şey, nihayetinde başvurulan verinin yerinde kalmasını sağlamaktır. Bir işaretçi taşınırsa ama işaret ettiği veri Şekil 17-7’deki gibi aynı yerde kalırsa ortada olası bir sorun yoktur. (Bağımsız bir alıştırma olarak, ilgili türlerin yanı sıra std::pin modülünün belgelendirmesine göz atın ve bir Box sarmalayan Pin ile bunu nasıl yapacağınızı çözmeye çalışın.) Kilit nokta, kendine referanslı (self-referential) türün kendisinin hâlâ sabitlenmiş olduğu için taşınamamasıdır.
Bununla birlikte çoğu türün, bir Pin işaretçisinin arkasında bulunsa bile taşınması tamamen güvenlidir. Sabitlemeyi yalnızca öğeler içsel referanslar barındırdığında düşünmemiz gerekir. Sayılar ve mantıksal (bool) değerler gibi ilkel değerler açıktır ki hiçbir içsel referans barındırmadıkları için güvenlidir. Rust’ta normalde çalıştığınız çoğu türde de durum böyledir. Örneğin bir Vec değerini endişelenmeden taşıyabilirsiniz. Şimdiye dek gördüklerimize bakılırsa, elinizde bir Pin<Vec<String>> olsaydı her işi Pin tarafından sunulan güvenli ama kısıtlayıcı API’ler üzerinden yapmak zorunda kalırdınız; oysa kendisine başka hiçbir referans olmadığı sürece bir Vec<String> değerini taşımak her zaman güvenlidir. Bu gibi durumlarda öğeleri taşımanın sorun olmadığını derleyiciye söylemenin bir yoluna ihtiyacımız vardır — işte Unpin tam burada devreye girer.
Unpin, Bölüm 16’da gördüğümüz Send ve Sync trait’lerine benzer bir işaretleyici trait’tir (marker trait) ve dolayısıyla kendine ait hiçbir işlevselliği yoktur. İşaretleyici trait’ler yalnızca derleyiciye ilgili trait’i uygulayan türün belirli bir bağlamda kullanılmasının güvenli olduğunu bildirmek için vardır. Unpin, ilgili türün söz konusu değerin güvenle taşınıp taşınamayacağına ilişkin herhangi bir sabitleme güvencesini sürdürmesine gerek olmadığını derleyiciye bildirir.
Tıpkı Send ve Sync trait’lerinde olduğu gibi derleyici, güvenli olduğunu kanıtlayabildiği tüm türler için Unpin trait’ini otomatik olarak uygular. Yine Send ve Sync’e benzer özel bir durum ise Unpin trait’inin bir tür için uygulanmamasıdır. Bunun gösterimi impl !Unpin for BirTur şeklindedir; burada BirTur, kendisine yönelik bir işaretçi Pin içinde her kullanıldığında güvenli olabilmek için bu güvenceleri koruması gereken bir türün adıdır.
Başka bir deyişle, Pin ile Unpin arasındaki ilişki hakkında akılda tutulması gereken iki nokta vardır. Birincisi, Unpin “normal” durumdur; !Unpin ise özel durumdur. İkincisi, bir türün Unpin ya da !Unpin uygulaması yalnızca o türe yönelik Pin<&mut BirTur> gibi sabitlenmiş bir işaretçi kullandığınızda önem taşır.
Bunu somutlaştırmak için "merhaba" metnini tutan bir String düşünün. Bu metin 7 ASCII karakterden oluşur ve uzunluğu 7 bayttır. Şekil 17-8’de görüldüğü gibi bir String değerini Pin içine sarabiliriz. Ancak String, Rust’taki diğer çoğu tür gibi Unpin trait’ini otomatik olarak uygular.
Sonuç olarak, String bunun yerine !Unpin uygulasaydı geçersiz sayılacak şeyleri yapabiliriz; örneğin Şekil 17-9’daki gibi "merhaba" değerini bellekteki aynı konumda "selam" değeriyle değiştirebiliriz. Yeni metin 5 ASCII karakterden oluştuğu için uzunluğu 5 bayttır. Bu işlem Pin sözleşmesini ihlal etmez; çünkü String, taşınmasını güvensiz kılacak hiçbir içsel referansa sahip değildir. !Unpin yerine Unpin uygulamasının tam nedeni de budur.
Artık Liste 17-23’teki join_all çağrısı için bildirilen hataları anlayacak kadar bilgi sahibiyiz. Başlangıçta async bloklar tarafından üretilen future’ları bir Vec<Box<dyn Future<Output = ()>>> içine taşımaya çalışmıştık; ancak gördüğümüz gibi bu future’lar içsel referanslara sahip olabileceğinden Unpin trait’ini otomatik olarak uygulamazlar. Onları sabitlediğimizde, ortaya çıkan Pin türünü future’ların altındaki verilerin taşınmayacağından emin olarak Vec içine geçirebiliriz. Liste 17-24, üç future’ın her birinin tanımlandığı yerde pin! makrosunu çağırarak ve trait nesnesi türünü uyarlayarak kodun nasıl düzeltileceğini göstermektedir.
extern crate trpl; // required for mdbook test
use std::pin::{Pin, pin};
// --snip--
use std::time::Duration;
fn main() {
trpl::block_on(async {
let (gonderici, mut alici) = trpl::channel();
let gonderici1 = gonderici.clone();
let gonderici1_gelecegi = pin!(async move {
// --snip--
let degerler = vec![
String::from("merhaba"),
String::from("gelecekten"),
String::from("selam"),
String::from("var"),
];
for deger in degerler {
gonderici1.send(deger).unwrap();
trpl::sleep(Duration::from_secs(1)).await;
}
});
let alici_gelecegi = pin!(async {
// --snip--
while let Some(alinan) = alici.recv().await {
println!("alındı '{alinan}'");
}
});
let gonderici_gelecegi = pin!(async move {
// --snip--
let degerler = vec![
String::from("daha"),
String::from("fazla"),
String::from("mesaj"),
String::from("sana"),
];
for deger in degerler {
gonderici.send(deger).unwrap();
trpl::sleep(Duration::from_secs(1)).await;
}
});
let gelecekler: Vec<Pin<&mut dyn Future<Output = ()>>> =
vec![gonderici1_gelecegi, alici_gelecegi, gonderici_gelecegi];
trpl::join_all(gelecekler).await;
});
}
Bu örnek artık derlenir ve çalışır; çalışma zamanında vektöre future ekleyip çıkarabilir ve hepsini birleştirebiliriz.
Not: Derleyici hata mesajında hem pin! makrosunun hem de Box::pin
fonksiyonunun önerildiğine dikkat etmiş olabilirsiniz. std::pin::pin!
makrosu, ek bir öbek (heap) tahsisi yapmadan değeri bulunduğu yerde
sabitler ve Pin<&mut T> üretir. Buna yerel sabitleme demek daha doğrudur:
async kodda .await boyunca tutulan yerel değerler, dış future’ın
durumunun parçası olabilir; o future’ın saklandığı yer yığın da öbek de
olabilir. Liste 17-24’teki gibi yerel kullanım için pin! yeterlidir. Sabitlenmiş
bir future’ı mevcut kapsamın dışına döndürmeniz, bir struct alanında
saklamanız veya sahipliğini başka bir göreve taşımanız gerektiğinde ise değeri
öbekte sabitleyerek sahipliği taşınabilen Pin<Box<T>> üreten Box::pin
tercih edilir.
Pin ve Unpin, günlük Rust kodundan ziyade daha alt seviyeli kütüphaneler ya da bizzat bir çalışma zamanı geliştirirken önem kazanır. Yine de bu trait’leri hata mesajlarında gördüğünüzde artık kodunuzu nasıl düzelteceğiniz konusunda çok daha net bir fikriniz olacak!
Not: Pin ve Unpin’in bu birleşimi, kendine referanslı oldukları için aksi
takdirde uygulanması çok zor olacak koca bir karmaşık tür sınıfını Rust’ta
güvenli biçimde uygulamayı mümkün kılar. Pin gerektiren türler günümüzde en
yaygın olarak asenkron Rust kodunda karşımıza çıkar, ancak nadiren de olsa
başka bağlamlarda da onları görebilirsiniz.
Pin ve Unpin’in çalışma ayrıntıları ve uymaları gereken kurallar,
std::pin için hazırlanan API belgelendirmesinde kapsamlı biçimde ele
alınmaktadır; dolayısıyla daha fazlasını öğrenmek isterseniz başlamak için
harika bir yerdir.
İşlerin perde arkasında nasıl yürüdüğünü çok daha ayrıntılı anlamak isterseniz Asynchronous Programming in Rust kitabının 2. ve 4. bölümlerine göz atabilirsiniz.
Stream Trait’i
Artık Future, Pin ve Unpin trait’lerini daha derinden kavradığınıza göre dikkatimizi Stream trait’ine çevirebiliriz. Bölümün başlarında öğrendiğiniz gibi akışlar, asenkron iteratörlere benzer. Ancak Iterator ve Future’ın aksine, bu satırlar yazılırken Stream trait’inin standart kütüphanede bir tanımı yoktur; bunun yerine ekosistem genelinde kullanılan ve futures crate’inden gelen çok yaygın bir tanım mevcuttur.
Bir Stream trait’inin bunları nasıl bir araya getirebileceğine bakmadan önce Iterator ve Future trait’lerinin tanımlarını kısaca hatırlayalım. Iterator trait’inden ardışık öğe dizisi fikrini alırız: onun next metodu bir Option<Self::Item> sunar. Future trait’inden ise zaman içinde hazır olma fikrini alırız: onun poll metodu bir Poll<Self::Output> sunar. Zaman içinde hazır hâle gelen bir öğe dizisini temsil etmek için bu özellikleri bir araya getiren bir Stream trait’i tanımlarız:
#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};
trait Stream {
type Item;
fn poll_next(
self: Pin<&mut Self>,
cx: &mut Context<'_>
) -> Poll<Option<Self::Item>>;
}
}
Stream trait’i, akış tarafından üretilen öğelerin türü için Item adında bir ilişkili tür tanımlar. Bu yönüyle sıfır veya daha fazla öğenin bulunabileceği Iterator’a benzer; birim türü () olsa bile her zaman tek bir Output üreten Future’dan ise ayrılır.
Stream ayrıca bu öğeleri almak için bir metot da tanımlar. Tıpkı Future::poll gibi yoklama yaptığını ve tıpkı Iterator::next gibi bir öğe dizisi ürettiğini açıkça belirtmek için ona poll_next adını veririz. Dönüş türü Poll ile Option’ı birleştirir. Dıştaki tür Poll’dur, çünkü tıpkı bir future’da olduğu gibi hazır olup olmadığının kontrol edilmesi gerekir. İçteki tür ise Option’dır, çünkü tıpkı bir iteratörde olduğu gibi daha fazla mesaj kalıp kalmadığını bildirmesi gerekir.
Bu tanıma çok benzeyen bir yapı büyük olasılıkla ileride Rust’ın standart kütüphanesinin bir parçası hâline gelecektir. O zamana kadar çoğu çalışma zamanının araç setinin bir parçası olduğundan ona güvenebilirsiniz ve bundan sonra ele alacağımız her şey genel olarak geçerli olacaktır!
Bununla birlikte, “Akışlar (Streams): Sıralı Future’lar” bölümünde gördüğümüz örneklerde poll_next veya Stream kullanmadık; bunun yerine next ve StreamExt kullandık. Elbette tıpkı poll metotları üzerinden future’larla doğrudan çalışabileceğimiz gibi, kendi Stream durum makinelerimizi elle yazarak doğrudan poll_next API’si üzerinden de çalışabilirdik. Ancak await kullanmak çok daha hoştur ve StreamExt trait’i tam da bunu yapabilmemiz için next metodunu sunar:
#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};
trait Stream {
type Item;
fn poll_next(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Self::Item>>;
}
trait StreamExt: Stream {
async fn next(&mut self) -> Option<Self::Item>
where
Self: Unpin;
// diğer metotlar...
}
}
Not: Bölümün önceki kısımlarında kullandığımız gerçek tanım bundan biraz farklı görünür; çünkü henüz trait’ler içinde async fonksiyon kullanımını desteklemeyen Rust sürümlerini de desteklemektedir. Sonuç olarak tanım şöyle görünür:
fn next(&mut self) -> Next<'_, Self> where Self: Unpin;
Buradaki Next türü, Future trait’ini uygulayan bir struct’tır ve await
anahtar kelimesinin bu metotla çalışabilmesi için Next<'_, Self> aracılığıyla
self referansının ömrünü adlandırmamıza olanak tanır.
StreamExt trait’i aynı zamanda akışlarla kullanılabilecek tüm ilginç metotların da yuvasıdır. StreamExt, Stream trait’ini uygulayan her tür için otomatik olarak uygulanır; ancak topluluğun temel trait’i etkilemeden yardımcı API’ler üzerinde geliştirme yapabilmesini sağlamak amacıyla bu trait’ler ayrı ayrı tanımlanmıştır.
trpl crate’inde kullanılan StreamExt sürümünde trait, yalnızca next metodunu tanımlamakla kalmaz; aynı zamanda Stream::poll_next çağrısının ayrıntılarını doğru şekilde ele alan varsayılan bir next uygulaması da sunar. Bu da kendi akış veri türünüzü yazmanız gerektiğinde bile yalnızca Stream trait’ini uygulamanızın yeterli olduğu ve ardından veri türünüzü kullanan herkesin StreamExt ile onun metotlarını otomatik olarak kullanabileceği anlamına gelir.
Bu trait’lerin alt seviye ayrıntıları için ele alacaklarımız bu kadar. Bölümü tamamlamak üzere, şimdi future’ların (akışlar dahil), görevlerin ve thread’lerin birbiriyle nasıl bütünleştiğine bakalım!
Future'lar, Görevler (Tasks) ve Thread'ler
Future’lar, Görevler (Tasks) ve Thread’ler
Bölüm 16’da gördüğümüz gibi, thread’ler (iş parçacıkları) eşzamanlılığa yönelik bir yaklaşım sunar. Bu bölümde ise başka bir yaklaşım gördük: future’lar ve akışlarla birlikte async kullanmak. Bir yöntemi diğerine ne zaman tercih edeceğinizi merak ediyorsanız yanıt şudur: duruma göre değişir! Üstelik pek çok durumda tercih thread’ler veya async değil, thread’ler ve async’tir.
Pek çok işletim sistemi onlarca yıldır thread tabanlı eşzamanlılık modelleri sunmaktadır ve bunun sonucunda pek çok programlama dili de bunları destekler. Ne var ki bu modellerin de kendine özgü ödünleşimleri vardır. Birçok işletim sisteminde her bir thread için hatırı sayılır miktarda bellek kullanılır. Ayrıca thread’ler yalnızca işletim sisteminiz ve donanımınız onları desteklediğinde bir seçenektir. Yaygın masaüstü ve mobil bilgisayarların aksine, bazı gömülü sistemlerde bir işletim sistemi bile bulunmaz; dolayısıyla thread’leri de yoktur.
Asenkron model ise farklı —ve nihayetinde tamamlayıcı— bir ödünleşim kümesi sunar. Asenkron modelde eşzamanlı işlemler kendi thread’lerini gerektirmez. Bunun yerine, bu bölümün başlarında “spawn_task ile Yeni Bir Görev Oluşturma” alt bölümünde yeni bir iş başlatmak üzere trpl::spawn_task kullandığımızda olduğu gibi görevler üzerinde çalışabilirler. Bir görev bir thread’e benzer; ancak işletim sistemi tarafından yönetilmek yerine kütüphane düzeyindeki kod —yani çalışma zamanı— tarafından yönetilir.
Thread başlatma API’leri ile görev başlatma API’lerinin birbirine bu kadar benzemesinin bir nedeni vardır. Thread’ler, senkron işlem kümeleri için bir sınır görevi görür; eşzamanlılık thread’ler arasında mümkündür. Görevler ise asenkron işlem kümeleri için bir sınır görevi görür; bir görev kendi gövdesindeki future’lar arasında geçiş yapabildiğinden, eşzamanlılık hem görevler arasında hem de görevlerin içinde mümkündür. Son olarak future’lar, Rust’ın en ince ayrıntılı eşzamanlılık birimidir ve her bir future, diğer future’lardan oluşan bir ağacı temsil edebilir. Çalışma zamanı —daha doğrusu onun yürütücüsü (executor)— görevleri yönetir, görevler de future’ları yönetir. Bu bakımdan görevler; işletim sistemi yerine bir çalışma zamanı tarafından yönetilmenin getirdiği ek yeteneklere sahip, çalışma zamanı yönetimindeki hafif thread’lere benzer.
Bu durum, asenkron görevlerin her zaman thread’lerden daha iyi olduğu (ya da tam tersinin geçerli olduğu) anlamına gelmez. Thread’lerle eşzamanlılık, bazı açılardan async ile eşzamanlılığa kıyasla daha basit bir programlama modelidir. Bu bir güçlü yön de olabilir, bir zayıf yön de. Thread’ler bir bakıma “ateşle ve unut” (fire and forget) mantığındadır; bir future’ın doğal karşılığına sahip olmadıklarından, bizzat işletim sistemi tarafından kesintiye uğratılmadıkları sürece kesintisiz biçimde tamamlanana dek çalışırlar.
Üstelik görevler (en azından bazı çalışma zamanlarında) thread’ler arasında taşınabildiği için, thread’ler ve görevler çoğu zaman birlikte çok iyi çalışır. Nitekim perde arkasında —spawn_blocking ve spawn_task fonksiyonlarını da barındıran— bu bölüm boyunca kullandığımız çalışma zamanı varsayılan olarak çok thread’lidir! Pek çok çalışma zamanı, sistemin genel performansını artırmak amacıyla thread’lerin o anki kullanım durumuna bağlı olarak görevleri thread’ler arasında şeffaf biçimde taşıyan iş çalma (work stealing) adlı bir yaklaşım kullanır. Bu yaklaşım aslında hem thread’leri hem de görevleri —ve dolayısıyla future’ları— gerektirir.
Hangi yöntemi ne zaman kullanacağınızı düşünürken şu pratik kuralları göz önünde bulundurabilirsiniz:
- İş yüksek ölçüde paralelleştirilebiliyorsa (yani CPU bağımlıysa) —örneğin her bir parçası ayrı ayrı işlenebilecek büyük miktarda veriyi işlemek gibi— thread’ler daha iyi bir tercihtir.
- İş yüksek ölçüde eşzamanlıysa (yani G/Ç bağımlıysa) —örneğin pek çok farklı kaynaktan farklı aralıklarda veya farklı hızlarda gelebilecek mesajları ele almak gibi— async daha iyi bir tercihtir.
Hem paralelliğe hem de eşzamanlılığa ihtiyaç duyuyorsanız, thread’ler ile async arasında seçim yapmak zorunda değilsiniz. Her birinin en iyi olduğu rolü üstlenmesine izin vererek ikisini özgürce bir arada kullanabilirsiniz. Örneğin Liste 17-25, gerçek dünyadaki Rust kodlarında bu tür bir birleşimin oldukça yaygın bir örneğini göstermektedir.
extern crate trpl; // for mdbook test
use std::{thread, time::Duration};
fn main() {
let (gonderici, mut alici) = trpl::channel();
thread::spawn(move || {
for i in 1..11 {
gonderici.send(i).unwrap();
thread::sleep(Duration::from_secs(1));
}
});
trpl::block_on(async {
while let Some(mesaj) = alici.recv().await {
println!("{mesaj}");
}
});
}
İşe bir asenkron kanal oluşturarak başlıyor, ardından move anahtar kelimesini kullanarak kanalın gönderici tarafının (gonderici) sahipliğini alan bir thread başlatıyoruz. Thread içinde 1’den 10’a kadar olan sayıları gönderiyor ve her birinin arasında bir saniye uyuyoruz. Son olarak, bölüm boyunca yaptığımız gibi trpl::block_on fonksiyonuna geçirilen bir async blokla oluşturulmuş future’ı çalıştırıyoruz. Bu future içinde, gördüğümüz diğer mesaj iletimi örneklerinde olduğu gibi bu mesajları (mesaj) await ile bekliyoruz.
Bölümü açtığımız senaryoya geri dönecek olursak; bir dizi video kodlama işini (video kodlama hesaplama bağımlı olduğu için) adanmış bir thread kullanarak çalıştırdığınızı, ancak bu işlemlerin tamamlandığını kullanıcı arayüzüne bir asenkron kanal aracılığıyla bildirdiğinizi düşünün. Gerçek dünya kullanım senaryolarında bu tür birleşimlerin sayısız örneği vardır.
Not: Tek Bakışta Eşzamanlılık Katmanları ve Senkron-Asenkron Köprüsü
Bölüm 16 ve Bölüm 17’de ele aldığımız üç temel yapı taşı, birbirinin rakibi değil farklı soyutlama katmanlarıdır:
- Future (Tamamlanabilecek bir hesaplamayı temsil eden değer):
asynckoddan üretilen future, derleyicinin oluşturduğu bir durum makinesidir;Futuretrait’ini elle uygulayan türler de yazılabilir. Boyutu, yakalanan verileri ve bekleme noktalarında korunacak durumu içerir. Kendi başına çalışmaz; yoklandıkça işbirlikli (cooperative) ilerler. - Görev / Task (Çalışma zamanı yönetiminde birim): Bir future ağacının yürütme sınırıdır. Yürütücü (executor) tarafından kullanıcı alanında çizelgelenir; bağlam değişimi işletim sistemi çekirdeğine inmeden çok düşük maliyetle gerçekleştiği için binlerce eşzamanlı G/Ç bağlantısını yönetmeye uygundur.
- Thread (İşletim sistemi yönetiminde birim): Kendine ait ayrı bir yığın (stack) belleği ayırır ve işletim sistemi çekirdeği tarafından kesmeli (preemptive) olarak çizelgelenir. Fiziksel CPU çekirdeklerinde gerçek paralellik sağladığı için yoğun hesaplama yüklerine uygundur.
Hibrit mimarilerde bu katmanları birleştirirken Liste 17-25’teki inceliğe
dikkat edin: sınırsız (unbounded) asenkron kanallarda gonderici.send(i)
çağrısı karşı tarafı beklemeden anında tamamlandığı için senkron bir metottur
ve await gerektirmeden doğrudan std::thread içinden çağrılabilir;
alici.recv().await ise mesaj gelene dek bekleyebildiği için asenkrondur. Bu
sayede kanallar, senkron thread dünyası ile asenkron görev dünyası arasında
doğal ve güvenli bir köprü kurar.
Özet
Bu kitapta eşzamanlılık konusunu son kez görmüyorsunuz. Bölüm 21’deki proje, bu kavramları burada ele aldığımız daha basit örneklerden daha gerçekçi bir durumda uygulayacak ve problemleri thread’lerle çözmek ile görevler ve future’larla çözmeyi daha doğrudan karşılaştıracaktır.
Bu yaklaşımlardan hangisini seçerseniz seçin, Rust —ister yüksek verimli, yoğun trafikli bir web sunucusu ister gömülü bir işletim sistemi için olsun— güvenli, hızlı ve eşzamanlı kod yazmak üzere ihtiyaç duyduğunuz araçları size sunar.
Sıradaki bölümde, Rust programlarınız büyüdükçe problemleri modellemenin ve çözümleri yapılandırmanın deyimsel (idiomatic) yollarından bahsedeceğiz. Ek olarak, Rust’ın deyimsel kalıplarının nesne yönelimli programlamadan aşina olabileceğiniz yaklaşımlarla nasıl bir ilişki kurduğunu tartışacağız.
Nesne Yönelimli Programlama Özellikleri
Nesne yönelimli programlama (NYP; İng. object-oriented programming, OOP), programları modellemenin yollarından biridir. Bir programlama kavramı olarak nesneler, 1960’larda Simula programlama dilinde ortaya çıktı. Bu nesneler, Alan Kay’in nesnelerin birbirine mesaj ilettiği programlama mimarisini etkiledi. Kay, 1967’de bu mimariyi tanımlamak için nesne yönelimli programlama terimini ortaya attı. NYP’nin ne olduğuna dair birbiriyle çelişen pek çok tanım vardır; bu tanımların bazılarına göre Rust nesne yönelimlidir, bazılarına göreyse değildir.
Bu bölümde, yaygın olarak nesne yönelimli kabul edilen bazı karakteristikleri ve bu karakteristiklerin deyimsel Rust’ta nasıl karşılık bulduğunu inceleyeceğiz. Ardından nesne yönelimli bir tasarım desenini Rust’ta nasıl uygulayacağınızı gösterecek, bu yolun ödünleşimlerini (trade-offs) de Rust’ın güçlü yönlerinden yararlanan bir çözümle karşılaştırarak tartışacağız.
Not: Rust çok paradigmalı (multi-paradigm) bir dildir: veriyi struct ve
enum ile, davranışı ise impl blokları ve trait’lerle modeller. Klasik
sınıf tabanlı kalıtım (class inheritance) yerine bileşim (composition),
trait sınırları (trait bounds), trait nesneleri (dyn Trait) ve
cebirsel veri türleri (enum) kullanarak hem çalışma zamanı esnekliğini
hem de derleme zamanı tür ve sahiplik güvenliğini bir arada sunar.
Nesne Yönelimli Dillerin Karakteristikleri
Nesne Yönelimli Dillerin Karakteristikleri
Programlama topluluğunda, bir dilin nesne yönelimli sayılabilmesi için hangi özelliklere sahip olması gerektiği konusunda bir fikir birliği yoktur. Rust, NYP dahil olmak üzere pek çok programlama paradigmasından etkilenmiştir; örneğin Bölüm 13’te fonksiyonel programlamadan gelen özellikleri incelemiştik. Tartışmaya açık olsa da NYP dillerinin bazı ortak karakteristikleri paylaştığı söylenebilir: nesneler, kapsülleme ve kalıtım. Şimdi bu karakteristiklerin her birinin ne anlama geldiğine ve Rust’ın bunları destekleyip desteklemediğine bakalım.
Nesneler Veri ve Davranışı Birlikte Taşır
Erich Gamma, Richard Helm, Ralph Johnson ve John Vlissides’in yazdığı Design Patterns: Elements of Reusable Object-Oriented Software (Addison-Wesley, 1994), halk arasında Dörtlü Çete (The Gang of Four) kitabı olarak bilinir ve nesne yönelimli tasarım desenlerinin bir kataloğudur. Bu kitap NYP’yi şöyle tanımlar:
Nesne yönelimli programlar nesnelerden oluşur. Bir nesne, hem veriyi hem de o veri üzerinde çalışan yordamları bir arada paketler. Bu yordamlar genellikle metot veya işlem olarak adlandırılır.
Bu tanıma göre Rust nesne yönelimlidir: struct’lar ve enum’lar veri tutar, impl blokları ise struct’lar ve enum’lar üzerinde metotlar sağlar. Metotlara sahip struct ve enum’lar nesne olarak adlandırılmasa da Dörtlü Çete’nin nesne tanımındaki işlevselliğin aynısını sunarlar.
Uygulama Ayrıntılarını Gizleyen Kapsülleme
NYP ile sıkça ilişkilendirilen bir diğer yön de, bir nesnenin uygulama ayrıntılarının o nesneyi kullanan kod tarafından erişilebilir olmaması anlamına gelen kapsülleme (encapsulation) düşüncesidir. Dolayısıyla bir nesneyle etkileşim kurmanın tek yolu, onun açık (public) API’sidir; nesneyi kullanan kod, nesnenin iç yapısına ulaşıp veriyi veya davranışı doğrudan değiştirememelidir. Bu sayede programcı, nesneyi kullanan kodu değiştirmeye gerek duymadan nesnenin iç yapısını değiştirebilir ve yeniden düzenleyebilir.
Kapsüllemeyi nasıl denetleyeceğimizi Bölüm 7’de ele almıştık: Kodumuzdaki hangi modüllerin, türlerin, fonksiyonların ve metotların dışarıya açık olacağına karar vermek için pub anahtar kelimesini kullanabiliriz; bunun dışındaki her şey varsayılan olarak gizlidir. Örneğin, i32 değerlerinden oluşan bir vektör tutan bir alana sahip OrtalamaliKoleksiyon adında bir struct tanımlayabiliriz. Bu struct ayrıca vektördeki değerlerin ortalamasını tutan bir alana da sahip olabilir; böylece birisi ortalamaya her ihtiyaç duyduğunda değerin anlık olarak yeniden hesaplanması gerekmez. Başka bir deyişle OrtalamaliKoleksiyon, hesaplanan ortalamayı bizim için önbellekte tutar. Liste 18-1, OrtalamaliKoleksiyon struct’ının tanımını içerir.
pub struct OrtalamaliKoleksiyon {
liste: Vec<i32>,
ortalama: f64,
}
OrtalamaliKoleksiyon struct’ıDiğer kodların kullanabilmesi için struct pub olarak işaretlenmiştir, ancak struct içindeki alanlar gizli kalır. Bu örnekte bu durum önemlidir; çünkü listeye ne zaman bir değer eklense veya listeden bir değer çıkarılsa ortalamanın da güncellendiğinden emin olmak istiyoruz. Bunu Liste 18-2’de gösterildiği gibi struct üzerinde ekle, cikar ve ortalama metotlarını uygulayarak yaparız.
pub struct OrtalamaliKoleksiyon {
liste: Vec<i32>,
ortalama: f64,
}
impl OrtalamaliKoleksiyon {
pub fn ekle(&mut self, deger: i32) {
self.liste.push(deger);
self.ortalamayi_guncelle();
}
pub fn cikar(&mut self) -> Option<i32> {
let sonuc = self.liste.pop();
match sonuc {
Some(deger) => {
self.ortalamayi_guncelle();
Some(deger)
}
None => None,
}
}
pub fn ortalama(&self) -> f64 {
self.ortalama
}
fn ortalamayi_guncelle(&mut self) {
let toplam: i32 = self.liste.iter().sum();
self.ortalama = toplam as f64 / self.liste.len() as f64;
}
}
OrtalamaliKoleksiyon üzerindeki açık ekle, cikar ve ortalama metotlarının uygulamalarıAçık ekle, cikar ve ortalama metotları, bir OrtalamaliKoleksiyon örneğindeki veriye erişmenin veya onu değiştirmenin tek yoludur. ekle metodu kullanılarak liste’ye bir öğe eklendiğinde ya da cikar metodu kullanılarak çıkarıldığında, her iki metodun uygulaması da ortalama alanını güncelleme işini üstlenen gizli ortalamayi_guncelle metodunu çağırır.
liste ve ortalama alanlarını gizli bırakırız ki dışarıdaki kodun liste alanına doğrudan öğe eklemesinin veya oradan öğe çıkarmasının bir yolu olmasın; aksi takdirde liste değiştiğinde ortalama alanı güncelliğini yitirip uyumsuz hâle gelebilirdi. ortalama metodu ortalama alanındaki değeri döndürerek dışarıdaki kodun ortalama değerini okumasına izin verir, ancak değiştirmesine izin vermez.
OrtalamaliKoleksiyon struct’ının uygulama ayrıntılarını kapsüllediğimiz için gelecekte veri yapısı gibi iç ayrıntıları kolayca değiştirebiliriz. Örneğin liste alanı için Vec<i32> yerine bir HashSet<i32> kullanabiliriz. Açık ekle, cikar ve ortalama metotlarının imzaları aynı kaldığı sürece, OrtalamaliKoleksiyon kullanan kodun değişmesine gerek kalmaz. Bunun yerine liste alanını pub yapmış olsaydık durum böyle olmayabilirdi: HashSet<i32> ve Vec<i32> türleri öğe eklemek ve çıkarmak için farklı metotlara sahiptir; dolayısıyla dışarıdaki kod liste alanını doğrudan değiştiriyor olsaydı büyük olasılıkla onun da değişmesi gerekirdi.
Bir dilin nesne yönelimli sayılabilmesi için kapsülleme zorunlu bir özellikse, Rust bu gereksinimi karşılar. Kodun farklı bölümlerinde pub kullanıp kullanmama seçeneği, uygulama ayrıntılarının kapsüllenmesini sağlar.
Not: Kapsüllemenin bir diğer kritik faydası, bir türün değişmezlerini (type
invariants) tek bir noktadan koruyabilmektir. Örneğin Liste 18-2’deki
ortalamayi_guncelle metoduna dikkatli bakarsanız, koleksiyondaki son öğe
cikar ile silindiğinde self.liste.len() değerinin 0 olacağını ve
0.0 / 0.0 bölme işleminin IEEE 754 kayan nokta kuralları gereği f64::NAN
(Not a Number) üreteceğini fark edebilirsiniz. Alanlar dışarıya kapalı
olduğundan, boş koleksiyon durumunda ortalamayı 0.0’a sıfırlama düzeltmesini
dışarıdaki hiçbir çağrıyı bozmadan yalnızca ortalamayi_guncelle metodunun
içinden güvenle yapabiliriz.
Tür Sistemi ve Kod Paylaşımı Olarak Kalıtım
Kalıtım (inheritance), bir nesnenin başka bir nesnenin tanımından öğeler miras alabildiği ve böylece üst nesnenin verisini ve davranışını sizin yeniden tanımlamanıza gerek kalmadan kazandığı bir mekanizmadır.
Bir dilin nesne yönelimli olabilmesi için kalıtıma sahip olması şartsa, Rust böyle bir dil değildir. Bir makro kullanmaksızın, üst struct’ın alanlarını ve metot uygulamalarını miras alan bir struct tanımlamanın yolu yoktur.
Ancak programlama alet çantanızda kalıtımın bulunmasına alışkınsanız, kalıtıma hangi amaçla başvurduğunuza bağlı olarak Rust’ta başka çözümler kullanabilirsiniz.
Kalıtımı başlıca iki nedenden ötürü tercih edersiniz. Birincisi kodun yeniden kullanımıdır: Belirli bir davranışı bir tür için uygulayabilirsiniz ve kalıtım, bu uygulamayı farklı bir tür için yeniden kullanmanıza olanak tanır. Bunu Rust kodunda sınırlı bir biçimde, Liste 10-14’te Ozet trait’i üzerindeki ozetle metoduna varsayılan bir uygulama eklediğimizde gördüğünüz varsayılan trait metodu uygulamalarını kullanarak yapabilirsiniz. Ozet trait’ini uygulayan herhangi bir tür, fazladan hiçbir kod yazılmasına gerek kalmadan ozetle metodunu kullanıma hazır olarak edinir. Bu durum, bir üst sınıfın bir metot uygulamasına sahip olmasına ve ondan miras alan bir alt sınıfın da aynı metot uygulamasına sahip olmasına benzer. Ayrıca Ozet trait’ini uygularken ozetle metodunun varsayılan uygulamasını geçersiz kılabiliriz (override); bu da bir alt sınıfın üst sınıftan miras aldığı bir metodun uygulamasını geçersiz kılmasına benzer.
Kalıtımı kullanmanın diğer nedeni tür sistemiyle ilgilidir: alt türün, üst türle aynı yerlerde kullanılabilmesini sağlamak. Buna çok biçimlilik (polymorphism) de denir; belirli karakteristikleri paylaşan birden fazla nesneyi çalışma zamanında birbirinin yerine kullanabilmeniz anlamına gelir.
Çok Biçimlilik
Pek çok kişi için çok biçimlilik, kalıtım ile eş anlamlıdır. Ancak aslında birden fazla türdeki veriyle çalışabilen kodu ifade eden daha genel bir kavramdır. Kalıtım söz konusu olduğunda bu türler genellikle alt sınıflardır.
Rust ise bunun yerine farklı olası türler üzerinde soyutlama yapmak için jenerikleri, bu türlerin neleri sağlaması gerektiğine dair kısıtlamalar koymak için de trait sınırlarını kullanır. Buna bazen sınırlı parametrik çok biçimlilik (bounded parametric polymorphism) denir.
Rust, kalıtım sunmayarak farklı bir ödünleşim kümesi seçmiştir. Kalıtım çoğu zaman gerekenden fazla kod paylaşma riski taşır. Alt sınıflar üst sınıflarının tüm karakteristiklerini her zaman paylaşmamalıdır, ancak kalıtım kullanıldığında bunu yaparlar. Bu durum bir programın tasarımını daha az esnek hâle getirebilir. Ayrıca alt sınıfa uymayan metotlar yüzünden, alt sınıflar üzerinde anlamsız olan veya hatalara yol açan metotların çağrılması olasılığı da doğar. Buna ek olarak bazı diller yalnızca tekli kalıtıma (single inheritance) —yani bir alt sınıfın yalnızca tek bir sınıftan miras alabilmesine— izin vererek program tasarımının esnekliğini daha da kısıtlar.
Not: Kalıtımın yerini Rust’ta çoğunlukla bileşim (composition) alır: Ortak veriyi ayrı bir struct’ta toplayıp onu diğer struct’larda bir alan olarak barındırır, ortak davranışı ise trait’lerde tanımlarsınız. Trait’ler arasında “bu trait’i uygulayan her tür şu trait’i de uygulamalıdır” biçiminde bir bağımlılık kurmak içinse Bölüm 20’deki “Üst Trait’leri Kullanmak” alt bölümünde göreceğimiz üst trait’ler (supertraits) kullanılır. Bu mekanizma, kalıtımdaki sınıf hiyerarşisine benzer bir ilişkiyi veri düzeyinde değil, yalnızca davranış düzeyinde ifade eder.
Bu nedenlerle Rust, çalışma zamanında çok biçimlilik elde etmek için kalıtım yerine trait nesnelerini kullanma yönünde farklı bir yaklaşım benimser. Şimdi trait nesnelerinin nasıl çalıştığına bakalım.
Ortak Davranışı Soyutlamak İçin Trait Nesnelerini Kullanma
Ortak Davranışı Soyutlamak İçin Trait Nesnelerini Kullanma
Bölüm 8’de vektörlerin kısıtlamalarından birinin yalnızca tek bir türdeki öğeleri saklayabilmek olduğundan bahsetmiştik. Liste 8-9’da tam sayıları, kayan noktalı sayıları ve metinleri tutacak varyantlara sahip bir ElektronikTabloHucresi enum’ı tanımlayarak buna geçici bir çözüm üretmiştik. Bu sayede her hücrede farklı türde veriler saklayabiliyor ve yine de bir hücre satırını temsil eden tek bir vektöre sahip olabiliyorduk. Birbiri yerine geçebilen öğelerimiz kodumuz derlendiği sırada bildiğimiz sabit bir tür kümesinden oluştuğunda bu son derece iyi bir çözümdür.
Ancak kimi zaman kütüphanemizi kullanan geliştiricilerin, belirli bir durumda geçerli olan tür kümesini genişletebilmesini isteriz. Bunu nasıl başarabileceğimizi göstermek için örnek bir grafiksel kullanıcı arayüzü (GUI) aracı oluşturacağız. Bu araç, bir öğe listesi üzerinde yineleme yaparak her öğeyi ekrana çizmek için öğenin ciz metodunu çağıracak; bu, GUI araçlarında yaygın olarak kullanılan bir tekniktir. Bir GUI kütüphanesinin iskeletini barındıran arayuz adında bir kütüphane crate’i oluşturacağız. Bu crate, geliştiricilerin kullanabileceği Dugme veya MetinAlani gibi bazı hazır türler içerebilir. Buna ek olarak, arayuz kullanıcıları ekrana çizilebilen kendi türlerini de oluşturmak isteyecektir: Örneğin bir programcı Gorsel türü eklerken, bir başkası SecimKutusu türü ekleyebilir.
Kütüphaneyi yazdığımız sırada diğer programcıların oluşturmak isteyebileceği tüm türleri bilmemiz ve tanımlamamız mümkün değildir. Ancak arayuz crate’inin farklı türlerden pek çok değeri takip etmesi ve bu farklı türdeki değerlerin her biri üzerinde bir ciz metodu çağırması gerektiğini biliyoruz. Kütüphanenin ciz metodunu çağırdığımızda tam olarak ne gerçekleşeceğini bilmesine gerek yoktur; yalnızca ilgili değerin çağırabileceğimiz bu metoda sahip olduğunu bilmesi yeterlidir.
Kalıtıma sahip bir dilde bunu yapmak için, üzerinde ciz (draw) adında bir metot bulunan Bilesen (Component) adlı bir sınıf tanımlayabilirdik. Dugme, Gorsel ve SecimKutusu gibi diğer sınıflar Bilesen sınıfından miras alır ve dolayısıyla ciz metodunu da devralırdı. Her biri kendi özel davranışını tanımlamak için ciz metodunu geçersiz kılabilirdi (override), ancak çatı (framework) tüm bu türlere sanki Bilesen örnekleriymiş gibi davranıp üzerlerinde ciz metodunu çağırabilirdi. Ne var ki Rust’ta kalıtım bulunmadığından, kullanıcıların kütüphaneyle uyumlu yeni türler oluşturmasına olanak tanımak için arayuz kütüphanesini yapılandırmanın başka bir yoluna ihtiyacımız vardır.
Ortak Davranış İçin Bir Trait Tanımlamak
arayuz kütüphanesinin sahip olmasını istediğimiz davranışı uygulamak için, üzerinde ciz adında tek bir metot bulunan Ciz adlı bir trait tanımlayacağız. Ardından trait nesnelerini tutan bir vektör tanımlayabiliriz. Bir trait nesnesi (trait object), hem belirttiğimiz trait’i uygulayan bir türün örneğine hem de çalışma zamanında o tür üzerindeki trait metotlarını bulmak için kullanılan bir tabloya işaret eder. & referansı veya Box<T> akıllı işaretçisi gibi bir işaretçi türünü, ardından dyn anahtar kelimesini ve son olarak ilgili trait’i belirterek bir trait nesnesi oluştururuz. (Trait nesnelerinin neden bir işaretçi kullanmak zorunda olduğunu Bölüm 20’deki “Dinamik Boyutlu Türler ve Sized Trait’i” alt bölümünde ele alacağız.) Trait nesnelerini jenerik veya somut bir türün yerine kullanabiliriz. Bir trait nesnesi kullandığımız her yerde Rust’ın tür sistemi, o bağlamda kullanılan her değerin ilgili trait nesnesinin trait’ini uyguladığını derleme zamanında güvence altına alır. Sonuç olarak derleme zamanında olası tüm türleri bilmemize gerek kalmaz.
Rust’ta struct’ları ve enum’ları diğer dillerdeki nesnelerden ayırt etmek için onlara “nesne” demekten kaçındığımızdan bahsetmiştik. Bir struct veya enum’da, struct alanlarındaki veri ile impl bloklarındaki davranış birbirinden ayrılmıştır; oysa diğer dillerde tek bir kavram altında birleştirilen veri ve davranış genellikle nesne olarak adlandırılır. Bununla birlikte trait nesneleri, veriyi ve davranışı bir araya getirmeleri bakımından diğer dillerdeki nesnelere daha çok benzer. Ancak trait nesnelerine veri ekleyemememiz, onları geleneksel nesnelerden ayırır. Trait nesneleri diğer dillerdeki nesneler kadar genel amaçlı değildir: Asıl amaçları, ortak davranış üzerinde soyutlama yapılmasına olanak tanımaktır.
Liste 18-3, ciz adında tek bir metoda sahip Ciz adlı bir trait’in nasıl tanımlanacağını gösterir.
pub trait Ciz {
fn ciz(&self);
}
Ciz trait’inin tanımıBu sözdizimi, Bölüm 10’da trait’lerin nasıl tanımlanacağına dair tartışmalarımızdan tanıdık gelecektir. Şimdi yeni bir sözdizimine geçiyoruz: Liste 18-4, bilesenler adında bir vektör tutan Ekran adlı bir struct tanımlar. Bu vektörün öğeleri bir trait nesnesi olan Box<dyn Ciz> türündedir; bu ifade, bir Box içinde yer alan ve Ciz trait’ini uygulayan herhangi bir türün yerini tutar.
pub trait Ciz {
fn ciz(&self);
}
pub struct Ekran {
pub bilesenler: Vec<Box<dyn Ciz>>,
}
Ciz trait’ini uygulayan trait nesnelerinden oluşan bir vektör tutan bilesenler alanına sahip Ekran struct’ının tanımıNot: Bellek düzeni açısından &dyn Ciz veya Box<dyn Ciz> gibi bir trait
nesnesi işaretçisi, sıradan tek adreslik bir işaretçi değil; bellekte iki
usize genişliğinde yer kaplayan (64-bit mimarilerde 16 bayt) bir geniş
işaretçidir (fat pointer / wide pointer). Bu iki işaretçiden ilki
öbekteki veya yığındaki somut verinin (Dugme, SecimKutusu vb.) bellek
adresini, ikincisi ise derleyicinin o somut tür ve trait çifti için ürettiği
sanal metot tablosunu (vtable) gösterir. Sanal metot tablosunda somut
türün boyutu, hizalaması (alignment), değer düşürülürken (drop)
çağrılacak fonksiyonun işaretçisi ve Ciz::ciz gibi trait metotlarının
fonksiyon işaretçileri yer alır. Her Box<dyn Ciz> öğesi, gösterdiği somut
türden bağımsız olarak sabit boyutta (64-bit mimarilerde 16 bayt) olduğundan,
Vec<Box<dyn Ciz>> vektörü bu sabit boyutlu işaretçileri yan yana tutabilir.
Farklı boyutlardaki bileşenlerin kendileri ise her bir Box için ayrılan
ayrı öbek alanlarında bulunur; vektörün içinde doğrudan yan yana yer almazlar.
Ekran struct’ı üzerinde, Liste 18-5’te gösterildiği gibi bilesenler alanındaki her bir öğe üzerinde ciz metodunu çağıracak calistir adlı bir metot tanımlayacağız.
pub trait Ciz {
fn ciz(&self);
}
pub struct Ekran {
pub bilesenler: Vec<Box<dyn Ciz>>,
}
impl Ekran {
pub fn calistir(&self) {
for bilesen in self.bilesenler.iter() {
bilesen.ciz();
}
}
}
Ekran üzerinde her bileşen için ciz metodunu çağıran bir calistir metoduBu yapı, trait sınırlarına sahip bir jenerik tür parametresi kullanan bir struct tanımlamaktan farklı çalışır. Jenerik bir tür parametresinin yerine her seferinde yalnızca tek bir somut tür geçirilebilirken, trait nesneleri çalışma zamanında kendilerinin yerine birden fazla somut türün geçebilmesine olanak tanır. Örneğin Ekran struct’ını Liste 18-6’daki gibi jenerik bir tür ve bir trait sınırı kullanarak tanımlayabilirdik.
pub trait Ciz {
fn ciz(&self);
}
pub struct Ekran<T: Ciz> {
pub bilesenler: Vec<T>,
}
impl<T> Ekran<T>
where
T: Ciz,
{
pub fn calistir(&self) {
for bilesen in self.bilesenler.iter() {
bilesen.ciz();
}
}
}
Ekran struct’ı ve calistir metodunun jenerikler ve trait sınırları kullanan alternatif bir uygulamasıBu yaklaşım bizi, bileşenlerinin tamamı Dugme türünde veya tamamı MetinAlani türünde olan bir listeye sahip bir Ekran örneğiyle sınırlar. Yalnızca homojen (tek türden oluşan) koleksiyonlarınız olacaksa jenerikleri ve trait sınırlarını kullanmak tercih edilir; çünkü tanımlar somut türleri kullanacak şekilde derleme zamanında tek biçimlileştirilecektir (monomorphized).
Öte yandan trait nesneleri kullanan yaklaşımla tek bir Ekran örneği, hem bir Box<Dugme> hem de bir Box<MetinAlani> içeren bir Vec<T> tutabilir. Şimdi bunun nasıl çalıştığına bakalım, ardından da çalışma zamanı performansı üzerindeki etkilerini ele alalım.
Trait’i Uygulamak
Şimdi Ciz trait’ini uygulayan bazı türler ekleyeceğiz. İlk olarak Dugme türünü sağlayacağız. Yine belirtelim ki gerçek bir GUI kütüphanesi yazmak bu kitabın kapsamı dışındadır; bu nedenle ciz metodunun gövdesinde işlevsel bir uygulama yer almayacaktır. Uygulamanın nasıl görünebileceğini canlandırmak için bir Dugme struct’ı, Liste 18-7’de gösterildiği gibi genislik, yukseklik ve etiket alanlarına sahip olabilir.
pub trait Ciz {
fn ciz(&self);
}
pub struct Ekran {
pub bilesenler: Vec<Box<dyn Ciz>>,
}
impl Ekran {
pub fn calistir(&self) {
for bilesen in self.bilesenler.iter() {
bilesen.ciz();
}
}
}
pub struct Dugme {
pub genislik: u32,
pub yukseklik: u32,
pub etiket: String,
}
impl Ciz for Dugme {
fn ciz(&self) {
// bir düğmeyi gerçekten çizmek için kod
}
}
Ciz trait’ini uygulayan bir Dugme struct’ıDugme üzerindeki genislik, yukseklik ve etiket alanları diğer bileşenlerdeki alanlardan farklı olacaktır; örneğin bir MetinAlani türü aynı alanlara ek olarak bir yer_tutucu (placeholder) alanına sahip olabilir. Ekrana çizmek istediğimiz türlerin her biri Ciz trait’ini uygulayacak, ancak tıpkı burada Dugme türünün yaptığı gibi (belirttiğimiz üzere gerçek GUI kodu olmaksızın) o türe özgü çizim işleminin nasıl yapılacağını tanımlamak için ciz metodunda farklı kodlar kullanacaktır. Ayrıca Dugme türü, kullanıcı düğmeye tıkladığında ne olacağıyla ilgili metotlar içeren ek bir impl bloğuna sahip olabilir. Bu tür metotlar MetinAlani gibi türler için geçerli olmayacaktır.
Kütüphanemizi kullanan bir geliştirici genislik, yukseklik ve secenekler alanlarına sahip bir SecimKutusu struct’ı yazmaya karar verirse, Liste 18-8’de gösterildiği gibi SecimKutusu türü üzerinde de Ciz trait’ini uygulayacaktır.
use arayuz::Ciz;
struct SecimKutusu {
genislik: u32,
yukseklik: u32,
secenekler: Vec<String>,
}
impl Ciz for SecimKutusu {
fn ciz(&self) {
// bir seçim kutusunu gerçekten çizmek için kod
}
}
fn main() {}
arayuz crate’ini kullanan ve bir SecimKutusu struct’ı üzerinde Ciz trait’ini uygulayan başka bir crateKütüphanemizin kullanıcısı artık bir Ekran örneği oluşturmak için kendi main fonksiyonunu yazabilir. Bir SecimKutusu ve bir Dugme örneğinin her birini trait nesnesine dönüştürmek üzere Box<T> içine koyarak Ekran örneğine ekleyebilir. Ardından Ekran örneği üzerinde, bileşenlerin her birinde ciz metodunu çağıracak olan calistir metodunu çağırabilir. Liste 18-9 bu uygulamayı gösterir.
use arayuz::Ciz;
struct SecimKutusu {
genislik: u32,
yukseklik: u32,
secenekler: Vec<String>,
}
impl Ciz for SecimKutusu {
fn ciz(&self) {
// bir seçim kutusunu gerçekten çizmek için kod
}
}
use arayuz::{Dugme, Ekran};
fn main() {
let ekran = Ekran {
bilesenler: vec![
Box::new(SecimKutusu {
genislik: 75,
yukseklik: 10,
secenekler: vec![
String::from("Evet"),
String::from("Belki"),
String::from("Hayır"),
],
}),
Box::new(Dugme {
genislik: 50,
yukseklik: 10,
etiket: String::from("Tamam"),
}),
],
};
ekran.calistir();
}
Kütüphaneyi yazdığımız sırada birilerinin SecimKutusu türünü ekleyebileceğini bilmiyorduk; ancak SecimKutusu türü Ciz trait’ini —dolayısıyla da ciz metodunu— uyguladığı için Ekran uygulamamız bu yeni tür üzerinde çalışabildi ve onu çizebildi.
Bir değerin somut türünden ziyade yalnızca yanıt verdiği mesajlarla ilgilenme düşüncesi, dinamik tür sistemine sahip dillerdeki ördek tiplemesi (duck typing) kavramına benzer: Bir şey ördek gibi yürüyor ve ördek gibi vaklıyorsa, o bir ördek olmalıdır! Liste 18-5’te Ekran üzerinde tanımladığımız calistir metodunun, her bir bileşenin somut türünün ne olduğunu bilmesine gerek yoktur. Bir bileşenin Dugme mi yoksa SecimKutusu örneği mi olduğunu denetlemez; yalnızca bileşen üzerinde ciz metodunu çağırır. bilesenler vektöründeki değerlerin türü olarak Box<dyn Ciz> belirterek, Ekran struct’ını üzerinde ciz metodunu çağırabileceğimiz değerlere ihtiyaç duyacak biçimde tanımlamış olduk.
Ördek tiplemesi kullanan koda benzer kodlar yazmak için trait nesnelerini ve Rust’ın tür sistemini kullanmanın avantajı, bir değerin belirli bir metodu uygulayıp uygulamadığını çalışma zamanında hiçbir zaman denetlemek zorunda kalmamamız ya da bir değer bir metodu uygulamadığı hâlde onu çağırdığımızda çalışma zamanı hatası alma endişesi yaşamamamızdır. Değerler trait nesnelerinin ihtiyaç duyduğu trait’leri uygulamıyorsa Rust kodumuzu derlemez.
Örneğin Liste 18-10, bileşen olarak bir String içeren bir Ekran oluşturmaya çalışırsak ne olacağını gösterir.
use arayuz::Ekran;
fn main() {
let ekran = Ekran {
bilesenler: vec![Box::new(String::from("Merhaba"))],
};
ekran.calistir();
}
String, Ciz trait’ini uygulamadığı için şu hatayı alırız:
$ cargo run
Compiling arayuz v0.1.0 (file:///projects/arayuz)
error[E0277]: the trait bound `String: Ciz` is not satisfied
--> src/main.rs:5:26
|
5 | bilesenler: vec![Box::new(String::from("Merhaba"))],
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Ciz` is not implemented for `String`
|
help: the trait `Ciz` is implemented for `Dugme`
--> src/lib.rs:23:1
|
23 | impl Ciz for Dugme {
| ^^^^^^^^^^^^^^^^^^
= note: required for the cast from `Box<String>` to `Box<dyn Ciz>`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `arayuz` (bin "arayuz") due to 1 previous error
Bu hata bize ya Ekran örneğine istemediğimiz bir şey geçirdiğimizi ve dolayısıyla farklı bir tür geçirmemiz gerektiğini ya da Ekran’ın onun üzerinde ciz metodunu çağırabilmesi için String üzerinde Ciz trait’ini uygulamamız gerektiğini bildirir.
Dinamik Gönderim Gerçekleştirmek
Bölüm 10’daki “Jenerik Kullanan Kodların Performansı” alt bölümünde, derleyicinin jenerikler üzerinde gerçekleştirdiği tek biçimlileştirme (monomorphization / monomorfizasyon) sürecine dair tartışmamızı hatırlayın: Derleyici, jenerik bir tür parametresinin yerine kullandığımız her somut tür için fonksiyonların ve metotların jenerik olmayan uygulamalarını üretir. Tek biçimlileştirme sonucunda ortaya çıkan kod statik gönderim (statik dağıtım, İng. static dispatch) yapar; yani derleyici hangi metodu çağırdığınızı derleme zamanında bilir. Bunun karşısında ise derleyicinin hangi metodu çağırdığınızı derleme zamanında bilemediği dinamik gönderim (dinamik dağıtım, İng. dynamic dispatch) yer alır. Dinamik gönderim durumlarında derleyici, hangi metodun çağrılacağını çalışma zamanında belirleyecek kodu üretir.
Trait nesneleri kullandığımızda Rust dinamik gönderim kullanmak zorundadır. Derleyici, trait nesneleri kullanan kodla birlikte kullanılabilecek tüm türleri bilmediği için hangi tür üzerinde uygulanmış hangi metodu çağıracağını da bilemez. Bunun yerine Rust, çalışma zamanında hangi metodu çağıracağını bulmak için trait nesnesinin içindeki işaretçileri kullanır. Bu arama işlemi, statik gönderimde meydana gelmeyen bir çalışma zamanı maliyeti doğurur. Dinamik gönderim ayrıca derleyicinin bir metodun kodunu satır içine açmayı (inlining) seçmesini de engeller ve bu durum bazı eniyilemeleri (optimizations) devre dışı bırakır. Bunun yanı sıra Rust’ın, dinamik gönderimi nerede kullanıp nerede kullanamayacağınıza ilişkin dyn uyumluluğu (dyn compatibility) adı verilen bazı kuralları vardır. Bu kurallar buradaki tartışmamızın kapsamı dışındadır, ancak ayrıntılarını referans kılavuzundan okuyabilirsiniz. Yine de Liste 18-5’te yazdığımız ve Liste 18-9’da destekleyebildiğimiz kodda fazladan esneklik elde ettik; dolayısıyla bu, göz önünde bulundurulması gereken bir ödünleşimdir.
Not: Eski Rust sürümlerinde ve topluluk kaynaklarında nesne güvenliği
(object safety) olarak adlandırılan kavram, güncel Rust sürümlerinde dyn
anahtar kelimesiyle doğrudan ilişkilendirilerek resmî olarak dyn
uyumluluğu (dyn compatibility) adını almıştır. Bir trait’in dyn Trait
biçiminde trait nesnesi olarak kullanılabilmesi için sağlaması gereken en
temel kurallar şunlardır: where Self: Sized ile ayrıca sınırlanmadıkça
trait metotları, somut boyutu derleme zamanında bilinmeyen Self türünü
geri dönüş türü veya parametre türü olarak doğrudan kullanmamalı ve
jenerik tür parametreleri (fn islem<T>(&self, x: T)) içermemelidir (çünkü
sanal metot tablosuna sonsuz sayıda olası T türü için fonksiyon işaretçisi
eklenemez). Alıcısı (self, &self vb.) olmayan ilişkili fonksiyonlar da
yine where Self: Sized ile işaretlenmedikçe trait’i dyn uyumsuz kılar;
çünkü elde somut bir değer olmadan hangi uygulamanın çağrılacağı
belirlenemez. Ayrıca trait’in kendisi Sized gerektirmemelidir; örneğin
standart kütüphanedeki Clone trait’i Sized gerektirdiği ve
fn clone(&self) -> Self metodu Self döndürdüğü için dyn Clone
yazılamaz.
Nesne Yönelimli Bir Tasarım Desenini Uygulama
Nesne Yönelimli Bir Tasarım Desenini Uygulama
Durum deseni (state pattern), nesne yönelimli bir tasarım desenidir. Desenin özü, bir değerin içsel olarak sahip olabileceği bir durum kümesi tanımlamamızdır. Bu durumlar bir dizi durum nesnesi (state object) ile temsil edilir ve değerin davranışı içinde bulunduğu duruma göre değişir. Örnek olarak, durumunu bir alanda tutan bir blog gönderisi struct’ı üzerinde çalışacağız; bu alandaki durum nesnesi “taslak”, “inceleme” veya “yayınlanmış” kümesinden biri olacaktır.
Durum nesneleri işlevselliği paylaşır: Elbette Rust’ta nesneler ve kalıtım yerine struct’ları ve trait’leri kullanırız. Her durum nesnesi kendi davranışından ve ne zaman başka bir duruma geçmesi gerektiğini yönetmekten sorumludur. Durum nesnesini tutan değer ise durumların farklı davranışları veya durumlar arasında ne zaman geçiş yapılacağı hakkında hiçbir şey bilmez.
Durum desenini kullanmanın avantajı, programın iş gereksinimleri değiştiğinde durumu tutan değerin kodunu ya da bu değeri kullanan kodu değiştirmemize gerek kalmamasıdır. Kurallarını değiştirmek veya belki daha fazla durum nesnesi eklemek için yalnızca durum nesnelerinden birinin içindeki kodu güncellememiz yeterli olur.
İlk olarak durum desenini daha geleneksel nesne yönelimli bir yolla uygulayacağız. Ardından Rust’ta biraz daha doğal duran bir yaklaşım kullanacağız. Gelin durum desenini kullanarak bir blog gönderisi iş akışını adım adım uygulamaya koyulalım.
Nihai işlevsellik şöyle görünecektir:
- Bir blog gönderisi boş bir taslak olarak başlar.
- Taslak tamamlandığında gönderi için inceleme istenir.
- Gönderi onaylandığında yayınlanır.
- Onaylanmamış gönderilerin yanlışlıkla yayınlanmaması için yalnızca yayınlanmış blog gönderileri yazdırılacak içerik döndürür.
Bir gönderi üzerinde yapılmaya çalışılan diğer tüm değişiklikler etkisiz kalmalıdır. Örneğin inceleme istemeden önce taslak bir blog gönderisini onaylamaya çalışırsak, gönderi yayınlanmamış bir taslak olarak kalmalıdır.
Geleneksel Nesne Yönelimli Tarzı Denemek
Aynı problemi çözmek için kodu yapılandırmanın, her biri farklı ödünleşimler barındıran sonsuz yolu vardır. Bu alt bölümdeki uygulama daha geleneksel nesne yönelimli bir tarzdadır; bunu Rust’ta yazmak mümkündür ancak Rust’ın bazı güçlü yönlerinden yararlanmaz. Daha sonra yine nesne yönelimli tasarım desenini kullanan, fakat nesne yönelimli programlama deneyimine sahip programcılara daha az tanıdık gelebilecek bir biçimde yapılandırılmış farklı bir çözüm göstereceğiz. Rust kodunu diğer dillerdeki kodlardan farklı tasarlamanın getirdiği ödünleşimleri deneyimlemek için bu iki çözümü karşılaştıracağız.
Liste 18-11 bu iş akışını kod hâlinde göstermektedir: Bu, gunce adında bir kütüphane crate’inde uygulayacağımız API’nin örnek bir kullanımıdır. gunce crate’ini henüz uygulamadığımız için bu kod şimdilik derlenmeyecektir.
use gunce::Gonderi;
fn main() {
let mut gonderi = Gonderi::yeni();
gonderi.metin_ekle("Bugün öğle yemeğinde salata yedim");
assert_eq!("", gonderi.icerik());
gonderi.inceleme_iste();
assert_eq!("", gonderi.icerik());
gonderi.onayla();
assert_eq!("Bugün öğle yemeğinde salata yedim", gonderi.icerik());
}
gunce crate’imizin sahip olmasını istediğimiz davranışı gösteren kodKullanıcının Gonderi::yeni ile yeni bir taslak blog gönderisi oluşturabilmesini ve blog gönderisine metin eklenebilmesini istiyoruz. Onaydan önce gönderinin içeriğini hemen almaya çalışırsak, gönderi hâlâ taslak olduğu için hiçbir metin alamamalıyız. Koda gösterim amacıyla assert_eq! ekledik. Bunun için harika bir birim testi, taslak bir blog gönderisinin icerik metodundan boş bir string döndürdüğünü doğrulamak olurdu; ancak bu örnek için test yazmayacağız.
Ardından gönderi için inceleme istenebilmesini ve inceleme beklenirken icerik metodunun boş bir string döndürmesini istiyoruz. Gönderi onay aldığında yayınlanmalı; yani icerik çağrıldığında gönderinin metni döndürülmelidir.
Crate içinden doğrudan etkileşime girdiğimiz tek türün Gonderi olduğuna dikkat edin. Bu tür durum desenini kullanacak ve bir gönderinin içinde bulunabileceği çeşitli durumları —taslak, inceleme veya yayınlanmış— temsil eden üç durum nesnesinden biri olan bir değeri tutacaktır. Bir durumdan diğerine geçiş, Gonderi türünün içinde dahili olarak yönetilecektir. Durumlar, kütüphanemizin kullanıcılarının Gonderi örneği üzerinde çağırdığı metotlara yanıt olarak değişir; ancak kullanıcıların durum değişimlerini doğrudan yönetmesi gerekmez. Üstelik kullanıcılar, bir gönderiyi incelenmeden önce yayınlamak gibi durumlarla ilgili bir hata da yapamaz.
Gonderi’yi Tanımlamak ve Yeni Bir Örnek Oluşturmak
Kütüphaneyi uygulamaya başlayalım! Bir miktar içerik tutan açık (pub) bir Gonderi struct’ına ihtiyacımız olduğunu biliyoruz; bu yüzden Liste 18-12’de gösterildiği gibi struct’ın tanımıyla ve bir Gonderi örneği oluşturacak ilişkili açık bir yeni fonksiyonuyla başlayacağız. Ayrıca bir Gonderi için tüm durum nesnelerinin sahip olması gereken davranışı tanımlayacak gizli bir Durum trait’i de oluşturacağız.
Ardından Gonderi, durum nesnesini tutmak üzere durum adlı gizli bir alanda Option<T> içinde Box<dyn Durum> türünden bir trait nesnesi barındıracaktır. Option<T> kullanımının neden gerekli olduğunu birazdan göreceksiniz.
pub struct Gonderi {
durum: Option<Box<dyn Durum>>,
icerik: String,
}
impl Gonderi {
pub fn yeni() -> Gonderi {
Gonderi {
durum: Some(Box::new(Taslak {})),
icerik: String::new(),
}
}
}
trait Durum {}
struct Taslak {}
impl Durum for Taslak {}
Gonderi struct’ının, yeni bir Gonderi örneği oluşturan yeni fonksiyonunun, Durum trait’inin ve Taslak struct’ının tanımıDurum trait’i, farklı gönderi durumlarının paylaştığı davranışı tanımlar. Durum nesneleri Taslak, IncelemeBekleyen ve Yayinlanmis türleridir ve bunların tümü Durum trait’ini uygulayacaktır. Şimdilik trait’in herhangi bir metodu yoktur ve bir gönderinin başlamasını istediğimiz durum Taslak olduğu için yalnızca Taslak durumunu tanımlayarak başlayacağız.
Yeni bir Gonderi oluşturduğumuzda, durum alanını bir Box tutan Some değerine ayarlarız. Bu Box, Taslak struct’ının yeni bir örneğine işaret eder. Bu sayede ne zaman yeni bir Gonderi örneği oluştursak gönderinin bir taslak olarak başlaması güvence altına alınır. Gonderi’nin durum alanı gizli olduğu için Gonderi’yi başka herhangi bir durumda oluşturmanın hiçbir yolu yoktur! Gonderi::yeni fonksiyonunda icerik alanını da yeni ve boş bir String değerine ayarlarız.
Gönderi İçeriğinin Metnini Saklamak
Liste 18-11’de, metin_ekle adında bir metodu çağırabilmeyi ve ona blog gönderisinin metin içeriği olarak eklenecek bir &str geçirebilmeyi istediğimizi görmüştük. Bunu icerik alanını pub olarak dışarıya açmak yerine bir metot olarak uygularız; böylece daha sonra icerik alanındaki verinin nasıl okunacağını denetleyecek bir metot uygulayabiliriz. metin_ekle metodu oldukça doğrudandır; gelin Liste 18-13’teki uygulamayı impl Gonderi bloğuna ekleyelim.
pub struct Gonderi {
durum: Option<Box<dyn Durum>>,
icerik: String,
}
impl Gonderi {
// --snip--
pub fn yeni() -> Gonderi {
Gonderi {
durum: Some(Box::new(Taslak {})),
icerik: String::new(),
}
}
pub fn metin_ekle(&mut self, metin: &str) {
self.icerik.push_str(metin);
}
}
trait Durum {}
struct Taslak {}
impl Durum for Taslak {}
icerik alanına metin eklemek için metin_ekle metodunu uygulamakmetin_ekle metodu, çağrıldığı Gonderi örneğini değiştirdiği için self’e değiştirilebilir bir referans alır. Ardından icerik içindeki String üzerinde push_str metodunu çağırır ve kaydedilen icerik değerine eklemek üzere metin argümanını geçiririz. Bu davranış gönderinin içinde bulunduğu duruma bağlı değildir, dolayısıyla durum deseninin bir parçası değildir. metin_ekle metodu durum alanıyla hiç etkileşime girmez, ancak desteklemek istediğimiz davranışın bir parçasıdır.
Taslak Gönderinin İçeriğinin Boş Olmasını Sağlamak
metin_ekle metodunu çağırıp gönderimize bir miktar içerik ekledikten sonra bile, gönderi hâlâ taslak durumunda olduğu için —Liste 18-11’deki ilk assert_eq! çağrısının gösterdiği gibi— icerik metodunun boş bir string dilimi döndürmesini isteriz. Şimdilik icerik metodunu bu gereksinimi karşılayacak en basit şeyle uygulayalım: her zaman boş bir string dilimi döndürmek. İleride bir gönderinin durumunu değiştirip yayınlanabilme yeteneğini eklediğimizde bu metodu güncelleyeceğiz. Şimdiye kadar gönderiler yalnızca taslak durumunda olabildiğinden, gönderi içeriği her zaman boş olmalıdır. Liste 18-14 bu yer tutucu uygulamayı göstermektedir.
pub struct Gonderi {
durum: Option<Box<dyn Durum>>,
icerik: String,
}
impl Gonderi {
// --snip--
pub fn yeni() -> Gonderi {
Gonderi {
durum: Some(Box::new(Taslak {})),
icerik: String::new(),
}
}
pub fn metin_ekle(&mut self, text: &str) {
self.icerik.push_str(text);
}
pub fn icerik(&self) -> &str {
""
}
}
trait Durum {}
struct Taslak {}
impl Durum for Taslak {}
Gonderi üzerinde her zaman boş bir string dilimi döndüren icerik metodu için yer tutucu uygulama eklemekEklenen bu icerik metoduyla birlikte Liste 18-11’deki ilk assert_eq! satırına kadar her şey amaçlandığı gibi çalışır.
İnceleme İstemek ve Gönderinin Durumunu Değiştirmek
Sırada bir gönderi için inceleme isteme işlevselliğini eklemek var; bu işlem gönderinin durumunu Taslak’tan IncelemeBekleyen’e değiştirmelidir. Liste 18-15 bu kodu göstermektedir.
pub struct Gonderi {
durum: Option<Box<dyn Durum>>,
icerik: String,
}
impl Gonderi {
// --snip--
pub fn yeni() -> Gonderi {
Gonderi {
durum: Some(Box::new(Taslak {})),
icerik: String::new(),
}
}
pub fn metin_ekle(&mut self, text: &str) {
self.icerik.push_str(text);
}
pub fn icerik(&self) -> &str {
""
}
pub fn inceleme_iste(&mut self) {
if let Some(s) = self.durum.take() {
self.durum = Some(s.inceleme_iste())
}
}
}
trait Durum {
fn inceleme_iste(self: Box<Self>) -> Box<dyn Durum>;
}
struct Taslak {}
impl Durum for Taslak {
fn inceleme_iste(self: Box<Self>) -> Box<dyn Durum> {
Box::new(IncelemeBekleyen {})
}
}
struct IncelemeBekleyen {}
impl Durum for IncelemeBekleyen {
fn inceleme_iste(self: Box<Self>) -> Box<dyn Durum> {
self
}
}
Gonderi ve Durum trait’i üzerinde inceleme_iste metotlarını uygulamakGonderi’ye self için değiştirilebilir bir referans alan inceleme_iste adında açık bir metot veriyoruz. Ardından Gonderi’nin mevcut durumu üzerinde dahili bir inceleme_iste metodu çağırıyoruz ve bu ikinci inceleme_iste metodu mevcut durumu tüketip yeni bir durum döndürüyor.
Durum trait’ine inceleme_iste metodunu ekliyoruz; artık bu trait’i uygulayan tüm türlerin inceleme_iste metodunu uygulaması gerekecektir. Metodun ilk parametresi olarak self, &self veya &mut self yerine self: Box<Self> kullandığımıza dikkat edin. Bu sözdizimi, metodun yalnızca ilgili türü tutan bir Box üzerinde çağrıldığında geçerli olduğu anlamına gelir. Bu sözdizimi Box<Self> değerinin sahipliğini alarak eski durumu geçersiz kılar; böylece Gonderi’nin durum değeri yeni bir duruma dönüşebilir.
Eski durumu tüketmek için inceleme_iste metodunun durum değerinin sahipliğini alması gerekir. Gonderi’nin durum alanındaki Option tam burada devreye girer: Rust struct’larda doldurulmamış alanlar bulundurmamıza izin vermediği için, durum alanındaki Some değerini dışarı çıkarmak ve yerine bir None bırakmak üzere take metodunu çağırırız. Bu sayede durum değerini ödünç almak yerine Gonderi dışına taşıyabiliriz. Ardından gönderinin durum değerini bu işlemin sonucuna ayarlarız.
self.durum = self.durum.inceleme_iste(); gibi doğrudan bir atama kodu durum değerinin sahipliğini alamayacağından, sahipliği elde etmek için durum alanını geçici olarak None değerine ayarlamamız gerekir. Bu sayede Gonderi’nin, yeni bir duruma dönüştürdükten sonra eski durum değerini kullanamaması güvence altına alınır.
Not: Option sarmalayıcısı olmadan alanı durum: Box<dyn Durum> biçiminde
tanımlayıp self.durum = self.durum.inceleme_iste(); yazmayı deneseydik
derleyici error[E0507]: cannot move out of `self.durum` which is behind a mutable reference hatası verirdi. &mut self üzerinden alana yalnızca
ödünç erişimimiz vardır; inceleme_iste ise Box<Self> değerinin sahipliğini
istediği için değeri alanın dışına taşımamız gerekir. Oysa bu taşıma, yeni
değer atanana kadar struct’ı bir anlığına bile olsa “boş” bırakır ve Rust
buna izin vermez. Option::take, alana geçici olarak None bırakarak bu
boşluğu derleyicinin kabul ettiği geçerli bir değerle doldurur.
Taslak üzerindeki inceleme_iste metodu, bir gönderinin inceleme beklediği durumu temsil eden yeni bir IncelemeBekleyen struct’ının kutulanmış yeni bir örneğini döndürür. IncelemeBekleyen struct’ı da inceleme_iste metodunu uygular ancak herhangi bir dönüşüm yapmaz. Bunun yerine kendisini döndürür; çünkü zaten IncelemeBekleyen durumunda olan bir gönderi için inceleme istediğimizde gönderi IncelemeBekleyen durumunda kalmalıdır.
Artık durum deseninin avantajlarını görmeye başlayabiliriz: Gonderi üzerindeki inceleme_iste metodu, durum değeri ne olursa olsun aynıdır. Her durum kendi kurallarından sorumludur.
Gonderi üzerindeki icerik metodunu olduğu gibi, boş bir string dilimi döndürecek şekilde bırakacağız. Artık Taslak durumunun yanı sıra IncelemeBekleyen durumunda da bir Gonderi’ye sahip olabiliriz, ancak IncelemeBekleyen durumunda da aynı davranışı istiyoruz. Liste 18-11 artık ikinci assert_eq! çağrısına kadar çalışmaktadır!
icerik Davranışını Değiştirmek İçin onayla Metodunu Eklemek
onayla metodu inceleme_iste metoduna benzer olacaktır: Liste 18-16’da gösterildiği gibi, mevcut durum onaylandığında hangi değere sahip olması gerektiğini söylüyorsa durum alanını o değere ayarlayacaktır.
pub struct Gonderi {
durum: Option<Box<dyn Durum>>,
icerik: String,
}
impl Gonderi {
// --snip--
pub fn yeni() -> Gonderi {
Gonderi {
durum: Some(Box::new(Taslak {})),
icerik: String::new(),
}
}
pub fn metin_ekle(&mut self, text: &str) {
self.icerik.push_str(text);
}
pub fn icerik(&self) -> &str {
""
}
pub fn inceleme_iste(&mut self) {
if let Some(s) = self.durum.take() {
self.durum = Some(s.inceleme_iste())
}
}
pub fn onayla(&mut self) {
if let Some(s) = self.durum.take() {
self.durum = Some(s.onayla())
}
}
}
trait Durum {
fn inceleme_iste(self: Box<Self>) -> Box<dyn Durum>;
fn onayla(self: Box<Self>) -> Box<dyn Durum>;
}
struct Taslak {}
impl Durum for Taslak {
// --snip--
fn inceleme_iste(self: Box<Self>) -> Box<dyn Durum> {
Box::new(IncelemeBekleyen {})
}
fn onayla(self: Box<Self>) -> Box<dyn Durum> {
self
}
}
struct IncelemeBekleyen {}
impl Durum for IncelemeBekleyen {
// --snip--
fn inceleme_iste(self: Box<Self>) -> Box<dyn Durum> {
self
}
fn onayla(self: Box<Self>) -> Box<dyn Durum> {
Box::new(Yayinlanmis {})
}
}
struct Yayinlanmis {}
impl Durum for Yayinlanmis {
fn inceleme_iste(self: Box<Self>) -> Box<dyn Durum> {
self
}
fn onayla(self: Box<Self>) -> Box<dyn Durum> {
self
}
}
Gonderi ve Durum trait’i üzerinde onayla metodunu uygulamakDurum trait’ine onayla metodunu ekliyor ve Durum trait’ini uygulayan yeni bir struct olan Yayinlanmis durumunu tanımlıyoruz.
IncelemeBekleyen üzerindeki inceleme_iste metodunun çalışma biçimine benzer şekilde, bir Taslak üzerinde onayla metodunu çağırırsak onayla metodu self döndüreceği için bunun hiçbir etkisi olmayacaktır. IncelemeBekleyen üzerinde onayla çağırdığımızda ise Yayinlanmis struct’ının kutulanmış yeni bir örneğini döndürür. Yayinlanmis struct’ı Durum trait’ini uygular ve hem inceleme_iste metodu hem de onayla metodu için kendisini döndürür; çünkü bu durumlarda gönderi Yayinlanmis durumunda kalmalıdır.
Şimdi Gonderi üzerindeki icerik metodunu güncellememiz gerekiyor. icerik metodundan döndürülen değerin Gonderi’nin mevcut durumuna bağlı olmasını istiyoruz; bu nedenle Liste 18-17’de gösterildiği gibi Gonderi’nin kendi durum alanı üzerinde tanımlanmış bir icerik metoduna yetki devretmesini sağlayacağız.
pub struct Gonderi {
durum: Option<Box<dyn Durum>>,
icerik: String,
}
impl Gonderi {
// --snip--
pub fn yeni() -> Gonderi {
Gonderi {
durum: Some(Box::new(Taslak {})),
icerik: String::new(),
}
}
pub fn metin_ekle(&mut self, text: &str) {
self.icerik.push_str(text);
}
pub fn icerik(&self) -> &str {
self.durum.as_ref().unwrap().icerik(self)
}
// --snip--
pub fn inceleme_iste(&mut self) {
if let Some(s) = self.durum.take() {
self.durum = Some(s.inceleme_iste())
}
}
pub fn onayla(&mut self) {
if let Some(s) = self.durum.take() {
self.durum = Some(s.onayla())
}
}
}
trait Durum {
fn inceleme_iste(self: Box<Self>) -> Box<dyn Durum>;
fn onayla(self: Box<Self>) -> Box<dyn Durum>;
}
struct Taslak {}
impl Durum for Taslak {
fn inceleme_iste(self: Box<Self>) -> Box<dyn Durum> {
Box::new(IncelemeBekleyen {})
}
fn onayla(self: Box<Self>) -> Box<dyn Durum> {
self
}
}
struct IncelemeBekleyen {}
impl Durum for IncelemeBekleyen {
fn inceleme_iste(self: Box<Self>) -> Box<dyn Durum> {
self
}
fn onayla(self: Box<Self>) -> Box<dyn Durum> {
Box::new(Yayinlanmis {})
}
}
struct Yayinlanmis {}
impl Durum for Yayinlanmis {
fn inceleme_iste(self: Box<Self>) -> Box<dyn Durum> {
self
}
fn onayla(self: Box<Self>) -> Box<dyn Durum> {
self
}
}
Gonderi üzerindeki icerik metodunu Durum üzerindeki bir icerik metoduna yetki devredecek şekilde güncellemekAmaç tüm bu kuralları Durum trait’ini uygulayan struct’ların içinde tutmak olduğundan, durum içindeki değer üzerinde bir icerik metodu çağırır ve gönderi örneğini (yani self’i) argüman olarak geçiririz. Ardından durum değeri üzerindeki bu icerik çağrısından dönen değeri döndürürüz.
Option içindeki değerin sahipliğini değil, değere bir referans istediğimiz için Option üzerinde as_ref metodunu çağırırız. durum alanı bir Option<Box<dyn Durum>> olduğundan as_ref çağırdığımızda bir Option<&Box<dyn Durum>> döndürülür. Eğer as_ref çağırmasaydık hata alırdık; çünkü fonksiyon parametresindeki ödünç alınmış &self içinden durum değerini dışarı taşıyamayız.
Ardından unwrap metodunu çağırırız; Gonderi üzerindeki metotların tamamlandıklarında durum alanının her zaman bir Some değeri içermesini sağladığını bildiğimiz için bunun hiçbir zaman panik yapmayacağından eminiz. Bu durum, derleyici anlayamasa bile bir None değerinin hiçbir zaman mümkün olmadığını bildiğimiz, Bölüm 9’daki “Derleyiciden Daha Fazla Bilgiye Sahip Olduğunuz Durumlar” alt bölümünde ele aldığımız durumlardan biridir.
Bu noktada &Box<dyn Durum> üzerinde icerik çağırdığımızda & ve Box üzerinde deref zorlaması (deref coercion) devreye girer; böylece icerik metodu nihayetinde Durum trait’ini uygulayan tür üzerinde çağrılır. Bu da Durum trait tanımına icerik metodunu eklememiz gerektiği anlamına gelir ve Liste 18-18’de gösterildiği gibi hangi durumda bulunduğumuza bağlı olarak hangi içeriğin döndürüleceğine ilişkin mantığı oraya koyacağız.
pub struct Gonderi {
durum: Option<Box<dyn Durum>>,
icerik: String,
}
impl Gonderi {
pub fn yeni() -> Gonderi {
Gonderi {
durum: Some(Box::new(Taslak {})),
icerik: String::new(),
}
}
pub fn metin_ekle(&mut self, text: &str) {
self.icerik.push_str(text);
}
pub fn icerik(&self) -> &str {
self.durum.as_ref().unwrap().icerik(self)
}
pub fn inceleme_iste(&mut self) {
if let Some(s) = self.durum.take() {
self.durum = Some(s.inceleme_iste())
}
}
pub fn onayla(&mut self) {
if let Some(s) = self.durum.take() {
self.durum = Some(s.onayla())
}
}
}
trait Durum {
// --snip--
fn inceleme_iste(self: Box<Self>) -> Box<dyn Durum>;
fn onayla(self: Box<Self>) -> Box<dyn Durum>;
fn icerik<'a>(&self, gonderi: &'a Gonderi) -> &'a str {
""
}
}
// --snip--
struct Taslak {}
impl Durum for Taslak {
fn inceleme_iste(self: Box<Self>) -> Box<dyn Durum> {
Box::new(IncelemeBekleyen {})
}
fn onayla(self: Box<Self>) -> Box<dyn Durum> {
self
}
}
struct IncelemeBekleyen {}
impl Durum for IncelemeBekleyen {
fn inceleme_iste(self: Box<Self>) -> Box<dyn Durum> {
self
}
fn onayla(self: Box<Self>) -> Box<dyn Durum> {
Box::new(Yayinlanmis {})
}
}
struct Yayinlanmis {}
impl Durum for Yayinlanmis {
// --snip--
fn inceleme_iste(self: Box<Self>) -> Box<dyn Durum> {
self
}
fn onayla(self: Box<Self>) -> Box<dyn Durum> {
self
}
fn icerik<'a>(&self, gonderi: &'a Gonderi) -> &'a str {
&gonderi.icerik
}
}
Durum trait’ine icerik metodunu eklemekicerik metodu için boş bir string dilimi döndüren varsayılan bir uygulama ekliyoruz. Bu sayede Taslak ve IncelemeBekleyen struct’ları üzerinde icerik uygulamamıza gerek kalmaz. Yayinlanmis struct’ı ise icerik metodunu geçersiz kılar ve gonderi.icerik içindeki değeri döndürür. Kullanışlı olsa da Gonderi’nin içeriğini Durum üzerindeki icerik metodunun belirlemesi, Durum’un sorumluluğu ile Gonderi’nin sorumluluğu arasındaki çizgiyi bulanıklaştırmaktadır.
Bölüm 10’da ele aldığımız gibi bu metotta ömür (lifetime) bildirimlerine ihtiyaç duyduğumuza dikkat edin. Argüman olarak bir gonderi referansı alıyor ve o gonderi’nin bir parçasına referans döndürüyoruz; dolayısıyla döndürülen referansın ömrü gonderi argümanının ömrüyle ilişkilidir.
Ve işimiz bitti — Liste 18-11’in tamamı artık çalışıyor! Blog gönderisi iş akışının kurallarıyla durum desenini uygulamış olduk. Kurallarla ilgili mantık Gonderi boyunca dağılmak yerine durum nesnelerinin içinde yaşamaktadır.
Neden Enum Değil?
Olası farklı gönderi durumlarını varyantlar olarak içeren bir enum’ı neden
kullanmadığımızı merak etmiş olabilirsiniz. Bu kesinlikle olası bir çözümdür;
deneyin ve hangisini tercih ettiğinizi görmek için nihai sonuçları
karşılaştırın! Enum kullanmanın bir dezavantajı, enum’ın değerini denetleyen
her yerin olası her varyantı ele almak için bir match ifadesine veya benzer
bir yapıya ihtiyaç duyacak olmasıdır. Bu durum, bu trait nesnesi çözümüne
kıyasla daha tekrarlayıcı bir hâle gelebilir.
Durum Desenini Değerlendirmek
Rust’ın, bir gönderinin her durumda sahip olması gereken farklı davranış türlerini kapsüllemek için nesne yönelimli durum desenini uygulayabildiğini gösterdik. Gonderi üzerindeki metotlar çeşitli davranışlar hakkında hiçbir şey bilmez. Kodu düzenleme biçimimiz sayesinde, yayınlanmış bir gönderinin sergileyebileceği farklı davranışları öğrenmek için yalnızca tek bir yere bakmamız yeterlidir: Yayinlanmis struct’ı üzerindeki Durum trait’i uygulamasına.
Durum desenini kullanmayan alternatif bir uygulama oluşturacak olsaydık, bunun yerine Gonderi üzerindeki metotlarda veya hatta main kodunda gönderinin durumunu denetleyen ve davranışı o noktalarda değiştiren match ifadeleri kullanabilirdik. Bu da yayınlanmış durumdaki bir gönderinin tüm etkilerini anlamak için birden fazla yere bakmamız gerektiği anlamına gelirdi.
Durum deseniyle ise Gonderi metotlarının ve Gonderi kullandığımız yerlerin match ifadelerine ihtiyacı yoktur; ayrıca yeni bir durum eklemek için yalnızca yeni bir struct eklememiz ve trait metotlarını tek bir konumda o struct üzerinde uygulamamız yeterli olur.
Durum desenini kullanan uygulamayı daha fazla işlevsellik eklemek üzere genişletmek kolaydır. Durum desenini kullanan kodun bakımının ne kadar sade olduğunu görmek için şu önerilerden birkaçını deneyin:
- Gönderinin durumunu
IncelemeBekleyen’den tekrarTaslak’a çeviren birreddetmetodu ekleyin. - Durum
Yayinlanmisolarak değiştirilebilmeden önceonaylametodunun iki kez çağrılmasını zorunlu kılın. - Kullanıcıların yalnızca gönderi
Taslakdurumundayken metin içeriği eklemesine izin verin. İpucu: İçerikte nelerin değişebileceğinden durum nesnesini sorumlu tutun, ancakGonderi’yi değiştirmekten sorumlu tutmayın.
Durum deseninin bir dezavantajı, durumlar arası geçişleri bizzat durumların kendisi uyguladığı için bazı durumların birbirine bağımlı olmasıdır. IncelemeBekleyen ile Yayinlanmis arasına Planlanmis gibi başka bir durum eklersek, bunun yerine Planlanmis durumuna geçiş yapması için IncelemeBekleyen içindeki kodu değiştirmemiz gerekir. Yeni bir durum eklendiğinde IncelemeBekleyen’in değişmesine gerek kalmasaydı daha az iş çıkardı, ancak bu da başka bir tasarım desenine geçmek anlamına gelirdi.
Bir diğer dezavantaj ise bazı mantıkları tekrarlamış olmamızdır. Bu tekrarlardan bazılarını ortadan kaldırmak için Durum trait’i üzerindeki inceleme_iste ve onayla metotlarına self döndüren varsayılan uygulamalar yazmayı deneyebilirdik. Ancak bu çalışmazdı: Durum bir trait nesnesi olarak kullanıldığında, trait somut self türünün tam olarak ne olacağını bilmez; dolayısıyla dönüş türü derleme zamanında bilinmez. (Bu, daha önce değinilen dyn uyumluluğu kurallarından biridir.)
Bir başka tekrar da Gonderi üzerindeki inceleme_iste ve onayla metotlarının birbirine benzeyen uygulamalarıdır. Her iki metot da Gonderi’nin durum alanıyla Option::take kullanır ve durum değeri Some ise sarmalanan değerin aynı metoda ait uygulamasına yetki devredip durum alanının yeni değerini sonuca ayarlar. Gonderi üzerinde bu kalıbı izleyen çok sayıda metodumuz olsaydı, tekrarı ortadan kaldırmak için bir makro tanımlamayı düşünebilirdik (bkz. Bölüm 20’deki “Makrolar” alt bölümü).
Durum desenini nesne yönelimli diller için tanımlandığı şekliyle birebir uyguladığımızda Rust’ın güçlü yönlerinden olabildiğince tam yararlanmıyoruz. Gelin geçersiz durumları ve geçişleri derleme zamanı hatalarına dönüştürebilmek için gunce crate’inde yapabileceğimiz bazı değişikliklere bakalım.
Durumları ve Davranışı Türlerin Kendisi Olarak Kodlamak
Farklı bir ödünleşim kümesi elde etmek için durum desenini nasıl yeniden düşünebileceğimizi göstereceğiz. Durumları ve geçişleri dışarıdaki kodun onlar hakkında hiçbir bilgisi olmayacak şekilde tamamen kapsüllemek yerine, durumları farklı türler hâlinde kodlayacağız. Sonuç olarak Rust’ın tür denetim sistemi, yalnızca yayınlanmış gönderilere izin verilen yerlerde taslak gönderileri kullanma girişimlerini derleyici hatası vererek engelleyecektir.
Liste 18-11’deki main fonksiyonunun ilk bölümünü ele alalım:
use gunce::Gonderi;
fn main() {
let mut gonderi = Gonderi::yeni();
gonderi.metin_ekle("Bugün öğle yemeğinde salata yedim");
assert_eq!("", gonderi.icerik());
gonderi.inceleme_iste();
assert_eq!("", gonderi.icerik());
gonderi.onayla();
assert_eq!("Bugün öğle yemeğinde salata yedim", gonderi.icerik());
}
Gonderi::yeni kullanarak taslak durumunda yeni gönderiler oluşturulmasına ve gönderinin içeriğine metin eklenmesine yine olanak tanıyoruz. Ancak taslak gönderi üzerinde boş bir string döndüren bir icerik metodu bulundurmak yerine, taslak gönderilerin icerik metoduna hiç sahip olmamasını sağlayacağız. Bu sayede bir taslak gönderinin içeriğini almaya çalışırsak, metodun mevcut olmadığını söyleyen bir derleyici hatası alırız. Sonuç olarak üretim ortamında taslak gönderi içeriğini yanlışlıkla görüntülememiz imkânsız hâle gelir; çünkü o kod derlenmeyecektir bile. Liste 18-19 bir Gonderi struct’ının, bir TaslakGonderi struct’ının ve her birinin üzerindeki metotların tanımını göstermektedir.
pub struct Gonderi {
icerik: String,
}
pub struct TaslakGonderi {
icerik: String,
}
impl Gonderi {
pub fn yeni() -> TaslakGonderi {
TaslakGonderi {
icerik: String::new(),
}
}
pub fn icerik(&self) -> &str {
&self.icerik
}
}
impl TaslakGonderi {
pub fn metin_ekle(&mut self, metin: &str) {
self.icerik.push_str(metin);
}
}
icerik metoduna sahip bir Gonderi ve icerik metodu bulunmayan bir TaslakGonderiHem Gonderi hem de TaslakGonderi struct’ları blog gönderisi metnini saklayan gizli bir icerik alanına sahiptir. Durumun kodlanmasını struct’ların kendi türlerine taşıdığımız için bu struct’lar artık durum alanına sahip değildir. Gonderi struct’ı yayınlanmış bir gönderiyi temsil edecektir ve icerik değerini döndüren bir icerik metoduna sahiptir.
Yine bir Gonderi::yeni fonksiyonumuz var, ancak bir Gonderi örneği döndürmek yerine bir TaslakGonderi örneği döndürüyor. icerik alanı gizli olduğundan ve Gonderi döndüren hiçbir fonksiyon bulunmadığından, şu anda bir Gonderi örneği oluşturmak mümkün değildir.
TaslakGonderi struct’ının bir metin_ekle metodu vardır; dolayısıyla daha önce olduğu gibi icerik alanına metin ekleyebiliriz, ancak TaslakGonderi üzerinde tanımlanmış bir icerik metodu bulunmadığına dikkat edin! Böylece program artık tüm gönderilerin taslak gönderi olarak başlamasını ve taslak gönderilerin içeriklerinin görüntülenebilir olmamasını güvence altına alır. Bu kısıtların etrafından dolaşmaya yönelik her türlü girişim derleyici hatasıyla sonuçlanacaktır.
Peki yayınlanmış bir gönderiyi nasıl elde edeceğiz? Taslak bir gönderinin yayınlanabilmeden önce incelenmesi ve onaylanması gerektiği kuralını zorunlu kılmak istiyoruz. İnceleme bekleyen durumdaki bir gönderi de yine hiçbir içerik göstermemelidir. Gelin Liste 18-20’de gösterildiği gibi başka bir struct olan IncelemeBekleyenGonderi’yi ekleyerek, TaslakGonderi üzerinde IncelemeBekleyenGonderi döndüren inceleme_iste metodunu ve IncelemeBekleyenGonderi üzerinde Gonderi döndüren bir onayla metodunu tanımlayarak bu kısıtları uygulayalım.
pub struct Gonderi {
icerik: String,
}
pub struct TaslakGonderi {
icerik: String,
}
impl Gonderi {
pub fn yeni() -> TaslakGonderi {
TaslakGonderi {
icerik: String::new(),
}
}
pub fn icerik(&self) -> &str {
&self.icerik
}
}
impl TaslakGonderi {
// --snip--
pub fn metin_ekle(&mut self, metin: &str) {
self.icerik.push_str(metin);
}
pub fn inceleme_iste(self) -> IncelemeBekleyenGonderi {
IncelemeBekleyenGonderi {
icerik: self.icerik,
}
}
}
pub struct IncelemeBekleyenGonderi {
icerik: String,
}
impl IncelemeBekleyenGonderi {
pub fn onayla(self) -> Gonderi {
Gonderi {
icerik: self.icerik,
}
}
}
TaslakGonderi üzerinde inceleme_iste çağrılarak oluşturulan bir IncelemeBekleyenGonderi ve IncelemeBekleyenGonderi’yi yayınlanmış bir Gonderi’ye dönüştüren onayla metoduinceleme_iste ve onayla metotları self’in sahipliğini alır; böylece TaslakGonderi ve IncelemeBekleyenGonderi örneklerini tüketerek onları sırasıyla bir IncelemeBekleyenGonderi’ye ve yayınlanmış bir Gonderi’ye dönüştürür. Bu sayede üzerlerinde inceleme_iste çağırdıktan sonra ortalıkta asılı kalan hiçbir TaslakGonderi örneğimiz olmaz ve diğer geçişler için de aynı güvence geçerli olur. IncelemeBekleyenGonderi struct’ı üzerinde tanımlanmış bir icerik metodu yoktur; bu nedenle tıpkı TaslakGonderi’de olduğu gibi onun içeriğini okumaya çalışmak da derleyici hatasıyla sonuçlanır. Üzerinde icerik metodu tanımlı yayınlanmış bir Gonderi örneği elde etmenin tek yolu bir IncelemeBekleyenGonderi üzerinde onayla metodunu çağırmak ve bir IncelemeBekleyenGonderi elde etmenin tek yolu da bir TaslakGonderi üzerinde inceleme_iste metodunu çağırmak olduğundan, blog gönderisi iş akışını artık tür sisteminin içine kodlamış olduk.
Ancak main fonksiyonunda da bazı küçük değişiklikler yapmamız gerekir. inceleme_iste ve onayla metotları üzerinde çağrıldıkları struct’ı değiştirmek yerine yeni örnekler döndürdüğünden, döndürülen örnekleri kaydetmek için ek let gonderi = gölgeleme (shadowing) atamaları eklememiz gerekir. Ayrıca taslak ve inceleme bekleyen gönderilerin içeriklerinin boş string olduğuna dair doğrulamaları da artık tutamayız ve bunlara zaten ihtiyacımız da yoktur: Bu durumlardaki gönderilerin içeriğini kullanmaya çalışan kodları artık derleyemeyiz. main içindeki güncellenmiş kod Liste 18-21’de gösterilmektedir.
use gunce::Gonderi;
fn main() {
let mut gonderi = Gonderi::yeni();
gonderi.metin_ekle("Bugün öğle yemeğinde salata yedim");
let gonderi = gonderi.inceleme_iste();
let gonderi = gonderi.onayla();
assert_eq!("Bugün öğle yemeğinde salata yedim", gonderi.icerik());
}
main fonksiyonunda yapılan değişikliklergonderi değişkenini yeniden atamak için main içinde yapmamız gereken değişiklikler, bu uygulamanın artık nesne yönelimli durum desenini tam olarak izlemediği anlamına gelir: Durumlar arasındaki dönüşümler artık tamamen Gonderi uygulaması içinde kapsüllenmemektedir. Bununla birlikte kazancımız, tür sistemi ve derleme zamanında gerçekleşen tür denetimi sayesinde geçersiz durumların artık imkânsız hâle gelmesidir! Bu da yayınlanmamış bir gönderinin içeriğinin görüntülenmesi gibi belirli hataların üretim ortamına ulaşmadan önce keşfedilmesini güvence altına alır.
Kodun bu sürümünün tasarımı hakkında ne düşündüğünüzü görmek için bu alt bölümün başında önerilen görevleri Liste 18-21 sonrasındaki hâliyle gunce crate’i üzerinde deneyin. Bu görevlerden bazılarının bu tasarımda hâlihazırda tamamlanmış olabileceğine dikkat edin.
Rust nesne yönelimli tasarım desenlerini uygulayabilse de durumu tür sisteminin içine kodlamak gibi başka desenlerin de Rust’ta mevcut olduğunu gördük. Bu desenlerin farklı ödünleşimleri vardır. Nesne yönelimli desenlere çok aşina olsanız bile, Rust’ın özelliklerinden yararlanmak üzere problemi yeniden düşünmek bazı hataları derleme zamanında önlemek gibi kazanımlar sağlayabilir. Nesne yönelimli dillerin sahip olmadığı sahiplik gibi belirli özellikler nedeniyle nesne yönelimli desenler Rust’ta her zaman en iyi çözüm olmayacaktır.
Not: Durumları ve geçerli geçişleri doğrudan tür sisteminde modelleyen bu
ikinci yaklaşım, literatürde ve Rust ekosisteminde tür-durumu deseni
(typestate pattern) olarak adlandırılır (gömülü donanım pinlerinin
yapılandırılmasından HTTP istemcisi kurucularına kadar pek çok Rust
kütüphanesinde yaygın biçimde kullanılır). Tür-durumu deseni tam anlamıyla bir
sıfır maliyetli soyutlamadır (zero-cost abstraction): Option sarmalayıcısı,
Box<dyn Durum> için öbek (heap) ayırması ve dinamik gönderim maliyeti
tamamen ortadan kalkar; tüm kurallar çalışma zamanına tek bir bayt veya işlem
yükü bırakmadan derleme zamanında doğrulanır. (Durumu bir struct alanında
yerinde güncellemeniz gereken ve geçici bir yer tutucu değerin bulunduğu
senaryolarda ise alanı Option yapmadan eski değeri çıkarıp yerine yenisini
koymak için standart kütüphanedeki std::mem::replace veya Default
uygulayan türlerde std::mem::take fonksiyonlarından da yararlanabilirsiniz.)
Buna karşılık, örneğin bir veritabanından okunan ve durumları ancak çalışma
zamanında belli olan farklı aşamalardaki gönderileri tek bir Vec içinde bir
arada tutmanız gerektiğinde, TaslakGonderi ve Gonderi farklı somut türler
olduğu için doğrudan aynı vektöre konulamaz. Böyle heterojen çalışma zamanı
koleksiyonlarında ya bu türleri sarmalayan bir enum tanımlamak ya da ilk
yaklaşımdaki trait nesnelerine (Box<dyn Durum>) dönmek doğru mimari tercih
olacaktır.
Özet
Bu bölümü okuduktan sonra Rust’ın nesne yönelimli bir dil olduğunu düşünüp düşünmemenizden bağımsız olarak, artık Rust’ta nesne yönelimli bazı özellikleri elde etmek için trait nesnelerini kullanabileceğinizi biliyorsunuz. Dinamik gönderim, bir miktar çalışma zamanı performansı karşılığında kodunuza esneklik kazandırabilir. Bu esnekliği, kodunuzun sürdürülebilirliğine katkı sağlayabilecek nesne yönelimli desenleri uygulamak için kullanabilirsiniz. Rust ayrıca nesne yönelimli dillerin sahip olmadığı sahiplik gibi başka özelliklere de sahiptir. Nesne yönelimli bir desen, Rust’ın güçlü yönlerinden yararlanmanın her zaman en iyi yolu olmayacaktır; ancak elinizin altındaki geçerli seçeneklerden biridir.
Sırada, Rust’ın pek çok esneklik sağlayan bir diğer özelliği olan desenlere (patterns) bakacağız. Kitap boyunca onlara kısaca göz attık ama henüz tüm yeteneklerini görmedik. Haydi başlayalım!
Desenler ve Eşleştirme
Desenler (patterns), Rust’ta hem karmaşık hem de basit türlerin yapısıyla eşleştirme yapmayı sağlayan özel bir sözdizimidir. Desenleri match ifadeleri ve diğer yapılarla birlikte kullanmak, programın kontrol akışı üzerinde size daha fazla denetim sunar. Bir desen aşağıdaki bileşenlerin bir birleşiminden oluşabilir:
- Hazır değerler (literals)
- Ayrıştırılmış (destructured) diziler, enum’lar, struct’lar veya demetler
- Değişkenler
- Joker desenler (wildcards)
- Yer tutucular (placeholders)
Örnek desenler arasında x, (a, 3) ve Some(Renk::Kirmizi) sayılabilir. Desenlerin geçerli olduğu bağlamlarda bu bileşenler verinin şeklini tarif eder. Ardından programımız, değerleri bu desenlerle eşleştirerek belirli bir kod parçasını çalıştırmaya devam etmek için elindeki verinin doğru şekle sahip olup olmadığını belirler.
Bir deseni kullanmak için onu bir değerle karşılaştırırız. Desen değerle eşleşirse kodumuzda değerin parçalarını kullanırız. Bölüm 6’daki madeni para ayırma makinesi örneği gibi desen kullanan match ifadelerini hatırlayın. Değer desenin şekline uyuyorsa adlandırılmış parçaları kullanabiliriz. Uymuyorsa desenle ilişkili kod çalışmaz.
Bu bölüm, desenlerle ilgili her şey için bir başvuru kılavuzu niteliğindedir. Desenleri kullanmanın geçerli olduğu yerleri, çürütülebilir (refutable) ve çürütülemez (irrefutable) desenler arasındaki farkı ve karşılaşabileceğiniz farklı desen sözdizimi türlerini ele alacağız. Bölümün sonunda pek çok kavramı açık ve anlaşılır bir biçimde ifade etmek için desenleri nasıl kullanacağınızı biliyor olacaksınız.
Not: Rust’ta desen eşleştirme aynı anda iki temel görevi yerine getirir:
verinin belirli bir şekle uyup uymadığını sınamak ve uyuyorsa içindeki
alanları ayrıştırarak (destructuring) değişkenlere bağlamak. Üstelik
desenler Rust’ın sahiplik kurallarıyla doğrudan bütünleşiktir: Copy olmayan
bir değeri desende bir değişkene bağladığınızda sahiplik taşınır; referans
üzerinden eşleştirme yaptığınızda ise derleyici bağlanan değişkenleri otomatik
olarak referans (&T veya &mut T) hâline getirir.
Desenlerin Kullanılabileceği Tüm Yerler
Desenlerin Kullanılabileceği Tüm Yerler
Desenler Rust’ta pek çok yerde karşımıza çıkar ve farkında bile olmadan onları sıkça kullanmışsınızdır! Bu bölümde desenlerin geçerli olduğu tüm yerleri ele alacağız.
match Kolları
Bölüm 6’da ele aldığımız gibi, match ifadelerinin kollarında desenleri kullanırız. Biçimsel olarak match ifadeleri; match anahtar kelimesi, üzerinde eşleştirme yapılacak bir değer ve her biri bir desen ile değerin o kolun deseniyle eşleşmesi durumunda çalıştırılacak bir ifadeden oluşan bir veya daha fazla match kolu olarak tanımlanır:
match DEĞER {
DESEN => İFADE,
DESEN => İFADE,
DESEN => İFADE,
}
Örneğin, Liste 6-5’teki x değişkeninde bulunan bir Option<i32> değeri üzerinde eşleştirme yapan match ifadesi şöyledir:
match x {
None => None,
Some(i) => Some(i + 1),
}
Bu match ifadesindeki desenler, her okun sol tarafında yer alan None ve Some(i) kısımlarıdır.
match ifadelerinin temel gereksinimlerinden biri kapsamlı (exhaustive) olmalarıdır; yani match ifadesindeki değerin alabileceği tüm olasılıkların hesaba katılması gerekir. Her olasılığı kapsadığınızdan emin olmanın bir yolu, son kol için tümünü yakalayan (catch-all) bir desen kullanmaktır: Örneğin, herhangi bir değerle eşleşen bir değişken adı asla başarısız olmaz ve dolayısıyla geriye kalan tüm durumları kapsar.
Özel _ deseni her şeyle eşleşir ancak hiçbir zaman bir değişkene bağlanmaz; bu nedenle son match kolunda sıklıkla kullanılır. Örneğin, açıkça belirtilmeyen herhangi bir değeri yoksaymak istediğinizde _ deseni oldukça kullanışlıdır. _ desenini bu bölümün ilerleyen kısımlarında yer alan “Desende Değerleri Yoksaymak” başlığında daha ayrıntılı olarak ele alacağız.
let Deyimleri
Bu bölümden önce desen kullanımını yalnızca match ve if let bağlamında açıkça ele almıştık; ancak aslında let deyimleri de dahil olmak üzere başka yerlerde de desenleri kullandık. Örneğin, let ile yapılan şu doğrudan değişken atamasını düşünün:
#![allow(unused)]
fn main() {
let x = 5;
}
Farkında olmasanız da bu şekilde bir let deyimi kullandığınız her seferinde aslında desen kullanıyordunuz! Daha biçimsel olarak bir let deyimi şöyle görünür:
let DESEN = İFADE;
DESEN yuvasında bir değişken adının yer aldığı let x = 5; gibi deyimlerde, değişken adı yalnızca bir desenin son derece basit bir biçimidir. Rust ifadeyi desenle karşılaştırır ve desende bulduğu her ada, o ada karşılık gelen değeri atar. Dolayısıyla let x = 5; örneğinde x, “burada eşleşen şeyi x değişkenine bağla” anlamına gelen bir desendir. x adı desenin tamamını oluşturduğundan bu desen fiilen “değer ne olursa olsun her şeyi x değişkenine bağla” anlamına gelir.
let deyiminin desen eşleştirme yönünü daha net görmek için, bir demeti ayrıştırmak (destructure) üzere let ile desen kullanan Liste 19-1’i inceleyelim.
fn main() {
let (birinci, ikinci, ucuncu) = (1, 2, 3);
}
Burada bir demeti bir desenle eşleştiriyoruz. Rust (1, 2, 3) değerini (birinci, ikinci, ucuncu) deseniyle karşılaştırır ve değerin desenle eşleştiğini — yani her ikisindeki öğe sayısının aynı olduğunu — görür; böylece 1 değerini birinci, 2 değerini ikinci ve 3 değerini ucuncu değişkenine bağlar. Bu demet desenini, kendi içinde üç ayrı değişken desenini iç içe barındıran bir yapı olarak düşünebilirsiniz.
Desendeki öğe sayısı demetteki öğe sayısıyla eşleşmezse demetin bütününde tür uyuşmaz ve bir derleyici hatası alırız. Örneğin Liste 19-2, üç öğeli bir demeti iki değişkene ayrıştırma girişimini gösterir; bu kod derlenmeyecektir.
fn main() {
let (birinci, ikinci) = (1, 2, 3);
}
Bu kodu derlemeye çalışmak aşağıdaki tür hatasıyla sonuçlanır:
$ cargo run
Compiling desenler v0.1.0 (file:///projects/desenler)
error[E0308]: mismatched types
--> src/main.rs:2:9
|
2 | let (birinci, ikinci) = (1, 2, 3);
| ^^^^^^^^^^^^^^^^^ --------- this expression has type `({integer}, {integer}, {integer})`
| |
| expected a tuple with 3 elements, found one with 2 elements
|
= note: expected tuple `({integer}, {integer}, {integer})`
found tuple `(_, _)`
For more information about this error, try `rustc --explain E0308`.
error: could not compile `desenler` (bin "desenler") due to 1 previous error
Hatayı düzeltmek için, “Desende Değerleri Yoksaymak” bölümünde göreceğiniz gibi _ veya .. kullanarak demetteki değerlerden birini ya da birkaçını yoksayabiliriz. Eğer sorun desende çok fazla değişken bulunmasıysa, çözüm değişken sayısını demetteki öğe sayısına eşitleyecek şekilde fazladan değişkenleri kaldırarak türlerin eşleşmesini sağlamaktır.
Koşullu if let İfadeleri
Bölüm 6’da if let ifadelerini esas olarak yalnızca tek bir durumla eşleşen bir match ifadesinin eşdeğerini daha kısa yazmanın bir yolu olarak ele almıştık. İsteğe bağlı olarak if let, içindeki desen eşleşmediğinde çalıştırılacak kodu barındıran bir else bloğuna da sahip olabilir.
Liste 19-3; if let, else if ve else if let ifadelerini bir arada ve karışık biçimde kullanmanın da mümkün olduğunu göstermektedir. Bunu yapmak, desenlerle karşılaştırmak üzere yalnızca tek bir değeri ifade edebildiğimiz bir match ifadesine kıyasla bize daha fazla esneklik sağlar. Ayrıca Rust, bir dizi if let, else if ve else if let kolundaki koşulların birbiriyle ilişkili olmasını da şart koşmaz.
Liste 19-3’teki kod, çeşitli koşullara yönelik bir dizi kontrole dayanarak arka plan renginizin ne olacağını belirler. Bu örnek için, gerçek bir programın kullanıcı girdisinden alabileceği değerleri doğrudan koda sabitlenmiş (hardcoded) değişkenler olarak oluşturduk.
fn main() {
let favori_renk: Option<&str> = None;
let sali_mi = false;
let yas: Result<u8, _> = "34".parse();
if let Some(renk) = favori_renk {
println!("Arka plan olarak favori rengin olan {renk} kullanılıyor");
} else if sali_mi {
println!("Salı günü yeşil günü!");
} else if let Ok(yas) = yas {
if yas > 30 {
println!("Arka plan rengi olarak mor kullanılıyor");
} else {
println!("Arka plan rengi olarak turuncu kullanılıyor");
}
} else {
println!("Arka plan rengi olarak mavi kullanılıyor");
}
}
if let, else if, else if let ve else ifadelerini bir arada kullanmakKullanıcı favori bir renk belirtirse arka plan olarak o renk kullanılır. Favori bir renk belirtilmemişse ve bugün salıysa arka plan rengi yeşil olur. Aksi takdirde, kullanıcı yaşını bir string olarak belirtir ve biz bunu başarıyla bir sayı olarak ayrıştırabilirsek (parse), sayının değerine bağlı olarak renk mor veya turuncu olur. Bu koşulların hiçbiri geçerli değilse arka plan rengi mavi olur.
Bu koşullu yapı karmaşık gereksinimleri desteklememize olanak tanır. Burada kullandığımız sabitlenmiş değerlerle bu örnek ekrana Arka plan rengi olarak mor kullanılıyor yazdıracaktır.
if let ifadesinin de tıpkı match kolları gibi mevcut değişkenleri gölgeleyen (shadowing) yeni değişkenler tanımlayabildiğini görebilirsiniz: if let Ok(yas) = yas satırı, Ok varyantının içindeki değeri tutan yeni bir yas değişkeni tanımlayarak mevcut yas değişkenini gölgeler. Bu da if yas > 30 koşulunu o bloğun içine yerleştirmemiz gerektiği anlamına gelir: Rust 2021 ve daha eski sürümlerde bu iki koşulu if let Ok(yas) = yas && yas > 30 şeklinde birleştiremeyiz; çünkü 30 ile karşılaştırmak istediğimiz yeni yas değişkeni, süslü parantezle yeni kapsam başlayana kadar geçerli değildir. (Rust 2024 Sürümü bu kısıtlamayı kaldırmıştır; aşağıdaki nota bakın.)
Not: Kitabın bu bölümündeki anlatım geleneksel kapsam kuralını gösterse de,
Rust 1.88 (ve Rust 2024 Sürümü / edition = "2024") itibarıyla
zincirleme if let (let chains) özelliği kararlı hâle getirilmiştir!
Dolayısıyla edition = "2024" kullanan güncel Rust derleyicisinde if let ve
while let koşullarını && operatörüyle normal mantıksal ifadelerle veya
başka let desenleriyle doğrudan birleştirebilirsiniz:
#![allow(unused)]
fn main() {
let yas: Result<u8, _> = "34".parse();
if let Ok(yas) = yas && yas > 30 {
println!("Arka plan rengi olarak mor kullanılıyor");
}
}
Bu sözdiziminde let deseninin bağladığı yeni yas değişkeni hem &&
sonrasındaki koşulda hem de süslü parantez bloğunun içinde geçerlidir; böylece
yalnızca bu koşulun sağlandığı durumu ele almak için iç içe if gerekmez.
Ancak Liste 19-3’teki iç if/else yapısını tek bir zincirle değiştirmek
aynı davranışı korumaz: Ayrıştırılan yaş 30 veya daha küçükse özgün kod
turuncuyu seçerken zincir başarısız olur ve dıştaki sonraki kola geçilir.
Her iki yaş grubunu ayrı işlemek istiyorsanız özgün iç if/else yapısını
koruyun veya ikinci durumu ayrıca ele alın.
if let ifadelerini kullanmanın dezavantajı, match ifadelerinin aksine derleyicinin kapsamlılık (exhaustiveness) denetimi yapmamasıdır. Son else bloğunu atlarsak ve bu nedenle bazı durumları ele almayı kaçırırsak, derleyici bizi olası mantık hatasına karşı uyarmaz.
Koşullu while let Döngüleri
Yapıca if let’e benzeyen koşullu while let döngüsü, bir desenin eşleşmeye devam ettiği sürece bir while döngüsünün çalışmasına olanak tanır. Liste 19-4’te thread’ler arasında gönderilen mesajları bekleyen, ancak bu kez bir Option yerine bir Result değerini kontrol eden bir while let döngüsü gösteriyoruz.
fn main() {
let (gonderici, alici) = std::sync::mpsc::channel();
std::thread::spawn(move || {
for deger in [1, 2, 3] {
gonderici.send(deger).unwrap();
}
});
while let Ok(deger) = alici.recv() {
println!("{deger}");
}
}
alici.recv() çağrısı Ok döndürdüğü sürece değerleri yazdırmak için bir while let döngüsü kullanmakBu örnek sırasıyla 1, 2 ve ardından 3 yazdırır. recv metodu kanalın alıcı (receiver) ucundan ilk mesajı alır ve bir Ok(deger) döndürür. Bölüm 16’da recv metodunu ilk gördüğümüzde dönen Result değerini doğrudan unwrap ile açmış ya da bir for döngüsü kullanarak kanalla bir iteratör gibi etkileşime geçmiştik. Ancak Liste 19-4’ün gösterdiği gibi while let de kullanabiliriz; çünkü recv metodu gönderici var olduğu sürece her mesaj ulaştığında bir Ok döndürür, gönderici taraf bağlantıyı kestiğinde ise bir Err üretir.
for Döngüleri
Bir for döngüsünde for anahtar kelimesinden hemen sonra gelen kısım bir desendir. Örneğin for x in y ifadesinde x bir desendir. Liste 19-5, bir demeti for döngüsünün bir parçası olarak ayrıştırmak — yani parçalarına ayırmak — için for döngüsünde nasıl desen kullanılacağını göstermektedir.
fn main() {
let vektor = vec!['a', 'b', 'c'];
for (indeks, deger) in vektor.iter().enumerate() {
println!("{deger}, {indeks}. indekste");
}
}
for döngüsünde desen kullanmakListe 19-5’teki kod aşağıdaki çıktıyı yazdıracaktır:
$ cargo run
Compiling desenler v0.1.0 (file:///projects/desenler)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.52s
Running `target/debug/desenler`
a, 0. indekste
b, 1. indekste
c, 2. indekste
enumerate metodunu kullanarak bir iteratörü, her değeri ve o değerin indeksini bir demet içinde üretecek biçimde uyarlıyoruz. Üretilen ilk değer (0, 'a') demetidir. Bu değer (indeks, deger) deseniyle eşleştirildiğinde indeks değişkeni 0, deger değişkeni ise 'a' olur ve çıktının ilk satırı yazdırılır.
Fonksiyon Parametreleri
Fonksiyon parametreleri de birer desen olabilir. i32 türünde deger adlı tek bir parametre alan bir_fonksiyon adında bir fonksiyon bildiren Liste 19-6’daki kod artık size tanıdık geliyor olmalıdır.
fn bir_fonksiyon(deger: i32) {
// kod buraya gelir
}
fn main() {}
Buradaki deger kısmı bir desendir! Tıpkı let ile yaptığımız gibi, bir fonksiyonun argümanlarındaki bir demeti de desenle eşleştirebiliriz. Liste 19-7, bir demeti fonksiyona aktarırken içindeki değerleri birbirinden ayırır.
fn koordinatlari_yazdir(&(x, y): &(i32, i32)) {
println!("Güncel konum: ({x}, {y})");
}
fn main() {
let nokta = (3, 5);
koordinatlari_yazdir(&nokta);
}
Bu kod ekrana Güncel konum: (3, 5) yazdırır. &(3, 5) değeri &(x, y) deseniyle eşleşir; böylece x değişkeni 3, y değişkeni ise 5 değerini alır.
Bölüm 13’te ele aldığımız gibi kapanışlar (closures) fonksiyonlara benzediğinden, desenleri fonksiyon parametre listelerinde kullandığımız şekilde kapanış parametre listelerinde de kullanabiliriz.
Bu noktaya kadar desenleri kullanmanın çeşitli yollarını gördünüz; ancak desenler onları kullanabildiğimiz her yerde aynı şekilde çalışmaz. Bazı yerlerde desenlerin çürütülemez (irrefutable) olması zorunludur; başka durumlarda ise çürütülebilir (refutable) olabilirler. Sırada bu iki kavramı ele alacağız.
Çürütülebilirlik (Refutability): Bir Desenin Eşleşmeme İhtimali
Çürütülebilirlik (Refutability): Bir Desenin Eşleşmeme İhtimali
Desenler iki biçimde karşımıza çıkar: çürütülebilir ve çürütülemez. Aktarılan her olası değerle eşleşecek olan desenler çürütülemez (irrefutable) desenlerdir. Buna bir örnek, let x = 5; deyimindeki x desenidir; çünkü x her şeyle eşleşir ve dolayısıyla eşleşmemesi mümkün değildir. Bazı olası değerler için eşleşmesi başarısız olabilen desenler ise çürütülebilir (refutable) desenlerdir. Buna örnek olarak if let Some(x) = bir_deger ifadesindeki Some(x) verilebilir; çünkü bir_deger değişkenindeki değer Some yerine None olursa Some(x) deseni eşleşmeyecektir.
Fonksiyon parametreleri, let deyimleri ve for döngüleri yalnızca çürütülemez desenleri kabul edebilir; çünkü değerler eşleşmediğinde programın yapabileceği anlamlı hiçbir şey yoktur. if let ve while let ifadeleri ile let...else deyimi ise hem çürütülebilir hem de çürütülemez desenleri kabul eder; ancak derleyici çürütülemez desenlere karşı uyarı verir. Çünkü bu yapılar tanım gereği olası başarısızlık durumlarını ele almak için tasarlanmıştır: Bir koşul yapısının işlevselliği, başarı ya da başarısızlık durumuna göre farklı davranabilme yeteneğinde yatar.
Genel olarak çürütülebilir ve çürütülemez desenler arasındaki ayrım konusunda endişelenmenize gerek yoktur; ancak bir hata mesajında gördüğünüzde ne yapacağınızı bilmek için çürütülebilirlik kavramına aşina olmanız gerekir. Böyle durumlarda, kodun amaçlanan davranışına bağlı olarak ya deseni ya da deseni birlikte kullandığınız dil yapısını değiştirmeniz gerekecektir.
Şimdi Rust’ın çürütülemez bir desen zorunlu kıldığı yerde çürütülebilir bir desen kullanmaya çalıştığımızda — ve bunun tersi durumda — ne olduğuna bir örnekle bakalım. Liste 19-8 bir let deyimi göstermektedir, ancak desen olarak çürütülebilir bir desen olan Some(x) belirtilmiştir. Tahmin edebileceğiniz gibi bu kod derlenmeyecektir.
fn main() {
let bir_secenek_degeri: Option<i32> = None;
let Some(x) = bir_secenek_degeri;
}
let ile çürütülebilir bir desen kullanmaya çalışmakEğer bir_secenek_degeri bir None değeri olsaydı Some(x) deseniyle eşleşemezdi; bu da desenin çürütülebilir olduğu anlamına gelir. Oysa let deyimi yalnızca çürütülemez bir deseni kabul edebilir; çünkü bir None değeriyle kodun yapabileceği geçerli hiçbir işlem yoktur. Derleme zamanında Rust derleyicisi, çürütülemez bir desenin gerektiği yerde çürütülebilir bir desen kullanmaya çalıştığımızı belirterek hata verir:
$ cargo run
Compiling desenler v0.1.0 (file:///projects/desenler)
error[E0005]: refutable pattern in local binding
--> src/main.rs:3:9
|
3 | let Some(x) = bir_secenek_degeri;
| ^^^^^^^ pattern `None` not covered
|
= note: `let` bindings require an "irrefutable pattern", like a `struct` or an `enum` with only one variant
= note: for more information, visit https://doc.rust-lang.org/book/ch19-02-refutability.html
= note: the matched value is of type `Option<i32>`
help: you might want to use `let...else` to handle the variant that isn't matched
|
3 | let Some(x) = bir_secenek_degeri else { todo!() };
| ++++++++++++++++
For more information about this error, try `rustc --explain E0005`.
error: could not compile `desenler` (bin "desenler") due to 1 previous error
Some(x) deseniyle her geçerli değeri kapsamadığımız (ve zaten kapsayamayacağımız!) için Rust haklı olarak bir derleyici hatası üretir.
Çürütülemez bir desenin gerektiği yerde elimizde çürütülebilir bir desen varsa, bunu deseni kullanan kodu değiştirerek düzeltebiliriz: let kullanmak yerine let...else kullanabiliriz. Böylece desen eşleşmezse, süslü parantezler içindeki kod bu durumu ele alır. Liste 19-9, Liste 19-8’deki kodun nasıl düzeltileceğini göstermektedir.
fn main() {
let bir_secenek_degeri: Option<i32> = None;
let Some(x) = bir_secenek_degeri else {
return;
};
}
let yerine let...else ve bir blok kullanmakNot: let...else deyimindeki else bloğu ıraksayan (diverging, dönüş
türü ! — never type olan) bir blok olmak zorundadır; yani return,
break, continue veya panic! gibi kontrol akışını mevcut kapsamdan
çıkaran bir ifadeyle sonlanmalıdır. Normal akışın else bloğundan çıkıp alt
satırlara devam etmesine izin verilmez; böylece let...else satırından sonra
gelen dış kapsamda x değişkeninin her zaman ilklenmiş olması derleme
zamanında garanti edilir.
Koda bir çıkış yolu verdik! Bu kod tamamen geçerlidir; ancak bu aynı zamanda, bir uyarı almadan çürütülemez bir desen kullanamayacağımız anlamına gelir. Liste 19-10’da gösterildiği gibi let...else yapısına x gibi her zaman eşleşecek bir desen verirsek derleyici bir uyarı üretecektir.
fn main() {
let x = 5 else {
return;
};
}
let...else ile çürütülemez bir desen kullanmaya çalışmakRust derleyicisi, else bloğuna hiçbir zaman ulaşılamayacağı için let...else yapısını çürütülemez bir desenle kullanmanın mantıklı olmadığı konusunda uyarır:
$ cargo run
Compiling desenler v0.1.0 (file:///projects/desenler)
warning: unreachable `else` clause
--> src/main.rs:2:15
|
2 | let x = 5 else {
| --------- ^^^^
| |
| assigning to binding pattern will always succeed
|
= note: this pattern always matches, so the else clause is unreachable
= note: `#[warn(irrefutable_let_patterns)]` on by default
warning: `desenler` (bin "desenler") generated 1 warning
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.39s
Running `target/debug/desenler`
Bu nedenle match kolları — geriye kalan tüm değerlerle çürütülemez bir desen kullanarak eşleşmesi gereken son kol dışında — çürütülebilir desenler kullanmak zorundadır. Çürütülemez bir desenin ardından başka kollar gelirse o kollara hiçbir zaman ulaşılamaz; derleyici bu durumda unreachable_pattern uyarısı verir. Rust, tek kollu bir match ifadesinde çürütülemez bir desen kullanmamıza izin verir; ancak bu sözdizimi pek kullanışlı değildir ve daha basit bir let deyimiyle değiştirilebilir.
Artık desenlerin nerelerde kullanılacağını ve çürütülebilir ile çürütülemez desenler arasındaki farkı bildiğinize göre, desen oluşturmak için kullanabileceğimiz tüm sözdizimlerini ele alalım.
Desen Sözdizimi
Desen Sözdizimi
Bu bölümde, desenlerde geçerli olan tüm sözdizimlerini bir araya getirecek ve her birini neden ve ne zaman kullanmak isteyebileceğinizi ele alacağız.
Hazır Değerlerle Eşleşmek
Bölüm 6’da gördüğünüz gibi, desenleri hazır değerlerle (literals) doğrudan eşleştirebilirsiniz. Aşağıdaki kod bazı örnekler göstermektedir:
fn main() {
let x = 1;
match x {
1 => println!("bir"),
2 => println!("iki"),
3 => println!("üç"),
_ => println!("herhangi bir şey"),
}
}
Bu kod ekrana bir yazdırır; çünkü x değişkenindeki değer 1’dir. Bu sözdizimi, kodunuzun belirli bir somut değer aldığında harekete geçmesini istediğinizde kullanışlıdır.
Adlandırılmış Değişkenlerle Eşleşmek
Adlandırılmış değişkenler her değerle eşleşen çürütülemez desenlerdir ve bu kitapta onları pek çok kez kullandık. Ancak adlandırılmış değişkenleri match, if let veya while let ifadelerinde kullandığınızda dikkat edilmesi gereken bir durum ortaya çıkar. Bu ifade türlerinin her biri yeni bir kapsam başlattığından, bu ifadelerin içindeki bir desenin parçası olarak bildirilen değişkenler — tıpkı diğer tüm değişkenlerde olduğu gibi — bu ifadelerin dışındaki aynı adlı değişkenleri gölgeler (shadowing). Liste 19-11’de Some(5) değerine sahip x adında bir değişken ve 10 değerine sahip y adında bir değişken bildiriyoruz. Ardından x değeri üzerinde bir match ifadesi oluşturuyoruz. Kodu çalıştırmadan veya okumaya devam etmeden önce match kollarındaki desenlere ve sondaki println! makrosuna bakarak kodun ekrana ne yazdıracağını tahmin etmeye çalışın.
fn main() {
let x = Some(5);
let y = 10;
match x {
Some(50) => println!("50 alındı"),
Some(y) => println!("Eşleşti, y = {y}"),
_ => println!("Varsayılan durum, x = {x:?}"),
}
println!("sonda: x = {x:?}, y = {y}");
}
y değişkenini gölgeleyen yeni bir değişken tanıtan kola sahip bir match ifadesimatch ifadesi çalıştığında neler olduğuna adım adım bakalım. İlk match kolundaki desen (Some(50)), x değişkeninin tanımlı değeriyle eşleşmez; bu yüzden kod bir sonraki kola geçer.
İkinci match kolundaki desen, bir Some değerinin içindeki herhangi bir değerle eşleşecek olan y adında yeni bir değişken tanıtır. match ifadesinin içinde yeni bir kapsamda olduğumuzdan bu, başlangıçta 10 değeriyle bildirdiğimiz y değil, yeni bir y değişkenidir. Bu yeni y bağlaması bir Some içindeki herhangi bir değerle eşleşecektir ki x içinde de tam olarak bu vardır. Dolayısıyla bu yeni y, x içindeki Some varyantının iç değerine bağlanır. Bu değer 5 olduğundan o kolun ifadesi çalışır ve ekrana Eşleşti, y = 5 yazdırır.
Eğer x değişkeni Some(5) yerine bir None değeri olsaydı, ilk iki koldaki desenler eşleşmez ve değer alt çizgi (_) deseniyle eşleşirdi. Alt çizgi kolunun deseninde x değişkenini yeniden tanıtmadığımız için, bu koldaki ifadede yer alan x hâlâ gölgelenmemiş olan dışarıdaki x değişkenidir. Bu varsayımsal durumda match ifadesi ekrana Varsayılan durum, x = None yazdırırdı.
match ifadesi tamamlandığında kapsamı sona erer ve içerideki y değişkeninin kapsamı da onunla birlikte biter. Son println! çağrısı sonda: x = Some(5), y = 10 çıktısını üretir.
Mevcut y değişkenini gölgeleyen yeni bir değişken tanıtmak yerine dışarıdaki x ve y değerlerini karşılaştıran bir match ifadesi oluşturmak için bir match guard koşulu kullanmamız gerekir. Match guard’ları biraz ileride, “Match Guard ile Ek Koşul Eklemek” bölümünde ele alacağız.
Not: Desenlerde let değişkenleri ile const sabitlerinin davranışı
birbirinden tamamen farklıdır! const MAKS: i32 = 100; şeklinde bir sabit
tanımladığınızda, match kolunda Some(MAKS) yazmak yeni bir değişken
oluşturmaz; doğrudan sabitin değeri olan 100 ile eşleşme arar. Buna karşılık
dış kapsamda let maks = 100; ile oluşturulmuş bir değişken varken
Some(maks) yazmak, içteki değer için yeni bir bağlama oluşturur ve dıştaki
değişkeni gölgeler. Bu ayrımı harfin büyüklüğü değil, adın sabit ya da
değişken olarak çözülmesi belirler; adlandırma kuralları okunabilirlik
içindir. Rust’ta sabitlerin büyük
harflerle (SCREAMING_SNAKE_CASE), enum varyantlarının ise PascalCase ile
yazılması kuralı, bir desene baktığınızda neyin sabit bir değerle eşleştiğini,
neyin ise yeni bir değişken bağladığını ilk bakışta ayırt edebilmeniz
açısından kritik önem taşır.
Birden Fazla Deseni Eşleştirmek
match ifadelerinde, desen veya (or) operatörü olan | sözdizimini kullanarak birden fazla desenle eşleştirme yapabilirsiniz. Örneğin aşağıdaki kodda, x değerini match kollarıyla eşleştiriyoruz; ilk kolda bir veya seçeneği bulunuyor, yani x değeri bu koldaki değerlerden herhangi biriyle eşleşirse o kolun kodu çalışacaktır:
fn main() {
let x = 1;
match x {
1 | 2 => println!("bir ya da iki"),
3 => println!("üç"),
_ => println!("herhangi bir şey"),
}
}
Bu kod ekrana bir ya da iki yazdırır.
..= ile Değer Aralıklarını Eşleştirmek
..= sözdizimi, uç değerleri kapsayan (inclusive) bir değer aralığıyla eşleşmemize olanak tanır. Aşağıdaki kodda, bir desen verilen aralıktaki değerlerden herhangi biriyle eşleştiğinde ilgili kol çalışacaktır:
fn main() {
let x = 5;
match x {
1..=5 => println!("birden beşe kadar"),
_ => println!("başka bir şey"),
}
}
x değeri 1, 2, 3, 4 veya 5 ise ilk kol eşleşir. Bu sözdizimi, birden fazla eşleşme değeri için aynı fikri | operatörüyle ifade etmeye kıyasla çok daha kullanışlıdır; | kullansaydık 1 | 2 | 3 | 4 | 5 yazmamız gerekirdi. Özellikle de sözgelimi 1 ile 1.000 arasındaki herhangi bir sayıyla eşleşmek istediğimizde aralık belirtmek çok daha kısadır!
Derleyici derleme zamanında aralığın boş olmadığını denetler; Rust bir aralığın boş olup olmadığını yalnızca char ve sayısal değerler için anlayabildiğinden, aralık desenlerine yalnızca sayısal değerler veya char değerleriyle izin verilir.
İşte char değer aralıklarını kullanan bir örnek:
fn main() {
let x = 'c';
match x {
'a'..='j' => println!("erken ASCII harfi"),
'k'..='z' => println!("geç ASCII harfi"),
_ => println!("başka bir şey"),
}
}
Rust, 'c' karakterinin ilk desenin aralığında yer aldığını anlayabilir ve ekrana erken ASCII harfi yazdırır.
Değerleri Parçalarına Ayırmak İçin Ayrıştırma
Struct’ları, enum’ları ve demetleri ayrıştırarak (destructuring) bu değerlerin farklı parçalarını kullanmak için de desenlerden yararlanabiliriz. Gelin bu değerlerin her birini sırayla inceleyelim.
Struct’lar
Liste 19-12, bir let deyimindeki desen yardımıyla parçalarına ayırabileceğimiz x ve y adlı iki alana sahip bir Nokta struct’ını göstermektedir.
struct Nokta {
x: i32,
y: i32,
}
fn main() {
let nokta = Nokta { x: 0, y: 7 };
let Nokta { x: a, y: b } = nokta;
assert_eq!(0, a);
assert_eq!(7, b);
}
Bu kod, nokta struct’ının x ve y alanlarının değerleriyle eşleşen a ve b değişkenlerini oluşturur. Bu örnek, desendeki değişken adlarının struct’ın alan adlarıyla aynı olmak zorunda olmadığını gösterir. Bununla birlikte, hangi değişkenin hangi alandan geldiğini hatırlamayı kolaylaştırmak adına değişken adlarını alan adlarıyla eşleştirmek yaygın bir alışkanlıktır. Bu yaygın kullanım nedeniyle ve let Nokta { x: x, y: y } = nokta; yazmak gereksiz tekrar içerdiğinden, Rust struct alanlarıyla eşleşen desenler için bir kısaltma sunar: Yalnızca struct alanının adını yazmanız yeterlidir; desenden oluşturulan değişkenler aynı adlara sahip olacaktır. Liste 19-13, Liste 19-12’deki kodla aynı şekilde davranır; ancak let deseninde oluşturulan değişkenler a ve b yerine x ve y olur.
struct Nokta {
x: i32,
y: i32,
}
fn main() {
let nokta = Nokta { x: 0, y: 7 };
let Nokta { x, y } = nokta;
assert_eq!(0, x);
assert_eq!(7, y);
}
Bu kod, nokta değişkeninin x ve y alanlarıyla eşleşen x ve y değişkenlerini oluşturur. Sonuç olarak x ve y değişkenleri nokta struct’ındaki değerleri içerir.
Tüm alanlar için değişken oluşturmak yerine struct deseninin bir parçası olarak hazır değerlerle de ayrıştırma yapabiliriz. Bunu yapmak, diğer alanları ayrıştırmak üzere değişkenler oluştururken bazı alanların belirli değerlerle eşleşip eşleşmediğini sınamamıza olanak tanır.
Liste 19-14’te, Nokta değerlerini üç duruma ayıran bir match ifadesi yer almaktadır: doğrudan x ekseni üzerinde bulunan noktalar (y = 0 olduğunda geçerlidir), y ekseni üzerinde bulunanlar (x = 0) veya hiçbir eksen üzerinde bulunmayanlar.
struct Nokta {
x: i32,
y: i32,
}
fn main() {
let nokta = Nokta { x: 0, y: 7 };
match nokta {
Nokta { x, y: 0 } => println!("x ekseni üzerinde: {x}"),
Nokta { x: 0, y } => println!("y ekseni üzerinde: {y}"),
Nokta { x, y } => {
println!("Hiçbir eksen üzerinde değil: ({x}, {y})");
}
}
}
İlk kol, y alanının hazır değer 0 ile eşleşmesi koşulunu belirterek x ekseni üzerinde yer alan herhangi bir noktayla eşleşir. Desen yine de bu kolun kodunda kullanabileceğimiz bir x değişkeni oluşturur.
Benzer şekilde ikinci kol, x alanının değeri 0 olduğunda eşleşeceğini belirterek y ekseni üzerindeki herhangi bir noktayla eşleşir ve y alanının değeri için bir y değişkeni oluşturur. Üçüncü kol herhangi bir hazır değer belirtmez; dolayısıyla diğer tüm Nokta değerleriyle eşleşir ve hem x hem de y alanları için değişkenler oluşturur.
Bu örnekte nokta değeri, x alanının 0 içermesi nedeniyle ikinci kolla eşleşir; bu yüzden kod ekrana y ekseni üzerinde: 7 yazdıracaktır.
Bir match ifadesinin ilk eşleşen deseni bulduktan sonra diğer kolları kontrol etmeyi bıraktığını unutmayın; dolayısıyla Nokta { x: 0, y: 0 } hem x ekseni hem de y ekseni üzerinde olmasına rağmen bu kod yalnızca x ekseni üzerinde: 0 yazdıracaktır.
Enum’lar
Bu kitapta daha önce enum’ları ayrıştırmıştık (örneğin Bölüm 6’daki Liste 6-5), ancak bir enum’ı ayrıştırmak için kullanılan desenin, enum içinde saklanan verinin tanımlanma biçimine karşılık geldiğini henüz açıkça ele almamıştık. Örnek olarak Liste 19-15’te, Liste 6-2’deki Mesaj enum’ını kullanıyor ve her bir iç değeri ayrıştıracak desenlere sahip bir match yazıyoruz.
enum Mesaj {
Cik,
Tasi { x: i32, y: i32 },
Yaz(String),
RenkDegistir(i32, i32, i32),
}
fn main() {
let mesaj = Mesaj::RenkDegistir(0, 160, 255);
match mesaj {
Mesaj::Cik => {
println!("Çık varyantında ayrışacak veri yok.");
}
Mesaj::Tasi { x, y } => {
println!("x yönünde {x}, y yönünde {y} taşı");
}
Mesaj::Yaz(metin) => {
println!("Metin mesajı: {metin}");
}
Mesaj::RenkDegistir(r, g, b) => {
println!("Rengi kırmızı {r}, yeşil {g}, mavi {b} olarak değiştir");
}
}
}
Bu kod ekrana Rengi kırmızı 0, yeşil 160, mavi 255 olarak değiştir yazdıracaktır. Diğer kollardaki kodların çalıştığını görmek için mesaj değişkeninin değerini değiştirmeyi deneyin.
Mesaj::Cik gibi herhangi bir veri taşımayan enum varyantlarında değeri daha fazla ayrıştıramayız. Yalnızca doğrudan Mesaj::Cik değeriyle eşleşebiliriz ve bu desende hiçbir değişken yer almaz.
Mesaj::Tasi gibi struct benzeri enum varyantlarında, struct’larla eşleşmek için belirttiğimiz desene benzer bir desen kullanabiliriz. Varyant adından sonra süslü parantezler koyar ve bu kolun kodunda kullanmak üzere parçaları ayırmak için alanları değişkenlerle birlikte listeleriz. Burada Liste 19-13’te yaptığımız gibi kısaltılmış biçimi kullanıyoruz.
Tek öğeli bir demet tutan Mesaj::Yaz ve üç öğeli bir demet tutan Mesaj::RenkDegistir gibi demet benzeri enum varyantlarında ise desen, demetlerle eşleşmek için belirttiğimiz desene benzer. Desendeki değişken sayısı, eşleştirdiğimiz varyanttaki öğe sayısıyla birebir aynı olmalıdır.
İç İçe Geçmiş Struct ve Enum’lar
Şimdiye kadarki örneklerimizin tümü struct veya enum’ları tek bir seviye derinliğinde eşleştiriyordu, ancak eşleştirme iç içe geçmiş öğeler üzerinde de çalışabilir! Örneğin Liste 19-15’teki kodu, Liste 19-16’da gösterildiği gibi RenkDegistir mesajında hem RGB hem de HSV renklerini destekleyecek şekilde yeniden düzenleyebiliriz.
enum Renk {
Rgb(i32, i32, i32),
Hsv(i32, i32, i32),
}
enum Mesaj {
Cik,
Tasi { x: i32, y: i32 },
Yaz(String),
RenkDegistir(Renk),
}
fn main() {
let mesaj = Mesaj::RenkDegistir(Renk::Hsv(0, 160, 255));
match mesaj {
Mesaj::RenkDegistir(Renk::Rgb(r, g, b)) => {
println!("Rengi kırmızı {r}, yeşil {g}, mavi {b} olarak değiştir");
}
Mesaj::RenkDegistir(Renk::Hsv(h, s, v)) => {
println!("Rengi ton {h}, doygunluk {s}, değer {v} olarak değiştir");
}
_ => (),
}
}
match ifadesindeki ilk kolun deseni, içinde bir Renk::Rgb varyantı barındıran Mesaj::RenkDegistir enum varyantıyla eşleşir; ardından desen içerideki üç i32 değerine bağlanır. İkinci kolun deseni de bir Mesaj::RenkDegistir enum varyantıyla eşleşir, ancak içerideki enum bu kez Renk::Hsv ile eşleşir. İşin içinde iki ayrı enum olmasına rağmen bu karmaşık koşulları tek bir match ifadesinde belirtebiliriz.
Struct’lar ve Demetler
Ayrıştırma desenlerini çok daha karmaşık şekillerde birleştirebilir, eşleştirebilir ve iç içe geçirebiliriz. Aşağıdaki örnek, bir demetin içine struct ve demetleri yerleştirdiğimiz ve tüm ilkel (primitive) değerleri dışarı ayrıştırdığımız karmaşık bir ayrıştırma işlemini göstermektedir:
fn main() {
struct Nokta {
x: i32,
y: i32,
}
let ((ayak, inc), Nokta { x, y }) = ((3, 10), Nokta { x: 3, y: -10 });
}
Bu kod, karmaşık türleri bileşen parçalarına ayırmamıza olanak tanır; böylece ilgilendiğimiz değerleri birbirinden bağımsız olarak kullanabiliriz.
Desenlerle ayrıştırma yapmak, bir struct’taki her bir alanın değeri gibi değer parçalarını birbirinden ayrı olarak kullanmanın son derece pratik bir yoludur.
Desende Değerleri Yoksaymak
Bir match ifadesinin son kolunda aslında hiçbir şey yapmayan ama geriye kalan tüm olası değerleri hesaba katan kapsayıcı (catch-all) bir kol elde etmek gibi durumlarda, desendeki değerleri yoksaymanın zaman zaman yararlı olduğunu gördünüz. Bir desende değerlerin tamamını ya da belirli parçalarını yoksaymanın birkaç yolu vardır: _ desenini kullanmak (bunu daha önce gördünüz), başka bir desenin içinde _ desenini kullanmak, alt çizgiyle başlayan bir ad kullanmak veya bir değerin kalan parçalarını yoksaymak için .. kullanmak. Gelin bu desenlerin her birini nasıl ve neden kullanacağımızı inceleyelim.
Tüm Bir Değeri _ ile Yoksaymak
Alt çizgiyi herhangi bir değerle eşleşen ancak o değere bağlanmayan bir joker desen (wildcard pattern) olarak kullanmıştık. Bu özellikle bir match ifadesinde son kol olarak kullanışlıdır; ancak Liste 19-17’de gösterildiği gibi fonksiyon parametreleri de dahil olmak üzere herhangi bir desende de onu kullanabiliriz.
fn bir_fonksiyon(_: i32, y: i32) {
println!("Bu kod yalnızca y parametresini kullanıyor: {y}");
}
fn main() {
bir_fonksiyon(3, 4);
}
_ kullanmakBu kod, ilk argüman olarak aktarılan 3 değerini tamamen yoksayacak ve ekrana Bu kod yalnızca y parametresini kullanıyor: 4 yazdıracaktır.
Çoğu durumda belirli bir fonksiyon parametresine artık ihtiyacınız kalmadığında, kullanılmayan parametreyi içermemesi için fonksiyon imzasını değiştirirsiniz. Bir fonksiyon parametresini yoksaymak özellikle şu durumda işe yarar: Bir trait’i uygularken trait’in belirlediği imzaya uymak zorundasınızdır, ancak sizin uygulamanızdaki fonksiyon gövdesi parametrelerden birine ihtiyaç duymaz. Parametreye bir ad verseydiniz derleyiciden alacağınız “kullanılmayan parametre” uyarısından böylece kurtulmuş olursunuz.
İç İçe _ ile Değerin Parçalarını Yoksaymak
Bir değerin yalnızca bir kısmını yoksaymak için başka bir desenin içinde de _ kullanabiliriz; örneğin bir değerin yalnızca belirli bir parçasını sınamak istediğimizde ancak çalıştırmak istediğimiz ilgili kodda diğer parçalara ihtiyacımız olmadığında bu yöntem işe yarar. Liste 19-18, bir ayarın değerini yönetmekten sorumlu bir kodu göstermektedir. İş kurallarına göre kullanıcının bir ayardaki mevcut özelleştirmenin üzerine yazmasına izin verilmemeli, ancak kullanıcı ayarı sıfırlayabilmeli ve ayar şu anda atanmamışsa ona bir değer verebilmelidir.
fn main() {
let mut ayar_degeri = Some(5);
let yeni_ayar_degeri = Some(10);
match (ayar_degeri, yeni_ayar_degeri) {
(Some(_), Some(_)) => {
println!("Var olan özelleştirilmiş değerin üzerine yazılamaz");
}
_ => {
ayar_degeri = yeni_ayar_degeri;
}
}
println!("ayar değeri şu: {ayar_degeri:?}");
}
Some içindeki değeri kullanmamız gerekmediğinde Some varyantlarıyla eşleşen desenlerin içinde alt çizgi kullanmakBu kod ekrana önce Var olan özelleştirilmiş değerin üzerine yazılamaz, ardından da ayar değeri şu: Some(5) yazdıracaktır. İlk match kolunda, her iki Some varyantının da içindeki değerlerle eşleşmemiz veya onları kullanmamız gerekmez; ancak hem ayar_degeri hem de yeni_ayar_degeri değişkeninin Some varyantı olduğu durumu sınamamız gerekir. Bu durumda ayar_degeri değişkenini değiştirmeme nedenimizi yazdırırız ve değer değiştirilmez.
İkinci koldaki _ deseniyle ifade edilen diğer tüm durumlarda ise (yani ayar_degeri veya yeni_ayar_degeri değişkenlerinden herhangi biri None ise), yeni_ayar_degeri değerinin yeni ayar_degeri olmasına izin vermek isteriz.
Belirli değerleri yoksaymak için tek bir desenin birden fazla yerinde de alt çizgi kullanabiliriz. Liste 19-19, beş öğeli bir demette ikinci ve dördüncü değerleri yoksayma örneğini göstermektedir.
fn main() {
let sayilar = (2, 4, 8, 16, 32);
match sayilar {
(birinci, _, ucuncu, _, besinci) => {
println!("Bazı sayılar: {birinci}, {ucuncu}, {besinci}");
}
}
}
Bu kod ekrana Bazı sayılar: 2, 8, 32 yazdıracak ve 4 ile 16 değerleri yoksayılacaktır.
Adını _ ile Başlatarak Kullanılmayan Bir Değişkeni Yoksaymak
Bir değişken oluşturup onu hiçbir yerde kullanmazsanız, kullanılmayan bir değişken olası bir hataya işaret edebileceğinden Rust genellikle bir uyarı verir. Bununla birlikte, prototip geliştirirken veya bir projeye yeni başlarken olduğu gibi henüz kullanmayacağınız bir değişken oluşturabilmek bazen yararlıdır. Bu durumda değişkenin adını bir alt çizgiyle başlatarak Rust’a kullanılmayan değişken hakkında sizi uyarmamasını söyleyebilirsiniz. Liste 19-20’de kullanılmayan iki değişken oluşturuyoruz, ancak bu kodu derlediğimizde yalnızca biri hakkında uyarı almalıyız.
fn main() {
let _x = 5;
let y = 10;
}
Burada y değişkenini kullanmadığımız için bir uyarı alırız, ancak _x değişkenini kullanmadığımız için uyarı almayız.
Yalnızca _ kullanmak ile alt çizgiyle başlayan bir ad kullanmak arasında ince ama önemli bir fark olduğuna dikkat edin: _x sözdizimi değeri yine de değişkene bağlar, oysa _ hiçbir bağlama yapmaz. Bu ayrımın önemli olduğu bir durumu gösteren Liste 19-21 bize bir hata verecektir.
fn main() {
let metin = Some(String::from("Merhaba!"));
if let Some(_metin) = metin {
println!("bir metin bulundu");
}
println!("{metin:?}");
}
Burada bir hata alırız; çünkü metin içindeki değer yine de _metin değişkenine taşınacak (move) ve bu durum metin değişkenini sonrasında tekrar kullanmamızı engelleyecektir. Buna karşılık tek başına alt çizgi kullanmak değere hiçbir zaman bağlanmaz. Liste 19-22, metin değeri _ içine taşınmadığı için herhangi bir hata olmaksızın derlenecektir.
fn main() {
let metin = Some(String::from("Merhaba!"));
if let Some(_) = metin {
println!("bir metin bulundu");
}
println!("{metin:?}");
}
Bu kod sorunsuzca çalışır; çünkü metin değerini hiçbir şeye bağlamayız ve dolayısıyla sahipliği taşınmaz.
Not: Liste 19-21’deki gibi Copy olmayan bir değeri desenle eşleştirirken
sahipliğin taşınmasını istemiyorsanız, değerin kendisi yerine referansı
üzerinde eşleştirme yapabilirsiniz: if let Some(_metin) = &metin. Bu
durumda derleyici, desendeki bağlamaları otomatik olarak birer referans
(&String) hâline getirir; metin taşınmadığı için sonrasında da
kullanılmaya devam edebilir. Aynı etkiyi desenin içinde ref (değiştirilebilir
referans için ref mut) anahtar kelimesiyle de elde edebilirsiniz:
if let Some(ref _metin) = metin. Bu daha eski sözdizimine, özellikle
otomatik referans bağlama kuralından önce yazılmış kodlarda sıkça rastlarsınız.
.. ile Değerin Kalan Parçalarını Yoksaymak
Çok sayıda parçaya sahip değerlerde, belirli parçaları kullanıp geri kalanını yoksaymak için .. sözdizimini kullanabilir ve böylece yoksayılan her değer için tek tek alt çizgi yazma zahmetinden kurtulabiliriz. .. deseni, desenin geri kalanında açıkça eşleştirmediğimiz tüm parçaları yoksayar. Liste 19-23’te üç boyutlu uzayda bir koordinat tutan bir Nokta struct’ımız var. match ifadesinde yalnızca x koordinatı üzerinde işlem yapmak ve y ile z alanlarındaki değerleri yoksaymak istiyoruz.
fn main() {
struct Nokta {
x: i32,
y: i32,
z: i32,
}
let koken = Nokta { x: 0, y: 0, z: 0 };
match koken {
Nokta { x, .. } => println!("x şu: {x}"),
}
}
.. kullanarak bir Nokta struct’ının x dışındaki tüm alanlarını yoksaymakx değerini listeliyor ve ardından yalnızca .. desenini ekliyoruz. Bu, özellikle çok sayıda alana sahip struct’larla çalışırken yalnızca bir veya iki alanın önemli olduğu durumlarda y: _ ve z: _ yazmak zorunda kalmaktan çok daha pratiktir. Struct desenlerinde .. yalnızca alan listesinin en sonunda yer alabilir.
.. sözdizimi gerektiği kadar çok değere genişleyecektir. Liste 19-24, .. sözdiziminin bir demetle nasıl kullanılacağını göstermektedir.
fn main() {
let sayilar = (2, 4, 8, 16, 32);
match sayilar {
(birinci, .., son) => {
println!("Bazı sayılar: {birinci}, {son}");
}
}
}
Bu kodda ilk ve son değerler birinci ve son ile eşleştirilir. .. deseni ise ortadaki her şeyle eşleşecek ve onları yoksayacaktır.
Bununla birlikte, .. kullanımı belirsiz (ambiguous) olmamalıdır. Hangi değerlerin eşleştirilmesinin amaçlandığı ve hangilerinin yoksayılması gerektiği net değilse Rust bize bir hata verecektir. Liste 19-25, .. sözdiziminin belirsiz bir şekilde kullanıldığı ve bu nedenle derlenmeyecek bir örneği göstermektedir.
fn main() {
let sayilar = (2, 4, 8, 16, 32);
match sayilar {
(.., ikinci, ..) => {
println!("Bazı sayılar: {ikinci}")
}
}
}
.. sözdizimini belirsiz bir şekilde kullanma girişimiBu örneği derlediğimizde aşağıdaki hatayı alırız:
$ cargo run
Compiling desenler v0.1.0 (file:///projects/desenler)
error: `..` can only be used once per tuple pattern
--> src/main.rs:5:22
|
5 | (.., ikinci, ..) => {
| -- ^^ can only be used once per tuple pattern
| |
| previously used here
error: could not compile `desenler` (bin "desenler") due to 1 previous error
Rust’ın, ikinci değişkeniyle bir değeri eşleştirmeden önce demetteki kaç değeri yoksayacağını ve ardından sonrasında kaç değeri daha yoksayacağını belirlemesi imkânsızdır. Bu kod; 2’yi yoksayıp ikinci değişkenini 4’e bağlamak ve ardından 8, 16 ve 32’yi yoksaymak istediğimiz anlamına gelebileceği gibi, 2 ve 4’ü yoksayıp ikinci değişkenini 8’e bağlamak ve ardından 16 ile 32’yi yoksaymak istediğimiz anlamına da gelebilir. ikinci değişken adı Rust için özel bir anlam taşımaz; dolayısıyla iki yerde bu şekilde .. kullanmak belirsiz olduğu için derleyici hatası alırız.
Match Guard ile Ek Koşul Eklemek
Bir match guard (eşleşme koruyucusu), bir match kolundaki desenden sonra belirtilen ve o kolun seçilebilmesi için desenle birlikte sağlanması gereken ek bir if koşuludur. Match guard’lar, tek başına bir desenin izin verdiğinden daha karmaşık fikirleri ifade etmek için kullanışlıdır. Ancak yalnızca match ifadelerinde kullanılabildiklerini; if let veya while let ifadelerinde bulunmadıklarını unutmayın. (Rust 2024 Sürümü’nde if let koşullarını && ile zincirleyerek benzer bir etki elde edebilirsiniz; bkz. “Koşullu if let İfadeleri” bölümündeki not.)
Koşul, desende oluşturulan değişkenleri kullanabilir. Liste 19-26, ilk kolunun Some(x) desenine ve ek olarak (sayı çift ise true üretecek olan) if x % 2 == 0 match guard koşuluna sahip olduğu bir match ifadesini göstermektedir.
fn main() {
let sayi = Some(4);
match sayi {
Some(x) if x % 2 == 0 => println!("{x} sayısı çifttir"),
Some(x) => println!("{x} sayısı tektir"),
None => (),
}
}
Bu örnek ekrana 4 sayısı çifttir yazdıracaktır. sayi değişkeni ilk koldaki desenle karşılaştırıldığında, Some(4) değeri Some(x) ile eşleştiği için desen eşleşir. Ardından match guard, x’in 2’ye bölümünden kalanın 0’a eşit olup olmadığını kontrol eder; eşit olduğu için de ilk kol seçilir.
Eğer sayi bunun yerine Some(5) olsaydı, 5’in 2’ye bölümünden kalan 1 olduğu ve 0’a eşit olmadığı için ilk koldaki match guard false sonucunu üretecekti. Rust bunun üzerine ikinci kola geçecek ve ikinci kol bir match guard içermeyip herhangi bir Some varyantıyla eşleştiği için o kol eşleşecekti.
if x % 2 == 0 koşulunu doğrudan bir desenin içinde ifade etmenin hiçbir yolu yoktur; match guard bize bu mantığı ifade etme olanağı sunar. Bu ek ifade gücünün dezavantajı ise işin içine match guard ifadeleri girdiğinde derleyicinin kapsamlılık (exhaustiveness) denetimi yapmaya çalışmamasıdır: Derleyici, match guard içeren bir kolu olası değerleri kapsıyormuş gibi saymaz. Bu yüzden Liste 19-26’da Some(x) if x % 2 == 0 kolunun ardından koşulsuz bir Some(x) kolu da gerekir; aksi hâlde derleyici Some varyantının tamamen kapsanmadığını söyleyen bir hata verir.
Liste 19-11’i tartışırken desen gölgeleme sorunumuzu çözmek için match guard’ları kullanabileceğimizden bahsetmiştik. match dışındaki değişkeni kullanmak yerine match ifadesindeki desenin içinde yeni bir değişken oluşturduğumuzu hatırlayın. Bu yeni değişken, dışarıdaki değişkenin değeriyle karşılaştırma yapamadığımız anlamına geliyordu. Liste 19-27, bu sorunu düzeltmek için bir match guard’ın nasıl kullanılabileceğini göstermektedir.
fn main() {
let x = Some(5);
let y = 10;
match x {
Some(50) => println!("50 alındı"),
Some(n) if n == y => println!("Eşleşti, n = {n}"),
_ => println!("Varsayılan durum, x = {x:?}"),
}
println!("sonda: x = {x:?}, y = {y}");
}
Bu kod artık ekrana Varsayılan durum, x = Some(5) yazdıracaktır. İkinci match kolundaki desen, dışarıdaki y değişkenini gölgeleyecek yeni bir y değişkeni tanıtmaz; yani match guard içinde dışarıdaki y’yi kullanabiliriz. Dışarıdaki y’yi gölgeleyecek olan Some(y) desenini belirtmek yerine Some(n) belirtiyoruz. Bu, match dışında bir n değişkeni bulunmadığı için hiçbir şeyi gölgelemeyen yeni bir n değişkeni oluşturur.
if n == y match guard koşulu bir desen değildir ve dolayısıyla yeni değişkenler tanıtmaz. Buradaki y, onu gölgeleyen yeni bir y değil, gerçekten dışarıdaki y değişkenidir; böylece n ile y’yi karşılaştırarak dışarıdaki y ile aynı değere sahip bir değeri arayabiliriz.
Birden fazla desen belirtmek için match guard ile birlikte veya operatörü |’ı da kullanabilirsiniz; match guard koşulu desenlerin tümüne uygulanacaktır. Liste 19-28, | kullanan bir deseni bir match guard ile birleştirdiğimizdeki işlem önceliğini göstermektedir. Bu örneğin en önemli noktası, if y match guard koşulunun yalnızca 6’ya uygulanıyormuş gibi görünse bile aslında 4, 5 ve 6’nın tamamına uygulanmasıdır.
fn main() {
let x = 4;
let y = false;
match x {
4 | 5 | 6 if y => println!("evet"),
_ => println!("hayır"),
}
}
Buradaki eşleşme koşulu, kolun yalnızca x değeri 4, 5 veya 6’ya eşitse ve aynı zamanda y değeri true ise eşleşeceğini belirtir. Bu kod çalıştığında x değeri 4 olduğu için ilk kolun deseni eşleşir, ancak if y match guard koşulu false olduğundan ilk kol seçilmez. Kod ikinci kola geçer, bu kol eşleşir ve program ekrana hayır yazdırır. Bunun nedeni, if koşulunun yalnızca son değer olan 6’ya değil, 4 | 5 | 6 deseninin tamamına uygulanmasıdır. Başka bir deyişle, bir match guard’ın desene göre önceliği şu şekilde davranır:
(4 | 5 | 6) if y => ...
şu şekilde değil:
4 | 5 | (6 if y) => ...
Kodu çalıştırdıktan sonra öncelik davranışı açıkça görülür: Eğer match guard yalnızca | operatörü kullanılarak belirtilen değer listesinin son değerine uygulansaydı, kol eşleşir ve program ekrana evet yazdırırdı.
@ Bağlamalarını Kullanmak
At operatörü @, bir değeri desen eşleşmesi için sınadığımız anda aynı değeri tutan bir değişken oluşturmamıza olanak tanır. Liste 19-29’da bir Mesaj::Merhaba varyantının id alanının 3..=7 aralığında olup olmadığını sınamak istiyoruz. Ayrıca kolla ilişkili kodda kullanabilmek için bu değeri id değişkenine bağlamak istiyoruz.
fn main() {
enum Mesaj {
Merhaba { id: i32 },
}
let mesaj = Mesaj::Merhaba { id: 5 };
match mesaj {
Mesaj::Merhaba { id: id @ 3..=7 } => {
println!("Aralıkta bir kimlik bulundu: {id}")
}
Mesaj::Merhaba { id: 10..=12 } => {
println!("Başka bir aralıkta kimlik bulundu")
}
Mesaj::Merhaba { id } => println!("Başka bir kimlik bulundu: {id}"),
}
}
@ kullanmakBu örnek ekrana Aralıkta bir kimlik bulundu: 5 yazdıracaktır. 3..=7 aralığından önce id @ belirterek, değerin aralık deseniyle eşleştiğini sınarken aynı zamanda aralıkla eşleşen değer ne ise onu id adlı bir değişkende yakalıyoruz.
Desende yalnızca bir aralık belirttiğimiz ikinci kolda (10..=12), kolla ilişkili kod id alanının gerçek değerini içeren bir değişkene sahip değildir. id alanının değeri 10, 11 veya 12 olabilirdi; ancak bu desenle çalışan kod hangisi olduğunu bilmez. id değerini bir değişkene kaydetmediğimiz için desen kodu id alanındaki değeri kullanamaz.
Aralık belirtmeden yalnızca bir değişken yazdığımız son kolda ise değer, kolun kodunda kullanılabilecek id adlı bir değişkende gerçekten elimizdedir. Bunun nedeni struct alanı kısaltma sözdizimini kullanmış olmamızdır. Ancak bu kolda, ilk iki kolda yaptığımız gibi id alanındaki değere herhangi bir sınama uygulamadık: Herhangi bir değer bu desenle eşleşecektir.
@ kullanmak, tek bir desen içinde bir değeri hem sınamamıza hem de bir değişkene kaydetmemize olanak tanır.
Not: Bu bölümde gördüğümüz .. ve @ desenleri, sabit boyutlu diziler
([T; N]) ve dilimler (&[T]) üzerinde dilim desenleri (slice patterns)
olarak da çok güçlü bir şekilde kullanılabilir! Örneğin bir dilimin boş olup
olmadığını, tek öğe içerip içermediğini ya da ilk öğesini ayırıp geri kalan
alt dilimi tek seferde yakalamayı doğrudan desenlerle ifade edebilirsiniz:
#![allow(unused)]
fn main() {
let veriler: &[i32] = &[10, 20, 30, 40];
match veriler {
[] => println!("Dilim boş"),
[tek] => println!("Tek öğe var: {tek}"),
[bas, kuyruk @ ..] => {
println!("İlk öğe: {bas}, kalan alt dilim: {kuyruk:?}");
}
}
}
Buradaki kuyruk @ .. alt deseni, ilk öğeden sonraki tüm öğeleri &[20, 30, 40]
alt dilimi olarak kuyruk değişkenine bağlar; üstelik derleyici tüm olası
dilim uzunluklarının (0, 1 ve 2+ öğe) kapsandığını derleme zamanında
doğrular!
Özet
Rust’ın desenleri, farklı veri türlerini birbirinden ayırt etmede son derece kullanışlıdır. match ifadelerinde kullanıldıklarında Rust, desenlerinizin her olası değeri kapsamasını garanti altına alır; aksi takdirde programınız derlenmez. let deyimlerindeki ve fonksiyon parametrelerindeki desenler ise bu yapıları daha işlevsel hâle getirerek değerlerin daha küçük parçalara ayrıştırılmasını ve bu parçaların değişkenlere atanmasını sağlar. İhtiyaçlarımıza uyacak şekilde basit ya da karmaşık desenler oluşturabiliriz.
Sırada, kitabın sondan bir önceki bölümü için Rust’ın çeşitli özelliklerinin bazı gelişmiş yönlerine bakacağız.
Gelişmiş Özellikler
Buraya kadar Rust programlama dilinin en sık kullanılan kısımlarını öğrendiniz. Bölüm 21’de son bir proje daha yapmadan önce, ara sıra karşınıza çıkabilecek ama her gün kullanmayacağınız birkaç dil özelliğine bakacağız. Bilmediğiniz bir yapıyla karşılaştığınızda bu bölümü bir başvuru kaynağı olarak kullanabilirsiniz. Burada ele alınan özellikler çok belirli durumlarda işe yarar. Sık sık eliniz gitmese bile, Rust’ın sunduğu imkânların tamamına genel olarak hâkim olmanızı istiyoruz.
Bu bölümde şunları ele alacağız:
- Güvenli olmayan (unsafe) Rust: Rust’ın bazı garantilerinden bilinçli olarak vazgeçme ve bu garantileri elle koruma sorumluluğunu üstlenme yolları
- Gelişmiş trait’ler: İlişkili türler, varsayılan tür parametreleri, tam nitelikli sözdizimi, üst trait’ler (supertraits) ve trait’lerle ilişkili olarak newtype deseni
- Gelişmiş türler: Newtype deseni hakkında daha fazlası, tür takma adları, never (
!) türü ve dinamik boyutlu türler - Gelişmiş fonksiyonlar ve kapanışlar: Fonksiyon işaretçileri ve kapanış döndürme
- Makrolar: Derleme zamanında daha fazla kod üreten kodlar tanımlama yolları
Bu bölüm, herkese hitap eden zengin bir Rust özellikleri seçkisi sunuyor! Haydi başlayalım!
Not: Kendi uygulama kodunuzda unsafe bloklarına, prosedürel makrolara veya karmaşık ilişkili tür sınırlarına nadiren ihtiyaç duysanız bile, standart kütüphanenin (std) ve ekosistemdeki popüler kütüphanelerin (serde, tokio, rayon vb.) API imzalarını veya kaynak kodlarını okurken bu bölümdeki yapılarla sıkça karşılaşırsınız. Bu özellikleri tanımak, üçüncü parti kütüphanelerin belgelerini ve derleyici hata mesajlarını çok daha rahat yorumlamanızı sağlar.
Güvenli Olmayan (Unsafe) Rust
Güvenli Olmayan (Unsafe) Rust
Şimdiye kadar ele aldığımız tüm kodlarda Rust’ın bellek güvenliği garantileri derleme zamanında zorunlu kılınıyordu. Ancak Rust’ın içinde gizli, bu bellek güvenliği garantilerini zorunlu kılmayan ikinci bir dil daha vardır: Buna güvenli olmayan Rust (unsafe Rust) denir; tıpkı normal Rust gibi çalışır ama bize ek süper güçler verir.
Güvenli olmayan Rust’ın var olma nedeni, statik analizin doğası gereği temkinli olmasıdır. Derleyici bir kodun güvenlik garantilerini koruyup korumadığını belirlemeye çalışırken, bazı geçersiz programları kabul etmektense bazı geçerli programları reddetmesi daha iyidir. Kod gerçekte sorunsuz olabilse bile, Rust derleyicisi bundan emin olacak yeterli bilgiye sahip değilse kodu reddeder. Bu gibi durumlarda derleyiciye “Bana güven, ne yaptığımı biliyorum” demek için güvenli olmayan kod kullanabilirsiniz. Ancak güvenli olmayan Rust’ı tüm riski size ait olmak üzere kullandığınızı unutmayın: Güvenli olmayan kodu hatalı kullanırsanız, boş (null) işaretçinin başvurusunu çözmek gibi bellek güvenliği ihlallerinden kaynaklanan sorunlar meydana gelebilir.
Rust’ın güvenli olmayan bir ikinci yüzünün (alter ego) bulunmasının bir diğer nedeni de alttaki bilgisayar donanımının doğası gereği güvenli olmamasıdır. Rust güvenli olmayan işlemler yapmanıza izin vermeseydi bazı görevleri yerine getiremezdiniz. İşletim sistemiyle doğrudan etkileşim kurmak veya hatta kendi işletim sisteminizi yazmak gibi düşük seviyeli sistem programlama işlerini yapabilmenize Rust’ın olanak tanıması gerekir. Düşük seviyeli sistem programlamayla çalışabilmek, dilin temel hedeflerinden biridir. Gelin güvenli olmayan Rust ile neler yapabileceğimizi ve bunları nasıl gerçekleştireceğimizi inceleyelim.
Güvenli Olmayan Süper Güçleri Kullanmak
Güvenli olmayan Rust’a geçmek için unsafe anahtar kelimesini kullanır ve ardından güvenli olmayan kodu barındıran yeni bir blok başlatırsınız. Güvenli olmayan Rust içinde, güvenli Rust’ta yapamadığınız beş işlemi gerçekleştirebilirsiniz; bunlara güvenli olmayan süper güçler adını veriyoruz. Bu süper güçler şunları yapabilmeyi kapsar:
- Bir ham işaretçinin başvurusunu çözmek.
- Güvenli olmayan bir fonksiyon veya metot çağırmak.
- Değiştirilebilir bir statik değişkene erişmek veya onu değiştirmek.
- Güvenli olmayan bir trait uygulamak.
- Birleşim (
union) türlerinin alanlarına erişmek.
unsafe anahtar kelimesinin ödünç alma denetleyicisini kapatmadığını veya Rust’ın diğer güvenlik denetimlerini devre dışı bırakmadığını anlamak çok önemlidir: Güvenli olmayan kod içinde bir referans kullanırsanız bu referans yine denetlenir. unsafe anahtar kelimesi yalnızca, derleyici tarafından bellek güvenliği açısından denetlenmeyen bu beş özelliğe erişmenizi sağlar. Yani güvenli olmayan bir bloğun içinde bile belirli ölçüde güvenlik elde etmeye devam edersiniz.
Buna ek olarak unsafe, blok içindeki kodun mutlaka tehlikeli olduğu ya da kesinlikle bellek güvenliği sorunları barındıracağı anlamına gelmez: Buradaki amaç, programcı olarak sizin unsafe bloğu içindeki kodun belleğe geçerli bir biçimde erişmesini güvence altına almanızdır.
İnsanlar hata yapabilir ve hatalar kaçınılmaz olarak yaşanacaktır; ancak bu beş işlemi unsafe bloklarıyla işaretlemek, bellek güvenliğini elle doğrulamanız gereken noktaları görünür kılar. Bir ihlalin nedeni mutlaka bu blokta olmak zorunda değildir: Güvenli kodda değiştirilen bir alan da unsafe kodun dayandığı bir ön koşulu bozabilir. Bu nedenle güvenli sarmalayıcının koruduğu bütün değişmezleri birlikte değerlendirmelisiniz. unsafe bloklarını küçük tutun; ileride bellek hatalarını araştırırken bunun çok faydasını göreceksiniz.
Güvenli olmayan kodu mümkün olduğunca yalıtmak için en iyi yaklaşım, bu kodu güvenli bir soyutlamanın içine alıp dışarıya güvenli bir API sunmaktır; bunu bölümün ilerleyen kısımlarında güvenli olmayan fonksiyon ve metotları incelerken ele alacağız. Standart kütüphanenin bazı bölümleri, denetlenmiş güvenli olmayan kodların üzerine inşa edilen güvenli soyutlamalar olarak uygulanmıştır. Güvenli olmayan kodu güvenli bir soyutlamayla sarmalamak, sizin veya kullanıcılarınızın unsafe kodla uygulanmış işlevselliği kullanmak istediği her yere unsafe kullanımının sızmasını önler; çünkü güvenli bir soyutlamayı kullanmak güvenlidir.
Şimdi bu beş güvenli olmayan süper gücün her birini sırayla inceleyelim. Aynı zamanda güvenli olmayan koda güvenli bir arayüz sunan bazı soyutlamalara da göz atacağız.
Ham İşaretçinin Başvurusunu Çözmek
Bölüm 4’teki “Sarkan Referanslar” bölümünde, derleyicinin referansların her zaman geçerli olmasını sağladığından bahsetmiştik. Güvenli olmayan Rust, referanslara benzeyen ham işaretçiler (raw pointers) adında iki yeni türe sahiptir. Tıpkı referanslarda olduğu gibi ham işaretçiler de değiştirilemez veya değiştirilebilir olabilir ve sırasıyla *const T ile *mut T şeklinde yazılır. Buradaki yıldız işareti (*) başvuru çözme (dereferencing) operatörü değildir; tür adının bir parçasıdır. Ham işaretçiler bağlamında değiştirilemez olmak, işaretçinin başvurusu çözüldükten sonra ona doğrudan atama yapılamaması anlamına gelir.
Referanslardan ve akıllı işaretçilerden farklı olarak ham işaretçiler:
- Aynı konuma hem değiştirilemez hem değiştirilebilir işaretçilere ya da birden fazla değiştirilebilir işaretçiye sahip olarak ödünç alma kurallarını göz ardı edebilir
- Geçerli bir bellek alanına işaret ettikleri garanti edilmez
- Boş (
null) olabilir - Herhangi bir otomatik temizlik uygulamaz
Rust’ın bu garantileri zorunlu kılmasından vazgeçtiğinizde garanti edilen güvenlikten feragat edersiniz; karşılığında daha yüksek performans ya da Rust’ın garantilerinin geçerli olmadığı başka bir dil veya donanımla etkileşim kurabilme imkânı kazanırsınız.
Liste 20-1, değiştirilemez ve değiştirilebilir birer ham işaretçinin nasıl oluşturulacağını göstermektedir.
fn main() {
let mut num = 5;
let r1 = &raw const num;
let r2 = &raw mut num;
}
Bu kodda unsafe anahtar kelimesini kullanmadığımıza dikkat edin. Güvenli kod içinde ham işaretçiler oluşturabiliriz; yalnızca birazdan göreceğiniz gibi güvenli olmayan bir bloğun dışında ham işaretçilerin başvurusunu çözemeyiz.
Ham işaretçileri ham ödünç alma operatörlerini (raw borrow operators) kullanarak oluşturduk: &raw const num ifadesi *const i32 türünde değiştirilemez bir ham işaretçi, &raw mut num ifadesi ise *mut i32 türünde değiştirilebilir bir ham işaretçi oluşturur. Bunları doğrudan yerel bir değişkenden ürettiğimiz için bu ham işaretçilerin geçerli olduğunu biliyoruz; ancak herhangi bir ham işaretçi hakkında aynı varsayımda bulunamayız.
Bunu göstermek için şimdi, ham ödünç alma operatörünü kullanmak yerine bir değeri as anahtar kelimesiyle dönüştürerek geçerliliğinden o kadar da emin olamayacağımız bir ham işaretçi oluşturacağız. Liste 20-2, bellekteki keyfi bir konuma işaret eden bir ham işaretçinin nasıl oluşturulacağını gösterir. Keyfi bir bellek alanını kullanmaya çalışmak tanımsız davranıştır: O adreste veri bulunabilir de bulunmayabilir de; derleyici kodu hiçbir bellek erişimi yapılmayacak şekilde optimize edebilir ya da program bir segmentasyon hatasıyla (segmentation fault) sonlanabilir. Özellikle bunun yerine ham ödünç alma operatörü kullanabildiğiniz durumlarda böyle bir kod yazmak için genellikle hiçbir geçerli neden yoktur; ancak bunu yapmak mümkündür.
fn main() {
let address = 0x012345usize;
let r = address as *const i32;
}
Güvenli kod içinde ham işaretçiler oluşturabildiğimizi, ancak ham işaretçilerin başvurusunu çözüp işaret edilen veriyi okuyamadığımızı hatırlayın. Liste 20-3’te ham işaretçiler üzerinde başvuru çözme operatörü *’ı kullanıyoruz; bu işlem bir unsafe bloğu gerektirir.
fn main() {
let mut num = 5;
let r1 = &raw const num;
let r2 = &raw mut num;
unsafe {
println!("r1 değeri: {}", *r1);
println!("r2 değeri: {}", *r2);
}
}
unsafe bloğu içinde ham işaretçilerin başvurusunu çözmekBir işaretçi oluşturmanın tek başına hiçbir zararı yoktur; geçersiz bir değerle karşı karşıya kalma ihtimalimiz yalnızca işaretçinin gösterdiği değere erişmeye çalıştığımızda ortaya çıkar.
Ayrıca Liste 20-1 ve 20-3’te, her ikisi de num değerinin saklandığı aynı bellek konumuna işaret eden *const i32 ve *mut i32 ham işaretçileri oluşturduğumuza dikkat edin. Bunun yerine num değişkenine hem değiştirilemez hem de değiştirilebilir bir referans oluşturmaya çalışsaydık kod derlenmezdi; çünkü Rust’ın sahiplik kuralları herhangi bir değiştirilemez referansla aynı anda değiştirilebilir bir referans bulunmasına izin vermez. Ham işaretçilerle ise aynı konuma hem değiştirilebilir bir işaretçi hem de değiştirilemez bir işaretçi oluşturabilir ve değiştirilebilir işaretçi üzerinden veriyi değiştirerek potansiyel bir veri yarışı yaratabiliriz. Dikkatli olun!
Tüm bu tehlikelere rağmen neden ham işaretçi kullanasınız ki? Başlıca kullanım alanlarından biri, bir sonraki bölümde göreceğiniz gibi C koduyla etkileşim kurmaktır. Bir diğer durum ise ödünç alma denetleyicisinin anlayamadığı güvenli soyutlamalar inşa etmektir. Şimdi güvenli olmayan fonksiyonları tanıtacak, ardından güvenli olmayan kod kullanan güvenli bir soyutlama örneğine bakacağız.
Güvenli Olmayan Bir Fonksiyon veya Metot Çağırmak
Güvenli olmayan bir blok içinde gerçekleştirebileceğiniz ikinci işlem türü, güvenli olmayan fonksiyonları çağırmaktır. Güvenli olmayan fonksiyonlar ve metotlar görünüm olarak sıradan fonksiyon ve metotlarla birebir aynıdır; ancak tanımın geri kalanından önce fazladan bir unsafe anahtar kelimesi taşırlar. Bu bağlamda unsafe anahtar kelimesi, fonksiyonu çağırırken uymamız gereken bazı gereksinimler bulunduğunu belirtir; çünkü Rust bu gereksinimleri karşıladığımızı garanti edemez. Güvenli olmayan bir fonksiyonu bir unsafe bloğu içinde çağırarak, bu fonksiyonun dokümantasyonunu okuduğumuzu ve fonksiyonun sözleşmelerini yerine getirme sorumluluğunu üstlendiğimizi beyan etmiş oluruz.
İşte gövdesinde hiçbir iş yapmayan dangerous adlı güvenli olmayan bir fonksiyon:
fn main() {
unsafe fn dangerous() {}
unsafe {
dangerous();
}
}
dangerous fonksiyonunu ayrı bir unsafe bloğu içinde çağırmamız gerekir. dangerous fonksiyonunu unsafe bloğu olmadan çağırmayı denersek bir hata alırız:
$ cargo run
Compiling guvensiz-ornek v0.1.0 (file:///projects/guvensiz-ornek)
error[E0133]: call to unsafe function `dangerous` is unsafe and requires unsafe block
--> src/main.rs:4:5
|
4 | dangerous();
| ^^^^^^^^^^^ call to unsafe function
|
= note: consult the function's documentation for information on how to avoid undefined behavior
For more information about this error, try `rustc --explain E0133`.
error: could not compile `guvensiz-ornek` (bin "guvensiz-ornek") due to 1 previous error
unsafe bloğuyla Rust’a fonksiyonun dokümantasyonunu okuduğumuzu, onu nasıl doğru kullanacağımızı anladığımızı ve fonksiyonun sözleşmesini yerine getirdiğimizi doğruladığımızı beyan ederiz.
Bir unsafe fonksiyonun gövdesinde güvenli olmayan işlemler gerçekleştirmek için, tıpkı sıradan bir fonksiyonun içinde olduğu gibi yine bir unsafe bloğu kullanmanız gerekir ve bunu unutursanız derleyici sizi uyarır. Güvenli olmayan işlemlere fonksiyon gövdesinin tamamında ihtiyaç duyulmayabileceğinden, bu kural unsafe bloklarını olabildiğince küçük tutmamıza yardımcı olur.
Güvenli Olmayan Kod Üzerine Güvenli Bir Soyutlama Oluşturmak
Bir fonksiyonun güvenli olmayan kod içermesi, fonksiyonun tamamını güvenli olmayan (unsafe) olarak işaretlememiz gerektiği anlamına gelmez. Hatta güvenli olmayan kodu güvenli bir fonksiyonun içine sarmalamak oldukça yaygın bir soyutlamadır. Örnek olarak standart kütüphanedeki, biraz güvenli olmayan kod gerektiren split_at_mut fonksiyonunu inceleyelim ve onu nasıl uygulayabileceğimizi keşfedelim. Bu güvenli metot, değiştirilebilir dilimler üzerinde tanımlanmıştır: Bir dilim alır ve argüman olarak verilen indeksten bölerek onu iki dilime ayırır. Liste 20-4, split_at_mut kullanımını göstermektedir.
fn main() {
let mut v = vec![1, 2, 3, 4, 5, 6];
let r = &mut v[..];
let (a, b) = r.split_at_mut(3);
assert_eq!(a, &mut [1, 2, 3]);
assert_eq!(b, &mut [4, 5, 6]);
}
split_at_mut fonksiyonunu kullanmakBu fonksiyonu yalnızca güvenli Rust kullanarak uygulayamayız. Böyle bir deneme Liste 20-5’teki gibi görünebilir, ancak bu kod derlenmeyecektir. Basitlik adına split_at_mut’u bir metot yerine fonksiyon olarak ve jenerik bir T türü yerine yalnızca i32 değerlerinden oluşan dilimler için uygulayacağız.
fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
let len = values.len();
assert!(mid <= len);
(&mut values[..mid], &mut values[mid..])
}
fn main() {
let mut vector = vec![1, 2, 3, 4, 5, 6];
let (left, right) = split_at_mut(&mut vector, 3);
}
split_at_mut uygulama denemesiBu fonksiyon önce dilimin toplam uzunluğunu alır. Ardından parametre olarak verilen indeksin uzunluktan küçük veya ona eşit olup olmadığını kontrol ederek dilim sınırları içinde kaldığını doğrular. Bu doğrulama, dilimi bölmek için uzunluktan daha büyük bir indeks geçirirsek fonksiyonun o indeksi kullanmaya çalışmadan önce panikleyeceği anlamına gelir.
Daha sonra bir demet içinde iki değiştirilebilir dilim döndürürüz: biri orijinal dilimin başlangıcından mid indeksine kadar, diğeri ise mid indeksinden dilimin sonuna kadar.
Liste 20-5’teki kodu derlemeyi denediğimizde şu hatayı alırız:
$ cargo run
Compiling guvensiz-ornek v0.1.0 (file:///projects/guvensiz-ornek)
error[E0499]: cannot borrow `*values` as mutable more than once at a time
--> src/main.rs:6:31
|
1 | fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
| - let's call the lifetime of this reference `'1`
...
6 | (&mut values[..mid], &mut values[mid..])
| --------------------------^^^^^^--------
| | | |
| | | second mutable borrow occurs here
| | first mutable borrow occurs here
| returning this value requires that `*values` is borrowed for `'1`
|
= help: use `.split_at_mut(position)` to obtain two mutable non-overlapping sub-slices
For more information about this error, try `rustc --explain E0499`.
error: could not compile `guvensiz-ornek` (bin "guvensiz-ornek") due to 1 previous error
Rust’ın ödünç alma denetleyicisi dilimin farklı bölümlerini ödünç aldığımızı anlayamaz; yalnızca aynı dilimden iki kez ödünç aldığımızı bilir. İki dilim birbiriyle örtüşmediği için bir dilimin farklı bölümlerini ödünç almak temelde hiçbir sorun oluşturmaz, ancak Rust bunu bilecek kadar akıllı değildir. Kodun sorunsuz olduğunu bildiğimiz fakat Rust’ın bunu anlayamadığı durumlarda güvenli olmayan koda başvurma zamanı gelmiştir.
Liste 20-6, split_at_mut uygulamasının çalışmasını sağlamak için bir unsafe bloğunun, bir ham işaretçinin ve güvenli olmayan fonksiyonlara yapılan bazı çağrıların nasıl kullanılacağını göstermektedir.
use std::slice;
fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
let len = values.len();
let ptr = values.as_mut_ptr();
assert!(mid <= len);
unsafe {
(
slice::from_raw_parts_mut(ptr, mid),
slice::from_raw_parts_mut(ptr.add(mid), len - mid),
)
}
}
fn main() {
let mut vector = vec![1, 2, 3, 4, 5, 6];
let (left, right) = split_at_mut(&mut vector, 3);
}
split_at_mut fonksiyonunun uygulanışında güvenli olmayan kod kullanmakBölüm 4’teki “Dilim (Slice) Türü” bölümünden, bir dilimin bazı verilere yönelen bir işaretçi ve dilimin uzunluğundan oluştuğunu hatırlayın. Bir dilimin uzunluğunu almak için len metodunu, dilimin ham işaretçisine erişmek içinse as_mut_ptr metodunu kullanırız. Bu örnekte i32 değerlerine ait değiştirilebilir bir dilimimiz olduğundan as_mut_ptr, *mut i32 türünde bir ham işaretçi döndürür ve biz de bunu ptr değişkeninde saklarız.
mid indeksinin dilim içinde kaldığını doğrulayan kontrolü koruruz. Ardından güvenli olmayan koda geçeriz: slice::from_raw_parts_mut fonksiyonu bir ham işaretçi ve bir uzunluk alır, bunlardan bir dilim oluşturur. Bu fonksiyonu ptr adresinden başlayan ve mid öğe uzunluğunda olan bir dilim oluşturmak için kullanırız. Sonra ptr üzerinde mid argümanıyla add metodunu çağırarak mid konumundan başlayan bir ham işaretçi elde ederiz; bu işaretçiyi ve uzunluk olarak mid’den sonra kalan öğe sayısını (len - mid) kullanarak ikinci dilimi oluştururuz.
slice::from_raw_parts_mut fonksiyonu güvenli değildir; çünkü bir ham işaretçi alır ve bu işaretçinin geçerli olduğuna güvenmek zorundadır. Ham işaretçiler üzerindeki add metodu da kaydırılan konumun geçerli bir işaretçi olduğuna güvenmek zorunda olduğu için güvenli değildir. Bu nedenle slice::from_raw_parts_mut ve add çağrılarımızı gerçekleştirebilmek için etraflarına bir unsafe bloğu koymamız gerekti. Koda bakarak ve mid değerinin len değerinden küçük veya ona eşit olmasını şart koşan doğrulamayı ekleyerek, unsafe bloğu içinde kullanılan tüm ham işaretçilerin dilim içindeki verilere işaret eden geçerli işaretçiler olacağını söyleyebiliriz. Bu, unsafe için kabul edilebilir ve yerinde bir kullanımdır.
Ortaya çıkan split_at_mut fonksiyonunu unsafe olarak işaretlememiz gerekmediğine ve bu fonksiyonu güvenli Rust kodundan çağırabildiğimize dikkat edin. unsafe kodu güvenli bir biçimde kullanan bir fonksiyon uygulamasıyla güvenli olmayan kod üzerine güvenli bir soyutlama oluşturmuş olduk; bu uygulama güvenlidir, çünkü yalnızca bu fonksiyonun erişebildiği verilerden geçerli işaretçiler üretir.
Buna karşılık, Liste 20-7’deki slice::from_raw_parts_mut kullanımı dilim kullanıldığında büyük olasılıkla çökecektir. Bu kod keyfi bir bellek konumu alır ve 10.000 öğe uzunluğunda bir dilim oluşturur.
fn main() {
use std::slice;
let address = 0x01234usize;
let r = address as *mut i32;
let values: &[i32] = unsafe { slice::from_raw_parts_mut(r, 10000) };
}
Bu keyfi konumdaki belleğin sahibi değiliz ve bu kodun oluşturduğu dilimin geçerli i32 değerleri içerdiğine dair hiçbir garanti yoktur. Geçerli bellek koşulları sağlanmıyorsa slice::from_raw_parts_mut çağrısının kendisi tanımsız davranıştır; dilimin elemanlarına sonradan erişmemek bu çağrıyı güvenli kılmaz.
Dış Kodu Çağırmak İçin extern Fonksiyonlarını Kullanmak
Bazen Rust kodunuzun başka bir dilde yazılmış kodlarla etkileşim kurması gerekebilir. Bunun için Rust, bir Yabancı Fonksiyon Arayüzü (Foreign Function Interface — FFI) oluşturmayı ve kullanmayı kolaylaştıran extern anahtar kelimesine sahiptir. FFI, bir programlama dilinin fonksiyonlar tanımlamasına ve farklı (yabancı) bir programlama dilinin bu fonksiyonları çağırmasına olanak tanıyan bir yöntemdir.
Liste 20-8, C standart kütüphanesindeki abs fonksiyonuyla nasıl entegrasyon kurulacağını göstermektedir. extern blokları içinde bildirilen fonksiyonları Rust kodundan çağırmak genellikle güvenli değildir; bu nedenle extern blokları da unsafe olarak işaretlenmelidir. Bunun nedeni, diğer dillerin Rust’ın kurallarını ve garantilerini zorunlu kılmaması ve Rust’ın bunları denetleyememesidir; dolayısıyla güvenliği sağlama sorumluluğu programcıya düşer.
unsafe extern "C" {
fn abs(input: i32) -> i32;
}
fn main() {
unsafe {
println!("C'ye göre -3'ün mutlak değeri: {}", abs(-3));
}
}
extern fonksiyonunu bildirmek ve çağırmakunsafe extern "C" bloğu içinde, başka bir dilden çağırmak istediğimiz dış fonksiyonların adlarını ve imzalarını listeleriz. "C" kısmı, dış fonksiyonun hangi uygulama ikili arayüzünü (application binary interface — ABI) kullandığını tanımlar: ABI, fonksiyonun assembly düzeyinde nasıl çağrılacağını belirler. "C" ABI’si en yaygın olanıdır ve C programlama dilinin ABI’sini takip eder. Rust’ın desteklediği tüm ABI’ler hakkındaki bilgilere Rust Referansı’ndan ulaşabilirsiniz.
Bir unsafe extern bloğu içinde bildirilen her öğe örtük olarak unsafe kabul edilir. Bununla birlikte, bazı FFI fonksiyonlarını çağırmak gerçekten güvenlidir. Örneğin C standart kütüphanesindeki abs fonksiyonunun bellek güvenliğiyle ilgili herhangi bir çekincesi yoktur ve herhangi bir i32 değeriyle çağrılabileceğini biliriz. Bu gibi durumlarda, bir unsafe extern bloğu içinde yer alsa bile bu belirli fonksiyonu çağırmanın güvenli olduğunu ifade etmek için safe anahtar kelimesini kullanabiliriz. Bu değişikliği yaptığımızda, Liste 20-9’da gösterildiği gibi onu çağırmak artık bir unsafe bloğu gerektirmez.
unsafe extern "C" {
safe fn abs(input: i32) -> i32;
}
fn main() {
println!("C'ye göre -3'ün mutlak değeri: {}", abs(-3));
}
unsafe extern bloğu içindeki fonksiyonu açıkça safe olarak işaretlemek ve güvenle çağırmakBir fonksiyonu safe olarak işaretlemek onu kendiliğinden güvenli hâle getirmez! Bunun yerine, Rust’a o fonksiyonun güvenli olduğuna dair verdiğiniz bir söz gibidir. Bu sözün tutulduğundan emin olmak yine sizin sorumluluğunuzdadır!
Rust Fonksiyonlarını Başka Dillerden Çağırmak
Diğer dillerin Rust fonksiyonlarını çağırmasına olanak tanıyan bir arayüz oluşturmak için de extern kullanabiliriz. Bütün bir extern bloğu oluşturmak yerine, ilgili fonksiyonun fn anahtar kelimesinden hemen önce extern anahtar kelimesini ekler ve kullanılacak ABI’yi belirtiriz. Ayrıca Rust derleyicisine bu fonksiyonun adını dönüştürmemesini (mangle etmemesini) söylemek için #[unsafe(no_mangle)] özniteliğini eklememiz gerekir. Ad dönüştürme (mangling), derleyicinin bir fonksiyona verdiğimiz adı, derleme sürecinin diğer aşamalarının kullanabileceği daha fazla bilgi içeren ancak insanlar tarafından okunması daha zor olan farklı bir ada dönüştürmesidir. Her programlama dilinin derleyicisi adları biraz farklı biçimde dönüştürür; bu nedenle bir Rust fonksiyonunun diğer diller tarafından adıyla çağrılabilmesi için Rust derleyicisinin ad dönüştürme işlemini devre dışı bırakmamız gerekir. Yerleşik ad dönüştürmesi olmadığında kütüphaneler arasında ad çakışmaları yaşanabileceğinden bu işlem güvenli değildir; dolayısıyla seçtiğimiz adın dönüştürülmeden dışa aktarılmasının güvenli olduğundan emin olmak bizim sorumluluğumuzdadır.
Aşağıdaki örnekte, paylaşılan bir kütüphane olarak derlenip C tarafından bağlandıktan sonra call_from_c fonksiyonunu C kodundan erişilebilir hâle getiriyoruz:
#![allow(unused)]
fn main() {
#[unsafe(no_mangle)]
pub extern "C" fn call_from_c() {
println!("Az önce C'den bir Rust fonksiyonu çağrıldı!");
}
}
extern anahtar kelimesinin bu kullanımı, extern bloğu üzerinde değil yalnızca öznitelik içinde unsafe gerektirir.
Değiştirilebilir Bir Statik Değişkene Erişmek veya Onu Değiştirmek
Bu kitapta şimdiye dek küresel değişkenlerden bahsetmedik; Rust bunları destekler ancak Rust’ın sahiplik kurallarıyla birlikte sorun yaratabilirler. İki thread aynı değiştirilebilir küresel değişkene erişiyorsa bu durum bir veri yarışına yol açabilir.
Rust’ta küresel değişkenlere statik (static) değişkenler denir. Liste 20-10, değeri bir string dilimi olan statik bir değişkenin bildirim ve kullanım örneğini göstermektedir.
static HELLO_WORLD: &str = "Merhaba, dünya!";
fn main() {
println!("değer: {HELLO_WORLD}");
}
Statik değişkenler, Bölüm 3’teki “Sabitleri Bildirmek” bölümünde ele aldığımız sabitlere benzer. Gelenek gereği statik değişkenlerin adları SCREAMING_SNAKE_CASE biçiminde yazılır. Statik değişkenler yalnızca 'static ömrüne sahip referansları saklayabilir; bu da Rust derleyicisinin ömrü kendisinin çıkarabileceği ve bizim bunu açıkça belirtmemize gerek olmadığı anlamına gelir. Değiştirilemez bir statik değişkene erişmek güvenlidir.
Sabitler ile değiştirilemez statik değişkenler arasındaki ince bir fark, statik değişkendeki değerlerin bellekte sabit bir adrese sahip olmasıdır. Bu değeri kullanmak her zaman aynı veriye erişecektir. Öte yandan sabitlerin, her kullanıldıklarında verilerini çoğaltmasına izin verilir. Bir diğer fark ise statik değişkenlerin değiştirilebilir olabilmesidir. Değiştirilebilir statik değişkenlere erişmek ve onları değiştirmek güvenli değildir. Liste 20-11, COUNTER adlı değiştirilebilir bir statik değişkenin nasıl bildirileceğini, ona nasıl erişileceğini ve nasıl değiştirileceğini göstermektedir.
static mut COUNTER: u32 = 0;
/// SAFETY: Bu fonksiyonu aynı anda birden fazla thread'den çağırmak tanımsız
/// davranıştır; bu yüzden aynı anda *yalnızca* tek bir thread'den çağırdığınızı
/// garanti etmelisiniz.
unsafe fn add_to_count(inc: u32) {
unsafe {
COUNTER += inc;
}
}
fn main() {
unsafe {
// SAFETY: Bu fonksiyon yalnızca `main` içindeki tek thread'den çağrılıyor.
add_to_count(3);
println!("COUNTER: {}", *(&raw const COUNTER));
}
}
Sıradan değişkenlerde olduğu gibi değiştirilebilirliği mut anahtar kelimesiyle belirtiriz. COUNTER değişkeninden okuma veya ona yazma yapan her kod bir unsafe bloğu içinde yer almalıdır. Liste 20-11’deki kod tek thread’li olduğu için derlenir ve beklediğimiz gibi COUNTER: 3 çıktısını verir. COUNTER’a senkronizasyon olmadan eşzamanlı erişilir ve erişimlerden en az biri yazma yaparsa veri yarışı, dolayısıyla tanımsız davranış oluşur. Sadece birden fazla thread bulunması tek başına tanımsız davranış değildir. Bu nedenle, fonksiyonu çağıran herkesin güvenli bir şekilde neleri yapıp neleri yapamayacağını bilmesi için fonksiyonun tamamını unsafe olarak işaretlememiz ve güvenlik kısıtlamasını belgelememiz gerekir.
Güvenli olmayan bir fonksiyonun belgelendirmesinde # Safety başlığı altında, çağıran kodun sağlaması gereken ön koşulları açıklamak yaygın bir Rust kullanım biçimidir. unsafe bloğunun yanındaki // SAFETY: yorumu ise bu çağrı noktasında söz konusu koşulların nasıl sağlandığını açıklar. Böylece fonksiyonun sözleşmesi ile belirli bir kullanımın gerekçesi birbirinden ayrılır.
Ayrıca derleyici, değiştirilebilir bir statik değişkene referans oluşturma girişimlerini bir derleyici denetimi (lint) aracılığıyla varsayılan olarak reddeder. Ya #[allow(static_mut_refs)] özniteliğini ekleyerek bu lint korumasından açıkça vazgeçmeniz ya da değiştirilebilir statik değişkene ham ödünç alma operatörlerinden biriyle oluşturulmuş bir ham işaretçi üzerinden erişmeniz gerekir. Bu kural, bu listedeki println! kullanımında olduğu gibi referansın görünmez biçimde oluşturulduğu durumları da kapsar. Ham işaretçi kullanmak, ara bir referans oluşturmadan erişim sağlamamıza ve güvenlik sorumluluğunu açıkça göstermemize yardımcı olur. Ham işaretçiden sonradan bir referans oluşturulursa o referansın bütün geçerlilik ve ödünç alma kuralları yine sağlanmalıdır; lint’i susturmak bu yükümlülüğü kaldırmaz.
Küresel olarak erişilebilen değiştirilebilir verilerde veri yarışı olmadığından emin olmak zordur; Rust’ın değiştirilebilir statik değişkenleri güvenli olmayan kabul etmesinin nedeni budur. Mümkün olan her durumda, farklı thread’lerden yapılan veri erişiminin güvenli bir şekilde gerçekleştirildiğini derleyicinin denetleyebilmesi için Bölüm 16’da ele aldığımız eşzamanlılık tekniklerini ve thread güvenli akıllı işaretçileri kullanmak tercih edilmelidir.
Not: Rust 2024 Sürümü ve Güvenli Olmayan Kod Sıkılaştırmaları: Eski ders kaynaklarında veya Rust 2021 ve öncesi sürümlerle yazılmış crate’lerde farklı unsafe sözdizimleriyle karşılaşabilirsiniz. Rust 2024 Sürümü (Ek E: Sürümler), güvenlik sorumluluklarını kod üzerinde çok daha açık ve görünür kılmak amacıyla önemli iyileştirmeler getirmiştir:
- Ham ödünç alma operatörleri (
&raw constve&raw mut): Eski kodlarda ham işaretçi oluşturmak için kullanılan&num as *const i32veyastd::ptr::addr_of!(num)yerine artık doğrudan&raw const numve&raw mut numoperatörleri kullanılır (bu operatörler Rust 1.82 ile kararlı hâle gelmiştir ve yalnızca 2024 Sürümü’ne özgü değildir). Bu operatörler ara aşamada geçici bir referans oluşturmadığı için hizalanmamış (unaligned) alanlara veyastatic mutdeğişkenlere tanımsız davranış riski olmadan işaretçi üretebilir. unsafe fngövdesinde açıkunsafebloğu: Eskiden bir fonksiyonunsafe fnolarak işaretlendiğinde tüm gövdesi örtük birunsafebloğu sayılıyordu. Rust 2024 ile birlikte, Liste 20-11’dekiadd_to_countfonksiyonunda gördüğünüz gibiunsafe fniçinde dahi güvenli olmayan işlemleri dar kapsamlıunsafe { ... }blokları içine almanız gerekir (aksi hâlde derleyici uyarı verir).unsafe externvesafe fn: Önceki sürümlerde yalnızcaextern "C" { ... }şeklinde yazılan bloklar, Rust 2024’te dış imzaların doğruluğunu garanti etme sorumluluğunu vurgulamak içinunsafe extern "C" { ... }olarak yazılır; buna karşılık her geçerli girdiyle çağrılması güvenli olan dış fonksiyonlar blok içindesafe fnile işaretlenebilir.- Güvenli olmayan öznitelikler (
#[unsafe(...)]): Sembol çakışmalarına yol açabilecek#[no_mangle]gibi öznitelikler artık güvenlik sözleşmesini açıkça göstermek üzere#[unsafe(no_mangle)]biçiminde yazılır. static_mut_refslint engeli: Değiştirilebilir statik değişkenlere doğrudan veyaprintln!("{COUNTER}")gibi makrolar aracılığıyla örtük referans oluşturulması derleyici tarafından varsayılan olarak reddedilir; Liste 20-11’deki*(&raw const COUNTER)ifadesinde olduğu gibi ham işaretçi kullanılması gerekir.
Sürümler arasındaki bu geçişlerin ayrıntılarını Rust Sürüm Rehberi üzerinden inceleyebilirsiniz.
Güvenli Olmayan Bir Trait Uygulamak
Güvenli olmayan bir trait uygulamak için unsafe kullanabiliriz. Metotlarından en az biri derleyicinin doğrulayamadığı bir değişmeze (invariant) sahip olduğunda o trait güvenli değildir. Bir trait’in unsafe olduğunu, Liste 20-12’de gösterildiği gibi trait öncesine unsafe anahtar kelimesini ekleyerek ve trait uygulamasını da unsafe olarak işaretleyerek bildiririz.
unsafe trait Foo {
// metotlar buraya gelir
}
unsafe impl Foo for i32 {
// metot uygulamaları buraya gelir
}
fn main() {}
unsafe impl kullanarak, derleyicinin doğrulayamadığı değişmezleri koruyacağımıza söz vermiş oluruz.
Örnek olarak, Bölüm 16’daki “Send ve Sync ile Genişletilebilir Eşzamanlılık” bölümünde ele aldığımız Send ve Sync işaretleyici trait’lerini hatırlayın: Türlerimiz tamamen Send ve Sync uygulayan diğer türlerden oluşuyorsa derleyici bu trait’leri otomatik olarak uygular. Ham işaretçiler gibi Send veya Sync uygulamayan bir tür içeren bir tür tanımlarsak ve bu türü Send ya da Sync olarak işaretlemek istersek unsafe kullanmamız gerekir. Rust, türümüzün thread’ler arasında güvenle gönderilebilme veya birden fazla thread’den güvenle erişilebilme garantilerini koruduğunu doğrulayamaz; bu nedenle bu denetimleri elle yapmamız ve bunu unsafe ile belirtmemiz gerekir.
union Alanlarına Erişmek
Yalnızca unsafe ile çalışan son işlem, bir birleşimin (union) alanlarına erişmektir. Bir union, struct’a benzer; ancak belirli bir örnekte herhangi bir anda bildirilen alanlardan yalnızca biri kullanılır. union’lar öncelikle C kodundaki union’larla etkileşim kurmak amacıyla kullanılır. Rust, union örneğinde o anda saklanan verinin türünü garanti edemediği için union alanlarına erişmek güvenli değildir. union’lar hakkında daha fazla bilgiyi Rust Referansı’nda bulabilirsiniz.
Not: Bu örnekte #[repr(C)] kullanan bir union, bütün alanlarını aynı
başlangıç adresinde tutar. Boyutu, en büyük alanı barındıracak kadar büyük
olmalı ve hizalama koşulunu da sağlamalıdır; bu yüzden dolgu baytları
nedeniyle en büyük alanın boyutundan daha büyük olabilir. Hangi alana en son
yazıldığını yalnızca siz bilirsiniz; Rust bunu takip etmez. Bu yüzden bir
alana yazmak güvenliyken bir alanı okumak unsafe gerektirir:
#[repr(C)]
union TamSayiVeyaKayan {
tam: u32,
kayan: f32,
}
fn main() {
let mut deger = TamSayiVeyaKayan { tam: 1 };
deger.kayan = 1.0; // Yazmak güvenlidir; artık `kayan` alanı geçerlidir.
// SAFETY: En son `kayan` alanına yazdık; dolayısıyla okuduğumuz bitler
// geçerli bir `f32` değeridir.
let okunan = unsafe { deger.kayan };
println!("{okunan}");
}
Bir union’ın alanları yalnızca Copy olan türlerden (ya da ManuallyDrop<T> ile sarmalanmış türlerden) oluşabilir; ayrıntılar için Rust Referansı’na bakın.
Güvenli Olmayan Kodu Denetlemek İçin Miri Kullanmak
Güvenli olmayan kod yazarken, yazdığınız kodun gerçekten güvenli ve doğru olup olmadığını denetlemek isteyebilirsiniz. Bunu yapmanın en iyi yollarından biri, tanımsız davranışları tespit etmeye yönelik resmî bir Rust aracı olan Miri’yi kullanmaktır. Ödünç alma denetleyicisi derleme zamanında çalışan statik bir araçken, Miri çalışma zamanında çalışan dinamik bir araçtır. Programınızı veya test paketini çalıştırarak ve Rust’ın nasıl çalışması gerektiğine dair bildiği kuralları ihlal ettiğiniz anları yakalayarak kodunuzu denetler.
Miri’yi kullanmak Rust’ın nightly (gecelik) sürümünü gerektirir (bunu Ek G: Rust Nasıl Geliştirilir ve “Nightly Rust” bölümünde daha ayrıntılı ele alıyoruz). rustup +nightly component add miri komutunu yazarak hem Rust’ın nightly sürümünü hem de Miri aracını kurabilirsiniz. Bu işlem projenizin kullandığı Rust sürümünü değiştirmez; yalnızca istediğiniz zaman kullanabilmeniz için aracı sisteminize ekler. Bir proje üzerinde Miri’yi cargo +nightly miri run veya cargo +nightly miri test yazarak çalıştırabilirsiniz.
Bunun ne kadar faydalı olabileceğine bir örnek olarak, Miri’yi Liste 20-7 üzerinde çalıştırdığımızda neler olduğuna bakalım.
$ cargo +nightly miri run
Compiling guvensiz-ornek v0.1.0 (file:///projects/guvensiz-ornek)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.17s
Running `file:///home/.rustup/toolchains/nightly/bin/cargo-miri runner target/miri/debug/guvensiz-ornek`
warning: integer-to-pointer cast
--> src/main.rs:5:13
|
5 | let r = address as *mut i32;
| ^^^^^^^^^^^^^^^^^^^ integer-to-pointer cast
|
= help: this program is using integer-to-pointer casts or (equivalently) `ptr::with_exposed_provenance`, which means that Miri might miss pointer bugs in this program
= help: see https://doc.rust-lang.org/nightly/std/ptr/fn.with_exposed_provenance.html for more details on that operation
= help: to ensure that Miri does not miss bugs in your program, use Strict Provenance APIs (https://doc.rust-lang.org/nightly/std/ptr/index.html#strict-provenance, https://crates.io/crates/sptr) instead
= help: you can then set `MIRIFLAGS=-Zmiri-strict-provenance` to ensure you are not relying on `with_exposed_provenance` semantics
= help: alternatively, `MIRIFLAGS=-Zmiri-permissive-provenance` disables this warning
error: Undefined Behavior: constructing invalid value of type &mut [i32]: encountered a dangling reference (0x1234[noalloc] has no provenance)
--> src/main.rs:7:35
|
7 | let values: &[i32] = unsafe { slice::from_raw_parts_mut(r, 10000) };
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Undefined Behavior occurred here
|
= help: this indicates a bug in the program: it performed an invalid operation, and caused Undefined Behavior
= help: see https://doc.rust-lang.org/nightly/reference/behavior-considered-undefined.html for further information
note: some details are omitted, run with `MIRIFLAGS=-Zmiri-backtrace=full` for a verbose backtrace
error: aborting due to 1 previous error; 1 warning emitted
Miri haklı olarak bizi bir tam sayıyı işaretçiye dönüştürdüğümüz konusunda uyarır (integer-to-pointer cast); bu bir sorun olabilir, ancak Miri işaretçinin nasıl ortaya çıktığını (köken / provenance bilgisini) bilmediği için ortada bir sorun olup olmadığını belirleyemez. Ardından Miri, sarkan bir referansla karşılaşıldığı (encountered a dangling reference — 0x1234 adresine ait bir bellek tahsisi ve köken bilgisi olmadığı) için Liste 20-7’nin tanımsız davranış içerdiği noktada bir hata döndürür. Miri sayesinde artık tanımsız davranış riski bulunduğunu biliyoruz ve kodu nasıl güvenli hâle getireceğimiz üzerine düşünebiliriz. Hatta bazı durumlarda Miri, hataların nasıl düzeltilebileceğine dair önerilerde bile bulunabilir.
Miri, güvenli olmayan kod yazarken yapabileceğiniz her hatayı yakalayamaz. Miri dinamik bir analiz aracı olduğundan yalnızca gerçekte çalıştırılan kodlardaki sorunları yakalar. Bu da yazdığınız güvenli olmayan koda duyduğunuz güveni artırmak için onu iyi test teknikleriyle birlikte kullanmanız gerektiği anlamına gelir. Ayrıca Miri, kodunuzun sağlam olmayan (unsound) —yani güvenli koddan çağrıldığında bile tanımsız davranışa yol açabilen— her olası yolunu kapsamaz.
Başka bir deyişle: Miri bir sorun yakalarsa ortada bir hata (bug) olduğunu kesin olarak bilirsiniz; ancak Miri’nin bir hata yakalamaması, ortada hiçbir sorun olmadığı anlamına gelmez. Yine de pek çok hatayı yakalayabilir. Onu bu bölümdeki diğer güvenli olmayan kod örnekleri üzerinde çalıştırmayı deneyin ve neler söylediğine bakın!
Miri hakkında daha fazla bilgiyi GitHub deposunda bulabilirsiniz.
Güvenli Olmayan Kodu Doğru Kullanmak
Az önce ele aldığımız beş süper güçten birini kullanmak amacıyla unsafe yazmak yanlış değildir, hatta hoş karşılanmayan bir durum bile sayılmaz; ancak derleyici bellek güvenliğini korumaya yardımcı olamadığı için unsafe kodu doğru yazmak daha inceliklidir. unsafe kod kullanmak için geçerli bir nedeniniz olduğunda bunu yapabilirsiniz; üstelik açık unsafe işaretlemesinin bulunması, bir sorun ortaya çıktığında kaynağının izini sürmeyi kolaylaştırır. Güvenli olmayan kod yazdığınız her seferde, yazdığınız kodun Rust’ın kurallarını koruduğundan daha emin olmanıza yardımcı olması için Miri’yi kullanabilirsiniz.
Güvenli olmayan Rust ile etkili bir şekilde nasıl çalışılacağını çok daha derinlemesine keşfetmek için Rust’ın resmî unsafe rehberi olan The Rustonomicon kitabını okuyabilirsiniz.
Gelişmiş Trait'ler
Gelişmiş Trait’ler
Trait’leri ilk olarak Bölüm 10’daki “Trait’lerle Ortak Davranış Tanımlama” bölümünde ele almıştık; ancak daha ileri düzey ayrıntılara girmemiştik. Artık Rust hakkında çok daha fazla şey bildiğinize göre işin inceliklerine inebiliriz.
İlişkili Türlerle Trait Tanımlamak
İlişkili türler (associated types), bir tür yer tutucusunu bir trait ile ilişkilendirir; böylece trait metot tanımları kendi imzalarında bu yer tutucu türleri kullanabilir. Bir trait’i uygulayan taraf, söz konusu uygulama için yer tutucu türün yerine kullanılacak somut türü belirler. Bu sayede, trait uygulanana kadar bu türlerin tam olarak ne olduğunu bilmemize gerek kalmadan bazı türleri kullanan bir trait tanımlayabiliriz.
Bu bölümde ele aldığımız gelişmiş özelliklerin çoğunu nadiren ihtiyaç duyulan yapılar olarak tanımladık. İlişkili türler ise ikisinin ortasında bir yerde durur: Kitabın geri kalanında açıklanan özelliklere kıyasla daha seyrek, ancak bu bölümde tartışılan diğer pek çok özelliğe kıyasla daha yaygın biçimde kullanılırlar.
İlişkili türe sahip bir trait’e örnek olarak standart kütüphanenin sunduğu Iterator trait’ini verebiliriz. Buradaki ilişkili türün adı Item’dır ve Iterator trait’ini uygulayan türün üzerinde ilerlediği değerlerin türünü temsil eder. Iterator trait’inin tanımı Liste 20-13’te gösterilmektedir.
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
Item adlı ilişkili türe sahip Iterator trait’inin tanımıBuradaki Item türü bir yer tutucudur ve next metodunun tanımı, Option<Self::Item> türünde değerler döndüreceğini gösterir. Iterator trait’ini uygulayanlar Item için somut türü belirler ve next metodu da bu somut türden bir değer içeren bir Option döndürür.
İlişkili türler, hangi türleri işleyebileceğini baştan belirtmeden bir fonksiyon tanımlamamıza olanak tanıyan jeneriklere benzer bir kavram gibi görünebilir. İki kavram arasındaki farkı incelemek için, Item türünü u32 olarak belirten Sayac adlı bir tür üzerindeki Iterator trait uygulamasına bakalım:
struct Sayac {
count: u32,
}
impl Sayac {
fn new() -> Sayac {
Sayac { count: 0 }
}
}
impl Iterator for Sayac {
type Item = u32;
fn next(&mut self) -> Option<Self::Item> {
// --snip--
if self.count < 5 {
self.count += 1;
Some(self.count)
} else {
None
}
}
}
Bu sözdizimi jeneriklerinkine oldukça benzer görünüyor. Öyleyse Iterator trait’ini neden Liste 20-14’te gösterildiği gibi doğrudan jeneriklerle tanımlamayalım?
pub trait Iterator<T> {
fn next(&mut self) -> Option<T>;
}
Iterator trait’inin jenerikler kullanılarak yazılmış varsayımsal bir tanımıAradaki fark şudur: Liste 20-14’te olduğu gibi jenerikleri kullandığımızda, her uygulamada türleri açıkça belirtmemiz gerekir; çünkü Sayac için Iterator<String> ya da başka herhangi bir türle de bir uygulama yazabilir, böylece Sayac için birden fazla Iterator uygulamasına sahip olabiliriz. Başka bir deyişle, bir trait jenerik bir parametreye sahip olduğunda, jenerik tür parametrelerinin somut türleri her seferinde değiştirilerek tek bir tür için birden fazla kez uygulanabilir. Sayac üzerinde next metodunu kullandığımızda ise hangi Iterator uygulamasını kullanmak istediğimizi belirtmek için tür açıklamaları eklememiz gerekirdi.
İlişkili türlerle ise tür açıklamaları eklememize gerek kalmaz; çünkü bir trait’i bir tür üzerinde birden fazla kez uygulayamayız. İlişkili türleri kullanan Liste 20-13’teki tanımda, Sayac için yalnızca tek bir impl Iterator for Sayac olabileceğinden Item türünün ne olacağını yalnızca bir kez seçebiliriz. Sayac üzerinde next çağırdığımız her yerde u32 değerlerinden oluşan bir iteratör istediğimizi belirtmek zorunda kalmayız.
İlişkili türler aynı zamanda trait’in sözleşmesinin bir parçası hâline gelir: Trait’i uygulayanlar, ilişkili tür yer tutucusunun yerine geçecek bir tür sağlamak zorundadır. İlişkili türler genellikle türün nasıl kullanılacağını açıklayan bir ada sahip olur ve API dokümantasyonunda ilişkili türü belgelendirmek iyi bir pratiktir.
Not: Kendi trait’inizi tasarlarken ilişkili tür ile jenerik tür parametresi arasında seçim yapmak için şu pratik ölçütü kullanabilirsiniz:
- Trait’i uygulayan her tür için yalnızca tek bir doğal çıktı türü olması mantıklıysa ilişkili tür seçin (
Iterator::Item,Future::OutputveyaDeref::Targetgibi). Böylece trait’i kullanan kodu her çağrıda tür açıklaması yazma yükünden kurtarırsınız. - Aynı türün bu trait’i farklı girdi veya dönüşüm türleriyle birden fazla kez uygulayabilmesi gerekiyorsa jenerik tür parametresi seçin (
From<T>ile bir türün hem&strhemi32değerlerinden dönüştürülebilmesi veya birazdan göreceğimizAdd<Rhs>ile farklı sağ taraf türleriyle toplanabilmesi gibi).
Varsayılan Jenerik Parametreler ve Operatör Aşırı Yükleme
Jenerik tür parametreleri kullandığımızda, jenerik tür için varsayılan bir somut tür belirtebiliriz. Bu, varsayılan tür iş görüyorsa trait’i uygulayanların somut bir tür belirtme zorunluluğunu ortadan kaldırır. Jenerik bir türü bildirirken varsayılan türü <YerTutucuTur=SomutTur> sözdizimiyle belirtirsiniz.
Bu tekniğin yararlı olduğu harika bir durum, belirli koşullarda bir operatörün (+ gibi) davranışını özelleştirdiğiniz operatör aşırı yüklemesidir (operator overloading).
Rust kendi operatörlerinizi oluşturmanıza veya rastgele operatörleri aşırı yüklemenize izin vermez. Ancak std::ops modülünde listelenen işlemleri ve bunlara karşılık gelen trait’leri, operatörle ilişkili trait’i uygulayarak aşırı yükleyebilirsiniz. Örneğin Liste 20-15’te, iki Nokta örneğini birbiriyle toplamak için + operatörünü aşırı yüklüyoruz. Bunu bir Nokta struct’ı üzerinde Add trait’ini uygulayarak yapıyoruz.
use std::ops::Add;
#[derive(Debug, Copy, Clone, PartialEq)]
struct Nokta {
x: i32,
y: i32,
}
impl Add for Nokta {
type Output = Nokta;
fn add(self, other: Nokta) -> Nokta {
Nokta {
x: self.x + other.x,
y: self.y + other.y,
}
}
}
fn main() {
assert_eq!(
Nokta { x: 1, y: 0 } + Nokta { x: 2, y: 3 },
Nokta { x: 3, y: 3 }
);
}
Nokta örnekleri için + operatörünü aşırı yüklemek üzere Add trait’ini uygulamakadd metodu, yeni bir Nokta oluşturmak için iki Nokta örneğinin x ve y değerlerini kendi aralarında toplar. Add trait’i, add metodundan döndürülecek türü belirleyen Output adında bir ilişkili türe sahiptir.
Not: Add trait’indeki add metodu self ve rhs parametrelerini referans olarak değil, değer olarak alır; yani a + b ifadesi her iki işlenenin de sahipliğini tüketir. Liste 20-15’te Nokta için Copy türetmemizin nedeni budur: Böylece p1 + p2 yazdıktan sonra p1 ve p2 kullanılmaya devam edebilir. Copy olmayan bir tür için + operatörünü değerleri taşımadan kullanmak isterseniz Add trait’ini &Nokta gibi referans türleri üzerinde de uygulamanız gerekir; standart kütüphane String için tam da bunu yapar (String + &str ifadesinde sol taraf tüketilir, sağ taraf yalnızca ödünç alınır).
Bu koddaki varsayılan jenerik tür, Add trait’inin içinde yer alır. Tanımı şöyledir:
#![allow(unused)]
fn main() {
trait Add<Rhs=Self> {
type Output;
fn add(self, rhs: Rhs) -> Self::Output;
}
}
Bu kod genel hatlarıyla tanıdık gelmelidir: Tek bir metoda ve bir ilişkili türe sahip bir trait. Yeni olan kısım Rhs=Self ifadesidir: Bu sözdizimine varsayılan tür parametreleri (default type parameters) denir. Rhs jenerik tür parametresi (“sağ taraf” anlamındaki right-hand side ifadesinin kısaltmasıdır), add metodundaki rhs parametresinin türünü tanımlar. Add trait’ini uygularken Rhs için somut bir tür belirtmezsek, Rhs parametresinin türü varsayılan olarak Self, yani üzerinde Add uyguladığımız tür olur.
Nokta için Add uyguladığımızda iki Nokta örneğini toplamak istediğimizden Rhs için varsayılan türü kullandık. Şimdi Add trait’ini uygularken varsayılanı kullanmak yerine Rhs türünü özelleştirmek istediğimiz bir örneğe bakalım.
Farklı birimlerde değerler tutan Milimetreler ve Metreler adında iki struct’ımız var. Mevcut bir türün başka bir struct içinde bu şekilde ince bir katmanla sarmalanması newtype deseni olarak bilinir; bunu “Dış Trait’leri Newtype Deseniyle Uygulamak” bölümünde daha ayrıntılı olarak ele alacağız. Milimetre cinsinden değerleri metre cinsinden değerlerle toplamak ve Add uygulamasının birim dönüşümünü doğru biçimde yapmasını istiyoruz. Liste 20-16’da gösterildiği gibi Rhs olarak Metreler türünü vererek Milimetreler için Add uygulayabiliriz.
use std::ops::Add;
struct Milimetreler(u32);
struct Metreler(u32);
impl Add<Metreler> for Milimetreler {
type Output = Milimetreler;
fn add(self, other: Metreler) -> Milimetreler {
Milimetreler(self.0 + (other.0 * 1000))
}
}
Milimetreler ile Metreler değerlerini toplamak için Milimetreler üzerinde Add trait’ini uygulamakMilimetreler ve Metreler değerlerini toplamak için, Self varsayılanını kullanmak yerine Rhs tür parametresinin değerini belirlemek üzere impl Add<Metreler> yazarız. Bu uygulama yalnızca Milimetreler + Metreler sıralamasını geçerli kılar; Metreler + Milimetreler yazabilmek için Metreler üzerinde ayrı bir Add<Milimetreler> uygulaması gerekir.
Varsayılan tür parametrelerini başlıca iki şekilde kullanırsınız:
- Mevcut kodu bozmadan bir türü genişletmek için
- Çoğu kullanıcının ihtiyaç duymayacağı özel durumlarda özelleştirmeye olanak tanımak için
Standart kütüphanenin Add trait’i ikinci amaca örnektir: Genellikle aynı türden iki değeri toplarsınız, ancak Add trait’i bunun ötesinde özelleştirme yapabilme olanağı sunar. Add trait tanımında varsayılan bir tür parametresi kullanmak, çoğu zaman fazladan parametreyi belirtmek zorunda kalmamanız anlamına gelir. Başka bir deyişle, uygulama sırasında tekrarlayan kalıp kodlara biraz daha az ihtiyaç duyulur ve bu da trait’in kullanımını kolaylaştırır.
İlk amaç ikinciye benzer, ancak ters yönde işler: Mevcut bir trait’e bir tür parametresi eklemek isterseniz, mevcut uygulama kodlarını bozmadan trait’in işlevselliğinin genişletilmesine olanak tanımak için bu parametreye bir varsayılan tür verebilirsiniz.
Aynı Adlı Metotlar Arasında Ayrım Yapmak
Rust’ta hiçbir kural bir trait’in başka bir trait’in metoduyla aynı ada sahip bir metot barındırmasını engellemez; her iki trait’i tek bir tür üzerinde uygulamanıza da engel olmaz. Ayrıca trait’lerdeki metotlarla aynı ada sahip bir metodu doğrudan türün kendisi üzerinde uygulamak da mümkündür.
Aynı ada sahip metotları çağırırken Rust’a hangisini kullanmak istediğinizi söylemeniz gerekir. Her ikisi de fly adında bir metoda sahip Pilot ve Buyucu adlı iki trait tanımladığımız Liste 20-17’deki kodu ele alalım. Ardından her iki trait’i, üzerinde hâlihazırda fly adlı bir metot uygulanmış olan Insan türü üzerinde uyguluyoruz. Her bir fly metodu farklı bir iş yapıyor.
trait Pilot {
fn fly(&self);
}
trait Buyucu {
fn fly(&self);
}
struct Insan;
impl Pilot for Insan {
fn fly(&self) {
println!("Kaptanınız konuşuyor.");
}
}
impl Buyucu for Insan {
fn fly(&self) {
println!("Yukarı!");
}
}
impl Insan {
fn fly(&self) {
println!("*çılgınca kollarını çırpıyor*");
}
}
fn main() {}
fly metoduna sahip olacak şekilde tanımlanıp Insan türü üzerinde uygulanan iki trait ve doğrudan Insan üzerinde uygulanan bir fly metoduBir Insan örneği üzerinde fly metodunu çağırdığımızda derleyici, Liste 20-18’de gösterildiği gibi varsayılan olarak doğrudan türün üzerinde uygulanmış olan metodu çağırır.
trait Pilot {
fn fly(&self);
}
trait Buyucu {
fn fly(&self);
}
struct Insan;
impl Pilot for Insan {
fn fly(&self) {
println!("Kaptanınız konuşuyor.");
}
}
impl Buyucu for Insan {
fn fly(&self) {
println!("Yukarı!");
}
}
impl Insan {
fn fly(&self) {
println!("*çılgınca kollarını çırpıyor*");
}
}
fn main() {
let person = Insan;
person.fly();
}
Insan örneği üzerinde fly metodunu çağırmakBu kodu çalıştırmak ekrana *çılgınca kollarını çırpıyor* yazdıracak ve Rust’ın doğrudan Insan üzerinde uygulanmış fly metodunu çağırdığını gösterecektir.
Gerek Pilot trait’inden gerekse Buyucu trait’inden gelen fly metotlarını çağırmak için hangi fly metodunu kastettiğimizi belirten daha açık bir sözdizimi kullanmamız gerekir. Liste 20-19 bu sözdizimini göstermektedir.
trait Pilot {
fn fly(&self);
}
trait Buyucu {
fn fly(&self);
}
struct Insan;
impl Pilot for Insan {
fn fly(&self) {
println!("Kaptanınız konuşuyor.");
}
}
impl Buyucu for Insan {
fn fly(&self) {
println!("Yukarı!");
}
}
impl Insan {
fn fly(&self) {
println!("*çılgınca kollarını çırpıyor*");
}
}
fn main() {
let person = Insan;
Pilot::fly(&person);
Buyucu::fly(&person);
person.fly();
}
fly metodunu çağırmak istediğimizi belirtmekMetot adından önce trait adını belirtmek, Rust’a hangi fly uygulamasını çağırmak istediğimizi netleştirir. Liste 20-19’da kullandığımız person.fly() ifadesine eşdeğer olan Insan::fly(&person) biçimini de yazabilirdik; ancak belirsizliği gidermemiz gerekmiyorsa bunu yazmak biraz daha uzundur.
Bu kodu çalıştırmak ekrana aşağıdakileri yazdırır:
Kaptanınız konuşuyor.
Yukarı!
*çılgınca kollarını çırpıyor*
fly metodu bir self parametresi aldığından, tek bir trait’i uygulayan iki farklı türümüz olsaydı, Rust self parametresinin türüne bakarak trait’in hangi uygulamasını kullanacağını çıkarabilirdi.
Ancak metot olmayan ilişkili fonksiyonların self parametresi yoktur. Metot olmayan ve aynı adı taşıyan fonksiyonlar tanımlayan birden fazla tür veya trait bulunduğunda, tam nitelikli sözdizimi (fully qualified syntax) kullanmadığınız sürece Rust hangi türü kastettiğinizi her zaman bilemez. Örneğin Liste 20-20’de, tüm yavru köpeklere Karabaş adını vermek isteyen bir hayvan barınağı için bir trait oluşturuyoruz. Metot olmayan ilişkili bir baby_name fonksiyonuna sahip Hayvan adında bir trait tanımlıyoruz. Hayvan trait’i, üzerinde doğrudan metot olmayan ilişkili bir baby_name fonksiyonu da sunduğumuz Kopek struct’ı için uygulanıyor.
trait Hayvan {
fn baby_name() -> String;
}
struct Kopek;
impl Kopek {
fn baby_name() -> String {
String::from("Karabaş")
}
}
impl Hayvan for Kopek {
fn baby_name() -> String {
String::from("yavru köpek")
}
}
fn main() {
println!("Bir yavru köpeğe {} denir", Kopek::baby_name());
}
Tüm yavru köpeklere Karabaş adını verme kodunu, Kopek üzerinde tanımlanan baby_name ilişkili fonksiyonunda uyguluyoruz. Kopek türü ayrıca tüm hayvanların sahip olduğu özellikleri tanımlayan Hayvan trait’ini de uygular. Köpek yavrularına yavru köpek denir ve bu durum, Kopek üzerindeki Hayvan trait uygulamasında Hayvan trait’iyle ilişkili baby_name fonksiyonu içinde ifade edilmiştir.
main içinde Kopek::baby_name ifadesini çağırıyoruz; bu çağrı doğrudan Kopek üzerinde tanımlanmış ilişkili fonksiyonu çalıştırır. Bu kod ekrana şunu yazdırır:
Bir yavru köpeğe Karabaş denir
Bu çıktı bizim istediğimiz sonuç değildir. Biz Kopek üzerinde uyguladığımız Hayvan trait’inin bir parçası olan baby_name fonksiyonunu çağırmak istiyoruz; böylece kod Bir yavru köpeğe yavru köpek denir çıktısını üretsin. Liste 20-19’da kullandığımız trait adını belirtme tekniği burada işe yaramaz; main fonksiyonunu Liste 20-21’deki gibi değiştirirsek bir derleme hatası alırız.
trait Hayvan {
fn baby_name() -> String;
}
struct Kopek;
impl Kopek {
fn baby_name() -> String {
String::from("Karabaş")
}
}
impl Hayvan for Kopek {
fn baby_name() -> String {
String::from("yavru köpek")
}
}
fn main() {
println!("Bir yavru köpeğe {} denir", Hayvan::baby_name());
}
Hayvan trait’inden baby_name fonksiyonunu çağırmayı denemek; ancak Rust hangi uygulamayı kullanacağını bilemezHayvan::baby_name bir self parametresine sahip olmadığından ve Hayvan trait’ini uygulayan başka türler de bulunabileceğinden, Rust Hayvan::baby_name fonksiyonunun hangi uygulamasını istediğimizi çıkaramaz. Şu derleyici hatasını alırız:
$ cargo run
Compiling trait-ornekleri v0.1.0 (file:///projects/trait-ornekleri)
error[E0790]: cannot call associated function on trait without specifying the corresponding `impl` type
--> src/main.rs:20:43
|
2 | fn baby_name() -> String;
| ------------------------- `Hayvan::baby_name` defined here
...
20 | println!("Bir yavru köpeğe {} denir", Hayvan::baby_name());
| ^^^^^^^^^^^^^^^^^^^ cannot call associated function of trait
|
help: use the fully-qualified path to the only available implementation
|
20 | println!("Bir yavru köpeğe {} denir", <Kopek as Hayvan>::baby_name());
| ++++++++++ +
For more information about this error, try `rustc --explain E0790`.
error: could not compile `trait-ornekleri` (bin "trait-ornekleri") due to 1 previous error
Belirsizliği gidermek ve Rust’a başka bir tür için olan Hayvan uygulaması yerine Kopek için olan Hayvan uygulamasını kullanmak istediğimizi söylemek için tam nitelikli sözdizimini kullanmamız gerekir. Liste 20-22 tam nitelikli sözdiziminin nasıl kullanılacağını göstermektedir.
trait Hayvan {
fn baby_name() -> String;
}
struct Kopek;
impl Kopek {
fn baby_name() -> String {
String::from("Karabaş")
}
}
impl Hayvan for Kopek {
fn baby_name() -> String {
String::from("yavru köpek")
}
}
fn main() {
println!("Bir yavru köpeğe {} denir", <Kopek as Hayvan>::baby_name());
}
Kopek üzerinde uygulandığı hâliyle Hayvan trait’indeki baby_name fonksiyonunu çağırmak istediğimizi belirtmek için tam nitelikli sözdizimi kullanmakAçılı parantezler içinde Rust’a bir tür açıklaması sağlıyoruz: <Kopek as Hayvan> yazarak bu fonksiyon çağrısı için Kopek türünü bir Hayvan olarak ele almak istediğimizi söylüyoruz. Böylece Kopek üzerinde uygulandığı hâliyle Hayvan trait’indeki baby_name fonksiyonu çağrılır. Bu kod artık istediğimiz çıktıyı yazdıracaktır:
Bir yavru köpeğe yavru köpek denir
Genel olarak tam nitelikli sözdizimi şöyle tanımlanır:
<Type as Trait>::function(receiver_if_method, next_arg, ...);
Metot olmayan ilişkili fonksiyonlarda bir receiver (alıcı, yani self) bulunmaz: Yalnızca diğer argümanların listesi yer alır. Fonksiyon veya metot çağırdığınız her yerde tam nitelikli sözdizimini kullanabilirsiniz. Ancak Rust’ın programdaki diğer bilgilerden çıkarabildiği her kısmı bu sözdiziminden atlayabilirsiniz. Bu daha ayrıntılı sözdizimini yalnızca aynı adı kullanan birden fazla uygulamanın bulunduğu ve Rust’ın hangi uygulamayı çağırmak istediğinizi belirlemek için yardıma ihtiyaç duyduğu durumlarda kullanmanız gerekir.
Üst Trait’leri Kullanmak
Bazen başka bir trait’e bağımlı olan bir trait tanımı yazabilirsiniz: Bir türün ilk trait’i uygulayabilmesi için ikinci trait’i de uygulamasını zorunlu kılmak istersiniz. Bunu, trait tanımınızın ikinci trait’in ilişkili öğelerinden yararlanabilmesi için yaparsınız. Trait tanımınızın dayandığı bu trait’e, trait’inizin üst trait’i (supertrait) denir.
Örneğin, verilen bir değeri yıldız işaretleriyle çerçevelenmiş biçimde yazdıracak outline_print metoduna sahip bir CerceveliYazdir trait’i yapmak istediğimizi varsayalım. Yani standart kütüphanedeki Display trait’ini (x, y) sonucu üretecek biçimde uygulayan bir Nokta struct’ı verildiğinde, x için 1 ve y için 3 değerine sahip bir Nokta örneği üzerinde outline_print çağırdığımızda metodun şunu yazdırması gerekir:
**********
* *
* (1, 3) *
* *
**********
outline_print metodunun uygulamasında Display trait’inin işlevselliğini kullanmak istiyoruz. Bu nedenle, CerceveliYazdir trait’inin yalnızca Display trait’ini de uygulayan ve CerceveliYazdir’ın ihtiyaç duyduğu işlevselliği sağlayan türler için çalışacağını belirtmemiz gerekir. Bunu trait tanımında CerceveliYazdir: Display belirterek yapabiliriz. Bu teknik, trait’e bir trait sınırı eklemeye benzer. Liste 20-23, CerceveliYazdir trait’inin bir uygulamasını göstermektedir.
use std::fmt;
trait CerceveliYazdir: fmt::Display {
fn outline_print(&self) {
let output = self.to_string();
let len = output.len();
println!("{}", "*".repeat(len + 4));
println!("*{}*", " ".repeat(len + 2));
println!("* {output} *");
println!("*{}*", " ".repeat(len + 2));
println!("{}", "*".repeat(len + 4));
}
}
fn main() {}
Display’den gelen işlevselliği gerektiren CerceveliYazdir trait’ini uygulamakCerceveliYazdir trait’inin Display trait’ini gerektirdiğini belirttiğimiz için, Display uygulayan her tür üzerinde otomatik olarak uygulanan to_string fonksiyonunu kullanabiliriz. Trait adından sonra iki nokta üst üste ekleyip Display trait’ini belirtmeden to_string kullanmayı deneseydik, geçerli kapsamda &Self türü için to_string adında bir metot bulunamadığını söyleyen bir hata alırdık.
Nokta struct’ı gibi Display uygulamayan bir tür üzerinde CerceveliYazdir uygulamaya çalıştığımızda ne olacağını görelim:
use std::fmt;
trait CerceveliYazdir: fmt::Display {
fn outline_print(&self) {
let output = self.to_string();
let len = output.len();
println!("{}", "*".repeat(len + 4));
println!("*{}*", " ".repeat(len + 2));
println!("* {output} *");
println!("*{}*", " ".repeat(len + 2));
println!("{}", "*".repeat(len + 4));
}
}
struct Nokta {
x: i32,
y: i32,
}
impl CerceveliYazdir for Nokta {}
fn main() {
let p = Nokta { x: 1, y: 3 };
p.outline_print();
}
Display’in gerekli olduğunu ancak uygulanmadığını belirten bir hata alırız:
$ cargo run
Compiling trait-ornekleri v0.1.0 (file:///projects/trait-ornekleri)
error[E0277]: `Nokta` doesn't implement `std::fmt::Display`
--> src/main.rs:20:26
|
20 | impl CerceveliYazdir for Nokta {}
| ^^^^^ unsatisfied trait bound
|
help: the trait `std::fmt::Display` is not implemented for `Nokta`
--> src/main.rs:15:1
|
15 | struct Nokta {
| ^^^^^^^^^^^^
note: required by a bound in `CerceveliYazdir`
--> src/main.rs:3:24
|
3 | trait CerceveliYazdir: fmt::Display {
| ^^^^^^^^^^^^ required by this bound in `CerceveliYazdir`
error[E0277]: `Nokta` doesn't implement `std::fmt::Display`
--> src/main.rs:24:7
|
24 | p.outline_print();
| ^^^^^^^^^^^^^ unsatisfied trait bound
|
help: the trait `std::fmt::Display` is not implemented for `Nokta`
--> src/main.rs:15:1
|
15 | struct Nokta {
| ^^^^^^^^^^^^
note: required by a bound in `CerceveliYazdir::outline_print`
--> src/main.rs:3:24
|
3 | trait CerceveliYazdir: fmt::Display {
| ^^^^^^^^^^^^ required by this bound in `CerceveliYazdir::outline_print`
4 | fn outline_print(&self) {
| ------------- required by a bound in this associated function
For more information about this error, try `rustc --explain E0277`.
error: could not compile `trait-ornekleri` (bin "trait-ornekleri") due to 2 previous errors
Bunu düzeltmek için Nokta üzerinde Display uygular ve CerceveliYazdir’ın gerektirdiği kısıtı şu şekilde karşılarız:
trait CerceveliYazdir: fmt::Display {
fn outline_print(&self) {
let output = self.to_string();
let len = output.len();
println!("{}", "*".repeat(len + 4));
println!("*{}*", " ".repeat(len + 2));
println!("* {output} *");
println!("*{}*", " ".repeat(len + 2));
println!("{}", "*".repeat(len + 4));
}
}
struct Nokta {
x: i32,
y: i32,
}
impl CerceveliYazdir for Nokta {}
use std::fmt;
impl fmt::Display for Nokta {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}
fn main() {
let p = Nokta { x: 1, y: 3 };
p.outline_print();
}
Ardından Nokta üzerinde CerceveliYazdir trait’ini uygulamak başarıyla derlenecek ve bir Nokta örneğini yıldızlardan oluşan bir çerçeve içinde görüntülemek için üzerinde outline_print metodunu çağırabileceğiz.
Dış Trait’leri Newtype Deseniyle Uygulamak
Bölüm 10’daki “Bir Tür Üzerinde Trait Uygulama” bölümünde, bir trait’i bir tür üzerinde yalnızca trait’in ya da türün veya her ikisinin birden kendi crate’imize yerel olması durumunda uygulayabileceğimizi belirten yetim kuralından (orphan rule) bahsetmiştik. Bir demet struct’ı içinde yeni bir tür oluşturmayı içeren newtype desenini kullanarak bu kısıtlamanın etrafından dolaşmak mümkündür. (Demet struct’larını Bölüm 5’teki “Demet Struct’ları ile Farklı Türler Oluşturma” bölümünde ele almıştık.) Bu demet struct’ı tek bir alana sahip olur ve kendisi için trait uygulamak istediğimiz türün etrafında ince bir sarmalayıcı görevi görür. Böylece sarmalayıcı tür kendi crate’imize yerel olur ve trait’i bu sarmalayıcı üzerinde uygulayabiliriz. Newtype, kökeni Haskell programlama diline dayanan bir terimdir. Bu deseni kullanmanın çalışma zamanında hiçbir performans maliyeti yoktur ve sarmalayıcı tür derleme zamanında ortadan kaldırılır.
Örnek olarak Vec<T> üzerinde Display uygulamak istediğimizi düşünelim; hem Display trait’i hem de Vec<T> türü crate’imizin dışında tanımlandığı için yetim kuralı bunu doğrudan yapmamızı engeller. Bir Vec<T> örneğini tutan bir Sarmalayici struct’ı oluşturabiliriz; ardından Liste 20-24’te gösterildiği gibi Sarmalayici üzerinde Display uygulayabilir ve Vec<T> değerini kullanabiliriz.
use std::fmt;
struct Sarmalayici(Vec<String>);
impl fmt::Display for Sarmalayici {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "[{}]", self.0.join(", "))
}
}
fn main() {
let w = Sarmalayici(vec![String::from("merhaba"), String::from("dünya")]);
println!("w = {w}");
}
Display uygulamak için Vec<String> etrafında bir Sarmalayici türü oluşturmakSarmalayici bir demet struct’ı olduğu ve Vec<T> demetteki 0 indeksli öğe olduğu için Display uygulaması içteki Vec<T> değerine erişmek üzere self.0 ifadesini kullanır. Ardından Sarmalayici üzerinde Display trait’inin işlevselliğini kullanabiliriz.
Bu tekniği kullanmanın dezavantajı, Sarmalayici’nın yeni bir tür olması ve dolayısıyla tuttuğu değerin metotlarına sahip olmamasıdır. Sarmalayici’yı tıpkı bir Vec<T> gibi kullanabilmek için Vec<T>’nin tüm metotlarını doğrudan Sarmalayici üzerinde, işlevi self.0’a devredecek şekilde uygulamamız gerekirdi. Yeni türün içteki türün sahip olduğu her metoda sahip olmasını isteseydik, içteki türü döndürecek şekilde Sarmalayici üzerinde Deref trait’ini uygulamak bir çözüm olurdu (Deref trait’ini uygulamayı Bölüm 15’teki “Akıllı İşaretçilere Normal Referanslar Gibi Davranma” bölümünde ele almıştık). Eğer Sarmalayici türünün içteki türün tüm metotlarına sahip olmasını istemeseydik —örneğin Sarmalayici türünün davranışını kısıtlamak amacıyla— yalnızca gerçekten istediğimiz metotları elle uygulamamız gerekirdi.
Bu newtype deseni, işin içinde trait’ler olmadığında bile kullanışlıdır. Şimdi odağımızı değiştirelim ve Rust’ın tür sistemiyle etkileşim kurmanın bazı gelişmiş yollarına bakalım.
Gelişmiş Türler
Gelişmiş Türler
Rust’ın tür sisteminde şimdiye kadar değindiğimiz ancak henüz ayrıntılı olarak ele almadığımız bazı özellikler vardır. İlk olarak newtype’ların (yeni türlerin) birer tür olarak neden yararlı olduğunu inceleyerek genel hatlarıyla newtype kavramını tartışacağız. Ardından newtype’lara benzeyen ancak anlambilimi (semantics) biraz farklı olan tür takma adlarına (type aliases) geçeceğiz. Ayrıca ! türünü ve dinamik boyutlu türleri (dynamically sized types) de ele alacağız.
Newtype Deseniyle Tür Güvenliği ve Soyutlama
Bu bölüm, daha önceki “Dış Trait’leri Newtype Deseniyle Uygulamak” alt bölümünü okuduğunuzu varsayar. Newtype deseni, şimdiye kadar ele aldıklarımızın ötesindeki görevler için de kullanışlıdır; değerlerin birbiriyle asla karıştırılmamasını derleme zamanında (statik olarak) zorunlu kılmak ve bir değerin birimini belirtmek bunların arasındadır. Birimleri göstermek amacıyla newtype kullanımının bir örneğini Liste 20-16’da görmüştünüz: Milimetreler ve Metreler struct’larının u32 değerlerini bir newtype içinde sarmaladığını hatırlayın. Parametresi Milimetreler türünde olan bir fonksiyon yazsaydık, bu fonksiyonu yanlışlıkla Metreler türünden bir değerle veya düz bir u32 ile çağırmaya çalışan bir programı derleyemezdik.
Newtype desenini bir türün bazı uygulama ayrıntılarını soyutlamak için de kullanabiliriz: Yeni tür, içteki özel (private) türün API’sinden farklı —örneğin kullanılabilir işlevselliği sınırlandıran— bir açık (public) API sunabilir.
Benzer şekilde newtype’lar iç temsil ayrıntılarını gizlemek için de kullanılabilir. Örneğin, bir kişinin kimlik numarasını adıyla ilişkilendirerek saklayan HashMap<i32, String> yapısını sarmalamak üzere bir Kisiler türü sağlayabiliriz. Kisiler türünü kullanan kod yalnızca sunduğumuz açık API ile —örneğin Kisiler koleksiyonuna bir isim string’i ekleyen bir metotla— etkileşime girer; bu kodun perde arkasında isimlere i32 türünde bir kimlik numarası atadığımızı bilmesine gerek kalmaz. Newtype deseni, Bölüm 18’deki “Uygulama Ayrıntılarını Gizleyen Kapsülleme” bölümünde ele aldığımız kapsüllemeyi elde etmenin hafif bir yoludur.
Tür Eşanlamlıları ve Tür Takma Adları
Rust, mevcut bir türe başka bir ad vermek amacıyla bir tür takma adı (type alias) bildirme olanağı sunar. Bunun için type anahtar kelimesini kullanırız. Örneğin i32 için Kilometreler takma adını şu şekilde oluşturabiliriz:
fn main() {
type Kilometreler = i32;
let x: i32 = 5;
let y: Kilometreler = 5;
println!("x + y = {}", x + y);
}
Artık Kilometreler takma adı, i32 için bir eşanlamlıdır (synonym); Liste 20-16’da oluşturduğumuz Milimetreler ve Metreler türlerinin aksine Kilometreler ayrı ve yeni bir tür değildir. Kilometreler türüne sahip değerler, i32 türündeki değerlerle birebir aynı şekilde ele alınır:
fn main() {
type Kilometreler = i32;
let x: i32 = 5;
let y: Kilometreler = 5;
println!("x + y = {}", x + y);
}
Kilometreler ve i32 aynı tür olduğundan her iki türdeki değerleri birbiriyle toplayabilir ve i32 parametresi alan fonksiyonlara Kilometreler değerleri geçirebiliriz. Ancak bu yöntemi kullandığımızda, az önce ele aldığımız newtype deseninin sağladığı tür denetimi avantajlarını elde edemeyiz. Başka bir deyişle, bir yerde Kilometreler ve i32 değerlerini karıştırırsak derleyici bize hata vermez.
Tür eşanlamlılarının başlıca kullanım amacı tekrarı azaltmaktır. Örneğin elimizde şöyle uzun bir tür olabilir:
Box<dyn Fn() + Send + 'static>
Bu uzun türü kodun her yerinde fonksiyon imzalarına ve tür açıklamalarına yazmak yorucu ve hataya açık olabilir. Projenizin Liste 20-25’teki gibi kodlarla dolu olduğunu hayal edin.
fn main() {
let f: Box<dyn Fn() + Send + 'static> = Box::new(|| println!("selam"));
fn takes_long_type(f: Box<dyn Fn() + Send + 'static>) {
// --snip--
}
fn returns_long_type() -> Box<dyn Fn() + Send + 'static> {
// --snip--
Box::new(|| ())
}
}
Bir tür takma adı, tekrarı azaltarak bu kodu daha yönetilebilir hâle getirir. Liste 20-26’da bu uzun ve ayrıntılı tür için ErtelenenIs adında bir takma ad tanımladık; böylece türün tüm kullanımlarını daha kısa olan ErtelenenIs takma adıyla değiştirebiliriz.
fn main() {
type ErtelenenIs = Box<dyn Fn() + Send + 'static>;
let f: ErtelenenIs = Box::new(|| println!("selam"));
fn takes_long_type(f: ErtelenenIs) {
// --snip--
}
fn returns_long_type() -> ErtelenenIs {
// --snip--
Box::new(|| ())
}
}
ErtelenenIs adlı bir tür takma adı tanımlamakBu kodu okumak ve yazmak çok daha kolaydır! Bir tür takma adı için anlamlı bir isim seçmek niyetinizi karşı tarafa aktarmanıza da yardımcı olur (İngilizce literatürde daha ileri bir zamanda değerlendirilecek kod parçalarını ifade etmek için thunk terimi kullanılır; bizim seçtiğimiz ErtelenenIs adı da saklanan bir kapanış için bu amacı doğrudan yansıtan uygun bir isimdir).
Tür takma adları, tekrarı azaltmak için Result<T, E> türüyle birlikte de yaygın olarak kullanılır. Standart kütüphanedeki std::io modülünü düşünün. G/Ç (I/O) işlemleri, işlemlerin başarısız olduğu durumları ele almak için çoğu zaman bir Result<T, E> döndürür. Bu kütüphanede olası tüm G/Ç hatalarını temsil eden bir std::io::Error struct’ı bulunur. Write trait’indeki şu fonksiyonlarda olduğu gibi, std::io içindeki fonksiyonların pek çoğu E parametresi std::io::Error olan Result<T, E> döndürür:
use std::fmt;
use std::io::Error;
pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result<usize, Error>;
fn flush(&mut self) -> Result<(), Error>;
fn write_all(&mut self, buf: &[u8]) -> Result<(), Error>;
fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<(), Error>;
}
Result<..., Error> ifadesi çok fazla tekrarlanmaktadır. Bu nedenle std::io modülü şu tür takma adı bildirimine sahiptir:
use std::fmt;
type Result<T> = std::result::Result<T, std::io::Error>;
pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result<usize>;
fn flush(&mut self) -> Result<()>;
fn write_all(&mut self, buf: &[u8]) -> Result<()>;
fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}
Bu bildirim std::io modülü içinde yer aldığından, tam nitelikli std::io::Result<T> takma adını —yani E parametresi std::io::Error olarak doldurulmuş bir Result<T, E> türünü— kullanabiliriz. Böylece Write trait’inin fonksiyon imzaları şu görünüme kavuşur:
use std::fmt;
type Result<T> = std::result::Result<T, std::io::Error>;
pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result<usize>;
fn flush(&mut self) -> Result<()>;
fn write_all(&mut self, buf: &[u8]) -> Result<()>;
fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}
Tür takma adı iki açıdan yardımcı olur: Kodu yazmayı kolaylaştırır ve tüm std::io genelinde bize tutarlı bir arayüz sunar. Bu yalnızca bir takma ad olduğundan özünde bildiğimiz Result<T, E> türünden ibarettir; yani Result<T, E> üzerinde çalışan tüm metotları ve ? operatörü gibi özel sözdizimlerini onunla da doğrudan kullanabiliriz.
Not: Newtype Deseni (struct Km(i32);) ile Tür Takma Adı (type Km = i32;)
Arasındaki Fark: Tür takma adı yalnızca başka bir ad verir; newtype ise ek
bir öbek tahsisi gerektirmeden ayrı bir tür oluşturur. Newtype için içteki
türle aynı bellek yerleşiminin garanti edilmesi ayrıca #[repr(transparent)]
gerektirebilir. Derleme zamanındaki temel farklar şunlardır:
- Newtype (
struct Km(i32);), derleyicinin gözünde yepyeni ve bağımsız bir tür oluşturur. Değerlerin yanlışlıkla birbirine karıştırılmasını derleme zamanında engeller, iç alanı gizleyerek kapsülleme sağlar ve yetim kuralına (orphan rule) takılmadan dış trait’leri uygulamanıza olanak tanır. Buna karşılık içtekii32türünün metotlarını ve+gibi operatörlerini kendiliğinden devralmaz (self.0üzerinden erişmeniz veya ilgili trait’leri uygulamanız gerekir). - Tür takma adı (
type Km = i32;) ise yeni bir tür oluşturmaz; yalnızca mevcut türe okunabilirliği artıran kısa bir eşanlamlı ad verir. Asıl türün tüm metotları, trait uygulamaları ve operatörleri herhangi bir sarmalama veya açma işlemi gerektirmeden doğrudan çalışır; ancak derleyiciKmilei32arasında hiçbir tür ayrımı yapmaz ve üzerinde ayrıca dış trait uygulamanıza izin vermez.
Hiç Dönmeyen Never Türü
Rust’ta, hiçbir değere sahip olmadığı için tür kuramı terminolojisinde boş tür (empty type) olarak bilinen ! adında özel bir tür vardır. Biz onu never türü olarak adlandırmayı tercih ediyoruz; çünkü bir fonksiyon asla geri dönmeyeceğinde dönüş türünün yerinde durur. İşte bir örnek:
fn bar() -> ! {
// --snip--
panic!();
}
Bu kod, “bar fonksiyonu asla dönmez” şeklinde okunur. Dönüş türü ! olan (asla dönmeyen) fonksiyonlara ıraksayan fonksiyonlar (diverging functions) denir. ! türünde değerler oluşturamayacağımız için bar fonksiyonunun geri dönmesi hiçbir koşulda mümkün değildir.
Peki hiçbir zaman değer oluşturamayacağınız bir tür ne işe yarar? Sayı tahmin oyununun bir parçası olan Liste 2-5’teki kodu hatırlayın; o kodun küçük bir bölümünü burada Liste 20-27’de yeniden verdik.
use std::cmp::Ordering;
use std::io;
use rand::prelude::*;
fn main() {
println!("Sayıyı tahmin et!");
let gizli_sayi = rand::rng().random_range(1..=100);
println!("Gizli sayı: {gizli_sayi}");
loop {
println!("Lütfen tahmininizi girin.");
let mut tahmin = String::new();
// --snip--
io::stdin()
.read_line(&mut tahmin)
.expect("Satır okunamadı");
let tahmin: u32 = match tahmin.trim().parse() {
Ok(sayi) => sayi,
Err(_) => continue,
};
println!("Tahmininiz: {tahmin}");
// --snip--
match tahmin.cmp(&gizli_sayi) {
Ordering::Less => println!("Çok küçük!"),
Ordering::Greater => println!("Çok büyük!"),
Ordering::Equal => {
println!("Kazandınız!");
break;
}
}
}
}
continue ile biten bir match ifadesiO sırada bu koddaki bazı ayrıntıları atlamıştık. Bölüm 6’daki “match Kontrol Akışı Yapısı” bölümünde, tüm match kollarının aynı türü döndürmesi gerektiğini ele almıştık. Dolayısıyla örneğin aşağıdaki kod çalışmaz:
fn main() {
let tahmin = "3";
let tahmin = match tahmin.trim().parse() {
Ok(_) => 5,
Err(_) => "merhaba",
};
}
Bu kodda tahmin değişkeninin türünün aynı anda hem bir tam sayı hem de bir string olması gerekirdi; oysa Rust, tahmin değişkeninin yalnızca tek bir türe sahip olmasını şart koşar. Peki continue ne döndürür? Liste 20-27’de bir koldan u32 döndürüp diğer kolu continue ile bitirmemize nasıl izin verildi?
Tahmin edebileceğiniz gibi continue bir ! değerine sahiptir. Yani Rust, tahmin değişkeninin türünü hesaplarken her iki match koluna da bakar: ilki u32 değerine, ikincisi ise ! değerine sahiptir. ! asla bir değere sahip olamayacağından Rust, tahmin değişkeninin türünün u32 olduğuna karar verir.
Bu davranışı tanımlamanın biçimsel yolu, ! türündeki ifadelerin başka herhangi bir türe zorlanabilmesidir (coerced). Bu match kolunu continue ile bitirmemize izin verilir; çünkü continue bir değer döndürmez, bunun yerine kontrol akışını döngünün en başına geri taşır ve dolayısıyla Err durumunda tahmin değişkenine hiçbir zaman bir değer atamayız.
Never türü panic! makrosuyla birlikte de kullanışlıdır. Bir değer üretmek ya da paniklemek için Option<T> değerleri üzerinde çağırdığımız unwrap fonksiyonunun şu tanımını hatırlayın:
enum Option<T> {
Some(T),
None,
}
use crate::Option::*;
impl<T> Option<T> {
pub fn unwrap(self) -> T {
match self {
Some(val) => val,
None => panic!("`Option::unwrap()` bir `None` değeri üzerinde çağrıldı"),
}
}
}
Bu kodda da Liste 20-27’deki match ifadesiyle aynı şey gerçekleşir: Rust, val değişkeninin T türünde ve panic! ifadesinin ! türünde olduğunu görür; dolayısıyla match ifadesinin genel sonucu T olur. Bu kod çalışır çünkü panic! bir değer üretmez; programı sonlandırır. None durumunda unwrap içinden bir değer döndürmeyeceğimiz için bu kod geçerlidir.
! türüne sahip son bir ifade de loop döngüsüdür:
fn main() {
print!("sonsuzca ");
loop {
print!("ve hep ");
}
}
Burada döngü hiçbir zaman sona ermez; bu yüzden ifadenin değeri ! olur. Ancak döngünün içine bir break ekleseydik bu geçerli olmazdı; çünkü döngü break ifadesine ulaştığında sonlanırdı.
Not: Standart kütüphanede karşılaştığınız std::convert::Infallible (örneğin asla başarısız olmayacak tür dönüşümlerindeki TryFrom uygulamalarında kullanılan Result<T, Infallible>), boş tür kavramının pratik bir karşılığıdır. Kararlı (stable) Rust’ta ! türü şimdilik yalnızca fonksiyon dönüş türü konumunda kullanılabilir; onu jenerik bir tür argümanı (Result<T, !>) gibi başka konumlarda kullanmak hâlâ kararsız (unstable) bir özelliktir. Bu yüzden standart kütüphane, hiçbir varyantı bulunmayan pub enum Infallible {} tanımını sunar. Hiçbir varyantı olmayan bir enum’ın da tıpkı ! gibi çalışma zamanında örneği oluşturulamaz; dolayısıyla Result<T, Infallible> döndüren bir işlemin Err üretmesi derleme zamanında imkânsızdır ve gelecekteki Rust sürümlerinde Infallible türünün doğrudan ! için bir tür takma adına dönüştürülmesi hedeflenmektedir.
Dinamik Boyutlu Türler ve Sized Trait’i
Rust’ın, belirli bir türdeki bir değer için ne kadar bellek alanı ayıracağı gibi türlerine ilişkin bazı ayrıntıları bilmesi gerekir. Bu gereksinim, tür sisteminin bir köşesini ilk bakışta biraz kafa karıştırıcı hâle getirir: dinamik boyutlu türler (dynamically sized types) kavramı. Bazen DST’ler veya boyutsuz türler (unsized types) olarak da adlandırılan bu türler, boyutunu yalnızca çalışma zamanında bilebileceğimiz değerleri kullanarak kod yazmamıza olanak tanır.
Gelin kitap boyunca kullandığımız str adlı dinamik boyutlu türün ayrıntılarına inelim. Evet, yanlış duymadınız: &str değil, tek başına str bir DST’dir. Kullanıcının girdiği bir metni saklamak gibi pek çok durumda string’in ne kadar uzun olduğunu çalışma zamanına kadar bilemeyiz. Bu durum, str türünde bir değişken oluşturamayacağımız ve str türünde bir argüman alamayacağımız anlamına gelir. Çalışmayan aşağıdaki kodu inceleyelim:
fn main() {
let s1: str = "Hey merhaba!";
let s2: str = "Nasıl gidiyor?";
}
Rust, belirli bir türün herhangi bir değeri için ne kadar bellek ayıracağını bilmek zorundadır ve bir türün tüm değerleri aynı miktarda bellek kullanmalıdır. Rust bu kodu yazmamıza izin verseydi, bu iki str değerinin bellekte aynı miktarda yer kaplaması gerekirdi. Oysa uzunlukları farklıdır: s1 için 12 bayt, s2 için ise 15 bayt depolama alanı gerekir ("Nasıl gidiyor?" metnindeki 'ı' harfi UTF-8 kodlamasında 2 bayt yer kaplar). İşte bu nedenle dinamik boyutlu bir türü doğrudan tutan bir değişken oluşturmak mümkün değildir.
Peki bu durumda ne yaparız? Burada cevabı zaten biliyorsunuz: s1 ve s2’nin türünü str yerine string dilimi (&str) yaparız. Bölüm 4’teki “String Dilimleri” bölümünden, dilim veri yapısının yalnızca başlangıç konumunu ve dilimin uzunluğunu sakladığını hatırlayın. Dolayısıyla &T, T değerinin bulunduğu bellek adresini saklayan tek bir değer olmasına rağmen bir string dilimi iki değerden oluşur: str verisinin adresi ve uzunluğu. (Bu tür iki parçalı işaretçilere geniş işaretçi (fat pointer) de denir.) Bu sayede bir string dilimi değerinin boyutunu derleme zamanında bilebiliriz: Boyutu bir usize uzunluğunun iki katıdır. Yani başvurduğu string ne kadar uzun olursa olsun, bir string diliminin boyutunu her zaman biliriz. Genel olarak Rust’ta dinamik boyutlu türler bu şekilde kullanılır: Dinamik bilginin boyutunu saklayan fazladan bir parça üstveriye (metadata) sahiptirler. Dinamik boyutlu türlerin altın kuralı şudur: Dinamik boyutlu türlerin değerlerini her zaman bir tür işaretçinin arkasına koymalıyız.
str türünü her türlü işaretçiyle birleştirebiliriz: örneğin Box<str> veya Rc<str>. Aslında bunu daha önce farklı bir dinamik boyutlu türle görmüştünüz: trait’ler. Her trait, dyn anahtar kelimesiyle birlikte adını kullanarak (dyn Trait) başvurabileceğimiz dinamik boyutlu bir türdür. Bölüm 18’deki “Ortak Davranışı Soyutlamak İçin Trait Nesnelerini Kullanma” bölümünde, trait’leri trait nesneleri olarak kullanabilmek için onları &dyn Trait veya Box<dyn Trait> gibi bir işaretçinin arkasına koymamız gerektiğinden bahsetmiştik (Rc<dyn Trait> de aynı şekilde çalışır).
DST’lerle çalışabilmek için Rust, bir türün boyutunun derleme zamanında bilinip bilinmediğini belirlemek üzere Sized trait’ini sağlar. Bu trait, boyutu derleme zamanında bilinen her şey için otomatik olarak uygulanır. Buna ek olarak Rust, her jenerik fonksiyona örtük olarak Sized üzerine bir sınır ekler. Yani şöyle bir jenerik fonksiyon tanımı:
fn generic<T>(t: T) {
// --snip--
}
gerçekte sanki şunu yazmışız gibi ele alınır:
fn generic<T: Sized>(t: T) {
// --snip--
}
Varsayılan olarak jenerik fonksiyonlar yalnızca derleme zamanında bilinen bir boyuta sahip türler üzerinde çalışır. Ancak bu kısıtlamayı gevşetmek için aşağıdaki özel sözdizimini kullanabilirsiniz:
fn generic<T: ?Sized>(t: &T) {
// --snip--
}
?Sized üzerindeki bir trait sınırı, “T türü Sized olabilir de olmayabilir de” anlamına gelir ve bu gösterim, jenerik türlerin derleme zamanında bilinen bir boyuta sahip olması gerektiği varsayılanını geçersiz kılar. Bu anlama sahip ?Trait sözdizimi yalnızca Sized için kullanılabilir; başka hiçbir trait için geçerli değildir.
Ayrıca t parametresinin türünü T’den &T’ye değiştirdiğimize dikkat edin. Tür Sized olmayabileceği için onu bir tür işaretçinin arkasında kullanmamız gerekir. Bu örnekte bir referans seçtik.
Sırada fonksiyonlar ve kapanışlar var!
Gelişmiş Fonksiyonlar ve Kapanışlar
Gelişmiş Fonksiyonlar ve Kapanışlar
Bu bölümde fonksiyon işaretçileri ve kapanış döndürme dahil olmak üzere fonksiyonlar ve kapanışlarla ilgili bazı gelişmiş özellikleri inceleyeceğiz.
Fonksiyon İşaretçileri
Kapanışları fonksiyonlara nasıl geçireceğimizi ele almıştık; normal fonksiyonları da başka fonksiyonlara geçirebilirsiniz! Bu teknik, yeni bir kapanış tanımlamak yerine önceden tanımladığınız bir fonksiyonu aktarmak istediğinizde kullanışlıdır. Fonksiyonlar, Fn kapanış trait’iyle karıştırılmaması gereken fn türüne (küçük f harfiyle) zorlanır. fn türüne fonksiyon işaretçisi (function pointer) denir. Fonksiyonları fonksiyon işaretçileri aracılığıyla aktarmak, onları başka fonksiyonlarda argüman olarak kullanmanıza olanak tanır.
Bir parametrenin fonksiyon işaretçisi olduğunu belirtmenin sözdizimi, Liste 20-28’de gösterildiği gibi kapanışlarınkine benzer. Burada parametresine 1 ekleyen add_one adlı bir fonksiyon tanımladık. do_twice fonksiyonu iki parametre alır: bir i32 parametresi alıp i32 döndüren herhangi bir fonksiyona işaret eden bir fonksiyon işaretçisi ve bir i32 değeri. do_twice fonksiyonu, f fonksiyonunu kendisine arg değerini geçirerek iki kez çağırır ve ardından bu iki fonksiyon çağrısının sonuçlarını toplar. main fonksiyonu ise do_twice fonksiyonunu add_one ve 5 argümanlarıyla çağırır.
fn add_one(x: i32) -> i32 {
x + 1
}
fn do_twice(f: fn(i32) -> i32, arg: i32) -> i32 {
f(arg) + f(arg)
}
fn main() {
let answer = do_twice(add_one, 5);
println!("Cevap: {answer}");
}
fn türünü kullanmakBu kod ekrana Cevap: 12 yazdırır. do_twice içindeki f parametresinin, i32 türünde tek bir parametre alıp bir i32 döndüren bir fn olduğunu belirtiyoruz. Ardından do_twice gövdesinde f’yi çağırabiliyoruz. main içinde ise do_twice fonksiyonuna ilk argüman olarak add_one fonksiyon adını geçirebiliyoruz.
Kapanışların aksine fn bir trait değil, doğrudan bir türdür; bu nedenle Fn trait’lerinden birini trait sınırı olarak kullanan jenerik bir tür parametresi bildirmek yerine parametre türü olarak doğrudan fn yazarız.
Fonksiyon işaretçileri kapanış trait’lerinin üçünü de (Fn, FnMut ve FnOnce) uygular; yani kapanış bekleyen bir fonksiyona argüman olarak her zaman bir fonksiyon işaretçisi geçirebilirsiniz. Fonksiyonlarınızı hem normal fonksiyonları hem de kapanışları kabul edebilecek şekilde jenerik bir tür ve kapanış trait’lerinden birini kullanarak yazmak en iyi yaklaşımdır.
Bununla birlikte, kapanışları değil de yalnızca fn türünü kabul etmek isteyeceğiniz durumlara bir örnek, kapanış kavramına sahip olmayan harici kodlarla etkileşim kurmaktır: C fonksiyonları argüman olarak fonksiyon kabul edebilir, ancak C dilinde kapanış yoktur.
Hem satır içinde tanımlanmış bir kapanışı hem de isimli bir fonksiyonu kullanabileceğiniz bir duruma örnek olarak, standart kütüphanedeki Iterator trait’inin sunduğu map metodunun kullanımına bakalım. Sayılardan oluşan bir vektörü string’lerden oluşan bir vektöre dönüştürmek üzere map metodunu kullanırken Liste 20-29’daki gibi bir kapanış yazabiliriz.
fn main() {
let list_of_numbers = vec![1, 2, 3];
let list_of_strings: Vec<String> =
list_of_numbers.iter().map(|i| i.to_string()).collect();
}
map metoduyla bir kapanış kullanmakYa da kapanış yerine map metoduna argüman olarak doğrudan bir fonksiyonun adını verebiliriz. Liste 20-30 bunun nasıl görüneceğini göstermektedir.
fn main() {
let list_of_numbers = vec![1, 2, 3];
let list_of_strings: Vec<String> =
list_of_numbers.iter().map(ToString::to_string).collect();
}
map metoduyla ToString::to_string fonksiyonunu kullanmakBurada to_string adında birden fazla kullanılabilir fonksiyon bulunduğu için, “Gelişmiş Trait’ler” bölümünde ele aldığımız tam nitelikli sözdizimini (fully qualified syntax) kullanmamız gerektiğine dikkat edin.
Bu örnekte, standart kütüphanenin Display trait’ini uygulayan her tür için uyguladığı ToString trait’inde tanımlı to_string fonksiyonunu kullanıyoruz.
Bölüm 6’daki “Enum Değerleri” kısmından hatırlayacağınız üzere, tanımladığımız her bir enum varyantının adı aynı zamanda bir başlatıcı fonksiyon hâline gelir. Bu başlatıcı fonksiyonları kapanış trait’lerini uygulayan fonksiyon işaretçileri olarak kullanabiliriz; bu da Liste 20-31’de görüldüğü gibi, kapanış alan metotlara argüman olarak doğrudan bu başlatıcı fonksiyonları verebileceğimiz anlamına gelir.
fn main() {
enum Durum {
Deger(u32),
Dur,
}
let list_of_statuses: Vec<Durum> = (0u32..20).map(Durum::Deger).collect();
}
Durum örnekleri oluşturmak için map metoduyla bir enum başlatıcısı kullanmakBurada, map metodunun üzerinde çağrıldığı aralıktaki her bir u32 değerini kullanarak Durum::Deger’in başlatıcı fonksiyonu aracılığıyla Durum::Deger örnekleri oluşturuyoruz. Kimi geliştiriciler bu üslubu, kimileri ise kapanış kullanmayı tercih eder. İkisi de birebir aynı makine koduna derlenir; dolayısıyla hangisi size daha anlaşılır geliyorsa onu kullanabilirsiniz.
Kapanış Döndürmek
Kapanışlar trait’lerle temsil edilir; bu da kapanışları doğrudan döndüremeyeceğiniz anlamına gelir. Bir trait döndürmek isteyebileceğiniz çoğu durumda, bunun yerine fonksiyonun dönüş değeri olarak o trait’i uygulayan somut türü kullanabilirsiniz. Ancak kapanışlarda bunu genellikle yapamazsınız; çünkü kapanışların döndürülebilir somut bir türü yoktur. Örneğin kapanış kendi kapsamından herhangi bir değer yakalıyorsa dönüş türü olarak fn fonksiyon işaretçisini kullanmanıza izin verilmez.
Bunun yerine normalde Bölüm 10’da öğrendiğimiz impl Trait sözdizimini kullanırsınız. Fn, FnOnce ve FnMut trait’lerini kullanarak herhangi bir fonksiyon türü döndürebilirsiniz. Örneğin Liste 20-32’deki kod sorunsuz bir şekilde derlenecektir.
#![allow(unused)]
fn main() {
fn returns_closure() -> impl Fn(i32) -> i32 {
|x| x + 1
}
}
impl Trait sözdizimini kullanarak bir fonksiyondan kapanış döndürmekAncak Bölüm 13’teki “Kapanış Türlerini Çıkarsamak ve Açıklamak” kısmında belirttiğimiz gibi, her kapanış aynı zamanda kendine özgü ayrı bir türdür. Aynı imzaya ancak farklı uygulamalara sahip birden fazla fonksiyonla çalışmanız gerekiyorsa bunlar için bir trait nesnesi kullanmanız gerekir. Liste 20-33’te gösterilen gibi bir kod yazdığınızda ne olacağına bakalım.
fn main() {
let handlers = vec![returns_closure(), returns_initialized_closure(123)];
for handler in handlers {
let output = handler(5);
println!("{output}");
}
}
fn returns_closure() -> impl Fn(i32) -> i32 {
|x| x + 1
}
fn returns_initialized_closure(init: i32) -> impl Fn(i32) -> i32 {
move |x| x + init
}
impl Fn türleri döndüren fonksiyonlarla tanımlanmış kapanışlardan bir Vec<T> oluşturmakBurada ikisi de impl Fn(i32) -> i32 döndüren returns_closure ve returns_initialized_closure adlı iki fonksiyonumuz var. Aynı trait’i uygulasalar bile döndürdükleri kapanışların birbirinden farklı olduğuna dikkat edin. Bu kodu derlemeye çalıştığımızda Rust derleyicisi bunun çalışmayacağını bize bildirir:
$ cargo build
Compiling fonksiyon-ornekleri v0.1.0 (file:///projects/fonksiyon-ornekleri)
error[E0308]: mismatched types
--> src/main.rs:2:44
|
2 | let handlers = vec![returns_closure(), returns_initialized_closure(123)];
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected opaque type, found a different opaque type
...
9 | fn returns_closure() -> impl Fn(i32) -> i32 {
| ------------------- the expected opaque type
...
13 | fn returns_initialized_closure(init: i32) -> impl Fn(i32) -> i32 {
| ------------------- the found opaque type
|
= note: expected opaque type `impl Fn(i32) -> i32`
found opaque type `impl Fn(i32) -> i32`
= note: distinct uses of `impl Trait` result in different opaque types
For more information about this error, try `rustc --explain E0308`.
error: could not compile `fonksiyon-ornekleri` (bin "fonksiyon-ornekleri") due to 1 previous error
Hata mesajı bize, ne zaman bir impl Trait döndürsek Rust’ın benzersiz bir opak tür (opaque type) oluşturduğunu söyler. Opak tür, Rust’ın bizim için inşa ettiği yapının ayrıntılarını göremediğimiz bir türdür; Rust’ın üreteceği bu türü tahmin edip kendimiz de yazamayız. Dolayısıyla bu fonksiyonlar aynı trait’i (Fn(i32) -> i32) uygulayan kapanışlar döndürse bile, Rust’ın her biri için ürettiği opak türler birbirinden farklıdır. (Bu durum, Bölüm 17’deki “Pin Türü ve Unpin Trait’i” kısmında gördüğümüz gibi, aynı çıktı türüne sahip olsalar bile Rust’ın farklı async blokları için farklı somut türler üretmesine benzer.) Bu sorunun çözümünü şimdiye dek birkaç kez gördük: Liste 20-34’te olduğu gibi bir trait nesnesi kullanabiliriz.
fn main() {
let handlers = vec![returns_closure(), returns_initialized_closure(123)];
for handler in handlers {
let output = handler(5);
println!("{output}");
}
}
fn returns_closure() -> Box<dyn Fn(i32) -> i32> {
Box::new(|x| x + 1)
}
fn returns_initialized_closure(init: i32) -> Box<dyn Fn(i32) -> i32> {
Box::new(move |x| x + init)
}
Box<dyn Fn> döndüren fonksiyonlarla tanımlanmış kapanışlardan bir Vec<T> oluşturmakBu kod sorunsuz bir şekilde derlenecektir. Trait nesneleri hakkında daha fazla bilgi için Bölüm 18’deki “Ortak Davranışı Soyutlamak İçin Trait Nesnelerini Kullanma” bölümüne başvurabilirsiniz.
Not: Çağrılabilir yapıları döndürürken veya saklarken bu üç yaklaşım arasındaki farkı bellek temsili üzerinden kolayca hatırlayabilirsiniz. Çevresinden değişken yakalamayan kapanışlar (örneğin |x| x + 1), tıpkı normal fonksiyonlar gibi yalnızca kod adresini tutan ince fonksiyon işaretçisine (fn(i32) -> i32) otomatik olarak zorlanabilir. Buna karşılık çevresinden değer yakalayan kapanışlar (örneğin init değerini taşıyan move |x| x + init), bellekte ek durum verisi barındırdığı için fn işaretçisine dönüştürülemez.
Yakalama yapan bir kapanış döndürürken tek bir somut kapanış için öbek
(heap) tahsisi yapmadan statik gönderim (static dispatch) elde etmek
istediğinizde impl Fn(...) kullanabilirsiniz. Farklı kapanışları (hatta
normal fonksiyonları) handlers vektöründe olduğu gibi aynı homojen
koleksiyon içinde saklamanız gerektiğinde için kullanabileceğiniz çözümlerden
biri, veriyi öbeğe taşıyıp dinamik gönderim (dynamic dispatch) sağlayan
Box<dyn Fn(...)> türüdür. Kapanışları yalnızca ödünç alarak saklamak
istiyorsanız uygun ömürlerle &dyn Fn(...) da kullanılabilir; olası türler
önceden belliyse bir enum başka bir seçenektir.
Şimdi makrolara bakalım!
Makrolar
Makrolar
Bu kitap boyunca println! gibi makroları kullandık, ancak bir makronun tam olarak ne olduğunu ve nasıl çalıştığını henüz derinlemesine incelemedik. Makro terimi, Rust’ta bir özellik ailesini ifade eder: macro_rules! ile tanımlanan bildirimsel makrolar (declarative macros) ve üç tür prosedürel makro (procedural macros):
- Struct ve enum’larda kullanılan
deriveözniteliğiyle eklenecek kodu belirleyen özel#[derive]makroları - Herhangi bir öğe üzerinde kullanılabilen özel öznitelikler tanımlayan öznitelik benzeri makrolar
- Fonksiyon çağrılarına benzeyen ancak argüman olarak belirtilen belirteçler (tokens) üzerinde çalışan fonksiyon benzeri makrolar
Bunların her birini sırayla ele alacağız; ancak öncelikle elimizde zaten fonksiyonlar varken makrolara neden ihtiyaç duyduğumuza bakalım.
Makrolar ile Fonksiyonlar Arasındaki Fark
Temelde makrolar, başka kodlar yazan kod yazmanın bir yoludur ve bu yaklaşıma metaprogramlama denir. Ek C’de, çeşitli trait uygulamalarını sizin yerinize üreten derive özniteliğini ele alıyoruz. Kitap boyunca println! ve vec! makrolarını da kullandık. Tüm bu makrolar, elle yazdığınız koddan daha fazla kod üretmek üzere genişler (expand).
Metaprogramlama, yazmanız ve bakımını yapmanız gereken kod miktarını azaltmak için kullanışlıdır; bu aynı zamanda fonksiyonların da görevlerinden biridir. Bununla birlikte makrolar, fonksiyonların sahip olmadığı bazı ek güçlere sahiptir.
Bir fonksiyon imzası, fonksiyonun aldığı parametrelerin sayısını ve türünü bildirmek zorundadır. Makrolar ise değişken sayıda parametre alabilir: println!("hello") çağrısını tek bir argümanla ya da println!("hello {}", name) çağrısını iki argümanla yapabiliriz. Ayrıca makrolar, derleyici kodun anlamını yorumlamadan önce genişletilir; dolayısıyla bir makro örneğin belirli bir tür üzerinde bir trait’i uygulayabilir. Bir fonksiyon bunu yapamaz, çünkü fonksiyon çalışma zamanında çağrılırken trait’in derleme zamanında uygulanması gerekir.
Bir fonksiyon yerine makro uygulamanın dezavantajı, Rust kodu yazan Rust kodu yazdığınız için makro tanımlarının fonksiyon tanımlarından daha karmaşık olmasıdır. Bu dolaylılık nedeniyle makro tanımlarını okumak, anlamak ve bakımını yapmak genellikle fonksiyon tanımlarına kıyasla daha zordur.
Makrolar ile fonksiyonlar arasındaki bir diğer önemli fark, bir dosyada makroları çağırmadan önce onları tanımlamanız veya kapsama dahil etmeniz gerekmesidir; oysa fonksiyonları dosyanın herhangi bir yerinde tanımlayıp herhangi bir yerinde çağırabilirsiniz.
Genel Metaprogramlama İçin Bildirimsel Makrolar
Rust’ta en yaygın kullanılan makro biçimi bildirimsel makrodur. Bunlar bazen “örnekle makrolar” (macros by example), “macro_rules! makroları” veya kısaca “makrolar” olarak da adlandırılır. Bildirimsel makrolar özünde Rust’taki match ifadesine benzer bir yapı yazmanıza olanak tanır. Bölüm 6’da ele aldığımız gibi match ifadeleri; bir ifadeyi alan, ifadenin sonuç değerini desenlerle karşılaştıran ve eşleşen desenle ilişkili kodu çalıştıran kontrol yapılarıdır. Makrolar da bir değeri belirli bir kodla ilişkilendirilmiş desenlerle karşılaştırır: Bu durumda değer, makroya aktarılan Rust kaynak kodunun bizzat kendisidir; desenler bu kaynak kodun yapısıyla karşılaştırılır ve eşleşme sağlandığında her desenle ilişkili kod, makroya aktarılan kodun yerini alır. Tüm bunlar derleme sırasında gerçekleşir.
Bir makro tanımlamak için macro_rules! yapısını kullanırsınız. vec! makrosunun nasıl tanımlandığına bakarak macro_rules! kullanımını inceleyelim. Bölüm 8’de, belirli değerlere sahip yeni bir vektör oluşturmak için vec! makrosunu nasıl kullanabileceğimizi ele almıştık. Örneğin aşağıdaki makro çağrısı, üç tam sayı içeren yeni bir vektör oluşturur:
#![allow(unused)]
fn main() {
let v: Vec<u32> = vec![1, 2, 3];
}
vec! makrosunu iki tam sayılı bir vektör veya beş string diliminden oluşan bir vektör oluşturmak için de kullanabilirdik. Değerlerin sayısını veya türünü önceden bilemeyeceğimiz için aynı işi bir fonksiyonla yapamazdık.
Liste 20-35, vec! makrosunun biraz basitleştirilmiş bir tanımını göstermektedir.
#[macro_export]
macro_rules! vec {
( $( $x:expr ),* ) => {
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($x);
)*
temp_vec
}
};
}
vec! makrosu tanımının basitleştirilmiş bir sürümüNot: Standart kütüphanedeki gerçek vec! makrosu tanımı, doğru miktarda belleği baştan ayıracak kodu da içerir. Örneği daha sade tutmak amacıyla bu optimizasyonu buraya dahil etmiyoruz.
#[macro_export] özniteliği, bu makronun tanımlandığı crate kapsama dahil edildiğinde makronun erişilebilir kılınması gerektiğini belirtir. Bu öznitelik olmadan makro kapsama dahil edilemez.
Ardından makro tanımına macro_rules! ve tanımladığımız makronun ünlem işareti olmayan adıyla başlarız. Bu örnekte vec olan adın ardından, makro tanımının gövdesini belirten süslü parantezler gelir.
vec! gövdesindeki yapı, bir match ifadesinin yapısına benzer. Burada ( $( $x:expr ),* ) desenine sahip tek bir kol ve onun ardından => ile bu desenle ilişkili kod bloğu yer alır. Desen eşleşirse ilişkili kod bloğu üretilir. Bu makrodaki tek desen bu olduğundan eşleşmenin yalnızca tek bir geçerli yolu vardır; diğer tüm desenler derleme hatasıyla sonuçlanır. Daha karmaşık makrolarda birden fazla kol bulunur.
Makro tanımlarındaki geçerli desen sözdizimi, Bölüm 19’da ele alınan desen sözdiziminden farklıdır; çünkü makro desenleri değerlerle değil, Rust kodunun yapısıyla eşleştirilir. Gelin Liste 20-35’teki desen parçalarının ne anlama geldiğini adım adım inceleyelim; makro desen sözdiziminin tamamı için Rust Başvuru Kılavuzu’na (Rust Reference) göz atabilirsiniz.
İlk olarak tüm deseni çevrelemek için bir çift parantez kullanırız. Desenle eşleşen Rust kodunu tutacak bir değişkeni makro sisteminde bildirmek için dolar işareti ($) kullanırız. Dolar işareti, bunun sıradan bir Rust değişkeni değil, bir makro değişkeni olduğunu açıkça gösterir. Ardından, yerine geçecek kodda kullanılmak üzere parantez içindeki desenle eşleşen değerleri yakalayan bir çift parantez gelir. $() içinde herhangi bir Rust ifadesiyle eşleşen ve bu ifadeye $x adını veren $x:expr yer alır.
$() sonrasındaki virgül, $() içindeki yapıyla eşleşen her kod örneği arasında ayırıcı olarak gerçek bir virgül karakterinin bulunması gerektiğini belirtir. * sembolü ise kendisinden önce gelen yapının sıfır veya daha fazla kez eşleşebileceğini ifade eder.
Not: expr yalnızca parça belirleyicilerden (fragment specifiers) biridir. Sık kullanılan diğerleri arasında bir tanımlayıcıyla eşleşen ident, bir türle eşleşen ty, bir hazır değerle eşleşen literal, bir desenle eşleşen pat, bir kod bloğuyla eşleşen block ve herhangi bir tek belirteç ağacıyla eşleşen tt sayılabilir. Tekrar operatörü olarak * (sıfır veya daha fazla) yanında + (bir veya daha fazla) ve ? (sıfır veya bir) da kullanılabilir. Bir makro birden fazla kola sahip olabilir; kollar yukarıdan aşağıya sırayla denenir ve eşleşen ilk kol seçilir:
macro_rules! selamla {
() => {
println!("Merhaba!")
};
($isim:expr) => {
println!("Merhaba, {}!", $isim)
};
($($isim:expr),+) => {
$( println!("Merhaba, {}!", $isim); )+
};
}
fn main() {
selamla!();
selamla!("Ayşe");
selamla!("Ali", "Veli");
}
#[macro_export] ile işaretlenmiş bir makro, tanımlandığı crate’in kök kapsamında yer alıyormuş gibi davranır; bu yüzden başka bir crate’ten use makro_crate::selamla; yazarak onu tıpkı bir fonksiyon gibi kapsama dahil edebilirsiniz.
Bu makroyu vec![1, 2, 3]; şeklinde çağırdığımızda $x deseni 1, 2 ve 3 ifadeleriyle üç kez eşleşir.
Şimdi bu kolla ilişkili kod gövdesindeki desene bakalım: $()* içindeki temp_vec.push() ifadesi, desenin kaç kez eşleştiğine bağlı olarak desendeki $() ile eşleşen her parça için sıfır veya daha fazla kez üretilir. $x, eşleşen her ifadeyle değiştirilir. Bu makroyu vec![1, 2, 3]; ile çağırdığımızda, bu makro çağrısının yerini alacak üretilmiş kod şöyle olur:
{
let mut temp_vec = Vec::new();
temp_vec.push(1);
temp_vec.push(2);
temp_vec.push(3);
temp_vec
}
Böylece herhangi bir türden istenilen sayıda argüman alabilen ve belirtilen elemanları içeren bir vektör oluşturacak kodu üretebilen bir makro tanımlamış olduk.
Makro yazımı hakkında daha fazla bilgi edinmek için çevrim içi belgelere veya Daniel Keep tarafından başlatılıp Lukas Wirth tarafından sürdürülen “The Little Book of Rust Macros” gibi diğer kaynaklara başvurabilirsiniz.
Özniteliklerden Kod Üreten Prosedürel Makrolar
Makroların ikinci biçimi, daha çok bir fonksiyon gibi davranan (ve bir prosedür türü olan) prosedürel makrodur. Prosedürel makrolar, bildirimsel makroların yaptığı gibi desenlerle eşleştirip kodu başka bir kodla değiştirmek yerine; girdi olarak bir miktar kod kabul eder, bu kod üzerinde işlem yapar ve çıktı olarak bir miktar kod üretir. Üç tür prosedürel makro vardır: özel derive, öznitelik benzeri ve fonksiyon benzeri makrolar; üçü de benzer bir biçimde çalışır.
Prosedürel makrolar oluştururken, tanımların özel bir crate türüne sahip kendi ayrı crate’lerinde yer alması gerekir. Bunun ileride ortadan kaldırmayı umduğumuz karmaşık teknik nedenleri vardır. Liste 20-36’da, belirli bir makro çeşidini kullanmak üzere some_attribute yer tutucusuyla bir prosedürel makronun nasıl tanımlanacağını gösteriyoruz.
use proc_macro::TokenStream;
#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}
Prosedürel bir makroyu tanımlayan fonksiyon, girdi olarak bir TokenStream alır ve çıktı olarak bir TokenStream üretir. TokenStream türü, Rust ile birlikte gelen proc_macro crate’i tarafından tanımlanır ve bir belirteç (token) dizisini temsil eder. Makronun çekirdeği budur: Makronun üzerinde işlem yaptığı kaynak kod girdi TokenStream’ini oluşturur ve makronun ürettiği kod da çıktı TokenStream’idir. Fonksiyonun üzerinde ayrıca hangi tür prosedürel makro oluşturduğumuzu belirten bir öznitelik yer alır. Aynı crate içinde birden fazla türde prosedürel makro bulundurabiliriz.
Şimdi farklı prosedürel makro türlerine bakalım. Özel bir derive makrosuyla başlayacağız ve ardından diğer biçimleri farklı kılan küçük ayrımları açıklayacağız.
Özel derive Makroları
merhaba_makro adında tek bir ilişkili fonksiyona sahip MerhabaMakro adlı bir trait tanımlayan merhaba_makro isimli bir crate oluşturalım. Kullanıcılarımızı kendi türlerinin her biri için MerhabaMakro trait’ini elle uygulamak zorunda bırakmak yerine, türlerini #[derive(MerhabaMakro)] ile işaretleyerek merhaba_makro fonksiyonunun varsayılan bir uygulamasını elde edebilmeleri için prosedürel bir makro sağlayacağız. Varsayılan uygulama Merhaba, Makro! Benim adım TurAdi! metnini yazdıracak; buradaki TurAdi, bu trait’in üzerinde tanımlandığı türün adıdır. Başka bir deyişle, başka bir programcının crate’imizi kullanarak Liste 20-37’deki gibi bir kod yazmasına olanak tanıyan bir crate yazacağız.
use merhaba_makro::MerhabaMakro;
use merhaba_makro_turet::MerhabaMakro;
#[derive(MerhabaMakro)]
struct Krepler;
fn main() {
Krepler::merhaba_makro();
}
İşimiz bittiğinde bu kod Merhaba, Makro! Benim adım Krepler! yazdıracaktır. İlk adım, aşağıdaki gibi yeni bir kütüphane crate’i oluşturmaktır:
$ cargo new merhaba_makro --lib
Ardından Liste 20-38’de MerhabaMakro trait’ini ve ilişkili fonksiyonunu tanımlayacağız.
pub trait MerhabaMakro {
fn merhaba_makro();
}
derive makrosuyla birlikte kullanacağımız basit bir traitElimizde bir trait ve onun fonksiyonu var. Bu noktada crate kullanıcımız, istenen işlevselliği elde etmek için Liste 20-39’da olduğu gibi trait’i kendisi uygulayabilir.
use merhaba_makro::MerhabaMakro;
struct Krepler;
impl MerhabaMakro for Krepler {
fn merhaba_makro() {
println!("Merhaba, Makro! Benim adım Krepler!");
}
}
fn main() {
Krepler::merhaba_makro();
}
MerhabaMakro trait’ini elle uygulasaydı kodun nasıl görüneceğiAncak merhaba_makro ile kullanmak istedikleri her tür için bu uygulama bloğunu tek tek yazmaları gerekirdi; biz onları bu zahmetten kurtarmak istiyoruz.
Üstelik merhaba_makro fonksiyonuna, trait’in uygulandığı türün adını yazdıracak varsayılan bir uygulama da henüz sağlayamayız: Rust’ın yansıma (reflection) yetenekleri yoktur, dolayısıyla çalışma zamanında türün adını sorgulayamaz. Derleme zamanında kod üretecek bir makroya ihtiyacımız vardır.
Bir sonraki adım prosedürel makroyu tanımlamaktır. Bu satırların yazıldığı sırada prosedürel makroların kendi ayrı crate’lerinde bulunması gerekmektedir. İleride bu kısıtlama kaldırılabilir. Crate’leri ve makro crate’lerini yapılandırma geleneği şöyledir: foo adındaki bir crate için özel derive prosedürel makro crate’ine foo_derive adı verilir (biz de Türkçe adlandırmayla _turet sonekini kullanacağız). merhaba_makro projemizin içinde merhaba_makro_turet adlı yeni bir crate başlatalım:
$ cargo new merhaba_makro_turet --lib
İki crate’imiz birbiriyle sıkı sıkıya ilişkili olduğundan, prosedürel makro crate’ini merhaba_makro crate’imizin dizini içinde oluşturuyoruz. merhaba_makro içindeki trait tanımını değiştirirsek, merhaba_makro_turet içindeki prosedürel makronun uygulamasını da değiştirmemiz gerekecektir. Bu iki crate’in ayrı ayrı yayınlanması gerekecek ve bu crate’leri kullanan programcıların her ikisini de bağımlılık olarak ekleyip ikisini de kapsama dahil etmesi gerekecektir. Bunun yerine merhaba_makro crate’inin merhaba_makro_turet crate’ini bir bağımlılık olarak kullanmasını ve prosedürel makro kodunu yeniden dışa aktarmasını (re-export) da sağlayabilirdik. Ancak projeyi yapılandırma biçimimiz, programcıların derive işlevselliğini istemeseler bile merhaba_makro crate’ini tek başına kullanabilmelerini mümkün kılar.
merhaba_makro_turet crate’ini bir prosedürel makro crate’i olarak bildirmemiz gerekiyor. Birazdan göreceğiniz gibi syn ve quote crate’lerindeki işlevlere de ihtiyacımız olacak, bu yüzden onları bağımlılık olarak eklemeliyiz. merhaba_makro_turet için Cargo.toml dosyasına aşağıdakileri ekleyin:
[lib]
proc-macro = true
[dependencies]
syn = "2.0"
quote = "1.0"
Prosedürel makroyu tanımlamaya başlamak için Liste 20-40’taki kodu merhaba_makro_turet crate’inizin src/lib.rs dosyasına yerleştirin. impl_merhaba_makro fonksiyonunun tanımını ekleyene kadar bu kodun derlenmeyeceğini unutmayın.
use proc_macro::TokenStream;
use quote::quote;
#[proc_macro_derive(MerhabaMakro)]
pub fn merhaba_makro_derive(input: TokenStream) -> TokenStream {
// Üzerinde işlem yapabileceğimiz bir sözdizimi ağacı olarak
// Rust kodunun temsilini oluştur.
let ast = syn::parse(input).unwrap();
// Trait uygulamasını oluştur.
impl_merhaba_makro(&ast)
}
Kodu, TokenStream’i ayrıştırmaktan sorumlu merhaba_makro_derive fonksiyonu ve sözdizimi ağacını dönüştürmekten sorumlu impl_merhaba_makro fonksiyonu olarak ikiye ayırdığımıza dikkat edin: Bu ayrım prosedürel makro yazmayı daha kullanışlı hâle getirir. Dış fonksiyondaki kod (bu örnekte merhaba_makro_derive), göreceğiniz veya oluşturacağınız neredeyse her prosedürel makro crate’inde aynı olacaktır. İç fonksiyonun gövdesinde belirttiğiniz kod (bu örnekte impl_merhaba_makro) ise prosedürel makronuzun amacına bağlı olarak değişecektir.
Üç yeni crate tanıttık: proc_macro, syn ve quote. proc_macro crate’i Rust ile birlikte gelir, bu nedenle onu Cargo.toml dosyasındaki bağımlılıklara eklememize gerek kalmadı. proc_macro crate’i, derleyicinin kendi kodumuz içinden Rust kodunu okumamıza ve işlememize olanak tanıyan API’sidir.
syn crate’i, Rust kodunu bir metinden üzerinde işlem yapabileceğimiz bir veri yapısına ayrıştırır. quote crate’i ise syn veri yapılarını tekrar Rust koduna dönüştürür. Bu crate’ler, ele almak isteyebileceğimiz her türlü Rust kodunu ayrıştırmayı çok daha kolay hâle getirir: Rust kodu için eksiksiz bir ayrıştırıcı (parser) yazmak hiç de basit bir iş değildir.
Kütüphanemizin bir kullanıcısı bir tür üzerinde #[derive(MerhabaMakro)] belirttiğinde merhaba_makro_derive fonksiyonu çağrılacaktır. Bunun mümkün olmasının nedeni, buradaki merhaba_makro_derive fonksiyonuna proc_macro_derive özniteliğini eklemiş ve trait adımızla eşleşen MerhabaMakro adını belirtmiş olmamızdır; çoğu prosedürel makro bu geleneği izler.
merhaba_makro_derive fonksiyonu ilk olarak input değerini bir TokenStream’den daha sonra yorumlayıp üzerinde işlemler yapabileceğimiz bir veri yapısına dönüştürür. İşte syn tam bu noktada devreye girer. syn içindeki parse fonksiyonu bir TokenStream alır ve ayrıştırılmış Rust kodunu temsil eden bir DeriveInput struct’ı döndürür. Liste 20-41, struct Krepler; metnini ayrıştırdığımızda elde ettiğimiz DeriveInput struct’ının ilgili kısımlarını göstermektedir.
DeriveInput {
// --snip--
ident: Ident {
ident: "Krepler",
span: #0 bytes(95..103)
},
data: Struct(
DataStruct {
struct_token: Struct,
fields: Unit,
semi_token: Some(
Semi
)
}
)
}
DeriveInput örneğiBu struct’ın alanları, ayrıştırdığımız Rust kodunun ident (identifier, yani tanımlayıcı / ad) değeri Krepler olan bir birim struct’ı olduğunu gösterir. Bu struct üzerinde her türlü Rust kodunu tanımlamaya yönelik daha pek çok alan bulunur; daha fazla bilgi için DeriveInput hakkındaki syn belgelerine göz atabilirsiniz.
Birazdan, eklemek istediğimiz yeni Rust kodunu üretecek olan impl_merhaba_makro fonksiyonunu tanımlayacağız. Ancak buna geçmeden önce, derive makromuzun çıktısının da bir TokenStream olduğuna dikkat edin. Döndürülen TokenStream, crate kullanıcılarımızın yazdığı koda eklenir; böylece kendi crate’lerini derlediklerinde, değiştirilmiş TokenStream içinde sağladığımız ek işlevselliği elde ederler.
Burada syn::parse fonksiyonu çağrısı başarısız olursa merhaba_makro_derive fonksiyonunun paniklemesi için unwrap çağırdığımızı fark etmiş olabilirsiniz. Prosedürel makro API’sine uymak adına proc_macro_derive fonksiyonlarının Result yerine TokenStream döndürmesi zorunlu olduğundan, hata doğrudan bir Err dönüş değeri olarak iletilemez. Makro panikleyebilir veya açıklayıcı bir compile_error! çağrısı içeren bir TokenStream üretebilir. Bu örneği unwrap kullanarak basitleştirdik; üretim kodunda panic! veya expect kullanarak neyin ters gittiğine dair daha özgül hata mesajları sağlamalısınız.
Artık öznitelik eklenmiş Rust kodunu bir TokenStream’den DeriveInput örneğine dönüştüren koda sahip olduğumuza göre, Liste 20-42’de gösterildiği gibi öznitelik eklenmiş tür üzerinde MerhabaMakro trait’ini uygulayan kodu üretelim.
use proc_macro::TokenStream;
use quote::quote;
#[proc_macro_derive(MerhabaMakro)]
pub fn merhaba_makro_derive(input: TokenStream) -> TokenStream {
// Üzerinde işlem yapabileceğimiz bir sözdizimi ağacı olarak
// Rust kodunun temsilini oluştur.
let ast = syn::parse(input).unwrap();
// Trait uygulamasını oluştur.
impl_merhaba_makro(&ast)
}
fn impl_merhaba_makro(ast: &syn::DeriveInput) -> TokenStream {
let name = &ast.ident;
let generated = quote! {
impl MerhabaMakro for #name {
fn merhaba_makro() {
println!("Merhaba, Makro! Benim adım {}!", stringify!(#name));
}
}
};
generated.into()
}
MerhabaMakro trait’ini uygulamakast.ident kullanarak öznitelik eklenmiş türün adını (tanımlayıcısını) içeren bir Ident struct örneği elde ederiz. Liste 20-41’deki struct, impl_merhaba_makro fonksiyonunu Liste 20-37’deki kod üzerinde çalıştırdığımızda elde edeceğimiz ident değerinin "Krepler" değerine sahip bir ident alanı barındıracağını gösterir. Dolayısıyla Liste 20-42’deki name değişkeni, yazdırıldığında Liste 20-37’deki struct’ın adı olan "Krepler" metnini verecek bir Ident struct örneği içerir.
quote! makrosu, döndürmek istediğimiz Rust kodunu tanımlamamıza olanak tanır. Derleyici, quote! makrosunun doğrudan ürettiği sonuçtan farklı bir tür beklediği için bunu bir TokenStream’e dönüştürmemiz gerekir. Bunu, bu ara temsili tüketen ve gerekli TokenStream türünde bir değer döndüren into metodunu çağırarak yaparız.
quote! makrosu ayrıca oldukça etkileyici şablonlama mekanikleri sunar: #name yazabiliriz ve quote! bunu name değişkenindeki değerle değiştirir. Hatta normal makroların çalışma biçimine benzer tekrarlamalar bile yapabilirsiniz. Kapsamlı bir giriş için quote crate’inin belgelerine göz atabilirsiniz.
Prosedürel makromuzun, kullanıcının öznitelik eklediği tür için —bunu #name kullanarak elde edebiliyoruz— MerhabaMakro trait’imizin bir uygulamasını üretmesini istiyoruz. Bu trait uygulaması, gövdesi sağlamak istediğimiz işlevselliği içeren tek bir merhaba_makro fonksiyonuna sahiptir: Merhaba, Makro! Benim adım metnini ve ardından öznitelik eklenmiş türün adını yazdırmak.
Burada kullanılan stringify! makrosu Rust’ta yerleşiktir. 1 + 2 gibi bir Rust ifadesi alır ve derleme zamanında bu ifadeyi "1 + 2" gibi bir string sabitine (string literal) dönüştürür. Bu davranış, ifadeyi değerlendirip ardından sonucu bir String değerine çeviren format! veya println! makrolarından farklıdır. #name girdisinin olduğu gibi yazdırılması gereken bir ifade olma ihtimali bulunduğundan stringify! kullanırız. stringify! kullanmak, #name girdisini derleme zamanında bir string sabitine dönüştürerek çalışma zamanında fazladan bir bellek tahsisinden de tasarruf sağlar.
Bu noktada hem merhaba_makro hem de merhaba_makro_turet içinde cargo build komutu başarıyla tamamlanmalıdır. Prosedürel makroyu çalışırken görmek için bu crate’leri Liste 20-37’deki koda bağlayalım! projects dizininizde cargo new krepler komutuyla yeni bir ikili (binary) proje oluşturun. krepler crate’inin Cargo.toml dosyasına merhaba_makro ve merhaba_makro_turet crate’lerini bağımlılık olarak eklememiz gerekiyor. merhaba_makro ve merhaba_makro_turet sürümlerinizi crates.io üzerinde yayınlıyorsanız bunlar normal bağımlılıklar olurdu; yayınlamıyorsanız bunları aşağıdaki gibi path bağımlılıkları olarak belirtebilirsiniz:
[dependencies]
merhaba_makro = { path = "../merhaba_makro" }
merhaba_makro_turet = { path = "../merhaba_makro/merhaba_makro_turet" }
Liste 20-37’deki kodu src/main.rs dosyasına koyun ve cargo run komutunu çalıştırın: Ekrana Merhaba, Makro! Benim adım Krepler! yazdırmalıdır. Prosedürel makrodan gelen MerhabaMakro trait’i uygulaması, krepler crate’inin bunu elle uygulamasına gerek kalmadan dahil edildi; trait uygulamasını #[derive(MerhabaMakro)] ekledi.
Şimdi diğer prosedürel makro türlerinin özel derive makrolarından nasıl farklılaştığını inceleyelim.
Öznitelik Benzeri Makrolar
Öznitelik benzeri makrolar özel derive makrolarına benzer, ancak derive özniteliği için kod üretmek yerine yeni öznitelikler oluşturmanıza olanak tanır. Ayrıca daha esnektirler: derive yalnızca struct ve enum’lar için çalışırken, öznitelikler fonksiyonlar gibi diğer öğelere de uygulanabilir. Öznitelik benzeri bir makronun kullanımına dair bir örneğe bakalım. Bir web uygulaması çatısı (framework) kullanırken fonksiyonları işaretlemek için route adında bir özniteliğiniz olduğunu varsayalım:
#[route(GET, "/")]
fn index() {
Bu #[route] özniteliği, çatı tarafından prosedürel bir makro olarak tanımlanırdı. Makro tanım fonksiyonunun imzası şöyle görünürdü:
#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {
Burada TokenStream türünde iki parametremiz vardır. İlki özniteliğin içeriği içindir: GET, "/" kısmı. İkincisi ise özniteliğin iliştirildiği öğenin gövdesidir: bu örnekte fn index() {} ve fonksiyon gövdesinin geri kalanı.
Bunun dışında öznitelik benzeri makrolar, özel derive makrolarıyla aynı şekilde çalışır: proc-macro crate türüne sahip bir crate oluşturur ve istediğiniz kodu üreten bir fonksiyon uygularsınız!
Fonksiyon Benzeri Makrolar
Fonksiyon benzeri makrolar, fonksiyon çağrılarına benzeyen makrolar tanımlar. macro_rules! makrolarına benzer şekilde fonksiyonlardan daha esnektirler; örneğin önceden bilinmeyen sayıda argüman alabilirler. Ancak macro_rules! makroları yalnızca daha önce “Genel Metaprogramlama İçin Bildirimsel Makrolar” bölümünde ele aldığımız match benzeri sözdizimi kullanılarak tanımlanabilir. Fonksiyon benzeri makrolar bir TokenStream parametresi alır ve tanımları, diğer iki prosedürel makro türünün yaptığı gibi Rust kodunu kullanarak bu TokenStream üzerinde işlem yapar. Fonksiyon benzeri bir makroya örnek olarak şöyle çağrılabilecek bir sql! makrosu verilebilir:
let sql = sql!(SELECT * FROM posts WHERE id=1);
Bu makro, içindeki SQL deyimini ayrıştırıp sözdizimsel olarak doğru olup olmadığını denetlerdi; bu da bir macro_rules! makrosunun yapabileceğinden çok daha karmaşık bir işlemdir. sql! makrosu şöyle tanımlanırdı:
#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {
Bu tanım, özel derive makrosunun imzasına benzer: Parantez içindeki belirteçleri alırız ve üretmek istediğimiz kodu döndürürüz.
Not: Rust makrolarını C/C++ dillerindeki #define ön işlemci makrolarından
ayıran en kritik güvencelerden biri makro hijyenidir (macro hygiene). C’deki
#define Rust’ın sözdizimi ve kapsam kurallarını gözetmeden önişlemci
belirteçlerini genişlettiği için beklenmedik operatör önceliği hatalarına veya
yerel değişken adı çakışmalarına yol açabilir. Rust’ta ise macro_rules!
makroları ham metin yerine belirteç ağacı (token tree) düzeyinde çalışır ve
kendi içlerinde tanımladıkları değişkenleri ayrı bir sözdizimsel kapsamda
yalıtır: Örneğin Liste 20-35’teki vec! tanımının içindeki temp_vec
değişkeni, çağıran kodda let temp_vec = 42; let v = vec![temp_vec]; yazsanız
bile sizin temp_vec değişkeninizle asla çakışmaz veya onu gölgelemez.
Hem macro_rules! makrolarını hem de #[derive(...)] ve öznitelik makroları gibi prosedürel makroları geliştirirken veya hata ayıklarken, derleyicinin perde arkasında tam olarak hangi Rust kodunu ürettiğini görmek büyük kolaylık sağlar. Bunun için cargo install cargo-expand komutuyla kurabileceğiniz cargo-expand alt komutunu kullanabilirsiniz: Örneğin krepler proje dizininde cargo expand çalıştırdığınızda, #[derive(MerhabaMakro)] ve println! makrolarının derleme zamanında genişletilmiş tam Rust çıktısını doğrudan inceleyebilirsiniz.
Özet
Oh be! Artık alet çantanızda muhtemelen sık kullanmayacağınız, ancak çok özel durumlarda elinizin altında olduğunu bileceğiniz bazı Rust özellikleri var. Hata mesajı önerilerinde veya başkalarının kodlarında karşılaştığınızda bu kavramları ve sözdizimini tanıyabilmeniz için birkaç karmaşık konuyu tanıttık. Sizi çözümlere yönlendirecek bir başvuru kaynağı olarak bu bölümü kullanabilirsiniz.
Sırada, kitap boyunca ele aldığımız her şeyi pratiğe dökeceğimiz son bir proje daha var!
Final Projesi: Çok İş Parçacıklı (Multithreaded) Web Sunucusu Geliştirme
Uzun bir yolculuk oldu ama kitabın sonuna ulaştık. Bu bölümde, son bölümlerde öğrendiğimiz kavramların bir kısmını uygulamalı olarak göstermek ve daha önceki bazı dersleri pekiştirmek için birlikte son bir proje daha geliştireceğiz.
Bu final projesinde, “Merhaba!” diyen ve bir web tarayıcısında Şekil 21-1’deki gibi görünen bir web sunucusu oluşturacağız.
Web sunucusunu şu planla geliştireceğiz:
- TCP ve HTTP hakkında biraz bilgi edinmek
- Bir soket üzerinden TCP bağlantılarını dinlemek
- Az sayıda HTTP isteğini ayrıştırmak
- Geçerli bir HTTP yanıtı oluşturmak
- Bir thread havuzu (thread pool) ile sunucumuzun işleme kapasitesini (throughput) artırmak
Şekil 21-1: Birlikte geliştireceğimiz web sunucusunun “Merhaba!” ve “Rust’tan selam” içeriğini sunduğu sayfa
Başlamadan önce iki ayrıntıya değinelim. Birincisi, kullanacağımız yöntem Rust ile bir web sunucusu geliştirmenin en iyi yolu olmayacaktır. Topluluk üyeleri, crates.io üzerinde bizim geliştireceğimizden çok daha eksiksiz web sunucusu ve thread havuzu uygulamaları sunan, üretime hazır pek çok crate yayımlamıştır. Ancak bu bölümdeki amacımız kolay yolu seçmek değil, öğrenmenize yardımcı olmaktır. Rust bir sistem programlama dili olduğundan, çalışmak istediğimiz soyutlama düzeyini kendimiz seçebilir ve diğer dillerde mümkün ya da pratik olandan daha alt bir seviyeye inebiliriz.
İkincisi, burada async ve await kullanmayacağız. Bir asenkron çalışma zamanı kurmayı işin içine katmadan, yalnızca bir thread havuzu geliştirmek bile tek başına yeterince büyük bir uğraştır! Yine de bu bölümde göreceğimiz bazı problemlere async ve await’in nasıl uygulanabileceğine değineceğiz. Sonuçta Bölüm 17’de de belirttiğimiz gibi, pek çok asenkron çalışma zamanı kendi işlerini yönetmek için arka planda thread havuzları kullanır.
Bu nedenle temel HTTP sunucusunu ve thread havuzunu elle yazacağız; böylece gelecekte kullanabileceğiniz crate’lerin arkasındaki genel fikirleri ve teknikleri öğrenmiş olacaksınız.
Not: Gerçek dünya üretim sistemlerinde Rust ile web sunucusu veya HTTP servisi geliştirirken genellikle tokio asenkron çalışma zamanı üzerinde yükselen axum ya da actix-web gibi çatılar (frameworks) tercih edilir. Bu bölümde TcpListener, TcpStream, kanallar (mpsc), Arc<Mutex<T>> ve Drop trait’ini doğrudan kullanarak kendi sunucunuzu sıfırdan inşa etmeniz, bu modern çatıların kaputun altında bağlantıları, görev kuyruklarını ve zarif kapanışı (graceful shutdown) nasıl yönettiğini somut olarak kavramanızı sağlayacaktır.
Tek İş Parçacıklı (Single-Threaded) Web Sunucusu Geliştirme
Tek İş Parçacıklı (Single-Threaded) Web Sunucusu Geliştirme
İşe, tek iş parçacıklı (tek thread’li) bir web sunucusunu çalışır hâle getirerek başlayacağız. Başlamadan önce, web sunucuları geliştirirken rol oynayan protokollere kısaca göz atalım. Bu protokollerin tüm ayrıntıları bu kitabın kapsamı dışındadır; ancak kısa bir genel bakış ihtiyacınız olan bilgiyi sağlayacaktır.
Web sunucularında yer alan iki temel protokol Hiper Metin Aktarım Protokolü (Hypertext Transfer Protocol — HTTP) ve Aktarım Denetim Protokolü (Transmission Control Protocol — TCP) olarak adlandırılır. Her iki protokol de istek-yanıt (request-response) protokolüdür; yani bir istemci (client) istekleri başlatır, bir sunucu (server) ise bu istekleri dinleyip istemciye bir yanıt sağlar. Bu istek ve yanıtların içerikleri söz konusu protokoller tarafından tanımlanır.
TCP, bilginin bir sunucudan diğerine nasıl ulaştığının ayrıntılarını açıklayan, ancak bu bilginin ne olduğunu belirtmeyen daha alt seviyeli protokoldür. HTTP ise isteklerin ve yanıtların içeriğini tanımlayarak TCP’nin üzerine inşa edilir. HTTP’yi teknik olarak başka protokollerle kullanmak mümkündür; ancak durumların büyük çoğunluğunda HTTP, verilerini TCP üzerinden gönderir. Biz de bu bölümde TCP ve HTTP istek ve yanıtlarının ham baytlarıyla çalışacağız.
TCP Bağlantısını Dinleme
Web sunucumuzun bir TCP bağlantısını dinlemesi gerekir; dolayısıyla üzerinde çalışacağımız ilk kısım bu olacak. Standart kütüphane, bunu yapmamızı sağlayan bir std::net modülü sunar. Her zamanki gibi yeni bir proje oluşturalım:
$ cargo new merhaba
Created binary (application) `merhaba` project
$ cd merhaba
Başlamak için Liste 21-1’deki kodu src/main.rs dosyasına girin. Bu kod, gelen TCP akışları için yerel 127.0.0.1:7878 adresini dinleyecektir. Gelen bir akış yakaladığında ekrana Bağlantı kuruldu! yazdıracaktır.
use std::net::TcpListener;
fn main() {
let dinleyici = TcpListener::bind("127.0.0.1:7878").unwrap();
for akis in dinleyici.incoming() {
let akis = akis.unwrap();
println!("Bağlantı kuruldu!");
}
}
TcpListener kullanarak 127.0.0.1:7878 adresindeki TCP bağlantılarını dinleyebiliriz. Adreste iki nokta üst üste işaretinden önceki kısım bilgisayarınızı temsil eden bir IP adresidir (bu adres her bilgisayarda aynıdır ve özellikle yazarların bilgisayarını temsil etmez), 7878 ise port numarasıdır. Bu portu iki nedenden ötürü seçtik: HTTP trafiği normalde bu port üzerinden kabul edilmez, dolayısıyla sunucumuzun makinenizde çalışıyor olabilecek başka bir web sunucusuyla çakışma olasılığı düşüktür; ayrıca 7878, telefon tuş takımında rust kelimesinin tuşlanmış karşılığıdır.
Bu senaryoda bind fonksiyonu, yeni bir TcpListener örneği (dinleyici) döndürmesi bakımından new fonksiyonu gibi çalışır. Ağ programlamasında dinlemek üzere bir porta bağlanma işlemine İngilizcede “binding to a port” (porta bağlanma) dendiği için fonksiyonun adı bind olarak seçilmiştir.
bind fonksiyonu bir Result<T, E> döndürür; bu da bağlanma işleminin başarısız olabileceğini gösterir. Örneğin, programımızın iki örneğini aynı anda çalıştırırsak ve dolayısıyla iki program aynı portu dinlemeye çalışırsa bağlanma başarısız olur. Yalnızca öğrenme amacıyla temel bir sunucu yazdığımız için bu tür hataları yönetmekle uğraşmayacağız; bunun yerine bir hata oluştuğunda programı durdurmak için unwrap kullanıyoruz.
TcpListener üzerindeki incoming metodu, bize bir akış dizisi (daha doğrusu TcpStream türünde akışlar) veren bir iteratör döndürür. Tek bir akış (stream), istemci ile sunucu arasındaki açık bir bağlantıyı temsil eder. Bağlantı (connection) ise bir istemcinin sunucuya bağlandığı, sunucunun bir yanıt ürettiği ve ardından sunucunun bağlantıyı kapattığı istek ve yanıt sürecinin tamamına verilen addır. Bu doğrultuda, istemcinin ne gönderdiğini görmek için TcpStream üzerinden okuma yapacak ve istemciye veri geri göndermek için yanıtımızı akışa yazacağız. Genel olarak bu for döngüsü her bağlantıyı sırayla işleyecek ve ele almamız için bir dizi akış üretecektir.
Şimdilik akışı ele alışımız, akışta herhangi bir hata varsa programımızı sonlandırmak üzere unwrap çağırmaktan ibarettir; herhangi bir hata yoksa program bir mesaj yazdırır. Bir sonraki listede başarılı durum için daha fazla işlevsellik ekleyeceğiz. Bir istemci sunucuya bağlandığında incoming metodundan hata alabilmemizin nedeni, aslında doğrudan bağlantılar üzerinde değil, bağlantı girişimleri (connection attempts) üzerinde iterasyon yapıyor olmamızdır. Bağlantı, çoğu işletim sistemine özgü olan çeşitli nedenlerle başarılı olmayabilir. Örneğin birçok işletim sisteminin aynı anda destekleyebileceği açık bağlantı sayısına ilişkin bir sınırı vardır; açık bağlantılardan bazıları kapatılana kadar bu sayıyı aşan yeni bağlantı girişimleri hata üretecektir.
Bu kodu çalıştırmayı deneyelim! Terminalde cargo run komutunu çalıştırın ve ardından bir web tarayıcısında 127.0.0.1:7878 adresini yükleyin. Sunucu şu anda geriye hiçbir veri göndermediği için tarayıcı “Bağlantı sıfırlandı” (“Connection reset”) benzeri bir hata mesajı göstermelidir. Ancak terminalinize baktığınızda, tarayıcı sunucuya bağlandığında yazdırılmış birkaç mesaj görmelisiniz!
Running `target/debug/merhaba`
Bağlantı kuruldu!
Bağlantı kuruldu!
Bağlantı kuruldu!
Bazen tek bir tarayıcı isteği için birden fazla mesaj yazdırıldığını görürsünüz; bunun nedeni tarayıcının sayfanın yanı sıra tarayıcı sekmesinde görünen favicon.ico simgesi gibi diğer kaynaklar için de istek gönderiyor olması olabilir.
Ayrıca sunucu herhangi bir veriyle yanıt vermediği için tarayıcı sunucuya birden çok kez bağlanmaya çalışıyor da olabilir. Döngünün sonunda akis kapsam dışına çıkıp düşürüldüğünde (drop), TcpStream’in drop uygulamasının bir parçası olarak bağlantı kapatılır. Sorun geçici olabileceğinden tarayıcılar kapanan bağlantılara bazen yeniden bağlanmayı deneyerek karşılık verir.
Tarayıcılar kimi zaman da sunucuya henüz hiçbir istek göndermeden birden fazla bağlantı açar; böylece daha sonra gerçekten istek gönderirlerse, bu istekler daha hızlı gerçekleşebilir. Böyle bir durumda sunucumuz, üzerinden herhangi bir istek gelip gelmediğine bakmaksızın açılan her bağlantıyı görecektir. Örneğin Chrome tabanlı tarayıcıların birçok sürümü bunu yapar; gizli gezinme modunu kullanarak veya farklı bir tarayıcı tercih ederek bu optimizasyonu devre dışı bırakabilirsiniz.
Buradaki önemli nokta, bir TCP bağlantısını başarıyla yakalamış olmamızdır!
Kodun belirli bir sürümünü çalıştırmayı bitirdiğinizde ctrl-C tuşlarına basarak programı durdurmayı unutmayın. Ardından, en güncel kodu çalıştırdığınızdan emin olmak için yaptığınız her kod değişikliği grubundan sonra cargo run komutunu çağırarak programı yeniden başlatın.
İsteği Okuma
Şimdi tarayıcıdan gelen isteği okuma işlevselliğini uygulayalım! Önce bir bağlantı alma ve ardından bağlantı üzerinde bir işlem yapma sorumluluklarını birbirinden ayırmak için bağlantıları işleyecek yeni bir fonksiyon yazmaya başlayacağız. Bu yeni baglantiyi_isle fonksiyonunda TCP akışından veri okuyup yazdıracağız; böylece tarayıcıdan gönderilen veriyi görebileceğiz. Kodu Liste 21-2’deki gibi değiştirin.
use std::{
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
};
fn main() {
let dinleyici = TcpListener::bind("127.0.0.1:7878").unwrap();
for akis in dinleyici.incoming() {
let akis = akis.unwrap();
baglantiyi_isle(akis);
}
}
fn baglantiyi_isle(mut akis: TcpStream) {
let tamponlu_okuyucu = BufReader::new(&akis);
let http_istegi: Vec<_> = tamponlu_okuyucu
.lines()
.map(|result| result.unwrap())
.take_while(|line| !line.is_empty())
.collect();
println!("İstek: {http_istegi:#?}");
}
TcpStream üzerinden okuma ve veriyi yazdırmaAkıştan okuma ve akışa yazma yapmamızı sağlayan trait’lere ve türlere erişmek için std::io::BufReader ve std::io::prelude öğelerini kapsama dahil ediyoruz. main fonksiyonundaki for döngüsünde artık bağlantı kurduğumuzu söyleyen bir mesaj yazdırmak yerine yeni baglantiyi_isle fonksiyonunu çağırıyor ve akis değerini ona aktarıyoruz.
baglantiyi_isle fonksiyonunda, akis değişkenine bir referansı sarmalayan yeni bir BufReader örneği (tamponlu_okuyucu) oluşturuyoruz. BufReader, std::io::Read trait metotlarına yapılan çağrıları bizim yerimize yöneterek tamponlama (buffering) ekler.
Not: BufReader::new(&akis) çağrısında akis değişkeninin yalnızca paylaşımlı bir referansını (&akis) verdiğimize, buna rağmen ilerleyen listelerde aynı fonksiyonun sonunda akis üzerinde write_all çağırabildiğimize dikkat edin. Bu mümkündür; çünkü standart kütüphane Read ve Write trait’lerini yalnızca TcpStream için değil, &TcpStream için de uygular: Soketten okumak ve sokete yazmak, Rust açısından TcpStream değerini değiştirmek sayılmaz; asıl iş işletim sistemi tarafından yürütülür. baglantiyi_isle imzasındaki mut akis ise write_all metodunun &mut self almasından ötürü gereklidir; Liste 21-2’de henüz akışa yazmadığımız için derleyici bu mut için variable does not need to be mutable uyarısı verebilir, Liste 21-3’ten itibaren bu uyarı ortadan kalkar.
Tarayıcının sunucumuza gönderdiği isteğin satırlarını toplamak için http_istegi adında bir değişken oluşturuyoruz. Vec<_> tür açıklamasını ekleyerek bu satırları bir vektörde toplamak istediğimizi belirtiyoruz.
BufReader, lines metodunu sağlayan std::io::BufRead trait’ini uygular. lines metodu, veri akışında ne zaman bir satır sonu baytı görse akışı bölerek Result<String, std::io::Error> öğelerinden oluşan bir iteratör döndürür. Her bir String değerini elde etmek için her Result üzerinde map ve unwrap uyguluyoruz. Veri geçerli UTF-8 değilse veya akıştan okuma sırasında bir sorun yaşandıysa Result bir hata olabilir. Yine, üretime yönelik bir program bu hataları daha zarif biçimde ele almalıdır; ancak biz basitlik adına hata durumunda programı durdurmayı seçiyoruz.
Tarayıcı, art arda iki satır sonu dizisi göndererek bir HTTP isteğinin (daha doğrusu istek başlıklarının) sonunu bildirir; bu nedenle akıştan tek bir istek almak için take_while ile boş string olan bir satıra ulaşana dek satırları alıyoruz. lines metodu her satırın sonundaki \n karakterini (ve varsa ondan önceki \r karakterini) attığı için, başlıkları gövdeden ayıran boş satır bize tam olarak boş bir String olarak ulaşır; take_while bu boş satırı gördüğü anda durur ve onu vektöre eklemez. Satırları vektörde topladıktan sonra, web tarayıcısının sunucumuza gönderdiği yönergelere göz atabilmek için bunları okunaklı hata ayıklama biçimlendirmesiyle ekrana yazdırıyoruz.
Bu kodu deneyelim! Programı başlatın ve bir web tarayıcısında yeniden istek gönderin. Tarayıcıda hâlâ bir hata sayfası alacağımıza dikkat edin; ancak programımızın terminaldeki çıktısı artık şuna benzer görünecektir:
$ cargo run
Compiling merhaba v0.1.0 (file:///projects/merhaba)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.42s
Running `target/debug/merhaba`
İstek: [
"GET / HTTP/1.1",
"Host: 127.0.0.1:7878",
"User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:99.0) Gecko/20100101 Firefox/99.0",
"Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language: en-US,en;q=0.5",
"Accept-Encoding: gzip, deflate, br",
"DNT: 1",
"Connection: keep-alive",
"Upgrade-Insecure-Requests: 1",
"Sec-Fetch-Dest: document",
"Sec-Fetch-Mode: navigate",
"Sec-Fetch-Site: none",
"Sec-Fetch-User: ?1",
"Cache-Control: max-age=0",
]
Tarayıcınıza bağlı olarak biraz farklı bir çıktı alabilirsiniz. Artık istek verisini yazdırdığımıza göre, isteğin ilk satırındaki GET ifadesinden sonra gelen yola bakarak tek bir tarayıcı isteğinden neden birden fazla bağlantı aldığımızı görebiliriz. Tekrarlanan bağlantıların tümü / yolunu istiyorsa, programımızdan yanıt alamadığı için tarayıcının / adresini tekrar tekrar getirmeye çalıştığını anlarız.
Tarayıcının programımızdan ne istediğini anlamak için bu istek verisini parçalarına ayıralım.
Bir HTTP İsteğine Daha Yakından Bakma
HTTP metin tabanlı bir protokoldür ve bir istek şu biçimi alır:
Method Request-URI HTTP-Version CRLF
headers CRLF
message-body
İlk satır, istemcinin ne talep ettiğine dair bilgiyi tutan istek satırıdır (request line). İstek satırının ilk bölümü, kullanılan metodu (GET veya POST gibi) belirtir; metot, istemcinin bu isteği nasıl yaptığını açıklar. Bizim istemcimiz bir GET isteği kullandı; bu da bilgi talep ettiği anlamına gelir.
İstek satırının bir sonraki bölümü / olup istemcinin talep ettiği tekdüze kaynak tanımlayıcısını (uniform resource identifier — URI) belirtir: Bir URI, tekdüze kaynak bulucu (uniform resource locator — URL) ile neredeyse aynıdır ama tam olarak aynı şey değildir. URI ile URL arasındaki fark bu bölümdeki amaçlarımız açısından önemli değildir; ancak HTTP belirtimi URI terimini kullandığı için burada zihnimizde URI yerine URL koyabiliriz.
Son bölüm istemcinin kullandığı HTTP sürümüdür ve ardından istek satırı bir CRLF dizisiyle sona erer. (CRLF, daktilo günlerinden kalma terimler olan carriage return [satır başı] ve line feed [satır besleme] ifadelerinin kısaltmasıdır!) CRLF dizisi, \r satır başı ve \n satır besleme olmak üzere \r\n biçiminde de yazılabilir. CRLF dizisi, istek satırını geri kalan istek verisinden ayırır. CRLF yazdırıldığında ekranda \r\n görmek yerine yeni bir satırın başladığını gördüğümüze dikkat edin.
Programımızı şimdiye kadar çalıştırdığımızda aldığımız istek satırı verisine baktığımızda, metodun GET, istek URI’sinin / ve sürümün HTTP/1.1 olduğunu görürüz.
İstek satırından sonra Host: satırından itibaren gelen kalan satırlar başlıklardır (headers). GET isteklerinin gövdesi (body) yoktur.
İstek verisinin nasıl değiştiğini görmek için farklı bir tarayıcıdan istek göndermeyi veya 127.0.0.1:7878/test gibi farklı bir adresi istemeyi deneyin.
Artık tarayıcının ne istediğini bildiğimize göre biraz veri geri gönderelim!
Bir Yanıt Yazma
Şimdi bir istemci isteğine karşılık veri gönderme işlevini uygulayacağız. Yanıtlar aşağıdaki biçime sahiptir:
HTTP-Version Status-Code Reason-Phrase CRLF
headers CRLF
message-body
İlk satır; yanıtta kullanılan HTTP sürümünü, isteğin sonucunu özetleyen sayısal bir durum kodunu (status code) ve durum kodunun metinsel açıklamasını sağlayan bir neden ifadesini (reason phrase) içeren bir durum satırıdır (status line). CRLF dizisinden sonra varsa başlıklar, bir başka CRLF dizisi ve yanıtın gövdesi yer alır.
İşte HTTP 1.1 sürümünü kullanan, 200 durum koduna ve OK neden ifadesine sahip olan, başlığı ve gövdesi bulunmayan örnek bir yanıt:
HTTP/1.1 200 OK\r\n\r\n
200 durum kodu standart başarılı yanıttır. Bu metin küçük bir başarılı HTTP yanıtıdır. Başarılı bir isteğe yanıtımız olarak bunu akışa yazalım! baglantiyi_isle fonksiyonundan istek verisini yazdıran println! çağrısını kaldırın ve yerine Liste 21-3’teki kodu koyun.
use std::{
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
};
fn main() {
let dinleyici = TcpListener::bind("127.0.0.1:7878").unwrap();
for akis in dinleyici.incoming() {
let akis = akis.unwrap();
baglantiyi_isle(akis);
}
}
fn baglantiyi_isle(mut akis: TcpStream) {
let tamponlu_okuyucu = BufReader::new(&akis);
let http_istegi: Vec<_> = tamponlu_okuyucu
.lines()
.map(|result| result.unwrap())
.take_while(|line| !line.is_empty())
.collect();
let yanit = "HTTP/1.1 200 OK\r\n\r\n";
akis.write_all(yanit.as_bytes()).unwrap();
}
Eklenen ilk yeni satır, başarı mesajının verisini tutan yanit değişkenini tanımlar. Ardından, string verisini baytlara dönüştürmek için yanit üzerinde as_bytes metodunu çağırıyoruz. akis üzerindeki write_all metodu bir &[u8] alır ve bu baytları doğrudan bağlantı üzerinden gönderir. (write metodundan farklı olarak write_all, tek bir çağrıda baytların yalnızca bir kısmı gönderilebildiğinde yazmaya devam eder ve verilen dilimin tamamı gönderilene kadar geri dönmez.) write_all işlemi başarısız olabileceği için her zamanki gibi olası bir hata sonucu üzerinde unwrap kullanıyoruz. Yine, gerçek bir uygulamada buraya hata yönetimi eklemeniz gerekir.
Bu değişikliklerle kodumuzu çalıştıralım ve bir istek gönderelim. Artık terminale herhangi bir veri yazdırmadığımız için Cargo’nun çıktısı dışında hiçbir çıktı görmeyeceğiz. Bir web tarayıcısında 127.0.0.1:7878 adresini yüklediğinizde hata yerine boş bir sayfa almalısınız. Az önce bir HTTP isteği alıp yanıt göndermeyi elle kodladınız!
Gerçek HTML Döndürme
Şimdi boş bir sayfadan fazlasını döndürme işlevselliğini uygulayalım. Proje dizininizin kökünde (src dizininde değil) merhaba.html adlı yeni dosyayı oluşturun. İstediğiniz herhangi bir HTML içeriğini girebilirsiniz; Liste 21-4 olası bir örneği göstermektedir.
<!DOCTYPE html>
<html lang="tr">
<head>
<meta charset="utf-8">
<title>Merhaba!</title>
</head>
<body>
<h1>Merhaba!</h1>
<p>Rust'tan selam</p>
</body>
</html>
Bu, bir başlık ve biraz metin içeren minimal bir HTML5 belgesidir. Bir istek alındığında sunucudan bunu döndürmek için baglantiyi_isle fonksiyonunu Liste 21-5’te gösterildiği gibi HTML dosyasını okuyacak, yanıt gövdesi olarak ekleyecek ve gönderecek şekilde değiştireceğiz.
use std::{
fs,
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
};
// --snip--
fn main() {
let dinleyici = TcpListener::bind("127.0.0.1:7878").unwrap();
for akis in dinleyici.incoming() {
let akis = akis.unwrap();
baglantiyi_isle(akis);
}
}
fn baglantiyi_isle(mut akis: TcpStream) {
let tamponlu_okuyucu = BufReader::new(&akis);
let http_istegi: Vec<_> = tamponlu_okuyucu
.lines()
.map(|result| result.unwrap())
.take_while(|line| !line.is_empty())
.collect();
let durum_satiri = "HTTP/1.1 200 OK";
let icerik = fs::read_to_string("merhaba.html").unwrap();
let uzunluk = icerik.len();
let yanit =
format!("{durum_satiri}\r\nContent-Length: {uzunluk}\r\n\r\n{icerik}");
akis.write_all(yanit.as_bytes()).unwrap();
}
Standart kütüphanenin dosya sistemi modülünü kapsama dahil etmek için use deyimine fs ekledik. Bir dosyanın içeriğini string’e okuma kodu tanıdık gelmelidir; bunu G/Ç projemiz için dosya içeriğini okuduğumuz Liste 12-4’te de kullanmıştık.
Ardından, dosyanın içeriğini (icerik) başarılı yanıtın gövdesi olarak eklemek için format! makrosunu kullanıyoruz. Geçerli bir HTTP yanıtı oluşturmak adına, yanıt gövdemizin boyutuna —bu örnekte merhaba.html dosyasının boyutuna (uzunluk)— ayarlanan Content-Length başlığını ekliyoruz.
Not: HTTP protokolünde Content-Length başlığı karakter sayısını değil, gövdenin bayt (octet) sayısını belirtir. Rust’ta icerik.len() metodu da karakter sayısını değil, doğrudan UTF-8 bayt uzunluğunu döndürür. Bu sayede HTML dosyalarımızda (örneğin birazdan oluşturacağımız 404.html içindeki 'Ü', 'ğ', 'ı' gibi) UTF-8’de 2 bayt yer kaplayan Türkçe karakterler bulunsa bile yanıt gövdesinin uzunluğu tam ve doğru hesaplanır; böylece sayfa tarayıcıda kesilmeden eksiksiz görüntülenir.
Bu kodu cargo run ile çalıştırın ve tarayıcınızda 127.0.0.1:7878 adresini yükleyin; HTML içeriğinizin görüntülendiğini görmelisiniz!
Şu anda http_istegi içindeki istek verisini yok sayıyor ve HTML dosyasının içeriğini koşulsuz olarak geri gönderiyoruz. Bu da tarayıcınızda 127.0.0.1:7878/baska-bir-sey adresini istemeyi deneseniz bile yine aynı HTML yanıtını alacağınız anlamına gelir. Şu an itibarıyla sunucumuz oldukça sınırlıdır ve çoğu web sunucusunun yaptığını yapmamaktadır. Yanıtlarımızı gelen isteğe göre özelleştirmek ve HTML dosyasını yalnızca / adresine yapılan düzgün biçimlendirilmiş bir istek için geri göndermek istiyoruz.
İsteği Doğrulama ve Seçici Yanıt Verme
Şu anda web sunucumuz, istemci ne talep etmiş olursa olsun dosyadaki HTML’i döndürecektir. HTML dosyasını döndürmeden önce tarayıcının / yolunu isteyip istemediğini kontrol etme ve tarayıcı başka bir şey isterse bir hata döndürme işlevselliğini ekleyelim. Bunun için baglantiyi_isle fonksiyonunu Liste 21-6’da gösterildiği gibi değiştirmemiz gerekir. Bu yeni kod, alınan isteğin içeriğini / için yapılan bir isteğin nasıl göründüğüne dair bilgimizle karşılaştırır ve isteklere farklı davranmak üzere if ve else blokları ekler.
use std::{
fs,
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
};
fn main() {
let dinleyici = TcpListener::bind("127.0.0.1:7878").unwrap();
for akis in dinleyici.incoming() {
let akis = akis.unwrap();
baglantiyi_isle(akis);
}
}
// --snip--
fn baglantiyi_isle(mut akis: TcpStream) {
let tamponlu_okuyucu = BufReader::new(&akis);
let istek_satiri = tamponlu_okuyucu.lines().next().unwrap().unwrap();
if istek_satiri == "GET / HTTP/1.1" {
let durum_satiri = "HTTP/1.1 200 OK";
let icerik = fs::read_to_string("merhaba.html").unwrap();
let uzunluk = icerik.len();
let yanit = format!(
"{durum_satiri}\r\nContent-Length: {uzunluk}\r\n\r\n{icerik}"
);
akis.write_all(yanit.as_bytes()).unwrap();
} else {
// başka bir istek
}
}
Yalnızca HTTP isteğinin ilk satırına bakacağımız için isteğin tamamını bir vektöre okumak yerine iteratörden ilk öğeyi almak üzere next metodunu çağırıyoruz. İlk unwrap, Option değerini ele alır ve iteratörde hiç öğe yoksa programı durdurur. İkinci unwrap ise Result değerini ele alır ve Liste 21-2’de eklenen map içindeki unwrap ile aynı etkiye sahiptir.
Ardından istek_satiri değerinin / yoluna yapılan bir GET isteğinin istek satırına eşit olup olmadığını kontrol ediyoruz. Eşitse, if bloğu HTML dosyamızın içeriğini döndürür.
Eğer istek_satiri değeri / yoluna yapılan GET isteğine eşit değilse, başka bir istek aldığımız anlamına gelir. Diğer tüm isteklere yanıt vermek için birazdan else bloğuna kod ekleyeceğiz.
Şimdi bu kodu çalıştırın ve 127.0.0.1:7878 adresine istek gönderin; merhaba.html içindeki HTML’i almalısınız. 127.0.0.1:7878/baska-bir-sey gibi başka herhangi bir istek yaparsanız, Liste 21-1 ve Liste 21-2’deki kodu çalıştırdığınızda gördüklerinize benzer bir bağlantı hatası alırsınız.
Şimdi isteğe karşılık gelen içeriğin bulunamadığını bildiren 404 durum kodlu bir yanıt döndürmek için Liste 21-7’deki kodu else bloğuna ekleyelim. Ayrıca tarayıcıda görüntülenip son kullanıcıya bu yanıtı bildirecek bir sayfa için biraz HTML de döndüreceğiz.
use std::{
fs,
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
};
fn main() {
let dinleyici = TcpListener::bind("127.0.0.1:7878").unwrap();
for akis in dinleyici.incoming() {
let akis = akis.unwrap();
baglantiyi_isle(akis);
}
}
fn baglantiyi_isle(mut akis: TcpStream) {
let tamponlu_okuyucu = BufReader::new(&akis);
let istek_satiri = tamponlu_okuyucu.lines().next().unwrap().unwrap();
if istek_satiri == "GET / HTTP/1.1" {
let durum_satiri = "HTTP/1.1 200 OK";
let icerik = fs::read_to_string("merhaba.html").unwrap();
let uzunluk = icerik.len();
let yanit = format!(
"{durum_satiri}\r\nContent-Length: {uzunluk}\r\n\r\n{icerik}"
);
akis.write_all(yanit.as_bytes()).unwrap();
// --snip--
} else {
let durum_satiri = "HTTP/1.1 404 NOT FOUND";
let icerik = fs::read_to_string("404.html").unwrap();
let uzunluk = icerik.len();
let yanit = format!(
"{durum_satiri}\r\nContent-Length: {uzunluk}\r\n\r\n{icerik}"
);
akis.write_all(yanit.as_bytes()).unwrap();
}
}
Burada yanıtımız, 404 durum kodunu ve NOT FOUND neden ifadesini içeren bir durum satırına sahiptir. Yanıtın gövdesi 404.html dosyasındaki HTML olacaktır. Hata sayfası için merhaba.html dosyasının yanına bir 404.html dosyası oluşturmanız gerekecektir; yine istediğiniz herhangi bir HTML’i kullanmakta özgürsünüz ya da Liste 21-8’deki örnek HTML’i kullanabilirsiniz.
<!doctype html>
<html lang="tr">
<head>
<meta charset="utf-8" />
<title>Merhaba!</title>
</head>
<body>
<h1>Hay aksi!</h1>
<p>Üzgünüm, ne istediğinizi anlayamadım.</p>
</body>
</html>
Bu değişikliklerle sunucunuzu yeniden çalıştırın. 127.0.0.1:7878 adresini istemek merhaba.html içeriğini döndürmeli; 127.0.0.1:7878/baska-bir-sey gibi diğer herhangi bir istek ise 404.html içindeki hata HTML’ini döndürmelidir.
Yeniden Düzenleme
Şu anda if ve else bloklarında çok fazla tekrar var: Her ikisi de dosya okuyor ve dosyaların içeriğini akışa yazıyor. Tek fark durum satırı ve dosya adıdır. Bu farkları, durum satırı ve dosya adı değerlerini değişkenlere atayan ayrı bir if ve else ifadesine çekerek kodu daha özlü hâle getirelim; ardından dosyayı okumak ve yanıtı yazmak için bu değişkenleri kodda koşulsuz olarak kullanabiliriz. Liste 21-9, büyük if ve else bloklarının değiştirilmesinden sonra ortaya çıkan kodu göstermektedir.
use std::{
fs,
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
};
fn main() {
let dinleyici = TcpListener::bind("127.0.0.1:7878").unwrap();
for akis in dinleyici.incoming() {
let akis = akis.unwrap();
baglantiyi_isle(akis);
}
}
// --snip--
fn baglantiyi_isle(mut akis: TcpStream) {
// --snip--
let tamponlu_okuyucu = BufReader::new(&akis);
let istek_satiri = tamponlu_okuyucu.lines().next().unwrap().unwrap();
let (durum_satiri, dosya_adi) = if istek_satiri == "GET / HTTP/1.1" {
("HTTP/1.1 200 OK", "merhaba.html")
} else {
("HTTP/1.1 404 NOT FOUND", "404.html")
};
let icerik = fs::read_to_string(dosya_adi).unwrap();
let uzunluk = icerik.len();
let yanit =
format!("{durum_satiri}\r\nContent-Length: {uzunluk}\r\n\r\n{icerik}");
akis.write_all(yanit.as_bytes()).unwrap();
}
if ve else bloklarını yalnızca iki durum arasında farklılık gösteren kodu içerecek şekilde yeniden düzenlemeArtık if ve else blokları yalnızca durum satırı ve dosya adı için uygun değerleri bir demet (tuple) içinde döndürüyor; ardından Bölüm 19’da ele aldığımız gibi let deyiminde bir desen kullanarak yapı sökümü (destructuring) yoluyla bu iki değeri durum_satiri ve dosya_adi değişkenlerine atıyoruz.
Daha önce tekrarlanan kod artık if ve else bloklarının dışındadır ve durum_satiri ile dosya_adi değişkenlerini kullanır. Bu sayede iki durum arasındaki farkı görmek kolaylaşır; ayrıca dosya okuma ve yanıt yazma biçimini değiştirmek istersek kodu güncelleyeceğimiz tek bir yer olur. Liste 21-9’daki kodun davranışı Liste 21-7’dekiyle aynı olacaktır.
Harika! Artık yaklaşık 40 satırlık Rust koduyla, bir isteğe içerik sayfasıyla ve diğer tüm isteklere 404 yanıtıyla karşılık veren basit bir web sunucumuz var.
Şu anda sunucumuz tek bir thread üzerinde (tek iş parçacıklı olarak) çalışıyor; yani aynı anda yalnızca tek bir isteğe hizmet verebiliyor. Bazı yavaş istekleri simüle ederek bunun nasıl bir soruna yol açabileceğini inceleyelim. Ardından sunucumuzun aynı anda birden fazla isteği ele alabilmesi için bunu düzelteceğiz.
Tek İş Parçacıklı Sunucudan Çok İş Parçacıklı Sunucuya Geçiş
Tek İş Parçacıklı Sunucudan Çok İş Parçacıklı Sunucuya Geçiş
Şu anda sunucu her isteği sırayla işliyor; yani ilk bağlantının işlenmesi tamamlanmadan ikinci bir bağlantıyı işlemeye geçmiyor. Sunucu giderek daha fazla istek aldığında, bu sıralı çalıştırma biçimi gitgide daha verimsiz hâle gelecektir. Sunucu işlenmesi uzun süren bir istek alırsa, sonradan gelen yeni istekler hızlıca işlenebilecek olsa bile uzun isteğin bitmesini beklemek zorunda kalacaktır. Bu sorunu çözmemiz gerekecek; ancak önce sorunu çalışırken gözlemleyelim.
Yavaş Bir İsteği Taklit Etmek
Yavaş işlenen bir isteğin, mevcut sunucu uygulamamıza yapılan diğer istekleri nasıl etkileyebileceğine bakalım. Liste 21-10, sunucunun yanıt vermeden önce beş saniye uyumasına neden olacak yapay bir yavaş yanıtla /sleep yoluna gelen bir isteğin işlenmesini uygular.
use std::{
fs,
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
thread,
time::Duration,
};
// --snip--
fn main() {
let dinleyici = TcpListener::bind("127.0.0.1:7878").unwrap();
for akis in dinleyici.incoming() {
let akis = akis.unwrap();
baglantiyi_isle(akis);
}
}
fn baglantiyi_isle(mut akis: TcpStream) {
// --snip--
let tamponlu_okuyucu = BufReader::new(&akis);
let istek_satiri = tamponlu_okuyucu.lines().next().unwrap().unwrap();
let (durum_satiri, dosya_adi) = match &istek_satiri[..] {
"GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "merhaba.html"),
"GET /sleep HTTP/1.1" => {
thread::sleep(Duration::from_secs(5));
("HTTP/1.1 200 OK", "merhaba.html")
}
_ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
};
// --snip--
let icerik = fs::read_to_string(dosya_adi).unwrap();
let uzunluk = icerik.len();
let yanit =
format!("{durum_satiri}\r\nContent-Length: {uzunluk}\r\n\r\n{icerik}");
akis.write_all(yanit.as_bytes()).unwrap();
}
Artık üç durumumuz olduğu için if yapısından match yapısına geçtik. String sabiti (string literal) değerleriyle desen eşleştirmesi yapabilmek için istek_satiri değişkeninin bir dilimi (&istek_satiri[..]) üzerinde açıkça eşleştirme yapmamız gerekir: istek_satiri bir String, desenlerdeki string sabitleri ise &str türündedir ve match, eşitlik operatörünün (==) yaptığı gibi otomatik referans alma ve başvuru çözme (dereferencing) işlemi yapmaz.
İlk kol, Liste 21-9’daki if bloğuyla aynıdır. İkinci kol, /sleep yoluna yapılan bir istekle eşleşir. Bu istek alındığında sunucu, başarılı HTML sayfasını oluşturup göndermeden önce beş saniye uyuyacaktır. Üçüncü kol ise Liste 21-9’daki else bloğuyla aynıdır.
Sunucumuzun ne kadar ilkel olduğunu görebilirsiniz: Gerçek kütüphaneler farklı isteklerin tanınmasını çok daha özlü bir biçimde, çok daha az kodla ele alır!
cargo run kullanarak sunucuyu başlatın. Ardından iki tarayıcı penceresi açın: biri http://127.0.0.1:7878, diğeri ise http://127.0.0.1:7878/sleep için olsun. Daha önce yaptığınız gibi / URI adresini birkaç kez girerseniz hızlıca yanıt verdiğini görürsünüz. Ancak önce /sleep adresine girip hemen ardından / sayfasını yüklerseniz, / sayfasının yüklenmeden önce sleep çağrısının beş saniyelik uykusunu tamamen bitirmesini beklediğini görürsünüz.
Yavaş bir isteğin arkasında diğer isteklerin birikmesini önlemek için Bölüm 17’de yaptığımız gibi async kullanmak da dahil olmak üzere başvurabileceğimiz birden fazla teknik vardır; bizim uygulayacağımız teknik ise bir thread havuzu (thread pool) olacaktır.
Thread Havuzuyla İşleme Kapasitesini Artırmak
Thread havuzu (thread pool), bir görevi ele almaya hazır olarak bekleyen, önceden başlatılmış bir thread grubudur. Program yeni bir görev aldığında, havuzdaki thread’lerden birini bu göreve atar ve o thread görevi işler. Havuzda kalan diğer thread’ler, ilk thread çalışmaya devam ederken gelen başka görevleri ele almak üzere hazırdır. İlk thread görevini işlemeyi bitirdiğinde, yeni bir görevi üstlenmeye hazır biçimde boşta bekleyen thread’ler havuzuna geri döner. Bir thread havuzu, bağlantıları eşzamanlı olarak işlemenize olanak tanıyarak sunucunuzun işleme kapasitesini (throughput) artırır.
Bizi hizmet reddi (Denial of Service — DoS) saldırılarından korumak için havuzdaki thread sayısını küçük bir sayıyla sınırlandıracağız. Programımız gelen her istek için yeni bir thread oluştursaydı, sunucumuza 10 milyon istek gönderen biri sunucumuzun tüm kaynaklarını tüketip isteklerin işlenmesini durma noktasına getirerek büyük bir hasara yol açabilirdi.
Dolayısıyla sınırsız sayıda thread başlatmak yerine, havuzda bekleyen sabit sayıda thread bulunduracağız. Gelen istekler işlenmek üzere havuza gönderilir. Havuz, gelen isteklerden oluşan bir kuyruk tutar. Havuzdaki thread’lerin her biri bu kuyruktan bir istek çeker, isteği işler ve ardından kuyruktan başka bir istek ister. Bu tasarımla en fazla N isteği eşzamanlı olarak işleyebiliriz; burada N thread sayısıdır. Thread’lerin her biri uzun süren bir isteğe yanıt veriyorsa, sonradan gelen istekler yine kuyrukta birikebilir; ancak o noktaya ulaşmadan önce ele alabileceğimiz uzun süreli istek sayısını artırmış oluruz.
Bu teknik, bir web sunucusunun işleme kapasitesini artırmanın pek çok yolundan yalnızca biridir. İnceleyebileceğiniz diğer seçenekler arasında fork/join modeli, tek thread’li asenkron G/Ç (single-threaded async I/O) modeli ve çok thread’li asenkron G/Ç (multithreaded async I/O) modeli yer alır. Bu konuya ilgi duyuyorsanız diğer çözümler hakkında daha fazla okuma yapabilir ve bunları uygulamayı deneyebilirsiniz; Rust gibi alt seviyeli bir dille bu seçeneklerin tamamı mümkündür.
Bir thread havuzu uygulamaya başlamadan önce, havuzu kullanmanın nasıl görünmesi gerektiği üzerine konuşalım. Kod tasarlamaya çalışırken önce istemci arayüzünü (client interface) yazmak tasarımınıza yön vermeye yardımcı olabilir. Kodun API’sini, onu çağırmak istediğiniz biçimde yapılandırarak yazın; işlevselliği uygulayıp ardından açık (public) API’yi tasarlamak yerine, önce bu yapıyı kurup işlevselliği onun içinde uygulayın.
Bölüm 12’deki projede test güdümlü geliştirmeyi (test-driven development) kullandığımıza benzer biçimde, burada da derleyici yönlendirmeli geliştirme (compiler-driven development) kullanacağız. İstediğimiz fonksiyonları çağıran kodu yazacağız ve ardından kodun çalışmasını sağlamak üzere bir sonraki adımda neyi değiştirmemiz gerektiğini belirlemek için derleyiciden gelen hatalara bakacağız. Ancak bunu yapmadan önce, başlangıç noktası olarak kullanmayacağımız tekniği inceleyelim.
Her İstek İçin Bir Thread Başlatmak
İlk olarak, her bağlantı için gerçekten yeni bir thread oluştursaydı kodumuzun nasıl görüneceğini inceleyelim. Daha önce belirttiğimiz gibi, sınırsız sayıda thread başlatma olasılığının doğuracağı sorunlar nedeniyle nihai planımız bu değil; ancak önce çalışan çok thread’li (multithreaded) bir sunucu elde etmek için iyi bir başlangıç noktasıdır. Ardından iyileştirme olarak thread havuzunu ekleyeceğiz ve iki çözümü karşılaştırmak daha kolay olacak.
Liste 21-11, for döngüsü içinde her akışı ele almak üzere yeni bir thread başlatmak için main fonksiyonunda yapılacak değişiklikleri göstermektedir.
use std::{
fs,
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
thread,
time::Duration,
};
fn main() {
let dinleyici = TcpListener::bind("127.0.0.1:7878").unwrap();
for akis in dinleyici.incoming() {
let akis = akis.unwrap();
thread::spawn(|| {
baglantiyi_isle(akis);
});
}
}
fn baglantiyi_isle(mut akis: TcpStream) {
let tamponlu_okuyucu = BufReader::new(&akis);
let istek_satiri = tamponlu_okuyucu.lines().next().unwrap().unwrap();
let (durum_satiri, dosya_adi) = match &istek_satiri[..] {
"GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "merhaba.html"),
"GET /sleep HTTP/1.1" => {
thread::sleep(Duration::from_secs(5));
("HTTP/1.1 200 OK", "merhaba.html")
}
_ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
};
let icerik = fs::read_to_string(dosya_adi).unwrap();
let uzunluk = icerik.len();
let yanit =
format!("{durum_satiri}\r\nContent-Length: {uzunluk}\r\n\r\n{icerik}");
akis.write_all(yanit.as_bytes()).unwrap();
}
Bölüm 16’da öğrendiğiniz gibi thread::spawn, yeni bir thread oluşturur ve ardından kapanış içindeki kodu bu yeni thread’de çalıştırır. Bu kodu çalıştırıp tarayıcınızda /sleep sayfasını ve hemen ardından iki tarayıcı sekmesinde daha / sayfasını yüklerseniz, / yoluna yapılan isteklerin gerçekten de /sleep isteğinin bitmesini beklemek zorunda kalmadığını görürsünüz. Ne var ki belirttiğimiz gibi, herhangi bir sınır olmaksızın yeni thread’ler oluşturacağınız için bu yaklaşım eninde sonunda sistemi aşırı yükleyecektir.
Bölüm 17’den hatırlayacağınız üzere, bu tam da async ve await’in parladığı türden bir senaryodur! Thread havuzunu inşa ederken bunu aklınızda bulundurun ve async kullanılsaydı nelerin farklı görüneceğini, nelerin aynı kalacağını düşünün.
Sınırlı Sayıda Thread Oluşturmak
Thread’lerden thread havuzuna geçişin API’mizi kullanan kodda büyük değişiklikler gerektirmemesi için thread havuzumuzun benzer ve tanıdık bir biçimde çalışmasını istiyoruz. Liste 21-12, thread::spawn yerine kullanmak istediğimiz IsParcacigiHavuzu struct’ı için tasarladığımız varsayımsal arayüzü göstermektedir.
use std::{
fs,
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
thread,
time::Duration,
};
fn main() {
let dinleyici = TcpListener::bind("127.0.0.1:7878").unwrap();
let havuz = IsParcacigiHavuzu::new(4);
for akis in dinleyici.incoming() {
let akis = akis.unwrap();
havuz.calistir(|| {
baglantiyi_isle(akis);
});
}
}
fn baglantiyi_isle(mut akis: TcpStream) {
let tamponlu_okuyucu = BufReader::new(&akis);
let istek_satiri = tamponlu_okuyucu.lines().next().unwrap().unwrap();
let (durum_satiri, dosya_adi) = match &istek_satiri[..] {
"GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "merhaba.html"),
"GET /sleep HTTP/1.1" => {
thread::sleep(Duration::from_secs(5));
("HTTP/1.1 200 OK", "merhaba.html")
}
_ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
};
let icerik = fs::read_to_string(dosya_adi).unwrap();
let uzunluk = icerik.len();
let yanit =
format!("{durum_satiri}\r\nContent-Length: {uzunluk}\r\n\r\n{icerik}");
akis.write_all(yanit.as_bytes()).unwrap();
}
IsParcacigiHavuzu arayüzümüzYapılandırılabilir sayıda —bu örnekte dört— thread içeren yeni bir thread havuzu oluşturmak için IsParcacigiHavuzu::new kullanıyoruz. Ardından for döngüsünde havuz.calistir, havuzun her akış için çalıştırması gereken bir kapanış alması bakımından thread::spawn ile benzer bir arayüze sahiptir. havuz.calistir metodunu, kapanışı alıp çalıştırılmak üzere havuzdaki bir thread’e verecek şekilde uygulamamız gerekiyor. Bu kod henüz derlenmeyecektir; ancak derleyicinin onu nasıl düzelteceğimiz konusunda bize rehberlik edebilmesi için derlemeyi deneyeceğiz.
Derleyici Yönlendirmeli Geliştirme ile IsParcacigiHavuzu Oluşturmak
Liste 21-12’deki değişiklikleri src/main.rs dosyasına uygulayın; ardından geliştirme sürecimizi yönlendirmek için cargo check komutunun verdiği derleyici hatalarından yararlanalım. Aldığımız ilk hata şöyledir:
$ cargo check
Checking merhaba v0.1.0 (file:///projects/merhaba)
error[E0433]: cannot find type `IsParcacigiHavuzu` in this scope
--> src/main.rs:11:17
|
11 | let havuz = IsParcacigiHavuzu::new(4);
| ^^^^^^^^^^^^^^^^^ use of undeclared type `IsParcacigiHavuzu`
For more information about this error, try `rustc --explain E0433`.
error: could not compile `merhaba` (bin "merhaba") due to 1 previous error
Harika! Bu hata bize bir IsParcacigiHavuzu türüne veya modülüne ihtiyacımız olduğunu söylüyor; şimdi bunu oluşturalım. IsParcacigiHavuzu uygulamamız, web sunucumuzun yaptığı işin türünden bağımsız olacaktır. Bu nedenle IsParcacigiHavuzu uygulamamızı barındırmak üzere merhaba paketini yalnızca bir ikili crate (binary crate) olmaktan çıkarıp bir kütüphane crate’i (library crate) de içerecek hâle getirelim. Kütüphane crate’ine geçtikten sonra, bu ayrı thread havuzu kütüphanesini yalnızca web isteklerine yanıt vermek için değil, thread havuzu kullanarak yapmak istediğimiz herhangi bir iş için de kullanabiliriz.
Şimdilik sahip olabileceğimiz en basit IsParcacigiHavuzu struct tanımını içeren aşağıdaki src/lib.rs dosyasını oluşturun:
pub struct IsParcacigiHavuzu;
Ardından src/main.rs dosyasının en üstüne aşağıdaki kodu ekleyerek IsParcacigiHavuzu türünü kütüphane crate’inden kapsama dahil edecek şekilde main.rs dosyasını düzenleyin:
use merhaba::IsParcacigiHavuzu;
use std::{
fs,
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
thread,
time::Duration,
};
fn main() {
let dinleyici = TcpListener::bind("127.0.0.1:7878").unwrap();
let havuz = IsParcacigiHavuzu::new(4);
for akis in dinleyici.incoming() {
let akis = akis.unwrap();
havuz.calistir(|| {
baglantiyi_isle(akis);
});
}
}
fn baglantiyi_isle(mut akis: TcpStream) {
let tamponlu_okuyucu = BufReader::new(&akis);
let istek_satiri = tamponlu_okuyucu.lines().next().unwrap().unwrap();
let (durum_satiri, dosya_adi) = match &istek_satiri[..] {
"GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "merhaba.html"),
"GET /sleep HTTP/1.1" => {
thread::sleep(Duration::from_secs(5));
("HTTP/1.1 200 OK", "merhaba.html")
}
_ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
};
let icerik = fs::read_to_string(dosya_adi).unwrap();
let uzunluk = icerik.len();
let yanit =
format!("{durum_satiri}\r\nContent-Length: {uzunluk}\r\n\r\n{icerik}");
akis.write_all(yanit.as_bytes()).unwrap();
}
Bu kod hâlâ çalışmayacaktır; ancak ele almamız gereken bir sonraki hatayı görmek için kodu yeniden kontrol edelim:
$ cargo check
Checking merhaba v0.1.0 (file:///projects/merhaba)
error[E0599]: no associated function or constant named `new` found for struct `IsParcacigiHavuzu` in the current scope
--> src/main.rs:12:36
|
12 | let havuz = IsParcacigiHavuzu::new(4);
| ^^^ associated function or constant not found in `IsParcacigiHavuzu`
For more information about this error, try `rustc --explain E0599`.
error: could not compile `merhaba` (bin "merhaba") due to 1 previous error
Bu hata, bir sonraki adımda IsParcacigiHavuzu için new adında bir ilişkili fonksiyon oluşturmamız gerektiğini belirtiyor. Ayrıca new fonksiyonunun argüman olarak 4 değerini kabul edebilen bir parametreye sahip olması ve bir IsParcacigiHavuzu örneği döndürmesi gerektiğini de biliyoruz. Bu özelliklere sahip en basit new fonksiyonunu uygulayalım:
pub struct IsParcacigiHavuzu;
impl IsParcacigiHavuzu {
pub fn new(boyut: usize) -> IsParcacigiHavuzu {
IsParcacigiHavuzu
}
}
Negatif sayıda thread olmasının hiçbir anlam taşımadığını bildiğimiz için boyut parametresinin türü olarak usize seçtik. Ayrıca Bölüm 3’teki “Tam Sayı Türleri” alt bölümünde ele aldığımız üzere, bu 4 değerini bir thread koleksiyonundaki öğe sayısı olarak kullanacağımızı ve usize türünün tam da bu amaç için var olduğunu biliyoruz.
Kodu bir kez daha kontrol edelim:
$ cargo check
Checking merhaba v0.1.0 (file:///projects/merhaba)
error[E0599]: no method named `calistir` found for struct `IsParcacigiHavuzu` in the current scope
--> src/main.rs:17:15
|
17 | havuz.calistir(|| {
| ------^^^^^^^^ method not found in `IsParcacigiHavuzu`
For more information about this error, try `rustc --explain E0599`.
error: could not compile `merhaba` (bin "merhaba") due to 1 previous error
Şimdi hata, IsParcacigiHavuzu üzerinde bir calistir metodumuz olmadığı için ortaya çıkıyor. “Sınırlı Sayıda Thread Oluşturmak” bölümünden hatırlayacağınız üzere, thread havuzumuzun thread::spawn fonksiyonuna benzer bir arayüze sahip olmasına karar vermiştik. Ek olarak calistir metodunu, kendisine verilen kapanışı alıp çalıştırması için havuzdaki boşta bekleyen bir thread’e verecek biçimde uygulayacağız.
IsParcacigiHavuzu üzerindeki calistir metodunu parametre olarak bir kapanış alacak şekilde tanımlayacağız. Bölüm 13’teki “Yakalanan Değerleri Kapanışların Dışına Taşımak” alt bölümünden hatırlayacağınız üzere, kapanışları üç farklı trait ile parametre olarak alabiliriz: Fn, FnMut ve FnOnce. Burada ne tür bir kapanış kullanacağımıza karar vermemiz gerekiyor. Sonunda standart kütüphanedeki thread::spawn uygulamasına benzer bir şey yapacağımızı bildiğimiz için thread::spawn imzasının parametresi üzerinde hangi sınırların (bounds) bulunduğuna bakabiliriz. Belgelendirme bize şunu gösterir:
pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
F: FnOnce() -> T,
F: Send + 'static,
T: Send + 'static,
Burada bizi ilgilendiren F tür parametresidir; T tür parametresi dönüş değeriyle ilgilidir ve bizim onunla bir işimiz yoktur. spawn fonksiyonunun F üzerindeki trait sınırı olarak FnOnce kullandığını görebiliriz. calistir içinde aldığımız argümanı nihayetinde spawn fonksiyonuna aktaracağımız için muhtemelen bizim de istediğimiz budur. Bir isteği çalıştıracak olan thread, o isteğin kapanışını yalnızca bir kez yürüteceği için —ki bu da FnOnce adındaki Once (bir kez) ifadesiyle tam örtüşür— kullanmak istediğimiz trait’in FnOnce olduğundan daha da emin olabiliriz.
F tür parametresi ayrıca bizim durumumuzda da oldukça yararlı olan Send trait sınırına ve 'static ömür (lifetime) sınırına sahiptir: Kapanışı bir thread’den diğerine aktarmak için Send sınırına, thread’in çalışmasının ne kadar süreceğini bilmediğimiz için de 'static sınırına ihtiyacımız vardır. IsParcacigiHavuzu üzerinde bu sınırlara sahip F türünde jenerik bir parametre alan calistir metodunu oluşturalım:
pub struct IsParcacigiHavuzu;
impl IsParcacigiHavuzu {
// --snip--
pub fn new(boyut: usize) -> IsParcacigiHavuzu {
IsParcacigiHavuzu
}
pub fn calistir<F>(&self, f: F)
where
F: FnOnce() + Send + 'static,
{
}
}
FnOnce sonrasında yine de () kullanıyoruz; çünkü buradaki FnOnce, hiç parametre almayan ve birim türü (unit type) () döndüren bir kapanışı temsil eder. Tıpkı fonksiyon tanımlarında olduğu gibi dönüş türü imzadan çıkarılabilir; ancak hiç parametremiz olmasa bile parantezlere yine de ihtiyacımız vardır.
Yine bu, calistir metodunun olabilecek en basit uygulamasıdır: Hiçbir iş yapmaz; ancak şimdilik yalnızca kodumuzun derlenmesini sağlamaya çalışıyoruz. Kodu yeniden kontrol edelim:
$ cargo check
Checking merhaba v0.1.0 (file:///projects/merhaba)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.24s
Derleniyor! Ancak cargo run komutunu deneyip tarayıcıda bir istek yaparsanız, tarayıcıda bölümün başında gördüğümüz hataları göreceğinize dikkat edin. Kütüphanemiz henüz calistir metoduna aktarılan kapanışı gerçekten çağırmıyor!
Not: Haskell ve Rust gibi sıkı derleyicilere sahip diller hakkında duyabileceğiniz bir söz vardır: “Kod derleniyorsa çalışıyordur.” Ancak bu söz her durumda geçerli değildir. Projemiz derleniyor ama kesinlikle hiçbir iş yapmıyor! Gerçek ve eksiksiz bir proje geliştiriyor olsaydık, kodun hem derlendiğini hem de istediğimiz davranışa sahip olduğunu doğrulamak üzere birim testleri (unit tests) yazmaya başlamak için iyi bir zaman olurdu.
Düşünün: Burada bir kapanış yerine bir future çalıştıracak olsaydık neler farklı olurdu?
new İçinde Thread Sayısını Doğrulamak
new ve calistir parametreleriyle henüz hiçbir şey yapmıyoruz. Şimdi bu fonksiyonların gövdelerini istediğimiz davranışla uygulayalım. Başlangıç olarak new fonksiyonunu düşünelim. Daha önce negatif sayıda thread içeren bir havuzun anlamı olmadığı için boyut parametresinde işaretsiz (unsigned) bir tür seçmiştik. Ne var ki sıfır thread içeren bir havuz da anlamsızdır; buna rağmen sıfır, son derece geçerli bir usize değeridir. Liste 21-13’te gösterildiği gibi, bir IsParcacigiHavuzu örneği döndürmeden önce boyut değerinin sıfırdan büyük olduğunu denetleyen kodu ekleyecek ve sıfır değeri gelirse assert! makrosunu kullanarak programın paniklemesini sağlayacağız.
pub struct IsParcacigiHavuzu;
impl IsParcacigiHavuzu {
/// Yeni bir IsParcacigiHavuzu oluşturur.
///
/// Boyut, havuzdaki thread sayısıdır.
///
/// # Panics
///
/// `new` fonksiyonu, boyut sıfırsa panikler.
pub fn new(boyut: usize) -> IsParcacigiHavuzu {
assert!(boyut > 0);
IsParcacigiHavuzu
}
// --snip--
pub fn calistir<F>(&self, f: F)
where
F: FnOnce() + Send + 'static,
{
}
}
boyut sıfır olduğunda panikleyecek biçimde IsParcacigiHavuzu::new fonksiyonunu uygulamakAyrıca belgelendirme yorumlarıyla (doc comments) IsParcacigiHavuzu yapımız için biraz belgelendirme de ekledik. Bölüm 14’te ele aldığımız üzere, fonksiyonumuzun hangi durumlarda panikleyebileceğini açıkça belirten bir bölüm ekleyerek iyi belgelendirme pratiklerini izlediğimize dikkat edin. new için üretilen belgelerin nasıl göründüğünü incelemek üzere cargo doc --open komutunu çalıştırıp IsParcacigiHavuzu struct’ına tıklamayı deneyin!
Burada yaptığımız gibi assert! makrosunu eklemek yerine, G/Ç (I/O) projesindeki Liste 12-9’da Yapilandirma::olustur (Config::build) ile yaptığımız gibi new fonksiyonunu build olarak değiştirip bir Result da döndürebilirdik. Ancak bu durumda, hiç thread içermeyen bir thread havuzu oluşturmaya çalışmanın kurtarılamaz bir hata olmasına karar verdik. Kendinizi iddialı hissediyorsanız, new fonksiyonuyla karşılaştırmak üzere aşağıdaki imzaya sahip build adında bir fonksiyon yazmayı deneyin:
pub fn build(boyut: usize) -> Result<IsParcacigiHavuzu, HavuzOlusturmaHatasi> {
Thread’leri Saklayacak Alan Oluşturmak
Artık havuzda saklamak için geçerli sayıda thread’e sahip olduğumuzdan emin olmanın bir yolu bulunduğuna göre, bu thread’leri oluşturabilir ve struct’ı döndürmeden önce IsParcacigiHavuzu struct’ı içinde saklayabiliriz. Peki bir thread’i nasıl “saklarız”? thread::spawn imzasına bir kez daha göz atalım:
pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
F: FnOnce() -> T,
F: Send + 'static,
T: Send + 'static,
spawn fonksiyonu bir JoinHandle<T> döndürür; burada T, kapanışın döndürdüğü türdür. Biz de JoinHandle kullanmayı deneyelim ve ne olacağını görelim. Bizim durumumuzda thread havuzuna aktardığımız kapanışlar bağlantıyı işleyecek ve hiçbir değer döndürmeyecektir; dolayısıyla T, birim türü () olacaktır.
Liste 21-14’teki kod derlenecektir ancak henüz hiçbir thread oluşturmaz. IsParcacigiHavuzu tanımını thread::JoinHandle<()> örneklerinden oluşan bir vektör (threadler) tutacak şekilde değiştirdik, vektörü boyut kapasitesiyle ilklendirdik, thread’leri oluşturacak kodu çalıştıracak bir for döngüsü kurduk ve bunları barındıran bir IsParcacigiHavuzu örneği döndürdük.
use std::thread;
pub struct IsParcacigiHavuzu {
threadler: Vec<thread::JoinHandle<()>>,
}
impl IsParcacigiHavuzu {
// --snip--
/// Yeni bir IsParcacigiHavuzu oluşturur.
///
/// Boyut, havuzdaki thread sayısıdır.
///
/// # Panics
///
/// `new` fonksiyonu, boyut sıfırsa panikler.
pub fn new(boyut: usize) -> IsParcacigiHavuzu {
assert!(boyut > 0);
let mut threadler = Vec::with_capacity(boyut);
for _ in 0..boyut {
// thread'leri oluştur ve vektörde sakla
}
IsParcacigiHavuzu { threadler }
}
// --snip--
pub fn calistir<F>(&self, f: F)
where
F: FnOnce() + Send + 'static,
{
}
}
IsParcacigiHavuzu için thread’leri tutacak bir vektör oluşturmakIsParcacigiHavuzu içindeki vektörün öğe türü olarak thread::JoinHandle kullandığımız için kütüphane crate’inde std::thread modülünü kapsama dahil ettik.
Geçerli bir boyut alındığında IsParcacigiHavuzu yapımız boyut adet öğe tutabilen yeni bir vektör oluşturur. with_capacity fonksiyonu Vec::new ile aynı görevi yerine getirir, ancak önemli bir farkı vardır: Vektörde alanı önceden ayırır (pre-allocates). Vektörde boyut adet öğe saklamamız gerektiğini bildiğimizden, bu bellek ayırma işlemini baştan yapmak, öğeler eklendikçe kendisini yeniden boyutlandıran Vec::new kullanımına kıyasla biraz daha verimlidir.
cargo check komutunu yeniden çalıştırdığınızda derleme başarılı olmalıdır.
IsParcacigiHavuzu’ndan Bir Thread’e Kod Göndermek
Liste 21-14’teki for döngüsünde thread’lerin oluşturulmasına ilişkin bir yorum bırakmıştık. Şimdi thread’leri gerçekte nasıl oluşturacağımıza bakalım. Standart kütüphane, thread oluşturmanın bir yolu olarak thread::spawn fonksiyonunu sunar ve thread::spawn, thread oluşturulur oluşturulmaz çalıştırması gereken bir kod almayı bekler. Ancak bizim durumumuzda thread’leri oluşturup daha sonra göndereceğimiz kodu beklemelerini sağlamak istiyoruz. Standart kütüphanenin thread uygulaması bunu yapmanın herhangi bir yolunu içermez; bunu bizim elle uygulamamız gerekir.
Bu davranışı, IsParcacigiHavuzu ile thread’ler arasına bu yeni davranışı yönetecek yeni bir veri yapısı ekleyerek uygulayacağız. Havuzlama (pooling) uygulamalarında yaygın bir terim olan Worker (çalışan) kavramına karşılık bu veri yapısına Calisan adını vereceğiz. Calisan, çalıştırılması gereken kodu alır ve bu kodu kendi thread’inde çalıştırır.
Bir restoranın mutfağında çalışan insanları düşünün: Çalışanlar müşterilerden sipariş gelene kadar bekler, ardından bu siparişleri alıp hazırlamaktan sorumludurlar.
Thread havuzunda JoinHandle<()> örneklerinden oluşan bir vektör saklamak yerine, Calisan struct’ının örneklerini saklayacağız. Her Calisan tek bir JoinHandle<()> örneği tutacaktır. Ardından Calisan üzerinde, çalıştırılacak kodun kapanışını alıp bunu hâlihazırda çalışmakta olan thread’e yürütülmek üzere gönderecek bir metot uygulayacağız. Ayrıca günlük kaydı tutarken (logging) veya hata ayıklarken havuzdaki farklı Calisan örneklerini birbirinden ayırt edebilmek için her Calisan örneğine bir kimlik vereceğiz.
Bir IsParcacigiHavuzu oluşturduğumuzda gerçekleşecek yeni süreç şöyledir. Calisan yapısını bu şekilde kurduktan sonra kapanışı thread’e gönderen kodu uygulayacağız:
- Bir
kimlikve birJoinHandle<()>tutanCalisanstruct’ını tanımlamak. IsParcacigiHavuzuyapısınıCalisanörneklerinden oluşan bir vektör tutacak şekilde değiştirmek.- Bir
kimliknumarası alan ve bukimlikile boş bir kapanışla başlatılmış bir thread tutanCalisanörneğini döndürenCalisan::newfonksiyonunu tanımlamak. IsParcacigiHavuzu::newiçinde, birkimliküretmek içinfordöngüsü sayacını kullanmak, bukimlikile yeni birCalisanoluşturmak ve buCalisanörneğini vektörde saklamak.
Kendinizi sınamak isterseniz, Liste 21-15’teki koda bakmadan önce bu değişiklikleri kendi başınıza uygulamayı deneyin.
Hazır mısınız? İşte önceki değişiklikleri yapmanın bir yolunu gösteren Liste 21-15.
use std::thread;
pub struct IsParcacigiHavuzu {
calisanlar: Vec<Calisan>,
}
impl IsParcacigiHavuzu {
// --snip--
/// Yeni bir IsParcacigiHavuzu oluşturur.
///
/// Boyut, havuzdaki thread sayısıdır.
///
/// # Panics
///
/// `new` fonksiyonu, boyut sıfırsa panikler.
pub fn new(boyut: usize) -> IsParcacigiHavuzu {
assert!(boyut > 0);
let mut calisanlar = Vec::with_capacity(boyut);
for kimlik in 0..boyut {
calisanlar.push(Calisan::new(kimlik));
}
IsParcacigiHavuzu { calisanlar }
}
// --snip--
pub fn calistir<F>(&self, f: F)
where
F: FnOnce() + Send + 'static,
{
}
}
struct Calisan {
kimlik: usize,
thread: thread::JoinHandle<()>,
}
impl Calisan {
fn new(kimlik: usize) -> Calisan {
let thread = thread::spawn(|| {});
Calisan { kimlik, thread }
}
}
Calisan örneklerini tutacak şekilde IsParcacigiHavuzu yapısını değiştirmekIsParcacigiHavuzu üzerindeki alanın adını threadler yerine calisanlar olarak değiştirdik; çünkü bu alan artık JoinHandle<()> örnekleri yerine Calisan örneklerini tutuyor. for döngüsündeki sayacı Calisan::new fonksiyonuna argüman olarak veriyor ve oluşturulan her yeni Calisan örneğini calisanlar adlı vektörde saklıyoruz.
Dış kodların (src/main.rs içindeki sunucumuz gibi) IsParcacigiHavuzu içinde bir Calisan struct’ı kullanıldığına dair uygulama ayrıntılarını bilmesine gerek yoktur; bu nedenle Calisan struct’ını ve onun new fonksiyonunu gizli (private) yapıyoruz. Calisan::new fonksiyonu kendisine verdiğimiz kimlik değerini kullanır ve boş bir kapanışla yeni bir thread başlatılarak oluşturulan JoinHandle<()> örneğini thread alanında saklar.
Not: Yeterli sistem kaynağı bulunmadığı için işletim sistemi yeni bir thread oluşturamazsa thread::spawn panikleyecektir. Bazı thread’lerin oluşturulması başarılı olsa bile bu durum tüm sunucumuzun paniklemesine neden olur. Basitlik adına bu davranış kabul edilebilirdir; ancak üretime yönelik bir thread havuzu uygulamasında bunun yerine muhtemelen std::thread::Builder türünü ve onun Result döndüren spawn metodunu kullanmak istersiniz.
Bu kod derlenecek ve IsParcacigiHavuzu::new fonksiyonuna argüman olarak belirttiğimiz sayıda Calisan örneğini saklayacaktır. Ancak calistir içinde aldığımız kapanışı hâlâ işlemiyor. Şimdi bunu nasıl yapacağımıza bakalım.
Kanallar Aracılığıyla Thread’lere İstek Göndermek
Ele alacağımız bir sonraki sorun, thread::spawn fonksiyonuna verilen kapanışların kesinlikle hiçbir iş yapmamasıdır. Şu anda çalıştırmak istediğimiz kapanışı calistir metodunda alıyoruz. Oysa IsParcacigiHavuzu oluşturulurken her bir Calisan örneğini oluşturduğumuz sırada thread::spawn fonksiyonuna çalıştıracağı bir kapanış vermemiz gerekiyor.
Az önce oluşturduğumuz Calisan struct’larının, çalıştırılacak kodu IsParcacigiHavuzu içinde tutulan bir kuyruktan almasını ve çalıştırılmak üzere kendi thread’ine göndermesini istiyoruz.
Bölüm 16’da öğrendiğimiz mesajlaşma kanalları (mpsc kanalları) —iki thread arasında iletişim kurmanın basit bir yolu— bu kullanım senaryosu için biçilmiş kaftandır. Görev kuyruğu işlevi görmesi için bir kanal kullanacağız ve calistir metodu IsParcacigiHavuzu’ndan Calisan örneklerine bir görev gönderecek; Calisan örnekleri de bu görevi kendi thread’ine iletecektir. Planımız şöyledir:
IsParcacigiHavuzubir kanal oluşturacak ve gönderici ucunu (gonderici) elinde tutacak.- Her
Calisanalıcı ucunu (alici) elinde tutacak. - Kanal üzerinden göndermek istediğimiz kapanışları tutacak yeni bir
Gorevstruct’ı oluşturacağız. calistirmetodu, çalıştırmak istediği görevi gönderici aracılığıyla gönderecek.Calisan, kendi thread’i içinde alıcısı üzerinde döngü kuracak ve aldığı tüm görevlerin kapanışlarını çalıştıracak.
Liste 21-16’da gösterildiği gibi IsParcacigiHavuzu::new içinde bir kanal oluşturarak ve göndericiyi IsParcacigiHavuzu örneğinde tutarak işe başlayalım. Gorev struct’ı şimdilik hiçbir şey tutmuyor, ancak kanal üzerinden göndereceğimiz öğenin türü olacaktır.
use std::{sync::mpsc, thread};
pub struct IsParcacigiHavuzu {
calisanlar: Vec<Calisan>,
gonderici: mpsc::Sender<Gorev>,
}
struct Gorev;
impl IsParcacigiHavuzu {
// --snip--
/// Yeni bir IsParcacigiHavuzu oluşturur.
///
/// Boyut, havuzdaki thread sayısıdır.
///
/// # Panics
///
/// `new` fonksiyonu, boyut sıfırsa panikler.
pub fn new(boyut: usize) -> IsParcacigiHavuzu {
assert!(boyut > 0);
let (gonderici, alici) = mpsc::channel();
let mut calisanlar = Vec::with_capacity(boyut);
for kimlik in 0..boyut {
calisanlar.push(Calisan::new(kimlik));
}
IsParcacigiHavuzu { calisanlar, gonderici }
}
// --snip--
pub fn calistir<F>(&self, f: F)
where
F: FnOnce() + Send + 'static,
{
}
}
struct Calisan {
kimlik: usize,
thread: thread::JoinHandle<()>,
}
impl Calisan {
fn new(kimlik: usize) -> Calisan {
let thread = thread::spawn(|| {});
Calisan { kimlik, thread }
}
}
Gorev örneklerini ileten bir kanalın göndericisini saklayacak şekilde IsParcacigiHavuzu yapısını değiştirmekIsParcacigiHavuzu::new içinde yeni kanalımızı oluşturuyor ve havuzun gonderici ucunu tutmasını sağlıyoruz. Bu kod başarıyla derlenecektir.
Thread havuzu kanalı oluştururken kanalın alıcı ucunu her bir Calisan örneğine aktarmayı deneyelim. Alıcıyı Calisan örneklerinin başlattığı thread içinde kullanmak istediğimizi bildiğimizden, kapanış içinde alici parametresine başvuracağız. Liste 21-17’deki kod henüz tam olarak derlenmeyecektir.
use std::{sync::mpsc, thread};
pub struct IsParcacigiHavuzu {
calisanlar: Vec<Calisan>,
gonderici: mpsc::Sender<Gorev>,
}
struct Gorev;
impl IsParcacigiHavuzu {
// --snip--
/// Yeni bir IsParcacigiHavuzu oluşturur.
///
/// Boyut, havuzdaki thread sayısıdır.
///
/// # Panics
///
/// `new` fonksiyonu, boyut sıfırsa panikler.
pub fn new(boyut: usize) -> IsParcacigiHavuzu {
assert!(boyut > 0);
let (gonderici, alici) = mpsc::channel();
let mut calisanlar = Vec::with_capacity(boyut);
for kimlik in 0..boyut {
calisanlar.push(Calisan::new(kimlik, alici));
}
IsParcacigiHavuzu { calisanlar, gonderici }
}
// --snip--
pub fn calistir<F>(&self, f: F)
where
F: FnOnce() + Send + 'static,
{
}
}
// --snip--
struct Calisan {
kimlik: usize,
thread: thread::JoinHandle<()>,
}
impl Calisan {
fn new(kimlik: usize, alici: mpsc::Receiver<Gorev>) -> Calisan {
let thread = thread::spawn(|| {
alici;
});
Calisan { kimlik, thread }
}
}
Calisan örneğine aktarmakKüçük ve doğrudan birkaç değişiklik yaptık: Alıcıyı Calisan::new fonksiyonuna aktarıyor ve ardından kapanışın içinde kullanıyoruz.
Bu kodu kontrol etmeyi denediğimizde şu hatayı alırız:
$ cargo check
Checking merhaba v0.1.0 (file:///projects/merhaba)
error[E0382]: use of moved value: `alici`
--> src/lib.rs:26:50
|
21 | let (gonderici, alici) = mpsc::channel();
| ----- move occurs because `alici` has type `std::sync::mpsc::Receiver<Gorev>`, which does not implement the `Copy` trait
...
25 | for kimlik in 0..boyut {
| ---------------------- inside of this loop
26 | calisanlar.push(Calisan::new(kimlik, alici));
| ^^^^^ value moved here, in previous iteration of loop
|
note: consider changing this parameter type in method `new` to borrow instead if owning the value isn't necessary
--> src/lib.rs:47:34
|
47 | fn new(kimlik: usize, alici: mpsc::Receiver<Gorev>) -> Calisan {
| --- in this method ^^^^^^^^^^^^^^^^^^^^^ this parameter takes ownership of the value
For more information about this error, try `rustc --explain E0382`.
error: could not compile `merhaba` (lib) due to 1 previous error
Derleyici use of moved value: alici (taşınmış değerin kullanımı: alici) hatası veriyor; çünkü kodumuz alici değerini döngü içinde birden fazla Calisan örneğine aktarmaya çalışıyor. Bölüm 16’dan hatırlayacağınız üzere bu işe yaramaz: Rust’ın sunduğu kanal uygulaması çok üreticili, tek tüketicilidir (multiple producer, single consumer — mpsc). Bu da kodu düzeltmek için kanalın tüketici ucunu öylece klonlayamayacağımız anlamına gelir. Zaten bir mesajı birden fazla tüketiciye birden çok kez göndermek de istemiyoruz; her mesajın yalnızca bir kez işleneceği biçimde, birden fazla Calisan örneğinin paylaştığı tek bir mesaj listesi istiyoruz.
Ek olarak, kanal kuyruğundan bir görev almak alici değerini değiştirmeyi (mutating) içerir; dolayısıyla thread’lerin alici değerini paylaşmak ve değiştirmek için güvenli bir yola ihtiyacı vardır; aksi takdirde yarış durumlarıyla (race conditions, Bölüm 16’da ele aldığımız gibi) karşılaşabiliriz.
Bölüm 16’da ele aldığımız thread açısından güvenli (thread-safe) akıllı işaretçileri hatırlayın: Sahipliği birden fazla thread arasında paylaşmak ve thread’lerin değeri değiştirmesine izin vermek için Arc<Mutex<T>> kullanmamız gerekir. Arc türü birden fazla Calisan örneğinin alıcıya sahip olmasına olanak tanırken, Mutex aynı anda yalnızca tek bir Calisan örneğinin alıcıdan görev almasını güvence altına alır. Liste 21-18 yapmamız gereken değişiklikleri göstermektedir.
use std::{
sync::{Arc, Mutex, mpsc},
thread,
};
// --snip--
pub struct IsParcacigiHavuzu {
calisanlar: Vec<Calisan>,
gonderici: mpsc::Sender<Gorev>,
}
struct Gorev;
impl IsParcacigiHavuzu {
// --snip--
/// Yeni bir IsParcacigiHavuzu oluşturur.
///
/// Boyut, havuzdaki thread sayısıdır.
///
/// # Panics
///
/// `new` fonksiyonu, boyut sıfırsa panikler.
pub fn new(boyut: usize) -> IsParcacigiHavuzu {
assert!(boyut > 0);
let (gonderici, alici) = mpsc::channel();
let alici = Arc::new(Mutex::new(alici));
let mut calisanlar = Vec::with_capacity(boyut);
for kimlik in 0..boyut {
calisanlar.push(Calisan::new(kimlik, Arc::clone(&alici)));
}
IsParcacigiHavuzu { calisanlar, gonderici }
}
// --snip--
pub fn calistir<F>(&self, f: F)
where
F: FnOnce() + Send + 'static,
{
}
}
// --snip--
struct Calisan {
kimlik: usize,
thread: thread::JoinHandle<()>,
}
impl Calisan {
fn new(kimlik: usize, alici: Arc<Mutex<mpsc::Receiver<Gorev>>>) -> Calisan {
// --snip--
let thread = thread::spawn(|| {
alici;
});
Calisan { kimlik, thread }
}
}
Arc ve Mutex kullanarak alıcıyı Calisan örnekleri arasında paylaşmakIsParcacigiHavuzu::new içinde alıcıyı bir Arc ve bir Mutex içine koyuyoruz. Her yeni Calisan için, Calisan örneklerinin alıcının sahipliğini paylaşabilmesi amacıyla referans sayısını artırmak üzere Arc değerini klonluyoruz (Arc::clone(&alici)).
Not: Burada Mutex kullanmamızın derleyici açısından kesin nedeni, Bölüm 16’daki Send ve Sync trait’leriyle ilgilidir. mpsc::Receiver<T> türü Send uygular ancak Sync uygulamaz; yani bir alıcı bir thread’den diğerine taşınabilir, fakat aynı anda birden fazla thread’den referans yoluyla paylaşılamaz. Arc<T> değerinin başka bir thread’e gönderilebilmesi için T: Send + Sync koşulu arandığından yalnızca Arc<mpsc::Receiver<Gorev>> kullanmak derlenmez. Mutex<T> ise T: Send olduğu sürece Sync uygular; çünkü kilit, içerideki değere aynı anda yalnızca tek bir thread’in erişmesini garanti eder. Dolayısıyla Arc<Mutex<mpsc::Receiver<Gorev>>> hem alıcının sahipliğini paylaşmamızı hem de ona thread’ler arasında güvenle erişmemizi sağlar.
Bu değişikliklerle kod derleniyor! Hedefe yaklaşıyoruz!
calistir Metodunu Uygulamak
Sonunda IsParcacigiHavuzu üzerindeki calistir metodunu uygulayalım. Ayrıca Gorev tanımını bir struct olmaktan çıkarıp calistir metodunun aldığı kapanış türünü tutan bir trait nesnesi için tür takma adına (type alias) dönüştüreceğiz. Bölüm 20’deki “Tür Eşanlamlıları ve Tür Takma Adları” alt bölümünde ele aldığımız üzere, tür takma adları kullanım kolaylığı sağlamak amacıyla uzun türleri daha kısa hâle getirmemize olanak tanır. Liste 21-19’a bakın.
use std::{
sync::{Arc, Mutex, mpsc},
thread,
};
pub struct IsParcacigiHavuzu {
calisanlar: Vec<Calisan>,
gonderici: mpsc::Sender<Gorev>,
}
// --snip--
type Gorev = Box<dyn FnOnce() + Send + 'static>;
impl IsParcacigiHavuzu {
// --snip--
/// Yeni bir IsParcacigiHavuzu oluşturur.
///
/// Boyut, havuzdaki thread sayısıdır.
///
/// # Panics
///
/// `new` fonksiyonu, boyut sıfırsa panikler.
pub fn new(boyut: usize) -> IsParcacigiHavuzu {
assert!(boyut > 0);
let (gonderici, alici) = mpsc::channel();
let alici = Arc::new(Mutex::new(alici));
let mut calisanlar = Vec::with_capacity(boyut);
for kimlik in 0..boyut {
calisanlar.push(Calisan::new(kimlik, Arc::clone(&alici)));
}
IsParcacigiHavuzu { calisanlar, gonderici }
}
pub fn calistir<F>(&self, f: F)
where
F: FnOnce() + Send + 'static,
{
let gorev = Box::new(f);
self.gonderici.send(gorev).unwrap();
}
}
// --snip--
struct Calisan {
kimlik: usize,
thread: thread::JoinHandle<()>,
}
impl Calisan {
fn new(kimlik: usize, alici: Arc<Mutex<mpsc::Receiver<Gorev>>>) -> Calisan {
let thread = thread::spawn(|| {
alici;
});
Calisan { kimlik, thread }
}
}
Box için Gorev tür takma adı oluşturmak ve ardından görevi kanal üzerinden göndermekcalistir içinde aldığımız kapanışı kullanarak yeni bir Gorev örneği oluşturduktan sonra, bu görevi kanalın gönderici ucundan gönderiyoruz. Gönderme işleminin başarısız olma ihtimaline karşı send üzerinde unwrap çağırıyoruz. Bu durum, örneğin tüm thread’lerimizin çalışmasını durdurursak —yani alıcı uç yeni mesajlar almayı bırakırsa— meydana gelebilir. Şu an için thread’lerimizin çalışmasını durduramıyoruz: Havuz var olduğu sürece thread’lerimiz çalışmaya devam ediyor. unwrap kullanmamızın nedeni, hata durumunun gerçekleşmeyeceğini bizim bilmemiz, ancak derleyicinin bunu bilmemesidir.
Not: Gorev türünü neden Box<dyn FnOnce() + Send + 'static> olarak tanımladığımıza daha yakından bakalım. Bölüm 13’te gördüğümüz gibi her kapanışın derleyici tarafından üretilen, kendine özgü ve adlandırılamayan bir türü vardır; calistir metoduna her çağrıda farklı bir kapanış verilebilir. Oysa bir kanal yalnızca tek bir T türünde mesaj taşıyabilir ve bu türün boyutu derleme zamanında bilinmelidir. Kapanışı Box içine alıp dyn FnOnce() trait nesnesine dönüştürmek bu iki sorunu birden çözer: Farklı kapanışlar aynı Gorev türü altında toplanır ve öbekteki kapanışa işaret eden Box değerinin boyutu sabittir. Send sınırı, görevin ana thread’den bir Calisan thread’ine taşınabilmesi için; 'static sınırı ise görevin, onu oluşturan calistir çağrısından daha uzun yaşayabilmesi için gereklidir; tıpkı thread::spawn imzasında olduğu gibi.
Ancak işimiz henüz tam olarak bitmedi! Calisan içinde thread::spawn fonksiyonuna aktarılan kapanışımız hâlâ kanalın alıcı ucuna yalnızca başvuruda bulunuyor. Bunun yerine kapanışın sonsuza dek döngüde kalmasını, kanalın alıcı ucundan bir görev istemesini ve bir görev aldığında onu çalıştırmasını sağlamamız gerekiyor. Calisan::new üzerinde Liste 21-20’de gösterilen değişikliği yapalım.
use std::{
sync::{Arc, Mutex, mpsc},
thread,
};
pub struct IsParcacigiHavuzu {
calisanlar: Vec<Calisan>,
gonderici: mpsc::Sender<Gorev>,
}
type Gorev = Box<dyn FnOnce() + Send + 'static>;
impl IsParcacigiHavuzu {
/// Yeni bir IsParcacigiHavuzu oluşturur.
///
/// Boyut, havuzdaki thread sayısıdır.
///
/// # Panics
///
/// `new` fonksiyonu, boyut sıfırsa panikler.
pub fn new(boyut: usize) -> IsParcacigiHavuzu {
assert!(boyut > 0);
let (gonderici, alici) = mpsc::channel();
let alici = Arc::new(Mutex::new(alici));
let mut calisanlar = Vec::with_capacity(boyut);
for kimlik in 0..boyut {
calisanlar.push(Calisan::new(kimlik, Arc::clone(&alici)));
}
IsParcacigiHavuzu {
calisanlar,
gonderici,
}
}
pub fn calistir<F>(&self, f: F)
where
F: FnOnce() + Send + 'static,
{
let gorev = Box::new(f);
self.gonderici.send(gorev).unwrap();
}
}
struct Calisan {
kimlik: usize,
thread: thread::JoinHandle<()>,
}
// --snip--
impl Calisan {
fn new(kimlik: usize, alici: Arc<Mutex<mpsc::Receiver<Gorev>>>) -> Calisan {
let thread = thread::spawn(move || loop {
let gorev = alici.lock().unwrap().recv().unwrap();
println!("Çalışan {kimlik} bir görev aldı; çalıştırılıyor.");
gorev();
});
Calisan { kimlik, thread }
}
}
Calisan örneğinin thread’inde görevleri almak ve çalıştırmakBurada önce mutex kilidini edinmek için alici üzerinde lock çağırıyor, ardından olası herhangi bir hatada paniklemek üzere unwrap çağırıyoruz. Mutex zehirlenmiş (poisoned) bir durumdaysa kilit edinme işlemi başarısız olabilir; bu durum, başka bir thread kilidi serbest bırakmak yerine kilidi elinde tutarken paniklediyse gerçekleşebilir. Böyle bir durumda bu thread’in de paniklemesi için unwrap çağırmak yapılacak doğru eylemdir. Bu unwrap çağrısını sizin için anlamlı bir hata mesajı içeren bir expect çağrısıyla değiştirmekte özgürsünüz.
Kilidi edinebilirsek kanaldan bir Gorev almak için recv çağırırız. Sondaki unwrap da buradaki olası hatalarda aynı şekilde programı durdurur; alıcı kapandığında send metodunun Err döndürmesine benzer biçimde, kanalın gönderici ucu düşürülmüşse (yani göndericiyi tutan taraf kapanmışsa) burada da hata oluşabilir.
recv çağrısı engelleyicidir (blocking); yani henüz bir görev yoksa mevcut thread bir görev hazır olana kadar bekleyecektir. Mutex<T>, aynı anda yalnızca bir Calisan thread’inin görev istemeye çalışmasını güvence altına alır.
Thread havuzumuz artık çalışır durumda! cargo run komutunu çalıştırın ve birkaç istek gönderin:
$ cargo run
Compiling merhaba v0.1.0 (file:///projects/merhaba)
warning: field `calisanlar` is never read
--> src/lib.rs:7:5
|
6 | pub struct IsParcacigiHavuzu {
| ----------------- field in this struct
7 | calisanlar: Vec<Calisan>,
| ^^^^^^^^^^
|
= note: `#[warn(dead_code)]` on by default
warning: fields `kimlik` and `thread` are never read
--> src/lib.rs:51:5
|
50 | struct Calisan {
| ------- fields in this struct
51 | kimlik: usize,
| ^^^^^^
52 | thread: thread::JoinHandle<()>,
| ^^^^^^
warning: `merhaba` (lib) generated 2 warnings
Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.91s
Running `target/debug/merhaba`
Çalışan 0 bir görev aldı; çalıştırılıyor.
Çalışan 2 bir görev aldı; çalıştırılıyor.
Çalışan 1 bir görev aldı; çalıştırılıyor.
Çalışan 3 bir görev aldı; çalıştırılıyor.
Çalışan 0 bir görev aldı; çalıştırılıyor.
Çalışan 2 bir görev aldı; çalıştırılıyor.
Çalışan 1 bir görev aldı; çalıştırılıyor.
Çalışan 3 bir görev aldı; çalıştırılıyor.
Çalışan 0 bir görev aldı; çalıştırılıyor.
Çalışan 2 bir görev aldı; çalıştırılıyor.
Başardık! Artık bağlantıları birbirini bekletmeden (eşzamansız olarak) yürüten bir thread havuzumuz var. Hiçbir zaman dörtten fazla thread oluşturulmadığından, sunucu çok sayıda istek alsa bile sistemimiz aşırı yüklenmeyecektir. /sleep yoluna bir istek yaparsak sunucu, diğer istekleri başka thread’lerde çalıştırarak yanıtlamaya devam edebilecektir.
Not: Aynı anda birden fazla tarayıcı penceresinde /sleep adresini açarsanız, sayfalar beşer saniyelik aralıklarla teker teker yüklenebilir. Bazı web tarayıcıları, önbellekleme (caching) nedenleriyle aynı isteğin birden fazla örneğini sırayla yürütür. Bu kısıtlama bizim web sunucumuzdan kaynaklanmamaktadır.
Şimdi bir an durup şunu düşünmenin tam zamanı: Yapılacak iş için kapanış yerine future kullansaydık Liste 21-18, 21-19 ve 21-20’deki kod nasıl farklılaşırdı? Hangi türler değişirdi? Metot imzaları —eğer değişecekse— nasıl farklı olurdu? Kodun hangi kısımları aynı kalırdı?
Bölüm 17 ve Bölüm 19’da while let döngüsünü öğrendikten sonra, Calisan thread kodunu neden Liste 21-21’de gösterildiği gibi yazmadığımızı merak ediyor olabilirsiniz.
use std::{
sync::{Arc, Mutex, mpsc},
thread,
};
pub struct IsParcacigiHavuzu {
calisanlar: Vec<Calisan>,
gonderici: mpsc::Sender<Gorev>,
}
type Gorev = Box<dyn FnOnce() + Send + 'static>;
impl IsParcacigiHavuzu {
/// Yeni bir IsParcacigiHavuzu oluşturur.
///
/// Boyut, havuzdaki thread sayısıdır.
///
/// # Panics
///
/// `new` fonksiyonu, boyut sıfırsa panikler.
pub fn new(boyut: usize) -> IsParcacigiHavuzu {
assert!(boyut > 0);
let (gonderici, alici) = mpsc::channel();
let alici = Arc::new(Mutex::new(alici));
let mut calisanlar = Vec::with_capacity(boyut);
for kimlik in 0..boyut {
calisanlar.push(Calisan::new(kimlik, Arc::clone(&alici)));
}
IsParcacigiHavuzu {
calisanlar,
gonderici,
}
}
pub fn calistir<F>(&self, f: F)
where
F: FnOnce() + Send + 'static,
{
let gorev = Box::new(f);
self.gonderici.send(gorev).unwrap();
}
}
struct Calisan {
kimlik: usize,
thread: thread::JoinHandle<()>,
}
// --snip--
impl Calisan {
fn new(kimlik: usize, alici: Arc<Mutex<mpsc::Receiver<Gorev>>>) -> Calisan {
let thread = thread::spawn(move || {
while let Ok(gorev) = alici.lock().unwrap().recv() {
println!("Çalışan {kimlik} bir görev aldı; çalıştırılıyor.");
gorev();
}
});
Calisan { kimlik, thread }
}
}
Calisan::new için while let kullanan alternatif bir uygulamaBu kod derlenir ve çalışır, ancak istediğimiz çoklu thread davranışını üretmez: Yavaş bir istek yine diğer isteklerin işlenmek için beklemesine neden olur. Bunun nedeni biraz inceliklidir: Mutex struct’ının açık (public) bir unlock metodu yoktur; çünkü kilidin sahipliği, lock metodunun döndürdüğü LockResult<MutexGuard<T>> içindeki MutexGuard<T> değerinin ömrüne (lifetime) dayanır. Böylece ödünç alma denetleyicisi (borrow checker), Mutex tarafından korunan bir kaynağa kilidi elimizde tutmadığımız sürece erişilemeyeceği kuralını derleme zamanında zorunlu kılabilir. Ne var ki MutexGuard<T> ömrüne dikkat etmezsek, bu tasarım kilidin amaçlanandan daha uzun süre tutulmasıyla da sonuçlanabilir.
Liste 21-20’de let gorev = alici.lock().unwrap().recv().unwrap(); kullanan kod sorunsuz çalışır; çünkü let ile, eşittir işaretinin sağ tarafındaki ifadede kullanılan tüm geçici değerler (temporary values), let deyimi sona erdiği anda derhal düşürülür (drop). Buna karşılık while let (ve if let ile match), geçici değerleri ilişkili bloğun sonuna kadar düşürmez. Liste 21-21’de kilit, gorev() çağrısı süresince tutulmaya devam eder; bu da diğer Calisan örneklerinin görev alamaması anlamına gelir.
Not: Rust 2024 Sürümü’nde geçici değerlerin ömrüne (temporary lifetime) ilişkin kurallar if let ifadeleri (geçici değerler artık else koluna geçmeden önce düşürülür) ve blok sonundaki kuyruk ifadeleri (tail expressions) için iyileştirilmiş olsa da while let döngüsünün koşul ifadesinde üretilen geçici MutexGuard hâlâ döngü gövdesi boyunca tutulur. Bu nedenle Mutex kilitlerini mümkün olan en dar kapsamda serbest bırakmak ve kilidi tutarken uzun süren işlemler çalıştırmaktan kaçınmak için let gorev = alici.lock().unwrap().recv().unwrap(); gibi açık bir let deyimi kullanmak en güvenli yoldur: Noktalı virgüle ulaşıldığı anda geçici MutexGuard düşürülür ve kilit, gorev() çağrısından önce serbest bırakılır.
Zarif Kapanış (Graceful Shutdown) ve Temizlik
Zarif Kapanış (Graceful Shutdown) ve Temizlik
Liste 21-20’deki kod, amaçladığımız gibi bir thread (iş parçacığı) havuzu kullanarak isteklere birbirini bekletmeden (eşzamansız biçimde) yanıt veriyor. Doğrudan kullanmadığımız calisanlar, kimlik ve thread alanları hakkında bazı uyarılar alıyoruz; bu uyarılar bize henüz hiçbir temizlik işlemi yapmadığımızı hatırlatıyor. Ana thread’i durdurmak için daha kaba olan ctrl-C yöntemini kullandığımızda, diğer tüm thread’ler bir isteğe yanıt vermenin tam ortasında olsalar bile anında durduruluyor.
Şimdi, havuzdaki her bir thread üzerinde join çağırmak için Drop trait’ini uygulayacağız; böylece thread’ler kapanmadan önce üzerinde çalıştıkları istekleri tamamlayabilecekler. Ardından, thread’lere yeni istek kabul etmeyi bırakıp kapanmaları gerektiğini bildirecek bir mekanizma kuracağız. Bu kodu çalışırken görmek için sunucumuzu, thread havuzunu zarif biçimde kapatmadan önce yalnızca iki isteği kabul edecek şekilde değiştireceğiz.
İlerlerken dikkat etmeniz gereken bir nokta var: Buradaki değişikliklerin hiçbiri kapanışları (closures) çalıştıran kod bölümlerini etkilemez; dolayısıyla bir asenkron çalışma zamanı (async runtime) için thread havuzu yazıyor olsaydık da buradaki her şey birebir aynı olurdu.
IsParcacigiHavuzu Üzerinde Drop Trait’ini Uygulamak
Thread havuzumuz üzerinde Drop uygulayarak başlayalım. Havuz düşürüldüğünde, yürüttükleri işleri tamamladıklarından emin olmak için tüm thread’lerimizi join ile beklememiz gerekir. Liste 21-22, Drop uygulamasına yönelik ilk denemeyi gösterir; bu kod henüz tam olarak çalışmayacaktır.
use std::{
sync::{Arc, Mutex, mpsc},
thread,
};
pub struct IsParcacigiHavuzu {
calisanlar: Vec<Calisan>,
gonderici: mpsc::Sender<Gorev>,
}
type Gorev = Box<dyn FnOnce() + Send + 'static>;
impl IsParcacigiHavuzu {
/// Yeni bir IsParcacigiHavuzu oluşturur.
///
/// Boyut, havuzdaki thread sayısıdır.
///
/// # Panics
///
/// `new` fonksiyonu, boyut sıfırsa panikler.
pub fn new(boyut: usize) -> IsParcacigiHavuzu {
assert!(boyut > 0);
let (gonderici, alici) = mpsc::channel();
let alici = Arc::new(Mutex::new(alici));
let mut calisanlar = Vec::with_capacity(boyut);
for kimlik in 0..boyut {
calisanlar.push(Calisan::new(kimlik, Arc::clone(&alici)));
}
IsParcacigiHavuzu {
calisanlar,
gonderici,
}
}
pub fn calistir<F>(&self, f: F)
where
F: FnOnce() + Send + 'static,
{
let gorev = Box::new(f);
self.gonderici.send(gorev).unwrap();
}
}
impl Drop for IsParcacigiHavuzu {
fn drop(&mut self) {
for calisan in &mut self.calisanlar {
println!("Çalışan kapatılıyor: {}", calisan.kimlik);
calisan.thread.join().unwrap();
}
}
}
struct Calisan {
kimlik: usize,
thread: thread::JoinHandle<()>,
}
impl Calisan {
fn new(kimlik: usize, alici: Arc<Mutex<mpsc::Receiver<Gorev>>>) -> Calisan {
let thread = thread::spawn(move || loop {
let gorev = alici.lock().unwrap().recv().unwrap();
println!("Çalışan {kimlik} bir görev aldı; çalıştırılıyor.");
gorev();
});
Calisan { kimlik, thread }
}
}
join ile beklemekİlk olarak thread havuzunun calisanlar vektöründeki her öğe üzerinde döngü kurarız. Bunun için &mut kullanırız; çünkü self değiştirilebilir bir referanstır ve calisan örneğini de değiştirebilmemiz gerekir. Her calisan için bu Calisan örneğinin kapatıldığını belirten bir mesaj yazdırır ve ardından o Calisan örneğinin thread’i üzerinde join çağırırız. join çağrısı başarısız olursa, Rust’ın panikleyip zarif olmayan bir kapanışa geçmesi için unwrap kullanırız.
Bu kodu derlediğimizde şu hatayı alırız:
$ cargo check
Checking merhaba v0.1.0 (file:///projects/merhaba)
error[E0507]: cannot move out of `calisan.thread` which is behind a mutable reference
--> src/lib.rs:55:13
|
55 | calisan.thread.join().unwrap();
| ^^^^^^^^^^^^^^ ------ `calisan.thread` moved due to this method call
| |
| move occurs because `calisan.thread` has type `JoinHandle<()>`, which does not implement the `Copy` trait
|
note: `std::thread::JoinHandle::<T>::join` takes ownership of the receiver `self`, which moves `calisan.thread`
--> /rustc/58cf8f9c35b355199727627d07ae34a4e2846d67/library/std/src/thread/join_handle.rs:149:16
For more information about this error, try `rustc --explain E0507`.
error: could not compile `merhaba` (lib) due to 1 previous error
Hata bize, her calisan için yalnızca değiştirilebilir bir ödünç almaya sahip olduğumuz ve join metodu üzerinde çağrıldığı JoinHandle değerinin sahipliğini aldığı (self parametresini tükettiği) için join çağıramayacağımızı söyler. Bu sorunu çözmek için, join metodunun thread’i tüketebilmesi amacıyla thread alanının sahibi olan Calisan örneğinin içinden thread’i dışarı taşımamız gerekir. Bunu yapmanın bir yolu, Liste 18-15’te izlediğimiz yaklaşımın aynısını uygulamaktır. Calisan, bir Option<thread::JoinHandle<()>> tutsaydı, değeri Some varyantının dışına taşıyıp yerinde None varyantı bırakmak için Option üzerinde take metodunu çağırabilirdik. Başka bir deyişle, çalışmakta olan bir Calisan, thread alanında bir Some varyantı bulundurur; bir Calisan örneğini temizlemek istediğimizde ise Calisan’ın çalıştıracak bir thread’i kalmaması için Some yerine None koyardık.
Ancak bu durum yalnızca Calisan düşürülürken karşımıza çıkardı. Buna karşılık, calisan.thread alanına eriştiğimiz her yerde bir Option<thread::JoinHandle<()>> ile uğraşmak zorunda kalırdık. İdiyomatik Rust’ta Option oldukça sık kullanılır; fakat her zaman var olacağını bildiğiniz bir değeri sırf geçici bir çözüm olarak Option içine sardığınızı fark ettiğinizde, kodunuzu daha temiz ve hataya daha az açık hâle getirecek alternatif yaklaşımlar aramak iyi bir fikirdir.
Bu durumda daha iyi bir alternatif mevcuttur: Vec::drain metodu. Vektörden hangi öğelerin çıkarılacağını belirtmek için bir aralık (range) parametresi kabul eder ve bu öğeler üzerinde bir iteratör döndürür. .. aralık sözdizimini geçirmek, vektördeki her değeri çıkaracaktır. Öğeler iteratörden sahipli değerler olarak çıktığı için her calisan üzerinde join çağırabiliriz; döngü bittiğinde calisanlar vektörü boşalmış olur.
Dolayısıyla IsParcacigiHavuzu için drop uygulamasını şöyle güncellememiz gerekir:
#![allow(unused)]
fn main() {
use std::{
sync::{Arc, Mutex, mpsc},
thread,
};
pub struct IsParcacigiHavuzu {
calisanlar: Vec<Calisan>,
gonderici: mpsc::Sender<Gorev>,
}
type Gorev = Box<dyn FnOnce() + Send + 'static>;
impl IsParcacigiHavuzu {
/// Yeni bir IsParcacigiHavuzu oluşturur.
///
/// Boyut, havuzdaki thread sayısıdır.
///
/// # Panics
///
/// `new` fonksiyonu, boyut sıfırsa panikler.
pub fn new(boyut: usize) -> IsParcacigiHavuzu {
assert!(boyut > 0);
let (gonderici, alici) = mpsc::channel();
let alici = Arc::new(Mutex::new(alici));
let mut calisanlar = Vec::with_capacity(boyut);
for kimlik in 0..boyut {
calisanlar.push(Calisan::new(kimlik, Arc::clone(&alici)));
}
IsParcacigiHavuzu {
calisanlar,
gonderici,
}
}
pub fn calistir<F>(&self, f: F)
where
F: FnOnce() + Send + 'static,
{
let gorev = Box::new(f);
self.gonderici.send(gorev).unwrap();
}
}
impl Drop for IsParcacigiHavuzu {
fn drop(&mut self) {
for calisan in self.calisanlar.drain(..) {
println!("Çalışan kapatılıyor: {}", calisan.kimlik);
calisan.thread.join().unwrap();
}
}
}
struct Calisan {
kimlik: usize,
thread: thread::JoinHandle<()>,
}
impl Calisan {
fn new(kimlik: usize, alici: Arc<Mutex<mpsc::Receiver<Gorev>>>) -> Calisan {
let thread = thread::spawn(move || loop {
let gorev = alici.lock().unwrap().recv().unwrap();
println!("Çalışan {kimlik} bir görev aldı; çalıştırılıyor.");
gorev();
});
Calisan { kimlik, thread }
}
}
}
Bu değişiklik derleyici hatasını çözer ve kodumuzda başka herhangi bir değişiklik gerektirmez. Unutmayın ki drop, bir panik sırasında da çağrılabildiğinden buradaki unwrap çağrısı da panikleyerek ikili paniğe (double panic) neden olabilir; bu durum programı anında çökertir ve devam eden tüm temizlik işlemlerini yarıda keser. Örnek bir program için bu kabul edilebilirdir, ancak üretim kodu için önerilmez.
Thread’lere Yeni Görev Dinlemeyi Bırakmalarını Bildirmek
Yaptığımız tüm değişikliklerle kodumuz artık hiçbir uyarı vermeden derleniyor. Ancak kötü haber şu ki bu kod henüz istediğimiz biçimde çalışmıyor. Sorunun anahtarı, Calisan örneklerinin thread’leri tarafından çalıştırılan kapanışlardaki mantıktadır: Şu anda join çağırıyoruz, fakat bu çağrı thread’leri kapatmaz; çünkü thread’ler görev aramak için sonsuza dek loop içinde döner. Mevcut drop uygulamamızla IsParcacigiHavuzu örneğimizi düşürmeye çalışırsak, ana thread ilk thread’in bitmesini bekleyerek sonsuza dek bloklanır.
Bu sorunu düzeltmek için önce IsParcacigiHavuzu drop uygulamasında, ardından da Calisan döngüsünde değişiklik yapmamız gerekecek.
İlk olarak IsParcacigiHavuzu drop uygulamasını, thread’lerin bitmesini beklemeden önce gonderici değerini açıkça düşürecek şekilde değiştireceğiz. Liste 21-23, gonderici değerini açıkça düşürmek için IsParcacigiHavuzu üzerinde yapılan değişiklikleri gösterir. thread alanında olduğunun aksine, burada gonderici değerini Option::take ile IsParcacigiHavuzu dışına taşıyabilmek için gerçekten bir Option kullanmamız gerekir.
use std::{
sync::{Arc, Mutex, mpsc},
thread,
};
pub struct IsParcacigiHavuzu {
calisanlar: Vec<Calisan>,
gonderici: Option<mpsc::Sender<Gorev>>,
}
// --snip--
type Gorev = Box<dyn FnOnce() + Send + 'static>;
impl IsParcacigiHavuzu {
/// Yeni bir IsParcacigiHavuzu oluşturur.
///
/// Boyut, havuzdaki thread sayısıdır.
///
/// # Panics
///
/// `new` fonksiyonu, boyut sıfırsa panikler.
pub fn new(boyut: usize) -> IsParcacigiHavuzu {
// --snip--
assert!(boyut > 0);
let (gonderici, alici) = mpsc::channel();
let alici = Arc::new(Mutex::new(alici));
let mut calisanlar = Vec::with_capacity(boyut);
for kimlik in 0..boyut {
calisanlar.push(Calisan::new(kimlik, Arc::clone(&alici)));
}
IsParcacigiHavuzu {
calisanlar,
gonderici: Some(gonderici),
}
}
pub fn calistir<F>(&self, f: F)
where
F: FnOnce() + Send + 'static,
{
let gorev = Box::new(f);
self.gonderici.as_ref().unwrap().send(gorev).unwrap();
}
}
impl Drop for IsParcacigiHavuzu {
fn drop(&mut self) {
drop(self.gonderici.take());
for calisan in self.calisanlar.drain(..) {
println!("Çalışan kapatılıyor: {}", calisan.kimlik);
calisan.thread.join().unwrap();
}
}
}
struct Calisan {
kimlik: usize,
thread: thread::JoinHandle<()>,
}
impl Calisan {
fn new(kimlik: usize, alici: Arc<Mutex<mpsc::Receiver<Gorev>>>) -> Calisan {
let thread = thread::spawn(move || loop {
let gorev = alici.lock().unwrap().recv().unwrap();
println!("Çalışan {kimlik} bir görev aldı; çalıştırılıyor.");
gorev();
});
Calisan { kimlik, thread }
}
}
Calisan thread’lerini birleştirmeden önce gonderici değerini açıkça düşürmekgonderici değerinin düşürülmesi kanalı kapatır (bir kanal, tüm göndericileri düşürüldüğünde kapanır; bizim kanalımızın tek göndericisi budur); bu da artık başka mesaj gönderilmeyeceğini belirtir. Bu gerçekleştiğinde, Calisan örneklerinin sonsuz döngü içinde yaptığı tüm recv çağrıları bir hata döndürür. Liste 21-24’te, bu durumda döngüden zarif biçimde çıkacak şekilde Calisan döngüsünü değiştiriyoruz; bu da IsParcacigiHavuzu drop uygulaması üzerlerinde join çağırdığında thread’lerin tamamlanacağı anlamına gelir.
Not: drop metodu &mut self aldığı için self.gonderici alanını drop(self.gonderici) yazarak doğrudan taşıyamayız; bir referansın arkasındaki değerden alan taşımaya çalışmak, Liste 21-22’de gördüğümüz E0507 hatasını üretir. Option::take bu sorunu, Some içindeki göndericiyi alıp yerine None bırakarak çözer; IsParcacigiHavuzu böylece her an geçerli bir durumda kalır. Adımların sırası da önemlidir: Gönderici önce düşürülmeli, thread’ler sonra join ile beklenmelidir. Sırayı tersine çevirirsek gönderici hâlâ hayatta olduğu için recv hiçbir zaman hata döndürmez ve ilk join çağrısı sonsuza dek bloklanır.
use std::{
sync::{Arc, Mutex, mpsc},
thread,
};
pub struct IsParcacigiHavuzu {
calisanlar: Vec<Calisan>,
gonderici: Option<mpsc::Sender<Gorev>>,
}
type Gorev = Box<dyn FnOnce() + Send + 'static>;
impl IsParcacigiHavuzu {
/// Yeni bir IsParcacigiHavuzu oluşturur.
///
/// Boyut, havuzdaki thread sayısıdır.
///
/// # Panics
///
/// `new` fonksiyonu, boyut sıfırsa panikler.
pub fn new(boyut: usize) -> IsParcacigiHavuzu {
assert!(boyut > 0);
let (gonderici, alici) = mpsc::channel();
let alici = Arc::new(Mutex::new(alici));
let mut calisanlar = Vec::with_capacity(boyut);
for kimlik in 0..boyut {
calisanlar.push(Calisan::new(kimlik, Arc::clone(&alici)));
}
IsParcacigiHavuzu {
calisanlar,
gonderici: Some(gonderici),
}
}
pub fn calistir<F>(&self, f: F)
where
F: FnOnce() + Send + 'static,
{
let gorev = Box::new(f);
self.gonderici.as_ref().unwrap().send(gorev).unwrap();
}
}
impl Drop for IsParcacigiHavuzu {
fn drop(&mut self) {
drop(self.gonderici.take());
for calisan in self.calisanlar.drain(..) {
println!("Çalışan kapatılıyor: {}", calisan.kimlik);
calisan.thread.join().unwrap();
}
}
}
struct Calisan {
kimlik: usize,
thread: thread::JoinHandle<()>,
}
impl Calisan {
fn new(kimlik: usize, alici: Arc<Mutex<mpsc::Receiver<Gorev>>>) -> Calisan {
let thread = thread::spawn(move || loop {
let mesaj = alici.lock().unwrap().recv();
match mesaj {
Ok(gorev) => {
println!(
"Çalışan {kimlik} bir görev aldı; çalıştırılıyor."
);
gorev();
}
Err(_) => {
println!(
"Çalışan {kimlik} bağlantısı kesildi; kapatılıyor."
);
break;
}
}
});
Calisan { kimlik, thread }
}
}
recv hata döndürdüğünde döngüden açıkça çıkmakBu kodu çalışırken görmek için, Liste 21-25’te gösterildiği gibi sunucuyu zarif biçimde kapatmadan önce yalnızca iki isteği kabul edecek şekilde main fonksiyonunu değiştirelim.
use merhaba::IsParcacigiHavuzu;
use std::{
fs,
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
thread,
time::Duration,
};
fn main() {
let dinleyici = TcpListener::bind("127.0.0.1:7878").unwrap();
let havuz = IsParcacigiHavuzu::new(4);
for akis in dinleyici.incoming().take(2) {
let akis = akis.unwrap();
havuz.calistir(|| {
baglantiyi_isle(akis);
});
}
println!("Kapatılıyor.");
}
fn baglantiyi_isle(mut akis: TcpStream) {
let tamponlu_okuyucu = BufReader::new(&akis);
let istek_satiri = tamponlu_okuyucu.lines().next().unwrap().unwrap();
let (durum_satiri, dosya_adi) = match &istek_satiri[..] {
"GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "merhaba.html"),
"GET /sleep HTTP/1.1" => {
thread::sleep(Duration::from_secs(5));
("HTTP/1.1 200 OK", "merhaba.html")
}
_ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
};
let icerik = fs::read_to_string(dosya_adi).unwrap();
let uzunluk = icerik.len();
let yanit =
format!("{durum_satiri}\r\nContent-Length: {uzunluk}\r\n\r\n{icerik}");
akis.write_all(yanit.as_bytes()).unwrap();
}
Gerçek dünyadaki bir web sunucusunun yalnızca iki isteğe yanıt verdikten sonra kapanmasını istemezsiniz. Bu kod yalnızca zarif kapanış ve temizlik mekanizmasının düzgün çalıştığını gösterir.
take metodu Iterator trait’inde tanımlanmıştır ve yinelemeyi en fazla ilk iki öğeyle sınırlar. IsParcacigiHavuzu, main sonunda kapsam dışına çıkacak ve drop uygulaması çalışacaktır.
Sunucuyu cargo run ile başlatın ve üç istek gönderin. Üçüncü istek hata vermeli ve terminalinizde şuna benzer bir çıktı görmelisiniz:
$ cargo run
Compiling merhaba v0.1.0 (file:///projects/merhaba)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.41s
Running `target/debug/merhaba`
Çalışan 0 bir görev aldı; çalıştırılıyor.
Kapatılıyor.
Çalışan kapatılıyor: 0
Çalışan 3 bir görev aldı; çalıştırılıyor.
Çalışan 1 bağlantısı kesildi; kapatılıyor.
Çalışan 2 bağlantısı kesildi; kapatılıyor.
Çalışan 3 bağlantısı kesildi; kapatılıyor.
Çalışan 0 bağlantısı kesildi; kapatılıyor.
Çalışan kapatılıyor: 1
Çalışan kapatılıyor: 2
Çalışan kapatılıyor: 3
Yazdırılan Calisan kimliklerinin ve mesajların sırası sizde farklı olabilir. Bu kodun nasıl çalıştığını mesajlardan görebiliriz: 0 ve 3 numaralı Calisan örnekleri ilk iki isteği aldı. Sunucu ikinci bağlantıdan sonra yeni bağlantı kabul etmeyi bıraktı ve IsParcacigiHavuzu üzerindeki Drop uygulaması, Çalışan 3 henüz görevine bile başlamadan çalışmaya başladı. gonderici değerinin düşürülmesi tüm Calisan örneklerinin bağlantısını keser ve onlara kapanmalarını söyler. Her Calisan örneği bağlantısı kesildiğinde bir mesaj yazdırır ve ardından thread havuzu her bir Calisan thread’inin bitmesini beklemek için join çağırır.
Bu özel çalıştırmanın ilginç bir yönüne dikkat edin: IsParcacigiHavuzu, gonderici değerini düşürdü ve herhangi bir Calisan hata almadan önce Çalışan 0 üzerinde join çağırmayı denedik. Çalışan 0 henüz recv çağrısından bir hata almamıştı; bu yüzden ana thread bloklanarak Çalışan 0’ın bitmesini bekledi. Bu sırada Çalışan 3 bir görev aldı ve ardından tüm thread’ler birer hata aldı. Çalışan 0 işini bitirdiğinde ana thread geri kalan Calisan örneklerinin bitmesini bekledi. O noktada hepsi döngülerinden çıkmış ve durmuştu.
Not: Gerçek Üretim Sunucularında İşletim Sistemi Sinyalleriyle Zarif Kapanış: Gerçek dünyada bir sunucu take(2) ile değil, işletim sisteminden gelen sonlandırma sinyalleriyle (Ctrl+C ile gönderilen SIGINT veya konteyner/servis yöneticilerinin gönderdiği SIGTERM) zarif kapanışa geçer. Senkron Rust sunucularında ctrlc ya da signal-hook crate’leri kullanılarak bir kapatma bayrağı/kanalı tetiklenir ve dinleme döngüsü sonlandırılarak IsParcacigiHavuzu’nun doğal biçimde kapsam dışına çıkıp Drop çalıştırması sağlanır; axum ve tokio tabanlı asenkron sunucularda ise aynı desen tokio::signal::ctrl_c() ve with_graceful_shutdown ile kurulur. Ayrıca üretim kodunda Drop içinde calisan.thread.join().unwrap() yerine if let Err(hata) = calisan.thread.join() { eprintln!(...); } kullanmak, olası bir ikili paniğin (double panic) süreci anında sonlandırmasını önler.
Tebrikler! Artık projemizi tamamladık; birbirini bekletmeden (eşzamansız) yanıt vermek için bir thread havuzu kullanan temel bir web sunucumuz var. Havuzdaki tüm thread’leri temizleyen zarif bir sunucu kapanışı gerçekleştirebiliyoruz.
Referans olması için kodun tamamı aşağıda verilmiştir:
use merhaba::IsParcacigiHavuzu;
use std::{
fs,
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
thread,
time::Duration,
};
fn main() {
let dinleyici = TcpListener::bind("127.0.0.1:7878").unwrap();
let havuz = IsParcacigiHavuzu::new(4);
for akis in dinleyici.incoming().take(2) {
let akis = akis.unwrap();
havuz.calistir(|| {
baglantiyi_isle(akis);
});
}
println!("Kapatılıyor.");
}
fn baglantiyi_isle(mut akis: TcpStream) {
let tamponlu_okuyucu = BufReader::new(&akis);
let istek_satiri = tamponlu_okuyucu.lines().next().unwrap().unwrap();
let (durum_satiri, dosya_adi) = match &istek_satiri[..] {
"GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "merhaba.html"),
"GET /sleep HTTP/1.1" => {
thread::sleep(Duration::from_secs(5));
("HTTP/1.1 200 OK", "merhaba.html")
}
_ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
};
let icerik = fs::read_to_string(dosya_adi).unwrap();
let uzunluk = icerik.len();
let yanit =
format!("{durum_satiri}\r\nContent-Length: {uzunluk}\r\n\r\n{icerik}");
akis.write_all(yanit.as_bytes()).unwrap();
}
use std::{
sync::{Arc, Mutex, mpsc},
thread,
};
pub struct IsParcacigiHavuzu {
calisanlar: Vec<Calisan>,
gonderici: Option<mpsc::Sender<Gorev>>,
}
type Gorev = Box<dyn FnOnce() + Send + 'static>;
impl IsParcacigiHavuzu {
/// Yeni bir IsParcacigiHavuzu oluşturur.
///
/// Boyut, havuzdaki thread sayısıdır.
///
/// # Panics
///
/// `new` fonksiyonu, boyut sıfırsa panikler.
pub fn new(boyut: usize) -> IsParcacigiHavuzu {
assert!(boyut > 0);
let (gonderici, alici) = mpsc::channel();
let alici = Arc::new(Mutex::new(alici));
let mut calisanlar = Vec::with_capacity(boyut);
for kimlik in 0..boyut {
calisanlar.push(Calisan::new(kimlik, Arc::clone(&alici)));
}
IsParcacigiHavuzu {
calisanlar,
gonderici: Some(gonderici),
}
}
pub fn calistir<F>(&self, f: F)
where
F: FnOnce() + Send + 'static,
{
let gorev = Box::new(f);
self.gonderici.as_ref().unwrap().send(gorev).unwrap();
}
}
impl Drop for IsParcacigiHavuzu {
fn drop(&mut self) {
drop(self.gonderici.take());
for calisan in self.calisanlar.drain(..) {
println!("Çalışan kapatılıyor: {}", calisan.kimlik);
calisan.thread.join().unwrap();
}
}
}
struct Calisan {
kimlik: usize,
thread: thread::JoinHandle<()>,
}
impl Calisan {
fn new(kimlik: usize, alici: Arc<Mutex<mpsc::Receiver<Gorev>>>) -> Calisan {
let thread = thread::spawn(move || {
loop {
let mesaj = alici.lock().unwrap().recv();
match mesaj {
Ok(gorev) => {
println!(
"Çalışan {kimlik} bir görev aldı; çalıştırılıyor."
);
gorev();
}
Err(_) => {
println!(
"Çalışan {kimlik} bağlantısı kesildi; kapatılıyor."
);
break;
}
}
}
});
Calisan { kimlik, thread }
}
}
Burada daha fazlasını da yapabiliriz! Bu projeyi geliştirmeye devam etmek isterseniz işte birkaç fikir:
IsParcacigiHavuzutürüne ve açık (pub) metotlarına daha fazla belgelendirme yorumu ekleyin.- Kütüphanenin işlevselliği için testler ekleyin.
unwrapçağrılarını daha sağlam bir hata yönetimiyle değiştirin.- Web isteklerine yanıt vermek dışında başka bir görevi yerine getirmek için
IsParcacigiHavuzukullanın. - crates.io üzerinde bir thread havuzu crate’i bulun ve bunun yerine o crate’i kullanarak benzer bir web sunucusu yazın. Ardından API’sini ve sağlamlığını kendi uyguladığımız thread havuzuyla karşılaştırın.
Özet
Harika iş çıkardınız! Kitabın sonuna ulaştınız! Bu Rust yolculuğunda bize katıldığınız için teşekkür ederiz. Artık kendi Rust projelerinizi hayata geçirmeye ve başkalarının projelerine katkıda bulunmaya hazırsınız. Rust yolculuğunuzda karşılaşabileceğiniz her türlü zorlukta size yardımcı olmaktan mutluluk duyacak diğer Rustacean’lardan oluşan sıcak ve destekleyici bir topluluk olduğunu her zaman aklınızda bulundurun.
Ekler
İzleyen bölümler, Rust yolculuğunuzda elinizin altında bulundurabileceğiniz başvuru kaynaklarını içerir: dilin anahtar kelimeleri, operatör ve sembol sözlüğü, türetilebilir (derive) standart kütüphane trait’leri, yararlı geliştirme araçları, Rust sürümleri (editions), kitabın çevirileri ve Rust’ın geliştirilme süreci ile Nightly Rust.
A - Anahtar Kelimeler
A - Anahtar Kelimeler
Aşağıdaki listeler, Rust dili tarafından mevcut veya gelecekteki kullanımlar için ayrılmış anahtar kelimeleri (keywords) içerir. Bu nedenle bu kelimeler tanımlayıcı (identifier) olarak kullanılamaz (ancak “Ham Tanımlayıcılar” bölümünde ele alacağımız ham tanımlayıcı sözdizimi bunun istisnasıdır). Tanımlayıcılar; fonksiyonların, değişkenlerin, parametrelerin, struct alanlarının, modüllerin, crate’lerin, sabitlerin, makroların, statik değerlerin, özniteliklerin (attributes), türlerin, trait’lerin veya ömürlerin adlarıdır.
Şu Anda Kullanımda Olan Anahtar Kelimeler
Şu anda kullanımda olan anahtar kelimeler ve işlevleri aşağıda listelenmiştir:
as: İlkel tür dönüştürme (primitive casting) yapar, bir öğeyi barındıran belirli trait’in belirsizliğini giderir veyausedeyimlerinde öğeleri yeniden adlandırır.async: Mevcut thread’i (iş parçacığını) bloklamak yerine birFuturedöndürür.await: BirFuturedeğerinin sonucu hazır olana kadar yürütmeyi askıya alır.break: Bir döngüden anında çıkar.const: Sabit öğeleri veya sabit ham işaretçileri (raw pointers) tanımlar.continue: Bir sonraki döngü yinelemesine geçer.crate: Bir modül yolunda crate kökünü ifade eder.dyn: Bir trait nesnesine dinamik gönderim (dynamic dispatch) yapılacağını belirtir.else:ifveif letkontrol akışı yapılarında koşul sağlanmadığında çalışacak geri dönüş kolunu tanımlar.enum: Bir numaralandırma (enumeration) türü tanımlar.extern: Harici bir fonksiyonu, değişkeni veya crate’i bağlar.false: Mantıksal (bool) yanlış sabiti (literal).fn: Bir fonksiyon veya fonksiyon işaretçisi türü tanımlar.for: Bir iteratörden gelen öğeler üzerinde döngü kurar, bir trait’i uygular veya yüksek mertebeli (higher-ranked) bir ömür sınırı belirtir.if: Koşullu bir ifadenin sonucuna göre dallanma yapar.impl: Doğrudan türe ait (inherent) işlevselliği veya bir trait uygulamasını tanımlar.in:fordöngüsü sözdiziminin bir parçasıdır.let: Bir değişken bağlar.loop: Koşulsuz bir döngü oluşturur.match: Bir değeri desenlerle eşleştirir.mod: Bir modül tanımlar.move: Bir kapanışın (closure) yakaladığı tüm değerlerin sahipliğini almasını sağlar.mut: Referanslarda, ham işaretçilerde veya desen bağlamalarında değiştirilebilirliği belirtir.pub: Struct alanlarında,implbloklarında veya modüllerde açık (public) görünürlüğü belirtir.ref: Değeri taşımak yerine referansla bağlar.return: Bir fonksiyondan erken dönüş yapar.Self: Tanımlamakta veya uygulamakta olduğumuz tür için tür takma adı.self: Metot alıcısı (öznesi) veya mevcut modül.static: Programın tüm yürütme süresi boyunca yaşayan global değişken veya ömür.struct: Birstruct(yapı) tanımlar.super: Mevcut modülün üst modülü.trait: Bir trait tanımlar.true: Mantıksal (bool) doğru sabiti (literal).type: Bir tür takma adı veya ilişkili tür tanımlar.union: Bir birleşim (union) tanımlar; yalnızca birunionbildirimi içinde kullanıldığında anahtar kelimedir.unsafe: Güvenli olmayan (unsafe) kod bloklarını, fonksiyonları, trait’leri veya uygulamaları belirtir.use: Sembolleri mevcut kapsama dahil eder.where: Bir türü kısıtlayan sınır yan tümcelerini belirtir.while: Bir ifadenin sonucuna bağlı olarak koşullu döngü kurar.
Gelecekteki Kullanım İçin Ayrılmış Anahtar Kelimeler
Aşağıdaki anahtar kelimelerin henüz bir işlevi yoktur; ancak gelecekteki olası kullanımlar için Rust tarafından ayrılmıştır:
abstractbecomeboxdofinalgenmacrooverrideprivtrytypeofunsizedvirtualyield
Ham Tanımlayıcılar
Ham tanımlayıcılar (raw identifiers), normalde izin verilmeyen yerlerde anahtar kelimeleri isim olarak kullanmanıza olanak tanıyan sözdizimidir. Bir anahtar kelimenin önüne r# ekleyerek ham tanımlayıcı oluşturursunuz.
Örneğin match bir anahtar kelimedir. Adı match olan aşağıdaki fonksiyonu derlemeye çalışırsanız:
Dosya adı: src/main.rs
fn match(aranan: &str, samanlik: &str) -> bool {
samanlik.contains(aranan)
}
şu hatayı alırsınız:
error: expected identifier, found keyword `match`
--> src/main.rs:4:4
|
4 | fn match(aranan: &str, samanlik: &str) -> bool {
| ^^^^^ expected identifier, found keyword
Hata, match anahtar kelimesini fonksiyon tanımlayıcısı olarak kullanamayacağınızı gösterir. match kelimesini fonksiyon adı olarak kullanmak için ham tanımlayıcı sözdizimini aşağıdaki gibi yazmanız gerekir:
Dosya adı: src/main.rs
fn r#match(aranan: &str, samanlik: &str) -> bool {
samanlik.contains(aranan)
}
fn main() {
assert!(r#match("foo", "foobar"));
}
Bu kod hiçbir hata vermeden derlenecektir. Hem fonksiyon tanımındaki hem de main içindeki çağrıda fonksiyon adının önünde yer alan r# önekine dikkat edin.
Ham tanımlayıcılar, ayrılmış bir anahtar kelime olsa bile seçtiğiniz herhangi bir kelimeyi tanımlayıcı olarak kullanmanıza olanak tanır. Bu özellik, isim seçiminde daha fazla özgürlük sunmanın yanı sıra bu kelimelerin anahtar kelime olmadığı başka dillerde yazılmış programlarla (örneğin C kütüphaneleriyle FFI üzerinden) entegre olmamızı da kolaylaştırır. Buna ek olarak ham tanımlayıcılar, crate’inizin kullandığından farklı bir Rust sürümünde (edition) yazılmış kütüphaneleri kullanmanıza imkan verir. Örneğin try, 2015 sürümünde bir anahtar kelime değilken 2018, 2021 ve 2024 sürümlerinde ayrılmış bir anahtar kelimedir (benzer şekilde gen de Rust 2024 sürümüyle ayrılmış anahtar kelimeler arasına katılmıştır). 2015 sürümüyle yazılmış ve try adında bir fonksiyon barındıran bir kütüphaneye bağımlıysanız, daha yeni sürümlerdeki kodunuzdan bu fonksiyonu çağırmak için r#try ham tanımlayıcı sözdizimini kullanmanız gerekir. Sürümler hakkında daha fazla bilgi için Ek E bölümüne bakın.
Not: Bağlamsal Anahtar Kelimeler ve r# İstisnaları: Rust’ta yukarıdaki
katı anahtar kelimelere ek olarak yalnızca belirli dilbilgisi konumlarında
özel anlam taşıyan zayıf (bağlamsal) anahtar kelimeler de vardır: union’ın
yanı sıra macro_rules, 'static, &raw const / &raw mut ifadelerindeki
raw ve Rust 2024’teki unsafe extern blokları içinde kullanılan safe bu
gruba girer; union, macro_rules, raw ve safe gibi adlar, özel anlam
taşımadıkları konumlarda normal birer tanımlayıcı olabilir. 'static ise bir
ömür adıdır; normal değişken veya fonksiyon adı olarak kullanılamaz. Öte
yandan yol ve modül çözümlemesinde özel rol oynayan dört anahtar kelime —
crate, self, super ve Self — r#crate veya r#self
biçiminde ham tanımlayıcı olarak kullanılamaz.
B - Operatörler ve Semboller
B - Operatörler ve Semboller
Bu ek; operatörler ile tek başına ya da yollar, jenerikler, trait sınırları, makrolar, öznitelikler (attributes), yorumlar, demetler (tuples) ve parantezler bağlamında karşınıza çıkan diğer semboller dahil olmak üzere Rust sözdiziminin sözlüğünü içerir.
Operatörler
Tablo B-1; Rust’taki operatörleri, her operatörün bağlam içinde nasıl görüneceğine dair bir örneği, kısa bir açıklamayı ve o operatörün aşırı yüklenebilir (overloadable) olup olmadığını gösterir. Bir operatör aşırı yüklenebiliyorsa, onu aşırı yüklemek için kullanılacak ilgili std::ops veya std::cmp trait’i listelenmiştir.
Tablo B-1: Operatörler
| Operatör | Örnek | Açıklama | Aşırı Yüklenebilir mi? |
|---|---|---|---|
! | ident!(...), ident!{...}, ident![...] | Makro genişletmesi | |
! | !expr | Bit düzeyinde veya mantıksal tümleme (DEĞİL) | Not |
!= | expr != expr | Eşitsizlik karşılaştırması | PartialEq |
% | expr % expr | Aritmetik kalan (bölümden kalan) | Rem |
%= | var %= expr | Aritmetik kalan ve atama | RemAssign |
& | &expr, &mut expr | Ödünç alma (borrow) | |
& | &type, &mut type, &'a type, &'a mut type | Ödünç alınan işaretçi (referans) türü | |
& | expr & expr | Bit düzeyinde VE (bitwise AND) | BitAnd |
&= | var &= expr | Bit düzeyinde VE ve atama | BitAndAssign |
&& | expr && expr | Kısa devre (short-circuiting) mantıksal VE | |
* | expr * expr | Aritmetik çarpma | Mul |
*= | var *= expr | Aritmetik çarpma ve atama | MulAssign |
* | *expr | Başvuru çözme (dereference) | Deref, DerefMut |
* | *const type, *mut type | Ham işaretçi (raw pointer) türü | |
+ | trait + trait, 'a + trait | Bileşik tür kısıtlaması | |
+ | expr + expr | Aritmetik toplama | Add |
+= | var += expr | Aritmetik toplama ve atama | AddAssign |
, | expr, expr | Argüman ve öğe ayırıcısı | |
- | - expr | Aritmetik işaret değiştirme (negatifleme) | Neg |
- | expr - expr | Aritmetik çıkarma | Sub |
-= | var -= expr | Aritmetik çıkarma ve atama | SubAssign |
-> | fn(...) -> type, |…| -> type | Fonksiyon ve kapanış (closure) dönüş türü | |
. | expr.ident | Alan erişimi | |
. | expr.ident(expr, ...) | Metot çağrısı | |
. | expr.0, expr.1 vb. | Demet (tuple) indeksleme | |
.. | .., expr.., ..expr, expr..expr | Sağdan dışlayıcı aralık sabiti | PartialOrd |
..= | ..=expr, expr..=expr | Sağdan kapsayıcı aralık sabiti | PartialOrd |
.. | ..expr | Struct sabiti güncelleme sözdizimi | |
.. | variant(x, ..), struct_type { x, .. } | “Ve geri kalanı” desen bağlaması | |
... | expr...expr | (Kullanımdan kaldırıldı; yerine ..= kullanın) Desende kapsayıcı aralık deseni | |
/ | expr / expr | Aritmetik bölme | Div |
/= | var /= expr | Aritmetik bölme ve atama | DivAssign |
: | pat: type, ident: type | Tür ve trait sınırı kısıtlamaları | |
: | ident: expr | Struct alanı ilklendiricisi | |
: | 'a: loop {...} | Döngü etiketi | |
; | expr; | Deyim (statement) ve öğe sonlandırıcısı | |
; | [...; len] | Sabit boyutlu dizi sözdiziminin parçası | |
<< | expr << expr | Sola bit kaydırma | Shl |
<<= | var <<= expr | Sola bit kaydırma ve atama | ShlAssign |
< | expr < expr | Küçüktür karşılaştırması | PartialOrd |
<= | expr <= expr | Küçük veya eşittir karşılaştırması | PartialOrd |
= | var = expr, ident = type | Atama / denklik | |
== | expr == expr | Eşitlik karşılaştırması | PartialEq |
=> | pat => expr | match kolu sözdiziminin parçası | |
> | expr > expr | Büyüktür karşılaştırması | PartialOrd |
>= | expr >= expr | Büyük veya eşittir karşılaştırması | PartialOrd |
>> | expr >> expr | Sağa bit kaydırma | Shr |
>>= | var >>= expr | Sağa bit kaydırma ve atama | ShrAssign |
@ | ident @ pat | Desen bağlama | |
^ | expr ^ expr | Bit düzeyinde özel VEYA (XOR) | BitXor |
^= | var ^= expr | Bit düzeyinde özel VEYA ve atama | BitXorAssign |
| | pat | pat | Desen alternatifleri | |
| | expr | expr | Bit düzeyinde VEYA (OR) | BitOr |
|= | var |= expr | Bit düzeyinde VEYA ve atama | BitOrAssign |
|| | expr || expr | Kısa devre mantıksal VEYA | |
? | expr? | Hata yayılımı (error propagation) |
Operatör Olmayan Semboller
Aşağıdaki tablolar, operatör olarak işlev görmeyen —yani bir fonksiyon ya da metot çağrısı gibi davranmayan— tüm sembolleri içerir.
Tablo B-2, kendi başına görünen ve çeşitli konumlarda geçerli olan sembolleri gösterir.
Tablo B-2: Bağımsız Sözdizimi
| Sembol | Açıklama |
|---|---|
'ident | Adlandırılmış ömür (lifetime) veya döngü etiketi |
Hemen ardından u8, i32, f64, usize vb. gelen rakamlar | Belirli bir türde sayısal sabit (numeric literal) |
"..." | String sabiti (string literal) |
r"...", r#"..."#, r##"..."## vb. | Ham string sabiti; kaçış karakterleri (\) işlenmez |
b"..." | Bayt string sabiti; string yerine bir bayt dizisi (&[u8; N]) oluşturur |
br"...", br#"..."#, br##"..."## vb. | Ham bayt string sabiti; ham ve bayt string sabitinin birleşimi |
'...' | Karakter sabiti (char) |
b'...' | ASCII bayt sabiti (u8) |
|…| expr | Kapanış (closure) |
! | Iraksayan (diverging) fonksiyonlar için daima boş taban türü (never type) |
_ | “Görmezden gelinen” desen bağlaması; ayrıca sayısal sabitleri okunaklı kılmak için basamak ayırıcısı olarak kullanılır (1_000_000) |
Not: Modern Rust’ta Ham Adres Operatörleri ve C-String Sabitleri: Tablo B-1 ve B-2’deki klasik sözdizimine ek olarak güncel Rust kodlarında iki ek yapıyla sıkça karşılaşırsınız:
&raw const exprve&raw mut expr: Bölüm 20’de gördüğümüz gibi ara bir referans oluşturmadan güvenli biçimde doğrudan ham işaretçi (*const T/*mut T) üreten ham ödünç alma (raw borrow) operatörleridir.c"..."vecr"..."/cr#"..."#: C kütüphaneleriyle (FFI) çalışırken derleme zamanında sonuna otomatik\0baytı eklenmiş&core::ffi::CStrüreten C-string sabitleridir.- Ayrıca
&&ve||operatörlerinin neden aşırı yüklenemediğine dikkat edin: Trait metotlarında tüm argümanlar çağrıdan önce değerlendirilir; oysa kısa devre mantığında sol taraf sonucu belirlediğinde sağ tarafın hiç çalıştırılmaması garanti edilmelidir.
Tablo B-3, modül hiyerarşisi içinde bir öğeye giden yol (path) bağlamında görünen sembolleri gösterir.
Tablo B-3: Yolla İlgili Sözdizimi
| Sembol | Açıklama |
|---|---|
ident::ident | Ad alanı yolu |
::path | Harici bir crate’in köküne göre yol (örneğin ::std::fs; açıkça mutlak yol) |
self::path | Mevcut modüle göre yol (açıkça göreli yol) |
super::path | Mevcut modülün üst modülüne göre yol |
type::ident, <type as trait>::ident | İlişkili sabitler, fonksiyonlar ve türler |
<type>::... | Doğrudan adlandırılamayan bir tür için ilişkili öğe (örneğin <&T>::..., <[T]>::... vb.) |
trait::method(...) | Bir metot çağrısının belirsizliğini, onu tanımlayan trait’i adlandırarak giderme |
type::method(...) | Bir metot çağrısının belirsizliğini, üzerinde tanımlandığı türü adlandırarak giderme |
<type as trait>::method(...) | Bir metot çağrısının belirsizliğini hem türü hem de trait’i adlandırarak giderme (tam nitelikli sözdizimi) |
Tablo B-4, jenerik tür parametrelerinin kullanıldığı bağlamlarda görünen sembolleri gösterir.
Tablo B-4: Jenerikler
| Sembol | Açıklama |
|---|---|
path<...> | Bir tür ifadesinde jenerik türe parametrelerini belirtir (örneğin Vec<u8>) |
path::<...>, method::<...> | Bir ifadede jenerik türe, fonksiyona veya metoda parametrelerini belirtir; sıklıkla turbofish olarak adlandırılır (örneğin "42".parse::<i32>()) |
fn ident<...> ... | Jenerik fonksiyon tanımlar |
struct ident<...> ... | Jenerik struct tanımlar |
enum ident<...> ... | Jenerik enum tanımlar |
impl<...> ... | Jenerik uygulama (impl) bloğu tanımlar |
for<...> type | Yüksek mertebeli (higher-ranked) ömür sınırları |
type<ident=type> | Bir veya daha fazla ilişkili türü belirli bir türe bağlanmış jenerik tür (örneğin Iterator<Item=T>) |
Tablo B-5, jenerik tür parametrelerinin trait sınırlarıyla kısıtlandığı bağlamlarda görünen sembolleri gösterir.
Tablo B-5: Trait Sınırı Kısıtlamaları
| Sembol | Açıklama |
|---|---|
T: U | T jenerik parametresi, U trait’ini uygulayan türlerle kısıtlanmıştır |
T: 'a | T jenerik türü 'a ömründen daha uzun yaşamalıdır (yani tür, geçişli olarak 'a ömründen daha kısa ömürlü herhangi bir referans içeremez) |
T: 'static | T jenerik türü, 'static ömürlü olanlar dışında ödünç alınmış hiçbir referans içermez |
'b: 'a | 'b jenerik ömrü, 'a ömründen daha uzun yaşamalıdır |
T: ?Sized | Jenerik tür parametresinin dinamik boyutlu bir tür (DST) olmasına izin verir |
'a + trait, trait + trait | Bileşik tür kısıtlaması |
Tablo B-6, makroların çağrıldığı veya tanımlandığı ve bir öğe üzerinde özniteliklerin belirtildiği bağlamlarda görünen sembolleri gösterir.
Tablo B-6: Makrolar ve Öznitelikler
| Sembol | Açıklama |
|---|---|
#[meta] | Dış öznitelik (outer attribute) |
#![meta] | İç öznitelik (inner attribute) |
$ident | Makro yer değiştirmesi |
$ident:kind | Makro üst-değişkeni (metavariable) |
$(...)... | Makro tekrarı |
ident!(...), ident!{...}, ident![...] | Makro çağrısı |
Tablo B-7, yorum oluşturan sembolleri gösterir.
Tablo B-7: Yorumlar
| Sembol | Açıklama |
|---|---|
// | Satır yorumu |
//! | İç satır belgelendirme yorumu |
/// | Dış satır belgelendirme yorumu |
/*...*/ | Blok yorumu |
/*!...*/ | İç blok belgelendirme yorumu |
/**...*/ | Dış blok belgelendirme yorumu |
Tablo B-8, normal parantezlerin kullanıldığı bağlamları gösterir.
Tablo B-8: Normal Parantezler
| Sembol | Açıklama |
|---|---|
() | Boş demet (diğer adıyla birim / unit), hem sabit (değer) hem de tür olarak |
(expr) | Parantez içine alınmış ifade |
(expr,) | Tek öğeli demet ifadesi |
(type,) | Tek öğeli demet türü |
(expr, ...) | Demet ifadesi |
(type, ...) | Demet türü |
expr(expr, ...) | Fonksiyon çağrısı ifadesi; ayrıca demet struct’larını ve demet enum varyantlarını ilklendirmek için de kullanılır |
Tablo B-9, süslü parantezlerin kullanıldığı bağlamları gösterir.
Tablo B-9: Süslü Parantezler
| Bağlam | Açıklama |
|---|---|
{...} | Blok ifadesi |
Type {...} | Struct sabiti |
Tablo B-10, köşeli parantezlerin kullanıldığı bağlamları gösterir.
Tablo B-10: Köşeli Parantezler
| Bağlam | Açıklama |
|---|---|
[...] | Dizi (array) sabiti |
[expr; len] | expr değerinin len adet kopyasını içeren dizi sabiti |
[type; len] | type türünün len adet örneğini tutan sabit boyutlu dizi türü |
expr[expr] | Koleksiyon indeksleme; aşırı yüklenebilir (Index, IndexMut) |
expr[..], expr[a..], expr[..b], expr[a..b] | İndeks olarak Range, RangeFrom, RangeTo veya RangeFull kullanarak koleksiyon dilimleme gibi davranan koleksiyon indekslemesi |
C - Türetilebilir Trait'ler
C - Türetilebilir Trait’ler
Kitabın çeşitli bölümlerinde bir struct veya enum tanımına uygulayabileceğiniz derive özniteliğinden (attribute) bahsettik. derive özniteliği, derive sözdizimiyle işaretlediğiniz tür üzerinde ilgili trait’i kendi varsayılan davranışıyla uygulayacak kodu otomatik olarak üretir.
Bu ekte, standart kütüphanede bulunan ve derive ile kullanabileceğiniz tüm trait’ler için bir başvuru kaynağı sunuyoruz. Her alt bölüm şu konuları kapsar:
- Bu trait’i türetmenin hangi operatörleri ve metotları etkinleştireceği
derivetarafından sağlanan trait uygulamasının ne yaptığı- Trait’i uygulamanın tür hakkında ne ifade ettiği
- Trait’i uygulamanıza izin verilen veya verilmeyen koşullar
- Trait’i gerektiren işlem örnekleri
derive özniteliğinin sağladığı davranıştan farklı bir davranış istiyorsanız, bu trait’leri elle nasıl uygulayacağınıza ilişkin ayrıntılar için her bir trait’in standart kütüphane belgelendirmesine başvurabilirsiniz.
Burada listelenen trait’ler, standart kütüphane tarafından tanımlanmış olup kendi türleriniz üzerinde derive ile uygulanabilen yegâne trait’lerdir. Standart kütüphanede tanımlanan diğer trait’lerin her türe uyan mantıklı bir varsayılan davranışı yoktur; bu nedenle onları amacınıza uygun biçimde uygulamak size bırakılmıştır.
Türetilemeyen bir trait’e örnek olarak, son kullanıcılara yönelik biçimlendirmeyi yöneten Display trait’i verilebilir. Bir türü son kullanıcıya en uygun biçimde nasıl göstereceğinizi her zaman düşünmeniz gerekir: Türün hangi kısımlarını son kullanıcının görmesine izin verilmeli? Hangi alanlar kullanıcı için anlamlıdır? Veriyi hangi biçimde sunmak en anlaşılır olur? Rust derleyicisi bu öngörüye sahip olmadığından sizin adınıza uygun bir varsayılan Display davranışı üretemez.
Bu ekte sunulan türetilebilir trait listesi ekosistemin tamamını kapsamaz: Harici kütüphaneler kendi trait’leri için derive desteği sağlayabilir; bu da derive ile kullanabileceğiniz trait listesini ucu açık hâle getirir (örneğin serileştirme için serde::Serialize ve serde::Deserialize). Özel bir derive tanımlamak, Bölüm 20’deki “Özel derive Makroları” bölümünde ele alınan prosedürel makroların kullanılmasını içerir.
Programcı Çıktısı İçin Debug
Debug trait’i, biçimlendirme stringlerinde {} yer tutucularının içine :? (veya okunaklı çok satırlı çıktı için :#?) ekleyerek belirttiğiniz hata ayıklama biçimlendirmesini etkinleştirir.
Debug trait’i, hata ayıklama amacıyla bir türün örneklerini yazdırmanıza olanak tanır; böylece siz ve türünüzü kullanan diğer programcılar, programın yürütülmesindeki belirli bir noktada bir örneğin iç durumunu inceleyebilirsiniz.
Debug trait’i örneğin assert_eq! makrosunun kullanımında zorunludur. Bu makro, eşitlik doğrulaması başarısız olduğunda programcıların iki örneğin neden eşit olmadığını görebilmeleri için argüman olarak verilen örneklerin değerlerini ekrana yazdırır.
Eşitlik Karşılaştırmaları İçin PartialEq ve Eq
PartialEq trait’i, eşitliği denetlemek üzere bir türün örneklerini karşılaştırmanıza olanak tanır ve == ile != operatörlerinin kullanımını etkinleştirir.
PartialEq türetmek eq metodunu uygular (ne metodu varsayılan uygulamaya sahiptir). PartialEq struct’lar üzerinde türetildiğinde iki örnek yalnızca tüm alanları eşitse birbirine eşittir; herhangi bir alan farklıysa örnekler eşit değildir. Enum’lar üzerinde türetildiğinde ise her varyant yalnızca kendi varyantındaki eşdeğer değerlere eşittir ve diğer tüm varyantlara eşit değildir.
PartialEq trait’i örneğin bir türün iki örneğini eşitlik açısından karşılaştırması gereken assert_eq! makrosunda gereklidir.
Eq trait’inin hiçbir metodu yoktur. Amacı, işaretlenen türün her değeri için o değerin kendisine eşit olduğunu (yansıma özelliğini, yani x == x koşulunu) bildirmektir. Eq trait’i yalnızca aynı zamanda PartialEq uygulayan türlere uygulanabilir; ancak PartialEq uygulayan her tür Eq uygulayamaz. Bunun klasik örneği kayan noktalı sayı türleridir (f32, f64): IEEE 754 standardına göre iki “sayı değil” (NaN) örneği birbirine eşit değildir (f64::NAN != f64::NAN).
Eq trait’inin gerekli olduğu bir duruma örnek olarak, HashMap<K, V> yapısının iki anahtarın aynı olup olmadığını kesin biçimde ayırt edebilmesi için HashMap<K, V> anahtarları (K) verilebilir.
Sıralama Karşılaştırmaları İçin PartialOrd ve Ord
PartialOrd trait’i, sıralama amacıyla bir türün örneklerini karşılaştırmanıza olanak tanır. PartialOrd uygulayan bir tür <, >, <= ve >= operatörleriyle kullanılabilir. PartialOrd trait’ini yalnızca aynı zamanda PartialEq uygulayan türlere uygulayabilirsiniz.
PartialOrd türetmek, verilen değerler geçerli bir sıralama üretmediğinde None olan bir Option<Ordering> döndüren partial_cmp metodunu uygular. Türdeki çoğu değer karşılaştırılabilmesine rağmen sıralama üretmeyen bir değere örnek yine NaN kayan noktalı değeridir: Herhangi bir kayan noktalı sayı ile NaN değeri üzerinde partial_cmp çağırmak None döndürür.
Struct’lar üzerinde türetildiğinde PartialOrd, her alandaki değerleri alanların struct tanımında göründükleri sırayla (sözlük düzeninde) karşılaştırarak iki örneği kıyaslar. Enum’lar üzerinde türetildiğinde ise enum tanımında daha önce bildirilen varyantlar, daha sonra bildirilen varyantlardan daha küçük kabul edilir.
PartialOrd trait’i örneğin rand crate’inde bir aralık ifadesiyle belirtilen sınırlar içinde rastgele değer üreten random_range (eski sürümlerde gen_range) metodu için gereklidir.
Ord trait’i, işaretlenen türün herhangi iki değeri için daima geçerli bir tam sıralamanın (total ordering) var olacağını garanti eder. Her zaman geçerli bir sıralama mümkün olacağından Ord trait’i, Option<Ordering> yerine doğrudan Ordering döndüren cmp metodunu uygular. Ord trait’ini yalnızca aynı zamanda PartialOrd ve Eq uygulayan türlere uygulayabilirsiniz (Eq de PartialEq gerektirir). Struct’lar ve enum’lar üzerinde türetildiğinde cmp, PartialOrd için türetilen partial_cmp uygulamasıyla aynı sırayı izler.
Ord trait’inin gerekli olduğu duruma örnek olarak, verileri değerlerin sıralama düzenine göre saklayan BTreeSet<T> veya BTreeMap<K, V> veri yapılarında öğe/anahtar saklamak ya da bir dilim üzerinde .sort() çağırmak verilebilir.
Değerleri Çoğaltmak İçin Clone ve Copy
Clone trait’i, bir değerin derin kopyasını açıkça oluşturmanıza olanak tanır; bu çoğaltma süreci isteğe bağlı özel kod çalıştırmayı ve öbek (heap) verilerini kopyalamayı içerebilir. Clone hakkında daha fazla bilgi için Bölüm 4’teki “Değişkenlerin ve Verilerin Clone ile Etkileşimi” bölümüne bakın.
Clone türetmek, türün tamamı üzerinde çağrıldığında türün her bir parçası üzerinde clone çağıran clone metodunu uygular. Bu da Clone türetebilmek için türdeki tüm alanların veya varyant değerlerinin de Clone uygulaması gerektiği anlamına gelir.
Clone trait’inin gerekli olduğu duruma bir örnek, bir dilim (&[T]) üzerinde to_vec metodunu çağırmaktır. Dilim içerdiği tür örneklerinin sahibi değildir, ancak to_vec çağrısından döndürülen Vec<T> kendi örneklerinin sahibi olmak zorundadır; bu nedenle to_vec her öğe üzerinde clone çağırır. Dolayısıyla dilimde saklanan türün Clone uygulaması gerekir.
Copy trait’i, bir değeri oluşturan bitleri kopyalayarak çoğaltmanıza olanak tanır; bu işlem için türe özel bir klonlama kodu çalıştırılmaz. Bu özellik değerin yığında mı, öbekte mi tutulduğuna bağlı değildir. Copy hakkında daha fazla bilgi için Bölüm 4’teki “Yalnızca Yığındaki Veri: Copy” bölümüne bakın.
Copy trait’i, programcıların herhangi bir metodu özel olarak uygulayıp “arka planda hiçbir özel kod çalıştırılmadığı” varsayımını ihlal etmesini önlemek amacıyla hiçbir metot tanımlamaz. Bu sayede tüm programcılar bir Copy değerini kopyalamanın çok hızlı (yalnızca bit düzeyinde kopyalama) olacağını güvenle varsayabilir.
Tüm parçaları Copy uygulayan herhangi bir tür üzerinde Copy türetebilirsiniz. Copy uygulayan bir tür aynı zamanda Clone da uygulamak zorundadır (#[derive(Copy, Clone)]); çünkü Copy uygulayan her tür, Copy ile birebir aynı işi yapan önemsiz (trivial) bir Clone uygulamasına sahiptir.
Copy trait’i bir trait sınırı olarak nadiren zorunlu tutulur; ancak Copy uygulayan türlerde sahiplik taşınmadığı için clone çağırmanız gerekmez ve bu da kodu çok daha özlü kılar.
Copy ile mümkün olan her şeyi Clone ile de gerçekleştirebilirsiniz; ancak kod daha yavaş çalışabilir veya belirli noktalarda açıkça .clone() çağırmanızı gerektirebilir.
Bir Değeri Sabit Boyutlu Bir Değere Eşlemek İçin Hash
Hash trait’i, isteğe bağlı boyuttaki bir türün örneğini alıp bir karma (hash) fonksiyonu kullanarak sabit boyutlu bir değere eşlemenize olanak tanır. Hash türetmek hash metodunu uygular. hash metodunun türetilen uygulaması, türün her bir parçası üzerinde hash çağrılmasından elde edilen sonuçları birleştirir; dolayısıyla Hash türetebilmek için tüm alanların veya varyant değerlerinin de Hash uygulaması gerekir.
Hash trait’inin gerekli olduğu duruma bir örnek, verileri verimli biçimde depolamak üzere HashMap<K, V> veya HashSet<T> içinde anahtar saklamaktır.
Varsayılan Değerler İçin Default
Default trait’i, bir tür için varsayılan bir değer oluşturmanıza olanak tanır. Default türetmek default ilişkili fonksiyonunu uygular. default fonksiyonunun türetilen uygulaması, bir struct’ın her bir alanı üzerinde default fonksiyonunu çağırır; bu da bir struct üzerinde Default türetebilmek için tüm alanların da Default uygulaması gerektiği anlamına gelir (enum’lar için aşağıdaki nota bakın).
Default::default fonksiyonu, sıklıkla Bölüm 5’teki “Struct Güncelleme Sözdizimi ile Örnekler Oluşturma” bölümünde ele alınan struct güncelleme sözdizimiyle birlikte kullanılır. Bir struct’ın yalnızca birkaç alanını özelleştirip geri kalan tüm alanlara ..Default::default() yazarak varsayılan değerlerini atayabilirsiniz.
Default trait’i örneğin Option<T> örnekleri üzerinde unwrap_or_default metodunu kullandığınızda gereklidir: Option<T> değeri None ise unwrap_or_default metodu, içerideki T türü için Default::default sonucunu döndürür.
Not: Pratik derive İpuçları (#[default], Üst-Trait Bağımlılıkları ve Hash/PartialEq Sözleşmesi):
- Enum’larda
#[derive(Default)]: Yalnızca struct’larda değil, birim varyantlarından birinin üzerine#[default]özniteliği ekleyerek enum’larda daDefaulttüretebilirsiniz (örneğin#[derive(Default)] enum Durum { #[default] Beklemede, Calisiyor }). - Birlikte Türetme Gereksinimleri: Üst-trait (supertrait) ilişkileri
nedeniyle
CopyiçinClone,EqiçinPartialEq,Ordiçin isePartialEq,EqvePartialOrduygulamaları da gerekir. Bunları genellikle birlikte türetiriz (#[derive(Clone, Copy)]veya#[derive(PartialEq, Eq, PartialOrd, Ord)]); gerekli uygulamaları elle yazmak da mümkündür. HashvePartialEqUyum Kuralı: Bir tür üzerindePartialEqtrait’ini elle uygularsanız (impl PartialEq for ...),k1 == k2olduğundahash(k1) == hash(k2)eşitliğinin bozulmaması içinHashtrait’ini#[derive(Hash)]ile otomatik türetmek yerine eşitlik tanımınızla uyumlu olacak şekilde uygulamanız gerekebilir. Örneğin eşitlik bazı alanları yok sayıyorsa, hash hesabının da bu alanları yok sayması gerekir. Elle yazılmışPartialEqile türetilmişHashbu sözleşmeyi zaten koruyorsa ikisini birlikte kullanmak mümkündür; belirleyici olan eşit anahtarların aynı hash sonucunu üretmesidir.
D - Faydalı Geliştirme Araçları
D - Faydalı Geliştirme Araçları
Bu ekte, Rust projesinin sunduğu bazı faydalı geliştirme araçlarını inceleyeceğiz. Otomatik kod biçimlendirmeye, derleyici uyarılarına yönelik düzeltmeleri hızlıca uygulamanın yollarına, kod denetleyicisine (linter) ve tümleşik geliştirme ortamlarıyla (IDE) entegrasyona göz atacağız.
rustfmt ile Otomatik Biçimlendirme
rustfmt aracı, kodunuzu topluluk kod stiline göre yeniden biçimlendirir. Pek çok ortak proje, Rust yazarken hangi biçimlendirme stilinin kullanılacağı konusundaki tartışmaları önlemek için rustfmt kullanır: Herkes kodunu bu araçla biçimlendirir.
Rust kurulumları varsayılan olarak rustfmt içerir; dolayısıyla sisteminizde rustfmt ve cargo-fmt programları hâlihazırda kurulu olmalıdır. Bu iki komut, rustfmt aracının daha ince ayarlı denetime izin vermesi ve cargo-fmt aracının Cargo kullanan bir projenin kurallarını anlaması bakımından rustc ve cargo ikilisine benzer. Herhangi bir Cargo projesini biçimlendirmek için şu komutu çalıştırın:
$ cargo fmt
Bu komutu çalıştırmak, mevcut crate içindeki tüm Rust kodunu yeniden biçimlendirir. Bu işlem kodun anlamını (semantiğini) değil, yalnızca yazım stilini değiştirir. rustfmt hakkında daha fazla bilgi için belgelendirmesine göz atabilirsiniz.
rustfix ile Kodunuzu Düzeltme
Rust kurulumlarıyla birlikte gelen rustfix aracı, sorunu gidermenin açık ve büyük olasılıkla sizin de isteyeceğiniz net bir yolu bulunan derleyici uyarılarını otomatik olarak düzeltebilir. Daha önce derleyici uyarılarıyla mutlaka karşılaşmışsınızdır. Örneğin şu kodu ele alalım:
Dosya adı: src/main.rs
fn main() {
let mut x = 42;
println!("{x}");
}
Burada x değişkenini değiştirilebilir (mut) olarak tanımlıyoruz, ancak gerçekte onu hiçbir yerde değiştirmiyoruz. Rust bizi bu konuda uyarır:
$ cargo build
Compiling programim v0.1.0 (file:///projects/programim)
warning: variable does not need to be mutable
--> src/main.rs:2:9
|
2 | let mut x = 42;
| ----^
| |
| help: remove this `mut`
|
= note: `#[warn(unused_mut)]` on by default
Uyarı, mut anahtar kelimesini kaldırmamızı önerir. cargo fix komutunu çalıştırarak rustfix aracının bu öneriyi otomatik olarak uygulamasını sağlayabiliriz:
$ cargo fix
Checking programim v0.1.0 (file:///projects/programim)
Fixing src/main.rs (1 fix)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.59s
src/main.rs dosyasına yeniden baktığımızda cargo fix komutunun kodu güncellediğini görürüz:
Dosya adı: src/main.rs
fn main() {
let x = 42;
println!("{x}");
}
x değişkeni artık değiştirilemezdir ve derleyici uyarısı ortadan kalkmıştır.
cargo fix komutunu ayrıca kodunuzu farklı Rust sürümleri (editions) arasında geçirmek için de kullanabilirsiniz. Sürümler Ek E bölümünde ele alınmaktadır.
Clippy ile Daha Fazla Lint Denetimi
Clippy aracı, yaygın hataları yakalayabilmeniz ve Rust kodunuzu daha idiyomatik hâle getirebilmeniz için kodunuzu analiz eden bir lint (statik kod denetimi kuralı) koleksiyonudur. Clippy, standart Rust kurulumlarına dahildir.
Herhangi bir Cargo projesinde Clippy denetimlerini çalıştırmak için şu komutu girin:
$ cargo clippy
Örneğin pi (π) gibi matematiksel bir sabitin yaklaşık değerini elle yazdığınız şu programı düşünün:
fn main() {
let x = 3.1415;
let r = 8.0;
println!("dairenin alanı: {}", x * r * r);
}
Bu projede cargo clippy çalıştırmak şu hatayla sonuçlanır:
error: approximate value of `f{32, 64}::consts::PI` found
--> src/main.rs:2:13
|
2 | let x = 3.1415;
| ^^^^^^
|
= note: `#[deny(clippy::approx_constant)]` on by default
= help: consider using the constant directly
= help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#approx_constant
Bu hata size Rust standart kütüphanesinde zaten çok daha duyarlı bir PI sabitinin tanımlı olduğunu ve doğrudan bu sabiti kullanmanız hâlinde programınızın daha doğru sonuç üreteceğini bildirir. Bunun üzerine kodunuzu PI sabitini kullanacak şekilde güncellersiniz.
Aşağıdaki kod Clippy tarafından herhangi bir hata veya uyarı üretilmeden geçer:
fn main() {
let x = std::f64::consts::PI;
let r = 8.0;
println!("dairenin alanı: {}", x * r * r);
}
Clippy hakkında daha fazla bilgi için belgelendirmesine başvurabilirsiniz.
Not: Sürekli Entegrasyon (CI) Hattında Biçim ve Lint Denetimi: Ekip projelerinde ve CI (GitHub Actions, GitLab CI vb.) iş akışlarında dosyaları değiştirmeden yalnızca biçimlendirme kurallarına uyulup uyulmadığını sınamak için cargo fmt --all -- --check, tüm Clippy uyarılarını derleme hatasına dönüştürmek için ise cargo clippy --all-targets -- -D warnings komutları yaygın olarak kullanılır. Ayrıca Clippy’nin otomatik düzeltilebilir önerilerini tek adımda uygulamak isterseniz cargo clippy --fix komutunu çalıştırabilirsiniz.
rust-analyzer ile IDE Entegrasyonu
IDE ve kod düzenleyici entegrasyonu için Rust topluluğu rust-analyzer kullanımını önerir. Bu araç, geliştirme ortamları ile programlama dillerinin birbirleriyle iletişim kurmasına yönelik açık bir standart olan Dil Sunucusu Protokolü (Language Server Protocol — LSP) üzerinden çalışan derleyici merkezli bir yardımcı araçlar kümesidir. Visual Studio Code için rust-analyzer eklentisi, Zed, Neovim, Helix, Emacs veya JetBrains RustRover/IntelliJ gibi farklı istemciler rust-analyzer altyapısını kullanabilir.
Kurulum yönergeleri için rust-analyzer projesinin ana sayfasını ziyaret edin ve ardından kullandığınız düzenleyicide dil sunucusu desteğini etkinleştirin. Düzenleyiciniz bu sayede otomatik tamamlama, tanıma gitme (jump to definition), satır içi tür ipuçları (inlay hints) ve anlık hata gösterimi gibi yetenekler kazanacaktır.
E - Sürümler
E - Sürümler
Bölüm 1’de cargo new komutunun Cargo.toml dosyanıza bir sürüm (edition) bilgisi eklediğini görmüştünüz. Bu ekte bu kavramın ne anlama geldiğini ele alıyoruz! Buradaki sürüm (edition), rustc 1.85.0 gibi bir derleyici sürümünden farklıdır: edition, crate’in kaynak kodunun hangi dil kurallarıyla yorumlanacağını seçer. Aynı derleyici birden fazla edition’ı destekleyebilir; derleyiciyi güncellemek projenizin edition ayarını kendiliğinden değiştirmez.
Rust dili ve derleyicisi altı haftalık bir yayın döngüsüne sahiptir; yani kullanıcılar sürekli olarak yeni özelliklere kavuşur. Diğer pek çok programlama dili daha büyük değişiklikleri daha seyrek aralıklarla yayınlarken, Rust daha küçük güncellemeleri daha sık yayınlar. Bir süre sonra tüm bu küçük iyileştirmeler üst üste birikir. Yine de bir yayından diğerine geriye dönüp baktığınızda, “Vay canına, Rust 1.10 ile Rust 1.31 arasında dil ne kadar değişmiş!” diyebilmek her zaman kolay olmayabilir.
Yaklaşık her üç yılda bir Rust ekibi yeni bir Rust sürümü (edition) yayınlar. Her sürüm, o zamana dek dile eklenen özellikleri eksiksiz güncellenmiş belgelendirme ve araçlarla birlikte net bir paket hâlinde bir araya getirir. Yeni sürümler de olağan altı haftalık yayın sürecinin bir parçası olarak kullanıcılara sunulur.
Sürümler farklı kitleler için farklı amaçlara hizmet eder:
- Aktif Rust kullanıcıları için yeni bir sürüm, artımlı değişiklikleri anlaşılması kolay tek bir pakette toplar.
- Henüz Rust kullanmayanlar için yeni bir sürüm, Rust’a yeniden göz atmaya değecek önemli ilerlemelerin kaydedildiğinin sinyalini verir.
- Rust’ı geliştirenler için yeni bir sürüm, projenin bütünü adına ortak bir odaklanma noktası oluşturur.
Bu satırların yazıldığı sırada dört Rust sürümü mevcuttur: Rust 2015, Rust 2018, Rust 2021 ve Rust 2024. Bu kitap, Rust 2024 sürümünün idiyomları kullanılarak yazılmıştır.
Cargo.toml dosyasındaki edition anahtarı, derleyicinin kodunuz için hangi sürümü kullanması gerektiğini belirtir. Bu anahtar mevcut değilse Rust, geriye dönük uyumluluk amacıyla sürüm değeri olarak varsayılan 2015’i kullanır.
Her proje, varsayılan 2015 sürümünden farklı bir sürümü açıkça tercih edebilir. Sürümler, mevcut koddaki tanımlayıcılarla çakışabilecek yeni bir anahtar kelimenin eklenmesi gibi geriye dönük uyumsuz değişiklikler barındırabilir. Ancak siz bu yeni sürümü açıkça seçmediğiniz sürece, kullandığınız Rust derleyicisini en güncel sürüme yükseltseniz bile kodunuz sorunsuzca derlenmeye devam eder.
Tüm Rust derleyici sürümleri, o derleyicinin yayınlanmasından önce var olan tüm sürümleri (editions) destekler ve desteklenen herhangi bir sürümle yazılmış crate’leri birbirine sorunsuzca bağlayabilir. Sürüm değişiklikleri yalnızca derleyicinin kaynak kodu ön aşamada nasıl ayrıştırdığını ve yorumladığını etkiler. Bu nedenle siz projenizde Rust 2015 kullanıyorsanız ve bağımlılıklarınızdan biri Rust 2018 (veya Rust 2024) kullanıyorsa projeniz derlenecek ve o bağımlılığı kullanabilecektir. Projenizin daha yeni bir sürümü, bir bağımlılığınızın ise Rust 2015 sürümünü kullandığı tersi durum da aynı şekilde çalışır.
Netleştirmek gerekirse: Yeni özelliklerin büyük çoğunluğu tüm sürümlerde kullanılabilir. Hangi Rust sürümünü kullanırsa kullansın tüm geliştiriciler, yeni kararlı derleyici sürümleri yayınlandıkça iyileştirmelerden yararlanmaya devam eder. Yalnızca bazı özel durumlarda —özellikle yeni anahtar kelimeler eklendiğinde veya mevcut bir sözdiziminin varsayılan davranışı iyileştirildiğinde— belirli özellikler yalnızca yeni sürümlerde etkinleştirilir. Bu tür özelliklerden yararlanmak istediğinizde projenizin edition ayarını yükseltmeniz gerekir.
Not: Rust Sürümlerinin Kısa Özeti ve Otomatik Yükseltme (cargo fix --edition):
- Rust 2018: Modül sisteminde
extern cratezorunluluğunun kalkması, sözcüksel olmayan ömürler (non-lexical lifetimes — NLL) veasync/awaitanahtar kelimeleri. - Rust 2021: Diziler (
[T; N]) için doğrudan değer üzerindenIntoIterator, kapanışlarda alan bazlı (disjoint) yakalama ve ön tanımlı kapsama (prelude)TryInto,TryFrom,FromIteratoreklenmesi. - Rust 2024: Ön tanımlı kapsama
FutureveIntoFutureeklenmesi,-> impl Traitdönüş türlerinde ömür yakalama kurallarının sadeleşmesi,if letve blok sonu ifadelerinde geçici değer düşürme kapsamlarının iyileştirilmesi,unsafe externveunsafe fniçi güvenli olmayan işlem uyarıları, ayrılmışgenanahtar kelimesi ve Cargo’da MSRV duyarlıresolver = "3". - Mevcut bir crate’i yeni sürüme yükseltmek için edition geçişlerini sırayla
yapın. Örneğin Rust 2021’den Rust 2024’e geçerken önce mevcut edition
ayarıyla
cargo fix --editionçalıştırın; ardından Cargo.toml içindeedition = "2024"yazıpcargo testile doğrulayın. Daha eski bir edition kullanıyorsanız önce aradaki edition’lara geçişleri tamamlayın.
Daha fazla ayrıntı için Rust Sürüm Rehberi kitabına başvurabilirsiniz. Bu rehber, sürümler arasındaki tüm farkları tek tek sıralayan ve cargo fix aracılığıyla kodunuzu yeni bir sürüme otomatik olarak nasıl yükselteceğinizi adım adım açıklayan kapsamlı bir kaynaktır.
F - Kitabın Çevirileri
F - Kitabın Çevirileri
Bu ekte kitabın İngilizce dışındaki dillerde topluluk tarafından hazırlanan çevirilerine yer verilmiştir. Bu çevirilerin çoğu hâlâ devam etmektedir; katkıda bulunmak veya yeni bir çeviriyi bildirmek için resmî depodaki Translations etiketine göz atabilirsiniz!
- Türkçe: hakantr/kitap-rs (elinizdeki çeviri)
- Português (BR)
- Português (PT)
- 简体中文: KaiserY/trpl-zh-cn, gnu4cn/rust-lang-Zh_CN
- 正體中文
- Українська
- Español, alternatif, Español por RustLangES
- Русский
- 한국어
- 日本語
- Français
- Polski
- Cebuano
- Tagalog
- Esperanto
- ελληνική
- Svenska
- Farsi, Persian (FA)
- Deutsch
- हिंदी
- ไทย
- Danske
- O’zbek
- Tiếng Việt
- Italiano
- বাংলা
G - Rust Nasıl Geliştirilir ve "Nightly Rust"
G - Rust Nasıl Geliştirilir ve “Nightly Rust”
Bu ek, Rust’ın nasıl geliştirildiğini ve bu sürecin bir Rust geliştiricisi olarak sizi nasıl etkilediğini ele alır.
Durgunluk Olmadan İstikrar
Bir dil olarak Rust, kodunuzun istikrarını çok önemser. Rust’ın üzerine güvenle yazılım inşa edebileceğiniz kaya gibi sağlam bir temel olmasını istiyoruz; dil sürekli ve kırıcı biçimde değişiyor olsaydı bu imkânsız olurdu. Öte yandan yeni özellikler üzerinde deney yapamazsak, önemli tasarım kusurlarını ancak o özellikler yayınlandıktan —yani artık geriye dönüp değiştiremeyeceğimiz aşamaya gelindikten— sonra fark edebiliriz.
Bu ikileme bulduğumuz çözüm, “durgunluk olmadan istikrar” (stability without stagnation) adını verdiğimiz yaklaşımdır ve yol gösterici ilkemiz şudur: Kararlı (stable) Rust’ın yeni bir sürümüne geçmekten asla çekinmemelisiniz. Her yükseltme zahmetsiz olmalı; aynı zamanda size yeni özellikler, daha az hata ve daha hızlı derleme süreleri kazandırmalıdır.
Çuf, Çuf! Sürüm Kanalları ve Trenlere Binmek
Rust geliştirme süreci bir tren tarifesine (train schedule) göre işler. Tüm geliştirme çalışmaları Rust deposunun ana (main) dalında yürütülür. Sürümler, Cisco IOS ve diğer büyük yazılım projelerinde kullanılan yazılım yayın treni (software release train) modelini izler. Rust için üç sürüm kanalı (release channel) vardır:
- Nightly (Gecelik)
- Beta
- Stable (Kararlı)
Rust geliştiricilerinin büyük çoğunluğu öncelikle kararlı (stable) kanalı kullanır; deneysel yeni özellikleri erkenden denemek isteyenler ise nightly veya beta kanallarını tercih edebilir.
Geliştirme ve yayınlama sürecinin nasıl işlediğini somut bir örnekle görelim: Rust ekibinin Rust 1.5 sürümü üzerinde çalıştığını varsayalım. Bu sürüm Aralık 2015’te yayınlanmıştı, ancak mekanizmayı gerçek sürüm numaralarıyla göstermemizi sağlayacak. Rust’a yeni bir özellik eklenir: Ana dala yeni bir değişiklik kaydı (commit) girer. Her gece Rust’ın yeni bir nightly sürümü üretilir. Her gün bir yayın günüdür ve bu derlemeler yayın altyapımız tarafından otomatik olarak oluşturulur. Zaman ilerledikçe her gece bir kez üretilen sürümlerimiz şöyle görünür:
nightly: * - - * - - *
Her altı haftada bir yeni bir sürüm hazırlama zamanı gelir! Rust deposunun beta dalı, nightly tarafından kullanılan ana daldan ayrılır. Artık iki kanal vardır:
nightly: * - - * - - *
|
beta: *
Çoğu Rust kullanıcısı beta sürümlerini günlük geliştirmede doğrudan kullanmaz; ancak olası gerilemeleri (regressions) erkenden yakalamaya yardımcı olmak için sürekli entegrasyon (CI) sistemlerinde projelerini beta kanalına karşı da test eder. Bu sırada her gece yeni bir nightly sürümü üretilmeye devam eder:
nightly: * - - * - - * - - * - - *
|
beta: *
Diyelim ki bir gerileme hatası bulundu. Bu hatanın kararlı sürüme sızmasından önce beta sürümünü sınamak için zamanımız olması büyük bir avantajdır! Düzeltme önce ana dala uygulanır —böylece nightly düzeltilmiş olur— ardından aynı düzeltme beta dalına geri aktarılır (backport) ve beta kanalının yeni bir derlemesi üretilir:
nightly: * - - * - - * - - * - - * - - *
|
beta: * - - - - - - - - *
İlk beta dalının ayrılmasından altı hafta sonra kararlı sürümün zamanı gelir! stable sürümü beta dalından üretilir:
nightly: * - - * - - * - - * - - * - - * - * - *
|
beta: * - - - - - - - - *
|
stable: *
Harika, Rust 1.5 tamamlandı! Ancak unutmamamız gereken bir ayrıntı daha var: Altı hafta geçtiği için bir sonraki Rust sürümü olan 1.6’nın yeni beta dalına da ihtiyacımız vardır. Bu nedenle stable sürümü beta dalından üretildikten hemen sonra, bir sonraki beta dalı yeniden nightly üzerinden ayrılır:
nightly: * - - * - - * - - * - - * - - * - * - *
| |
beta: * - - - - - - - - * *
|
stable: *
Her altı haftada bir yeni bir sürüm “istasyondan ayrıldığı”, ancak kararlı sürüm olarak hedefe varmadan önce mutlaka beta kanalında altı haftalık bir yolculuk yaptığı için bu yaklaşıma “tren modeli” denir.
Rust, saat şaşmazlığıyla her altı haftada bir yeni sürüm yayınlar. Bir Rust sürümünün yayın tarihini biliyorsanız bir sonrakinin tarihini de bilirsiniz: Tam altı hafta sonradır. Sürümlerin her altı haftada bir düzenli planlanmasının çok güzel bir yanı, bir sonraki trenin her zaman yakın olmasıdır. Bir özellik belirli bir sürüm trenini kaçırırsa endişelenmeye gerek yoktur; çok kısa süre sonra yeni bir tren kalkacaktır! Bu düzen, henüz tam olgunlaşmamış özellikleri son teslim tarihine yetiştirmek adına aceleyle sürüme sıkıştırma baskısını ortadan kaldırır.
Bu süreç sayesinde bir sonraki Rust sürümünü her zaman önceden deneyebilir ve yükseltmenin sorunsuz olacağını kendi projenizde doğrulayabilirsiniz: Bir beta sürümü beklediğiniz gibi çalışmazsa durumu ekibe bildirebilir ve bir sonraki kararlı sürüm çıkmadan önce düzeltilmesini sağlayabilirsiniz! beta sürümlerinde kırılmalar oldukça nadirdir; ancak rustc sonuçta bir yazılımdır ve zaman zaman hatalar ortaya çıkabilir.
Bakım Süresi
Rust projesi en güncel kararlı sürümü destekler. Yeni bir kararlı sürüm yayınlandığında bir önceki sürüm kullanım ömrünün sonuna (end of life — EOL) ulaşır. Bu da her kararlı sürümün altı hafta boyunca desteklendiği anlamına gelir (kritik güvenlik veya gerileme düzeltmeleri gerektiğinde bu altı haftalık pencerede 1.x.1 gibi nokta sürümleri yayınlanabilir).
Kararsız Özellikler
Bu yayın modelinin önemli bir parçası daha vardır: kararsız (unstable) özellikler. Rust, belirli bir sürümde hangi özelliklerin etkin olacağını denetlemek için “özellik bayrakları” (feature flags) adı verilen bir teknik kullanır. Yeni bir özellik aktif geliştirme aşamasındayken ana dala —dolayısıyla nightly kanalına— eklenir, ancak bir özellik bayrağının arkasında tutulur. Bir kullanıcı olarak geliştirilmekte olan bu özelliği denemek isterseniz deneyebilirsiniz; fakat bunun için Rust’ın bir nightly sürümünü kullanmanız ve özelliği açıkça etkinleştirmek üzere crate kökünüze uygun #![feature(...)] özniteliğini eklemeniz gerekir.
Rust’ın beta veya stable kanallarını kullanıyorsanız hiçbir kararsız özellik bayrağını etkinleştiremezsiniz. Yeni özellikleri sonsuza dek kararlı ilan etmeden önce gerçek dünya koşullarında sınamamızı sağlayan kilit mekanizma budur. En yeni deneysel özellikleri denemek isteyenler bunu bilinçli olarak seçebilir; kaya gibi sağlam bir deneyim isteyenler ise stable kanalında kalarak kodlarının kırılmayacağından emin olabilir. Durgunluk olmadan istikrar.
Geliştirme aşamasındaki kararsız özellikler değişmeye devam ettiği ve bu satırların yazıldığı zaman ile kararlı derlemelerde etkinleştirilecekleri zaman arasında farklılaşabileceği için bu kitap yalnızca kararlı özellikleri kapsar. Yalnızca nightly kanalına özgü özelliklerin belgelendirmesine çevrim içi The Rust Unstable Book kaynağından ulaşabilirsiniz.
Rustup ve Rust Nightly’nin Rolü
rustup, sistem genelinde veya proje bazında farklı Rust sürüm kanalları arasında geçiş yapmayı kolaylaştırır. Varsayılan olarak sisteminizde kararlı (stable) Rust kuruludur. Örneğin nightly kanalını kurmak için şu komutu çalıştırabilirsiniz:
$ rustup toolchain install nightly
rustup ile kurduğunuz tüm araç zincirlerini (toolchains — Rust sürümleri ve ilişkili bileşen kümeleri) de listeleyebilirsiniz. Yazarlardan birinin Windows bilgisayarındaki örnek çıktı şöyledir:
> rustup toolchain list
stable-x86_64-pc-windows-msvc (default)
beta-x86_64-pc-windows-msvc
nightly-x86_64-pc-windows-msvc
Gördüğünüz gibi kararlı (stable) araç zinciri varsayılandır. Çoğu Rust kullanıcısı zamanının büyük bölümünde stable kanalını kullanır. Siz de günlük çalışmalarınızda stable kullanırken, deneysel bir özelliğe ihtiyaç duyduğunuz belirli bir projede nightly kullanmak isteyebilirsiniz. Bunun için ilgili projenin dizinindeyken rustup override komutunu çalıştırarak o dizinde kullanılacak araç zincirini nightly olarak ayarlayabilirsiniz:
$ cd ~/projects/needs-nightly
$ rustup override set nightly
Artık ~/projects/needs-nightly dizini içinde rustc veya cargo komutunu her çağırdığınızda, rustup varsayılan stable yerine nightly araç zincirinin kullanılmasını sağlar. Çok sayıda Rust projeniz olduğunda bu özellik son derece kullanışlıdır!
Not: Tek Seferlik +nightly Bayrağı ve rust-toolchain.toml Dosyası: Dizin ayarını kalıcı olarak değiştirmeden yalnızca tek bir komutu nightly ile çalıştırmak isterseniz cargo +nightly test veya Bölüm 20’de gördüğümüz cargo +nightly miri test kısayolunu kullanabilirsiniz. Ayrıca ekipçe geliştirilen depolarda yerel rustup override yerine proje köküne bir rust-toolchain.toml dosyası eklemek (örneğin [toolchain] altında channel = "nightly" veya belirli bir sürüm sabitlemesi), depoyu klonlayan herkesin otomatik olarak aynı araç zincirini kullanmasını sağlar.
RFC Süreci ve Ekipler
Peki bu yeni özelliklerden nasıl haberdar olursunuz ve süreç nasıl işler? Rust’ın geliştirme modeli Yorum İsteği (Request For Comments — RFC) sürecini izler. Rust’ta önemli bir değişiklik veya yeni bir özellik önermek isterseniz RFC adı verilen ayrıntılı bir tasarım önerisi yazabilirsiniz.
Rust’ı geliştirmek üzere herkes RFC yazabilir; bu öneriler birçok uzmanlık alt ekibinden oluşan Rust ekipleri tarafından incelenir ve toplulukla açık biçimde tartışılır. Projenin her alanından sorumlu ekiplerin (dil tasarımı, derleyici, kütüphaneler, Cargo, altyapı, belgelendirme vb.) tam listesine Rust yönetişim sayfasından ulaşabilirsiniz. İlgili ekip öneriyi ve topluluk yorumlarını okur, kendi değerlendirmelerini paylaşır ve sonunda özelliğin kabul edilmesi ya da reddedilmesi konusunda fikir birliğine (consensus) varılır.
Özellik kabul edilirse ana Rust deposunda bir takip kaydı (tracking issue) açılır ve gönüllü ya da ekip üyesi bir geliştirici özelliği uygulayabilir — özelliği kodlayan kişinin, öneriyi ilk yazan kişi olması şart değildir! Uygulama hazır olduğunda, “Kararsız Özellikler” bölümünde ele aldığımız gibi bir özellik geçidinin (feature gate) arkasında ana dala birleştirilir.
Belirli bir süre boyunca nightly kanalını kullanan geliştiriciler yeni özelliği gerçek projelerde deneyimledikten sonra, ilgili ekip üyeleri özelliğin pratikte nasıl sonuç verdiğini değerlendirir ve kararlı Rust’a alınıp alınmayacağına karar verir. Kararlı hâle getirilmesine karar verilirse özellik geçidi kaldırılır ve özellik artık kararlı (stable) kabul edilir! Böylece yayın trenine binerek yeni bir kararlı Rust sürümündeki yerini alır.