sağ taraf referansları – 1

Sağ taraf referansları C++11 standartlarıyla C++ diline eklenmiş en önemli araçlardan biridir.
Sağ taraf referanslarını iyi öğrenebilmek için öncelikle bu referansların ne işe yaradığını, ne amaçla dile eklendiğini bilmelisiniz. Şimdi sağ taraf referanslarının çözüm getirdiği problemleri problemleri anlamaya çalışalım.
Sağ taraf referansları başlıca iki sorunun çözümünde kullanılan bir araçtır:
Taşıma semantiğinin (move semantics) gerçekleştirilmesi
Mükemmel gönderim (perfect forwarding)

Önce taşıma semantiğiyle başlayacağız.
Konuyu ayrıntılı incelemeye başlamadan önce C++ dilinde sol taraf değeri (L value) ve sağ taraf değeri (R value) ne demek bir hatırlayalım:
Tam doğru ve ayrıntılı bir tanım fazla teknik olacağından şimdilik konuyu anlamamızı sağlayacak basit bir tanımla yetinebiliriz. C dilinin ilk dönemlerinden kalma şöyle bir tanımla başlayalım:
Atama işlecinin hem sol tarafında hem sağ tarafında yer alması geçerli olan ifadeler sol taraf değeridir.
Atama işlecinin yalnızca sağ tarafında yer alabilen (sol tarafında yer alamayan) ifadeler sağ taraf değeridir.

Bu tanım başlangıç faydalı ve pratik olabilir, ancak kodla tanımlanan türler (user defined types) söz konusu olduğunda değiştirilebilirlik ve atanabilirlik gibi kavramlar da devreye girecek ve tanımımız doğru olmaktan uzaklaşacak. Şimdilik bu kadar ayrıntıya girmeye gerek yok. Yüzde yüz doğru olmasa da konuyu anlamakta kullanabileceğimiz alternatif bir tanım şöyle olabilir:
Bir sol taraf değeri bir bellek alanına işaret eder ve bu bellek alanının adresini & (adres) işleciyle geçerli olarak elde edebiliriz.
Sol taraf değeri olmayan ifadeler sağ taraf değeridir.

Konu hakkında daha fazla bilgi edinmek için Mikael Kilpeläinen‘in ACCU’da yayımlanan konuyla ilgili makalesini okuyabilirsiniz.

taşıma semantiği

X, bir kaynağı yöneten bir sınıf olsun. Söz konusu kaynağın yönetimi  için X sınıfının bir veri elemanı kaynağın türünden bir gösterici tutan m_pResource isimli bir gösterici olsun.
Kaynak demekle, kurulması, kopyalanması ya da sonlandırılması belirli işlemleri gerektiren herhangi bir varlığı kast ediyoruz.

Örneğin standart kütüphanenin std::vector sınıfı, dinamik olarak elde edilmiş bir bellek alanına bir gösterici tutmaktadır. Burada kullanılan kaynak  vector sınıfıın yönettiği kaynak bu dinamik bellek alanıdır.

X sınıfının atama operator işlevi doğal olarak aşağıdaki gibi yazılacaktır:

Şimdi aşağıdaki koda bakalım:

Yukarıdaki kodda

deyimi ile aşağıdaki işler gerçekleştirilmiş olur:
foo işlevi tarafından geri döndürülen nesnenin kaynağının bir kopyası oluşturulur,
x‘in kaynağı sonlandırılır (geri verilir),
veri öğesi olan göstericinin oluşturulan kopyayı göstermesi sağlanır.
geri dönüş değeri olan geçici nesnenin sonlandırıcı işlevi çağrılır ve böylece geçici nesnenin kaynağı geri verilir.

Aslında burada çok daha verimli alternatif şöyle bir kod oluşturmak mümkündür:
x nesnesi ile geçici nesnenin yalnızca göstericilerini takas edip geçici nesnenin sonlandırıcı işlevinin x‘in kendi kaynağını geri vermesini sağlayabiliriz.
Bir başka deyişle eğer atama işlecinin sağ operandı bir sağ taraf değeri ise daha verimli olan şu alternatif kod çalıştırılması verimi artıracaktır:

Taşıma semantiği denen bu koşula bağlı davranış  C++11’de bir yükleme (overload) ile sağlanabilir:

