Ders 03 / 11
Sıfır ve Beş Kuralı
Bir yıkıcı yazmanın örtük taşıyan yapıcıyı nasıl bastırdığı, beş özel üye işlevin birbirine bağlılığı ve sıfır kuralının bu bağı hiç kurmadan sonuca ulaşması.
Yayın Güncelleme
Bu derste
- bir yıkıcı yazmanın örtük taşıyan yapıcıyı nasıl bastırdığını gösteren bir örnek kurar
- beş özel üye işlevi = default ile açıkça istemenin ne zaman gerektiğini açıklar
- sıfır kuralına uyan bir sınıfın taşıma sonrası üyesinin belirsiz sınıfına düştüğünü doğru biçimde ele alır
- sıfır ve beş kuralı arasındaki seçimin sınıf yazarının kararı olduğunu gerekçelendirir
İçindekiler
Önceki ders std::move’un tek başına bir taşıma garantilemediğini, gerçek kararı aşırı yükleme çözümlemesinin verdiğini gösterdi: const bir kaynak taşıyan yapıcıya bağlanamadığı için kopyalayan yapıcıya düşüyordu. Aynı düşüş, const olmayan bir kaynak için de yaşanabilir — bu kez nedeni tür niteleyicisi değil, sınıfın kendi yazdığı başka bir özel üye işlevdir. Bu ders, bir yıkıcı yazmanın örtük taşıyan yapıcıyı nasıl sessizce ortadan kaldırdığını ölçüyor.
Yıkıcı Yazmak, Taşıyan Yapıcıyı Bastırır
// kayit.cpp — yikici yazmanin ortuk tasiyan yapiciyi nasil bastirdigi
#include <cstdio>
#include <string>
#include <utility>
struct Kayit {
std::string ad;
~Kayit() {}
};
static void yikicili_sinif() {
Kayit a{"yikicisi yazilmis bir sinifin uzun adi, kisa dize eniyilemesini asan"};
std::string once = a.ad;
Kayit b = std::move(a);
std::printf("b.ad = %s\n", b.ad.c_str());
std::printf("a.ad ayni mi: %s\n", (a.ad == once) ? "evet" : "hayir");
}
struct Paket {
std::string icerik;
explicit Paket(std::string i) : icerik(std::move(i)) {}
~Paket() = default;
Paket(const Paket&) = default;
Paket& operator=(const Paket&) = default;
Paket(Paket&&) = default;
Paket& operator=(Paket&&) = default;
};
static void bes_default() {
Paket a("acikca istenen bes islevle tasinan bir paketin icerigi, uzun tutuldu");
Paket b = std::move(a);
(void)b;
std::printf("bes islev acikca istendi, tasima calisti\n");
}
struct SadeKayit {
std::string ad;
};
static void sifir_kurali() {
SadeKayit a{"sifir kuraline uyan sade bir kaydin uzun adi, tasima icin"};
SadeKayit b = std::move(a);
std::printf("b.ad = %s\n", b.ad.c_str());
a.ad = "yeni deger";
std::printf("a.ad (yeni deger verildikten sonra) = %s\n", a.ad.c_str());
}
int main() {
yikicili_sinif();
bes_default();
sifir_kurali();
}
b.ad = yikicisi yazilmis bir sinifin uzun adi, kisa dize eniyilemesini asan a.ad ayni mi: evet bes islev acikca istendi, tasima calisti b.ad = sifir kuraline uyan sade bir kaydin uzun adi, tasima icin a.ad (yeni deger verildikten sonra) = yeni deger
KY5. Kayit’in tek yazılmış özel üye işlevi yıkıcıdır — boş bir gövdeyle bile olsa, kullanıcı bir yıkıcı yazdığı an derleyici artık örtük bir taşıyan yapıcı ve örtük bir taşıyan atama üretmez. Kayit b = std::move(a); satırı bu yüzden taşıyan bir yapıcı bulamaz; aşırı yükleme çözümlemesi geriye, hâlâ örtük olarak var olan kopyalayan yapıcıya düşer. Sonuç, a.ad’nin taşımadan sonra da aynen kalmasıdır — çıktıdaki a.ad ayni mi: evet satırı bunu doğruluyor. std::move(a) burada da yazıldı, ama çağrılan şey bir taşıma değil bir kopyalamaydı.
Bu, önceki kursun ölçtüğü sorudan (M11/K04: bir ifade hangi nesneyle çalışıyor) farklı bir sonuca varıyor gibi görünse de aslında aynı ayrımı tekrarlıyor: K04’te kopya sınıfı bir hedefin yeni bir nesne açtığını, buradaki aynen ise kaynağın kendisinin değişmediğini adlandırıyor. Kayit b = std::move(a); satırı K04’ün ölçtüğü anlamda bir kopyalama işlemidir (b yeni bir nesnedir), ama bu kursun ölçtüğü anlamda kaynağın (a’nın) durumu aynendir — iki ölçüm aynı satırı iki farklı eksende değerlendiriyor.
Beş Özel Üye İşlev ve Beş Kuralı
Bir sınıfın beş özel üye işlevi vardır: yıkıcı, kopyalayan yapıcı, kopyalayan atama, taşıyan yapıcı, taşıyan atama. Bunlardan biri elle yazıldığında derleyicinin diğerleri için ürettiği örtük sürümler değişebilir ya da tamamen ortadan kalkabilir — yukarıdaki Kayit örneği tam olarak bunu gösterdi. Beş kuralı (rule of five) bu bağımlılığı bir disipline çevirir: bu beşten biri elle yazılıyorsa, geri kalan dördü de düşünülmeli, gerekiyorsa açıkça yazılmalı ya da açıkça istenmelidir.
Beşinin her biri farklı bir anda, farklı bir amaçla çalışır. Yıkıcı (~T()) nesnenin ömrü bittiğinde, tuttuğu her kaynağı serbest bırakmaktan sorumludur; bir sınıf çıplak bir kaynak (dosya tanıtıcısı, işaretçi, kilit) tutuyorsa yıkıcı bu kaynağı geri vermenin tek garanti edilmiş yeridir. Kopyalayan yapıcı (T(const T&)) yeni bir nesneyi var olan bir nesnenin kopyası olarak kurar; kaynağı paylaşmaz, kendi bağımsız kopyasını açar. Kopyalayan atama (T& operator=(const T&)) aynı kopyalama işini, zaten var olan bir hedef nesnenin üzerine yazarak yapar — bu nedenle önce hedefin eski kaynağını bırakması, sonra yenisini kurması gerekir. Taşıyan yapıcı (T(T&&)) ve taşıyan atama (T& operator=(T&&)) ise kopyalamak yerine kaynağı devralır: yeni bir kaynak açmadan, kaynaktakini hedefe taşır ve kaynağı yıkıcının güvenle çalışabileceği bir duruma bırakır. Bu beşi arasındaki bağ tam olarak buradan gelir — biri diğerinin varsayımı üzerine kuruludur: taşıyan yapıcı, yıkıcının kaynağı nasıl serbest bıraktığını bilmeden kaynağı güvenle boşaltamaz; kopyalayan atama, yıkıcının hangi kaynağı bıraktığını bilmeden eskisini bırakamaz. Bir sınıf bu beşten yalnızca birini kendi eliyle yazdığında, derleyici artık “geri kalanlar da aynı varsayımlarla mı yazıldı” sorusuna güvenle evet diyemez ve bazılarını üretmeyi durdurur.
Paket sınıfı bu isteği = default ile açıkça yapıyor: yıkıcı, kopyalayan yapıcı, kopyalayan atama, taşıyan yapıcı, taşıyan atama — beşi de = default ile derleyiciden istendi. Burada derleyicinin doldurması gereken bir boşluk yoktur: Paket’in kendi yazılmış yapıcısı (explicit Paket(std::string i)) yalnız örtük varsayılan yapıcının (parametresiz Paket()) üretilmesini engeller; öteki beş özel üye işlev, = default yazılmasaydı da aynı biçimde üretilirdi. = default sonucu değiştirmez, niyeti kodda görünür kılar: okur beş işlevin var olduğunu ve derleyicinin ürettiği hâliyle çalıştığını dosyayı taramadan görür. Çıktıdaki bes islev acikca istendi, tasima calisti satırı, taşıyan yapıcının burada gerçekten çağrıldığını (ve derleyicinin ürettiği hâliyle icerik üyesini taşıdığını) belirtiyor.
Bu kural, K04’ün üç kuralına (rule of three — bir kopyalayan yapıcı, bir kopyalayan atama, bir yıkıcı birbirine bağlıdır) taşıma işlevlerini de ekleyerek genişletilmiş hâlidir; C++11 öncesinde yalnız üç işlev birbirine bağlıyken, taşıma işlevlerinin eklenmesiyle bağ beşe çıktı. K04’teki gerekçe şuydu: bir sınıf kendi yıkıcısını yazıyorsa (genellikle çıplak bir kaynağı serbest bırakmak için), derleyicinin ürettiği örtük kopyalayan yapıcı ve kopyalayan atama o kaynağı üye üye kopyalar — iki nesne aynı kaynağı paylaşır ve ikisi de kendi yıkıcısında onu serbest bırakmaya çalışır, bu da çift serbest bırakmayla sonuçlanır. Taşıma işlevleri eklendiğinde aynı gerekçe onlara da uygulanır: örtük bir taşıyan yapıcı, kaynağı kaynaktan hedefe kopyalayıp kaynakta da bırakırsa (taşıyan yapıcıya özgü sıfırlama yapılmazsa) aynı çift serbest bırakma riskini taşır. Beş kuralı bu yüzden üç kuralının bir devamıdır, ayrı bir kural değildir: aynı “elle yazılmış bir özel üye işlev, diğerlerinin örtük varsayımını bozar” ilkesinin taşımayı da kapsayacak şekilde genişletilmiş hâlidir.
Bastırma İki Yönde Aynı Şekilde İşlemez
Kayit örneği bir yönü gösterdi: yıkıcı yazmak, taşıma işlevlerinin üretilmesini engeller — taşıyan yapıcı ve taşıyan atama yoktur, sanki hiç bildirilmemiş gibi. Aşırı yükleme çözümlemesi bu yokluğu görür ve hâlâ örtük olarak üretilen kopyalayan yapıcıya döner; bu yüzden Kayit b = std::move(a); derlenir ve çalışır, sadece bir kopyalama yapar.
Ters yön farklı işler. Bir sınıf taşıyan yapıcısını ya da taşıyan atamasını elle yazarsa, derleyici kopyalayan yapıcıyı ve kopyalayan atamayı örtük olarak üretmez, üstelik onları = delete ile açıkça silinmiş sayar. Böyle bir sınıfı kopyalamaya çalışan kod derlenmez; aşırı yükleme çözümlemesi bir yedek bulup sessizce ona düşmez, hata verir. Paket’in beşini de = default ile açıkça istemesinin nedeni tam olarak budur: yalnız taşıma işlevlerini yazıp kopyalamayı kendi hâline bırakmış olsaydı, Paket’i kopyalamaya çalışan her kod derleme zamanında reddedilirdi — bu, sınıfın niyet ettiği bir sonuç olabilir de olmayabilir de, ama sessizce olması istenmez.
Bu iki yönün asimetrisi rastgele değil, bir güvenlik tercihidir. Derleyici bir taşıma işlevi yazıldığını gördüğünde, sınıf yazarının kaynağı yönetme biçimi hakkında özel bir şey düşündüğünü varsayar — belki sınıf, birden fazla nesnenin aynı kaynağı paylaşmasını anlamsız ya da tehlikeli buluyordur. Böyle bir sınıf için örtük bir kopyalayan yapıcı üretmek (kaynağı üye üye kopyalayarak) sınıfın niyetini sessizce çiğneyebilir; iki nesnenin aynı çıplak kaynağı tutup ikisinin de onu serbest bırakmaya çalışması gibi bir hataya yol açabilir. Derleyici bu riski almak yerine kopyalamayı tamamen kapatır ve sınıf yazarının açıkça karar vermesini bekler. Yıkıcı yazıldığında ise aynı varsayım geçerli değildir — bir yıkıcı yazmak tek başına “bu sınıf taşınamaz” anlamına gelmez, bu yüzden derleyici kopyalamayı kapatmaz, yalnızca taşımayı örtük üretmekten vazgeçer ve daha güvenli bulduğu kopyalamaya düşer.
Sıfır Kuralı
Sıfır kuralı (rule of zero) beş kuralının tam tersi bir yoldan aynı hedefe varır: sınıf hiçbir özel üye işlevi elle yazmaz, kaynağı zaten kendi beşini doğru yöneten üyelere (std::string, std::unique_ptr, std::vector gibi) bırakır. SadeKayit bunu gösteriyor: tek üyesi bir std::string, kendi hiçbir özel üye işlevi yok. Derleyici, üyesinin taşıyan yapıcısını çağıran bir taşıyan yapıcıyı örtük olarak üretir — ve bu üretilen taşıyan yapıcı gerçek bir taşıma yapar.
Sıfır kuralının pratikte tercih edilmesinin nedeni budur: Paket’in beş = default satırı hiçbir yeni davranış eklemez, yalnızca derleyicinin zaten üreteceği şeyi açıkça ister — bu satırları hiç yazmadan icerik üyesini std::string yerine doğrudan başka bir kaynak yönetici üyeye (örneğin std::unique_ptr<std::string>) bırakmak, aynı beş işlevi derleyiciden örtük olarak, hiçbir satır yazmadan almanın bir yolu olurdu. Paket burada beşi elle istemek zorunda kaldı çünkü kendi yazdığı bir yapıcısı (explicit Paket(std::string i)) vardı; sıfır kuralına uyan bir sınıf, kendi hiçbir yapıcısını da elle yazmayan bir sınıftır. Bir sınıfın kaynak yönetimini üyelerine devretmesi, hem daha az kod hem de beş işlev arasındaki bağımlılığı elle doğru kurma riskini tamamen ortadan kaldırması bakımından beş kuralına göre daha güvenli kabul edilir.
KY6. Ama gerçek bir taşımanın sonucu, Kayit’inkinden farklı bir sınıfa düşüyor. b.ad kaynağın eski değerini alıyor (hedefin değeri her zaman tanımlıdır), ama a.ad artık std::string’in kendi taşıma sonrası kuralına tabi: geçerli ama belirtilmemiş durumda, yani belirsiz. Bu ders a.ad’yi okumadan önce ona "yeni deger" atıyor — çıktıdaki son satır bu atamadan sonraki değeri gösteriyor, taşıma sonrası ham içeriği değil.
Sınırlayıcı Ölçüm
Bu üç örnek yan yana konduğunda görülen şey şu: aynı görünen bir satır (X b = std::move(a);) sınıfın yazılışına göre üç farklı sonuca varabiliyor. Kayit için sonuç aynen (kopyalayan yapıcıya düşüldüğü için), SadeKayit için sonuç belirsiz (gerçek bir taşıma, ama üyenin kendi geçerli ama belirtilmemiş durum kuralına tabi). Yıkıcıyı silmek — Kayit‘ten yıkıcıyı çıkarmak — sınıfı SadeKayit‘in durumuna, yani aynen’den belirsiz’e taşır. Hangisinin istendiği bir doğru-yanlış meselesi değildir; sınıfın taşınabilir mi taşınamaz mı, taşındığında ne kadar ucuz olması gerektiği gibi sorulara sınıfı yazan kişinin vereceği bir karardır. Bu ders o kararın hangi yöne düştüğünü göstermiyor, yalnızca kararın sonucunun nasıl gözlemleneceğini gösteriyor.
Bu geçişin somut mekaniği şudur: Kayit’in gövdesi boş bir yıkıcı dışında SadeKayit’ten farksızdır — ikisi de tek üye olarak bir std::string tutar. Aradaki tek fark, Kayit‘in yıkıcıyı elle yazmış, SadeKayit‘in hiç yazmamış olmasıdır. Bu tek satırlık fark, X b = std::move(a); çağrısının hangi yapıcıya düştüğünü tamamen değiştirir: SadeKayit‘te derleyici taşıyan yapıcıyı üretir, kaynak (a.ad) gerçekten taşınır ve belirsiz sınıfına düşer; Kayit‘te taşıyan yapıcı hiç üretilmez, çağrı kopyalayan yapıcıya düşer ve kaynak aynen kalır. Sınıfa yıkıcı eklemek ya da çıkarmak, başka hiçbir satır değişmeden, aynı çağrının sonucunu aynen’den belirsiz’e ya da belirsiz’den aynen’e çevirebilir — bu ders boyunca izlenen dört sınıf arasında bir sınıf değişikliğinin bu kadar dolaylı bir yoldan (bir işlevi eklemek/çıkarmak, taşımayla hiç ilgisi olmayan bir işlevmiş gibi görünse de) tetiklenebildiği tek örnek budur.
Özet
- Bir yıkıcı elle yazmak, derleyicinin örtük taşıyan yapıcı ve taşıyan atama üretmesini engeller; bu durumda taşıma çağrıları kopyalayan sürüme düşer ve kaynak aynen kalır.
- Beş özel üye işlev (yıkıcı, kopyalayan yapıcı, kopyalayan atama, taşıyan yapıcı, taşıyan atama) birbirine bağlıdır; beş kuralı bunlardan biri yazıldığında hepsinin gözden geçirilmesini, gerekirse
= defaultile açıkça istenmesini önerir. - Sıfır kuralı hiçbir özel üye işlevi yazmadan, kaynağı zaten doğru yöneten üyelere (
std::stringgibi) bırakır; bu durumda derleyicinin ürettiği taşıyan yapıcı gerçek bir taşıma yapar. - Sıfır kuralına uyan bir sınıfın üyesi taşındıktan sonra kaynak belirsiz sınıfına düşer — okumadan önce yeni bir değer atanmalıdır.
- Aynen ile belirsiz arasındaki seçim sınıf yazarının kararıdır; bu dersin sınırlayıcı ölçümü aynı satırın (
X b = std::move(a);) sınıfın yazılışına göre iki farklı sınıfa düşebildiğini gösterir.
Sonraki Adım
Bu ders, bir kaynağın taşıma sonrası boş kalmasının standardın verdiği bir söz olabileceği gibi (unique_ptr örneğinde olduğu gibi) sınıfın kendi elle yazdığı bir davranış da olabileceğini örtük biçimde gösterdi. Sonraki ders bu ikinci yolu açık biçimde ele alıyor: kendi taşıyan yapıcısını yazan bir sınıfın kaynağı, standardın değil sınıfın kendisinin verdiği bir sözle boşalır.
İlerlemeni kaydetmek ve not almak için Giriş yap
Notlarım
Not almak için giriş yapmalısın.