Ders 02 / 11
Taşıma Semantiği
std::move bir tip dönüşümünden ibarettir; asıl taşımayı yapan taşıyan yapıcı ve taşıyan atama işlecidir. Bir std::string taşındıktan sonra kaynakta ne kaldığını ölçme.
Yayın Güncelleme
Bu derste
- sağ değer, sol değer ve sağ değer referansı arasındaki farkı ayırt eder
- std::move çağrısının bir tip dönüşümünden ibaret olduğunu gösteren bir örnek kurar
- taşınmış bir std::string'in geçerli ama belirtilmemiş durumda kaldığını doğru biçimde ele alır
- kaynağın yıkıcısının taşımadan sonra da çalıştığını basılan bir satırla kanıtlar
İçindekiler
Önceki ders taşınan bir unique_ptr’ın kaynağını standardın verdiği bir sözle boşalttığını gösterdi: taşıma bitince kaynak nullptr olur, bu güvence akıllı işaretçinin kendi türünden gelir. Ama her tür aynı kesinlikte konuşmaz. unique_ptr yalnızca bir işaretçi taşıdığı için boşluğu ucuza garanti edebilir; std::string gibi kendi içinde veri taşıyan bir tür taşındığında standart aynı sözü vermez. Bu ders, standardın daha az kesin konuştuğu o sonucu ölçüyor.
Sağ Değer, Sol Değer, Sağ Değer Referansı
Bir ifadenin sol değer (lvalue) mi sağ değer (rvalue) mi olduğu, adının olup olmamasından çok, o ifadenin adresinin alınıp alınamayacağıyla ilgilidir. s gibi adlandırılmış bir değişken bir sol değerdir; std::string("gecici") gibi geçici bir sonuç ya da std::move(s)’nin döndürdüğü ifade bir sağ değerdir. Sağ değer referansı (rvalue reference), T&& yazımıyla bildirilir ve yalnızca sağ değerlere bağlanabilir; bu bağlanma, derleyiciye “bu değer artık kimsenin ihtiyaç duymadığı geçici bir değer, içeriğini alabilirsin” sinyalini verir.
std::move bir işlev gibi görünür ama aslında hiçbir veriyi taşımaz; yaptığı tek şey, elindeki bir sol değeri bir sağ değer referansına dönüştürmektir — düz bir static_cast<T&&> çağrısından ibarettir. Bu dönüşüm tek başına hiçbir taşıyan yapıcı ya da taşıyan atama çağırmaz; yalnızca derleyicinin aşırı yükleme çözümlemesinde sağ değere özgü sürümü (varsa) seçmesine izin verir.
Bu yüzden std::move(s) ifadesinin kendisi hiçbir yan etki üretmez: s’nin türü değişmez, adresi değişmez, içeriği değişmez. Yan etki, bu ifade bir yapıcıya ya da atama işlecine argüman olarak verildiğinde ve derleyici o çağrı için taşıyan sürümü seçtiğinde ortaya çıkar. std::move(s) bir işlev çağrısının içine hiç girmeden, örneğin yalnızca bir değişkene atanıp bir daha kullanılmadan da yazılabilir — bu durumda hiçbir taşıma gerçekleşmez, çünkü hiçbir taşıyan yapıcı ya da taşıyan atama çağrılmamıştır. Adın “taşı” olması, çağrının kendisinin bir taşımayı garanti ettiği anlamına gelmez; yalnızca bir isteği, bir izni ifade eder.
Bir sınıf hem T(const T&) hem de T(T&&) sürümünü birlikte bildirdiğinde, hangisinin çağrılacağına derleyici argümanın değer kategorisine bakarak karar verir: adlandırılmış bir sol değer verilirse const T& sürümü, std::move ile üretilmiş ya da zaten geçici olan bir sağ değer verilirse T&& sürümü seçilir. Bu seçim çağıran tarafın hiçbir ek işaret vermesini gerektirmez — aynı çağrı yazımı (T yeni(kaynak);), kaynak’ın türüne göre farklı bir yapıcıya yönlenir. T&& parametresi olmayan bir sınıfta ise her iki durumda da tek seçenek olan const T& sürümü çalışır; sağ değer verilmiş olması bu durumda hiçbir fark yaratmaz, çünkü derleyicinin seçebileceği başka bir aşırı yükleme yoktur.
Taşıyan Yapıcı ve Taşıyan Atama İşleci
Bir sınıfın taşıyan yapıcısı (move constructor) T(T&& diger) imzasıyla, taşıyan atama işleci (move assignment operator) T& operator=(T&& diger) imzasıyla bildirilir. İkisi de kaynaktaki kaynağı (bir arabellek, bir tutamaç, bir sayı) hedefe devretmek üzere yazılır; kopyalayan sürümlerinden farkı, yeni bir kaynak açmak yerine var olanı almalarıdır.
Aşağıdaki Kutu sınıfı bunu elle gösteriyor. Taşıyan yapıcı, diger’in etiket_ üyesini kendi etiket_ üyesine taşıyor — burada da std::move yalnızca diger.etiket_’i bir sağ değere çeviriyor, gerçek taşımayı std::string’in kendi taşıyan yapıcısı yapıyor. Taşıyan atama işleci aynı devri, zaten var olan bir nesnenin üzerine yazarak yapıyor. İkisi de noexcept işaretlidir; bu işaret yalnızca bir belge notu değildir — standart kütüphanenin std::vector gibi kapsayıcıları, yeniden ayırma sırasında elemanları taşıyıp taşıyamayacaklarına bu işarete bakarak karar verir. noexcept olmayan bir taşıyan yapıcı, kapsayıcıyı taşımak yerine kopyalamaya zorlayabilir.
// kutu.cpp — tasiyan yapici, tasiyan atama ve kaynagin yikicisi
#include <cstdio>
#include <string>
#include <utility>
class Kutu {
public:
explicit Kutu(std::string etiket) : etiket_(std::move(etiket)) {}
Kutu(Kutu&& diger) noexcept : etiket_(std::move(diger.etiket_)) {
std::printf("tasiyan yapici calisti\n");
}
Kutu& operator=(Kutu&& diger) noexcept {
etiket_ = std::move(diger.etiket_);
std::printf("tasiyan atama calisti\n");
return *this;
}
Kutu(const Kutu&) = delete;
Kutu& operator=(const Kutu&) = delete;
~Kutu() { std::printf("Kutu yikicisi calisti\n"); }
private:
std::string etiket_;
};
static void tasiyan_islevler() {
Kutu a("ilk etiket, kisa dize eniyilemesini asacak kadar uzun bir metin");
Kutu b(std::move(a));
Kutu c("ikinci etiket, yine kisa dize eniyilemesini asacak kadar uzun bir metin");
c = std::move(b);
}
static void dize_tasima() {
std::string s = "kucuk dize eniyilemesini asacak kadar uzun bir metin, tasima icin";
std::string t = std::move(s);
std::printf("t (kaynaktan gelen deger) = %s\n", t.c_str());
s = "yeni deger";
std::printf("s (yeni deger verildikten sonra) = %s\n", s.c_str());
}
static void sabit_dize() {
const std::string c = "sabit bir metin, tasima yapicisina baglanamayacak kadar sabit";
std::string once = c;
std::string kopya = std::move(c);
std::printf("kopya (c'den gelen deger) = %s\n", kopya.c_str());
std::printf("c ayni mi: %s\n", (c == once) ? "evet" : "hayir");
}
int main() {
tasiyan_islevler();
dize_tasima();
sabit_dize();
}
tasiyan yapici calisti tasiyan atama calisti Kutu yikicisi calisti Kutu yikicisi calisti Kutu yikicisi calisti t (kaynaktan gelen deger) = kucuk dize eniyilemesini asacak kadar uzun bir metin, tasima icin s (yeni deger verildikten sonra) = yeni deger kopya (c'den gelen deger) = sabit bir metin, tasima yapicisina baglanamayacak kadar sabit c ayni mi: evet
KY3. tasiyan_islevler içindeki Kutu nesnelerinin hiçbiri, taşınmış olmaktan dolayı yıkıcısız kalmıyor: açılan her Kutu nesnesi kapsam sonunda kendi Kutu yikicisi calisti satırını basıyor. a’nın içeriği b’ye, b’nin içeriği c‘ye taşındıktan sonra bile hepsi işlev sonunda normal biçimde yok ediliyor. Bu, önceki dersin M10/K02 ve M11/K02 karşılaştırmasını burada da doğruluyor: Rust’ta taşınan bir değişken derleyici tarafından artık kullanılamaz sayılır ve kapsam sonunda onun için hiçbir yıkım kodu çalışmaz; C++’ta taşınan nesne yaşamaya devam eder, yıkıcısı yine çağrılır — taşıma nesnenin ömrünü değil, yalnızca içeriğini etkiler.
Taşıyan atama işlecinin bir başka inceliği kendi kendine atamaya karşı davranışıdır: x = std::move(x); gibi bir çağrı yazılabilir olmalı ve nesneyi bozmamalıdır. Kutu’nun taşıyan atama işleci burada etiket_ = std::move(diger.etiket_); satırıyla std::string’in kendi taşıyan atamasına güveniyor; std::string’in kendi kendine taşıyan ataması güvenlidir: tanımsız davranışa yol açmaz ve nesneyi geçerli bırakır, ama standart dizgenin ardından hangi değeri taşıyacağını söylemez; sonuç bu dersin belirsiz sınıfına girer. Kaynağı elle yöneten bir sınıf (örneğin çıplak bir işaretçi tutan bir sınıf) yazarken bu kontrolü açıkça eklemek gerekir — aksi hâlde kendi kendine atama nesnenin kaynağını serbest bırakıp hemen ardından o serbest bırakılmış kaynağı okumaya çalışabilir.
Kaynağın Yıkıcısı Neden Hâlâ Çalışır
tasiyan_islevler çıktısındaki her Kutu yikicisi calisti satırı rastgele bir ayrıntı değil; C++’ın taşıma modelinin doğrudan bir sonucudur. Bir nesne taşındığında kapsam içinde kalmaya devam eder — ne yaşam süresi kısalır ne de yıkıcı çağrısı atlanır. a içeriğini b’ye devrettikten sonra bile a hâlâ geçerli bir Kutu nesnesidir; kapsam sonuna geldiğinde derleyici onun için de normal yıkıcı çağrısını üretir. Yıkıcının kendisi taşınmış ya da taşınmamış olmak arasında bir ayrım yapmaz — ~Kutu() her zaman aynı gövdeyi çalıştırır, çünkü sınıf yazarı yıkıcıyı yalnızca bir kez, nesnenin olağan sonu için yazar.
Bu davranışın güvenli olmasının koşulu, taşıyan yapıcının ve taşıyan atamanın kaynağı yıkıcının güvenle çalışabileceği bir duruma bırakmasıdır. Kutu burada etiket_ üyesini std::string’e devrediyor; std::string’in kendi taşıyan yapıcısı, kaynak dizeyi geçerli ama belirtilmemiş bir duruma getirir ve bu durumun kendi yıkıcısı tarafından güvenle serbest bırakılabileceğini garanti eder. Eğer Kutu bir std::string yerine çıplak bir char* tutsaydı, taşıyan yapıcının kaynaktaki işaretçiyi nullptr’a çekmesi gerekirdi — aksi hâlde hem kaynak hem hedef aynı belleği serbest bırakmaya çalışır ve program çift serbest bırakma hatasına düşerdi. Bir taşıyan yapıcı yazmanın örtük kuralı budur: kaynağı, o kaynağın yıkıcısı ikinci kez zarar vermeden çalışabilecek bir duruma getirmek.
Bu, Rust’ın modeliyle tam burada ayrılıyor. Rust’ta taşınan bir değişken için derleyici kapsam sonunda hiçbir yıkım kodu üretmez — değişken artık “canlı” sayılmadığı için düşürme (drop) çağrısı ona hiç uygulanmaz. C++’ta ise böyle bir muafiyet yoktur: taşınan nesne kapsamın sonuna kadar yaşamaya devam eder ve yıkıcısı, tıpkı hiç taşınmamış bir nesneninki gibi, mutlaka çalışır. Bu yüzden C++’ta taşıma sonrası kaynağın hangi sınıfa (aynen, değişti, boş, belirsiz) düştüğünü bilmek önemlidir — o nesne ortadan kalkmaz, kodun geri kalanı onunla (ya da en azından onun yıkıcısıyla) hâlâ karşılaşacaktır.
Taşınmış Bir std::string: Geçerli Ama Belirtilmemiş Durum
dize_tasima işlevindeki std::string t = std::move(s); satırından sonra s’nin içeriği hiçbir yerde okunmadan, doğrudan s = "yeni deger"; ile üzerine yazılıyor. Bu sıralama rastgele değil: standart, taşınmış bir std::string’in “geçerli ama belirtilmemiş durumda” (valid but unspecified state) kalacağını söyler — nesne hâlâ geçerlidir, üzerinde size() çağırmak ya da yeni bir değer atamak güvenlidir, ama o an ne tuttuğu standart tarafından tanımlanmaz. Bu yüzden bu ders s’nin taşıma sonrası içeriğini hiçbir satırda basmıyor; yalnızca yeni bir değer verildikten sonraki hâlini okuyor.
t’nin değeri ise böyle değil: hedef nesnenin taşıma sonrası içeriği standart tarafından tam olarak tanımlıdır — kaynağın taşımadan önceki değeriyle aynı olacaktır. Çıktıdaki t (kaynaktan gelen deger) = ... satırı bu yüzden güvenle basılabiliyor; belirsiz sınıfına düşen yalnızca kaynak, hedef değil.
KY4. Taşımanın kopyalamadan ucuz olmasının nedeni de burada gizli: std::string’in taşıyan yapıcısı t’nin arabellek işaretçisini s’den devralır, karakterleri tek tek kopyalamaz. Bir kopyalayan yapıcı çağrılsaydı, kaynaktaki her karakter için yeni bir arabellek ayrılır ve o arabelleğe kaynaktan tek tek veri yazılırdı — dizenin uzunluğuyla orantılı bir işlem. Taşıyan yapıcı ise yalnızca birkaç üyeyi (arabellek işaretçisi, uzunluk, kapasite) hedefe kopyalar ve kaynağınkini sıfırlar; bu, dizenin içeriğinin ne kadar uzun olduğundan bağımsız, sabit maliyetli bir işlemdir. Bu ders bu devri saymaz — kaç karakterin taşındığı ya da kaç bayt kopyalanmadığı bu dersin ölçtüğü şey değil; ölçülen tek şey, işlem bitince kaynağın hangi sınıfa düştüğü.
Önceki kursun ölçtüğü “hangi nesne” sorusu (M11/K04) burada geçerli değil: orada kopya sınıfı bir hedefin adını taşıyordu, burada aynen bir kaynağın kendisinin adını taşıyor. Bir taşıma, hedefte her zaman kaynağın eski değerini kurar — hedef sınıfı buradaki ölçümün konusu değildir; ölçülen, geride kalan kaynaktır.
Sınırlayıcı Ölçüm
sabit_dize işlevi bu dersin sınırını gösteriyor: c bir const std::string olduğu için std::move(c) çağrısı const std::string&& türünde bir sağ değer referansı üretir. std::string’in taşıyan yapıcısı std::string&& (sabit olmayan) bir parametre bekler; const std::string&& ona bağlanamaz. Derleyici bu durumda aşırı yükleme çözümlemesinde geriye düşer ve kopyalayan yapıcıyı çağırır. Sonuç: c bir taşıma gibi görünen bir çağrıdan sonra bile aynen kalır — çıktıdaki c ayni mi: evet satırı bunu doğruluyor.
Bu, std::move yazmanın bir taşımayı garanti etmediğini gösteren en net örnektir. std::move yalnızca bir tip dönüşümüdür; hangi yapıcının gerçekten çağrılacağına derleyicinin aşırı yükleme çözümlemesi karar verir, ve bir const nitelemesi bu çözümlemeyi taşıyan yapıcıdan uzaklaştırabilir. Pratikte bu, const bir değişkeni bir işleve taşımak niyetiyle geçirmenin genellikle bir tasarım hatası olduğu anlamına gelir: değişken const olarak kaldığı sürece derleyicinin taşıyan sürümü seçmesi mümkün değildir, kod her zaman kopyalayan sürüme düşer — bu yüzden bir kaynağın gerçekten taşınması isteniyorsa o kaynağın const olmaması gerekir.
Özet
- Sol değer adlandırılmış, adresi alınabilen bir ifadedir; sağ değer geçici bir sonuçtur; sağ değer referansı (
T&&) yalnızca sağ değerlere bağlanır. std::movebir tip dönüşümüdür, kendisi hiçbir veriyi taşımaz — hangi yapıcının çağrılacağına aşırı yükleme çözümlemesi karar verir.- Taşınmış bir
std::string’in kaynağı geçerli ama belirtilmemiş durumdadır (belirsiz sınıfı); bu ders onun içeriğini okumadan önce ona her zaman yeni bir değer verdi. - Taşınan nesnenin yıkıcısı normal biçimde çalışmaya devam eder — C++’ta taşıma ömrü bitirmez, yalnızca içeriği devreder.
constbir kaynak üzerindestd::moveçağırmak taşıyan yapıcıya değil kopyalayan yapıcıya düşer; kaynak bu durumda aynen kalır — bu dersin sınırlayıcı ölçümü budur.
Sonraki Adım
Bu ders bir taşımanın gerçekleşip gerçekleşmediğinin, std::move yazmakla değil hangi yapıcının fiilen çağrıldığıyla belirlendiğini gösterdi. Bir sınıfın taşıyan yapıcısının derleyici tarafından örtük olarak yazılıp yazılmayacağı da benzer bir çözümlemeye bağlıdır — ve bu çözümlemeyi sınıfın yazdığı başka bir özel üye işlev bile bozabilir. Sonraki ders, bir yıkıcı yazmanın örtük taşıyan yapıcıyı nasıl bastırdığını ve sıfır kuralıyla beş kuralının bunu nasıl yönettiğini ölçüyor.
İlerlemeni kaydetmek ve not almak için Giriş yap
Notlarım
Not almak için giriş yapmalısın.