Bu amaçla atama operatör işlevinin yüklenmesi gerekiyor. Bu durumda yukarıdaki kodda atama operatörü işlevinin parametre değişkeninin türüne şimdilik “gizemli tür” dedik. Gizemli türümüz bir referans olmalı. Eğer atama işlecinin sağ tarafındaki nesne referans yoluyla işlevimize geçirilmez ise işlevimizin bu nesneyi değiştirme şansı olmaz, değil mi?
Ayrıca bu gizemli türden bir de beklentimiz var:
Sınıfın normal atama operatör işlevi ile (yani normal referansa sahip olan) gizemli türden parametreye sahip işlevi bir arada bulunduğunda, sağ taraf değerleri için gizemli tür parametreli atama operatör işlevi, sol taraf değerleri için normal referans parametreli işlevler seçilmeli.
İşte, gizemli tür yerine sağ taraf referansı terimini koyduğumuzda sağ taraf referansının tanımıyla karşılaşmış oluyoruz.

sağ taraf referansları

X bir tür olmak üzere, X&& türüne X‘e sağ taraf referansı denir.
Artık yeni bir referans türüne daha sahip olduğumuzdan daha önce kullanmakta olduğumuz (normal) referanslara da sol taraf referansı diyeceğiz.
Bir sağ taraf referansı birkaç istisna dışında daha önce öğrendiğimiz referanslar (X&) gibidir.
Bizi ilgilendiren şudur:
Sol taraf referansı ve sağ taraf referansı paremetrelere sahip aynı isimli işlevler bir arada bulunduğunda ve bu isim işlev çağrısında kullanıldığında hangi işlevin çağrıldığı  şöyle anlaşılır (function overload resolution):
Sol taraf değerleri için sol taraf referansı parametreli işlev,
sağ taraf değerleri için sağ taraf referans parametreli işlev
çağrılır. Aşağıdaki koda bakalım:

Sağ taraf refersansları bir işlevin derleme zamanında (işlev yüklemesi yoluyla) şu koşula bağlı olarak dallanmasını sağlar: Sol taraf değeri ile mi çağrılıyorum sağ taraf değeri ile mi çağrılıyorum?
C++11 ile işlevler bu şekilde yüklenebilmektedir.
Böyle bir yükleme çok büyük çoğunlukla taşıma semantiğinin oluşturulması amacıyla kurucu işlevler ve atama operatör işlevleri için gerekmektedir.

Kurucu işlevin de sağ taraf referansının benzer mantıkla oluşturulması gerekir.

Notlar:

işlevi varsa fakat

işlevi yok ise, foo işlevi sol taraf değerleri için çağrılabilir fakat sağ taraf değerleri için çağrılamaz. Eğer

işlevi varsa fakat

işlevi yok ise, sol taraf değeri ile sağ taraf değeri arasında işlev çağrısı açısından bir fark olmayacaktır. Hem sol taraf değerleri hem sağ taraf değerleri için aynı işlev çağrılacaktır. Sağ taraf değerleri ile sol taraf değerlerinin  farklı ele alınmalarını sağlamanın tek yolu

işlevini tanımlamaktır. Son olarak, eğer

işlevi tanımlanır da ne

işlevlerinin ikisi de tanımlanmaz ise

foo işlevi sadece sağ taraf değerleri için çağrılabilir. Sol taraf değerleri için foo işlevinin çağrılması sentaks hatasıdır.

taşıma semantiğine zorlamak

Taşıma semantiği normal olarak sağ taraf değerlerine uygulanan bir işlemdir. Çünkü sağ taraf değeri olan nesneler hayatı sona ermek üzeri olan nesnelerdir. Sağ taraf değeri olan nesnelerin kaynaklarını kopyalamak yerine kaynaklarını taşıma işlemine tabi tutabiliriz. Ne de olsa bu nesnelerin kaynaklarının çalınıp başka nesnelere taşınmasından sonra bu nesneleri başka kodlar kullanmayacaktır. Ancak C++, taşıma semantiğinin sol taraf değeri olan nesneler için de kullanılmasına izin vermektedir. Taşıma işleminin bazen sol taraf nesnelerine uygulanması kodun verimini arttırmaktadır. Bu konuya iyi bir örnek olarak swap işlevi verilebilir:

X sınıfı için taşıma semantiğini gerçekleştirmek amacıyla sağ taraf referansı parametreli kurucu işlev ve atama operatörü işlevi yazılmış olsun:

swap işlevi içinde bir sağ taraf değeri kullanılmıyor. Bu yüzden yapılan üç atama için de kopyalama semantiği kullanılır.
İşlevin kodunun ilk satırında oluşturulan tmp isimli nesne için kopyalayan kurucu işlev çağrılırken ikinci ve üçüncü satırda yapılan atamaları için de kopyalayan atama operatör işlevi çağrılır.
Fakat bu durumlarda kopyalama yerine taşıma işlemi yapılsaydı kod çok daha verimli olurdu, değil mi?
Her üç deyimde de ortak şöyle bir özellik var: Kaynağı kullanan nesnelere ya bu kopyalamadan sonra yeni bir değer atanıyor ya da nesne bir daha kullanılmıyor:

C++11 ile gelen standart kütüphane işlevi std::move burada imdadımıza yetişiyor. Standart move işlevi (işlev şablonu) sol taraf değerini sağ taraf değerine dönüştürüyor. Yani x bir sol taraf değeri olsa da move işlevinin geri dönüş değeri bir sağ taraf değeridir. Şimdilik

yazmanın

yazmakla aynı anlama geldiğini düşünebilirsiniz.

C++11‘deki swap işlevinin kodunun aşağıdaki şekilde olduğunu düşünebilirsiniz:

Şimdi her üç satırda da taşıma semantiği kullanılıyor.
Taşıma semantiğini uygulamayan türler için, yani taşıyan kurucu işlevi ve taşıyan atama işlevi olmayan sınıflar için yukarıdaki swap işlevi kopyalayan işlevleri kullanacaklar. Son derece basit bir işlev olan std::move  işlevinin kodunu daha sonra inceleyeceğiz. swap işlevinde olduğu gibi, Kullanabildiğimiz her yerde std::move işlevini kullanmak bize çok önemli şu avantajları sağlar:

Taşıma semantiğini gerçekleştiren türler söz konusu olduğunda standart algoritmaların kullanımında çok önemli performans kazançları olur. Yerinde sıralama (inplace sorting) işlemlerini örnek olarak verebiliriz. Yerinde sıralama algoritmalarının yaptığı neredeyse tek iş takas. Artık sort algoritmalarının gerçekleştirdiği takas işlemleri taşıma semantiğinden faydalanır.

STL kütüphanesi bir çok yerde, hizmet verdiği türlerin kopyalanabilir olma koşulunu aramaktadır. Örneğin kaplarda tutulacak türlerin kopyalanabilir özellikte olması gerekmektedir. Aslında kopyalama gereken birçok yerde kopyalama yerine taşıma işlemi de yapılabilir. Bu durumda kopyalama semantiğine sahip olmayıp yalnızca taşıma semantiğine sahip olan türler STL‘de aynı bağlamda rahatlıkla kullanabilirler. Örneğin artık C++11 standartları ile kopyalanamayan ancak taşınabilen türlerden öğeler kaplarda tutulabilmektedir. Standart basic_ostream sınıfı ve unique_ptr sınıfları bu duruma örnek olarak verilebilir.
move işlevine de değindiğimize göre, sağ taraf referansı parametreli atama operatör işlevinin yani taşıyan atama operatör işlevinin nasıl yazılması gerektiğine bakabiliriz. Şimdi şu basit atama işlemine bakalım:

Burada ne olmasını bekliyorsunuz? a nesnesinin tuttuğu kaynak b nesnesinin tuttuğu kaynak ile değiş tokuş edilecek ve bu değiş tokuş süreci  içinde a tarafından tutulan kaynak sonlandırılacak (geri verilecek).
Şimdi aşağıdaki kod satırına bakalım:

Eğer taşıma semantiği basit bir takas işlemi olarak gerçekleştirildiyse, a ve b nesnelerinin tuttukları kaynaklar değiş tokuş edilmiş olur. Ancak henüz hiç bir nesnenin hayatı bitmedi. Şüphesiz, a‘nın tuttuğu kaynak, b isminin kapsamı bittiğinde, b nesnesi için çağrılan sonlandırıcı işlevin kodunun çalışmasıyla, geri verilecek. (Taşıma işlevinin hedefi yine b nesnesinin kendisi değil ise)
Duruma atama operatörünün kodunu yazan gözünden baktığımızda, a nesnesi tarafından tutulan kaynağın tam olarak ne zaman geri verileceği bilinmemektedir:
a nesnesine bir atama yapılmasına karşın a nesnesinin kaynağı dışarıda bir yerlerde yaşıyor.
Bu kaynağın sonlandırılmasının dış dünya tarafından hissedilen bir yan etkisi olmadığı  sürece bir sorun olmaz. Ancak bazen sonlandırıcı işlevlerin yan etkileri söz konusudur. Sonlandırıcı işlevin içinde bir kilidin serbest bırakılması durumunu buna bir örnek olarak verebiliriz.
Bir nesnenin sonlandırılmasında bir yan etki söz konusu ise bu yan etki atama operatörünün sağ taraf referansı yüklemesinde gerçekleştirilmelidir:

bir sağ tarafı referansı sağ taraf değeri midir?

Daha önceki örneklerde olduğu gibi X, taşıma semantiğini uygulamış, yani taşıyan kurucu işleve ve taşıyan atama işlevine sahip bir sınıf olsun:

Burada ilginç ve önemli bir soru var:
foo işlevi içinde oluşturulan anotherX isimli nesne için kopyalayan kurucu işlev mi taşıyan kurucu işlev mi çağrılır?
Kodda da görüldüğü gibi parametre değişkeni olan x sağ taraf referansı olarak tanımlanmış bir parametre değişkeni.
Yani x sağ taraf değeri olan bir nesneye bağlanmış olabilir (böyle bir zorunluluk olmasa da). Başka bir deyişle, sağ taraf referansı olarak bildirilen her varlığın sağ taraf değeri olarak işlem göreceğini düşünmüş olabilirsiniz.
Yani anotherX isimli nesne için

işlevi mi çağrılır?

Sağ taraf referansları için tasarımcılar daha değişik bir çözüm getirmişler:
Sağ taraf referansı olarak bildirilen isimler sol taraf değeri de olabilirler sağ taraf değeri de olabilirler. Ayrıcı kriter şudur: Eğer varlığın ismi varsa sol taraf değeridir, varlık bir isme sahip değilse sağ taraf değeridir. Yukarıdaki örnekte sağ taraf referansı olarak bildirilen varlığın bir ismi olduğundan bildirim sonrasında bir ifade içinde kullanıldığında bu varlık sol taraf değeri olarak ele alınır.

Aşağıdaki örnekteki varlık bir sağ taraf referansı olarak bildiriliyor ve bir isme sahip olmadığı için bir sağ taraf değeri olarak işlem görüyor:

Şimdi bu kuralın arkasındaki mantığı anlamaya çalışalım.

Yukarıdaki kodda olduğu gibi, ismi olan bir varlığa yanikoddaki  x değişkenine taşıma semantiğinin uygulandığını düşünelim. Kafa karıştırıcı bu durum kodlama hatalarına davetiye çıkarmış olurdu. Kaynağını çaldığımız nesne halen hayatta ve bu deyimden sonraki kodlar halen hayatta olan x değişkenini kullanabilecek. Eğer x değişkenini kullanan kodlar x‘in kaynağının çalındığını göz önüne almazlar ise kodlama hatası kaçınılmaz olur.
Taşıma semantiğinin ana esprisi kaynağını çaldığımız nesnenin hayatının bitmek üzere olmasıdır. “Varlığın bir ismi var ise sol taraf değeridir.” kuralının gerekçesi budur. Peki önermenin bir de diğer tarafına bakalım:  Eğer varlığın bir ismi yoksa sağ taraf değeri midir?”;
Yukarıdaki goo() örneğine bakarsak, teknik olarak böyle bir ifade, yüksek bir ihtimal olmasa da, kaynağı çalınmış fakat halen erişilebilir durumda olan bir nesneyi temsil ediyor olabilir.
Ancak böylesi bir durumu özellikle isteyen biz olabiliriz!
Taşıma semantiğinin uygulanıp uygulanmayacağı konusunda  kararı kendimiz vermek isteyebiliriz. Yani bir sol taraf değeri üstünde de taşımanın kontrollü bir şekilde yapılmasını kendimiz tercih edebiliriz. İşte standrt kütüphanenin move işlevi burada devreye giriyor.
move işlevinin kodunu şimdi ele almayacak olsak da o kodu anlamaya biraz daha yaklaşıyoruz:
move işlevi kendisine geçilen argumanı referans yoluyla alır ve hiç bir şey yapmadan onu isimi olmayan sağ taraf referansı değeri olarak geri döndürür.
Bu yüzden

ifadesi bir isme sahip olmayan bir sağ taraf referansıdır. std::move işlevi kendisine argüman olarak gönderilen ifadeyi sağ taraf değerine dönüştürür ve bu yolla isim de gizlenmiş olduğundan ifade sağ taraf değeri olarak ele alımır.

Aşağıdaki kod “bir ismi var” kuralının ne denli önemli olduğunu gösteren başka bir örnek. Base isimli bir sınıf tanımladığınızı ve işlev yüklemesi ile bu sınıfın taşıyan kurucu işlevini ve taşıyan atama işlevini oluşturduğunuzu düşünelim:

şimdi Derived isimli sınıfı Base sınıfından türetiyorsunuz.

Taşıma semantiğinin türemiş sınıf nesnesinin taban sınıf alt nesnesine de uygulanabilmeai için türemiş sınıfın kurucu işlevinin ve atama operatör işlevinin yüklemesi gerekiyor. Önce kopyalayan kurucu işleve bakalım. Atama operatör işlevi de buna benzer şekilde yazılacak. Sol taraf değerleri için durum çok açık:

Gelelim taşıyan kurucu işleve. “İsmi olan bir varlık sol taraf değeridir” kuralının farkında değilsek şöyle bir kod yazabiliriz:

Eğer kod böyle yazılsaydı Base sınıfının taşıyan değil kopyalayan kurucu işlevi çağrılırdı. Çünkü rhs bir isim olduğundan kurallara göre sol taraf değeri olarak işlem görür. Eğer çağrılmasını istediğimiz Base sınıfının taşıyan kurucu işlevi ise kodumuz şöyle olmalı:

taşıma semantiği ve derleyicinin yaptığı optimizasyon

Aşağıdaki tanıma bakalım:

Yukarıdaki kodda yine X sınıfının taşıma semantiğini uygulamış, yani taşıyan kurucu işleve ve taşıyan atama işlevine sahip bir sınıf olduğunu düşünelim. foo işlevi X sınıfı türünden yerel bir değişken olan x‘in değeri ile geri dönüyor. Burada şöyle düşünebilirsiniz: İşlevin geri dönüş değerini tutacak geçici nesneye kopyalama yapmak yerine taşıma semantiğinin uygulanacağından emin olabilmek için aşağıdaki gibi bir kod yazsak:

Ne yazık ki bu çaba performansı iyileştirmek yerine daha da kötüleştirir. Modern bir derleyici ilk kodda geri dönüş değeri optimizasyonu yapar. Başka bir deyişle X sınıfı türünden bir nesneyi önce oluşturup sonra kopyalamak yerine, derleyici nesneyi doğrudan foo işlevinin geri dönüş değerinin kullanıldığı yerde hayata getirecek bir kod oluşturur. Böyle bir kod sağ taraf referansıyla geri dönmekten çok daha iyi bir perdormans sağlamaktadır. Sağ taraf referanslarından ve taşıma semantiğinden en iyi şekilde faydalanabilmek için günümüzdeki derleyicilerin geri dönüş değeri taşıması ve kopyalamadan kaçınma gibi optimizasyon teknikleri uyguladığının farkında olmanız gerekiyor.

Bu konuda daha ayrıntılı bir bilgilendirme için Scott Meyers‘ in “Effective Modern C++” isimli kitabındaki 25 ve 41 no’lu yazıları okumanızda fayda var.

Bu yazı Thomas Becker‘in “C++ Rvalue References Explained” isimli makalesinin serbest çevirisidir.

Share

Necati Ergin

C ve Sistem Programcıları Derneğinde eğitmen olarak çalışıyor.

Bunlar da ilginizi çekebilir

Kod Eklemek İçin Okuyun
Eklemek istediğiniz kodları lütfen aşağıdaki “pre” kodları arasında yazınız.
<pre class="lang:c++ decode:true ">
--yazacağınız kodlar--
</pre>
(buradan kopyalayarak kullanabilirsiniz)