C++0x - Sağ Taraf Değerine Bağlanan Referanslar (Rvalue References) Kaan Aslan 25 Ağustos 2008 C++ a eklenmesine karar verilen yeni bir özellik de sağ taraf değerine bağlanan referanslardır. C++0x standart taslaklarında normal refereranslara sol taraf değerine bağlanan referanslar (lvalue references) denilmektedir. Bu taslaklarda referans terimi hem sağ taraf değerine hem de sol taraf değerine bağlanan referansları kapsayacak biçimde kullanılmaktadır. Sağ taraf değerine bağlanan referanslar dekleratörde && atomu ile bildirilirler: int x; int &a = x; int &&b = foo(); /* normal referans (sol taraf değerine bağlanan referans) */ // sağ taraf değerine bağlanan referans Deklaratördeki && atomlarını ** atomlarına benzeterek referans referansları sanmayın. Bu tamamen başka bir anlama geliyor. Öncelikle normal bir referansa (sol taraf değerine bağlann referansa) verilen ilkdeğerin aynı türden nesne belirten bir ifade (yani sol taraf değeri) olması gerektiğini anımsatalım: int x; int &a = x; int &b = 10; int &c = foo(); Eğer referansa verilen ilkdeğer aynı türden bir nesne belirtmiyorsa referansın const olması gerekir. const olan normal referanslara referansın türüne dönüştürülebilen herahangi bir ifadeyle ilkdeğer verilebilir. Örneğin: double x; int &a = foo(); const int &b = foo(); int &c = 10; const int &c = 10; int &d = x; const int &d = x; 1
const olan normal bir referansa kendi türünden nesne belirtmeyen bir ifadeyle ilkdeğer verildiğinde derleyici tarafından geçici bir nesne yaratılır. Verilen ilkdeğer geçici nesneye atanır ve o geçici nesnenin adresi referansa yerleştirilir. Örneğin: double d = 3.2; const int &r = d; İşlemiyle aslında şunlar yapılmaktadır: double d = 3.2; const int temp = d; const int &r = temp; Bu biçimde yaratılan geçici nesnenin ömrü referansın faaliyet alanı ile ilişkilidir. Sağ taraf değerine bağlann referansların (rvalue references) sol taraf değerine bağlanan referanslardan (lvalue references) en önemli farklılığı sağ taraf değerine bağlanan referanslara sağ taraf değeri ile ilkdeğer verilebilmesidir. Bu durumda sağ taraf değerine bağlanan referanslar const olmak zorunda değildir. Yani sol taraf değerine bağlanan referanslara verilen ilkdeğerlerin nesne belirten ifadeler olması gerekirken sağ taraf değeri alan referanslara verilen ilkdeğerler için böyle bir zorunluluk yoktur. Örneğin: int &a = foo(); int &&b = foo(); Sağ taraf değerine bağlanan referanslar sol taraf değerleriyle (nesne belirten ifadelerle) de sağ taraf değeriyle de ilkdeğer verilerek bildirilebilir. Yani sağ taraf değerine bağlanan referanslar kullanım bakımından sol taraf değerine bağlanan referansları kapsamaktadır. Örneğin: int x; int &&a = x; int &&b = foo(); Sağ taraf değerine bağlanan referanslara verilen ilkdeğerler eğer sol taraf değeri ise doğrudan ilgili nesnenin adresi referansa yerleştirilir değilse yine geçici nesne yaratılarak geçici nesnenin adresi referansa yerleştirilir. Ancak yukarıda da belirttiğimiz gibi bu durumda sağ taraf değerine bağlanan referanslar const olmak zorunda değildir ve bu referanslarla geçici nesneler değiştirilebilir. Örneğin: double d = 3.2; int &&r = d; r = 5; // geçici nesne değiştiriliyor 2
Sol taraf değerine bağlanan referanslarla sağ taraf değerine bağlanan referanslar farklı türler belirttiklerinden fonksiyonların imzalarını (signatures) değiştirirler. Bu nedenle aynı isimli biri sol taraf değerine bağlanan diğeri sağ taraf değerine bağlanan fonksiyonlar aynı faaliyet alanında birarada bulunabilir. Örneğin: void foo(int &r); void foo(int &&r); Overload resolution işlemi için standart taslaklarına otomatik tür dönüştürmelerinde (implicit type conversions) derece belirlemeye (ranking) yönelik ek bir madde eklenmiştir (13.3.3-3). Buna göre: e ifadesi T & ve T && türlerine doğrudan dönüştürülebiliyor olsun. Eğer e ifadesi sol taraf değeri belirtiyorsa T & dönüştürmesi, sağ taraf değeri belirtiyorsa T && dönüştürmesi daha iyidir. Örneğin: void foo(const int &r); // 1 void foo(const int &&r); // 2 foo(100); Burada her iki fonksiyon da aday fonksiyondur. 100 sağ taraf değeri olduğu için ikinci fonksiyonun dönüştürme derecesi birinciden daha iyidir. Overload resolution işlemi sonucunda ikinci fonksiyon seçilir. Şimdi de şu örneğe bakınız: void foo(const int &r); // 1 void foo(const int &&r); // 2 int a; foo(a); Burada birinci fonksiyon daha iyi dönüştürme derecesine sahiptir. Overload resolution işlemi sonucunda birinci fonksiyon seçilir. Aşağıdaki örneğe bakınız: void foo(int &r); // 1 void foo(int &&r); // 2 foo(100); Burada zaten birinci fonksiyon aday fonksiyon (candidate function) değildir. Dolayısıyla overload resolution işlemine yalnızca ikinci fonksiyon girer. Sağ taraf değeri belirten bazı ifadeleri anımsatalım: - Sabitler. - Geri dönüş değeri referans olmayan fonksiyon çağrıları. Örneğin: int &&bar(); int &tar(); 3
foo() ve bar() sağ taraf değeri belirtir. Fakat tar() ifadesi sol taraf değeri belirtir. (Sağ taraf değeri alan referansların sol taraf değeri belirttiğine fakat fonksiyonun geri dönüş değeri sağ taraf değeri alan referanssa fonksiyon çağırma ifadesinin sağ taraf değeri belirttiğine dikkat ediniz.) - Geçici nesneler. Örneğin: class X ; X() ifadesi sağ taraf değeridir. - Çeşitli operatörlerle oluşturulan ifadeler. Örneğin: int a, b; a + b ifadesi sağ taraf değeri belirtir. Sınıfların static olmayan üye fonksiyonlarına geçirilen this parametreleri overload resolution işleminde referans olarak ele alınmaktadır. Bu referans fonksiyonun ilk parametresini oluşturur. Bu parametreye gizli nesne parametresi (implicit object parameter) diyeceğiz. Bu durumda fonksiyonun çağrıldığı nesne de bu parametreye karşı gelen argümandır. Bu argümana da gizli nesne argümanı (implicit object argument) diyeceğiz. C++0x te üye fonksiyonların gizli nesne parametreleri sağ taraf değeri alan referans ya da sol taraf değeri alan referans yapılabilir. Örneğin: class Sample public: void foo() &; void foo() &&; ; Gördüğünüz gibi bu belirlemeyi yapmak için deklaratörde fonksiyonun parametre parantezinden sonra & ya da && atomları yerleştirilir. & ve && belirleyicileri fonksiyonun imzasını değiştirmektedir. Bu belirleyicilerin tanımlama sırasında da kullanılması gerektiğini belirtelim. Böylece bu iki fonksiyon overload resolution işleminde farklı etkilere yol açar. Örneğin: Sample s; Sample().foo(); s.foo(); // foo() && çağrılır // foo() & çağrılır & belirleyicisini belirtmekle belirtmemek aynı anlamdadır. Yani default durum zaten gizli nesne parametresinin sol taraf değeri alan referans olmasıdır. Burada dikkat edilmesi gereken nokta şudur: Sol taraf değeri alan gizli nesne parametresine sahip üye fonksiyon sağ taraf değeri belirten nesneyle çağrılabilir. Yani: 4
class Sample public: void foo() &; ; Sample().foo(); Gizli nesne parametresinin sol taraf değeri ya da sağ taraf değeri belirtmesi sınıfta hem & hem de && belirleyicili aynı isimli fonksiyon bulunduğu zaman farklılık oluşmaktadır. Peki sağ taraf değeri alan referanslara neden gereksinim duyulmuştur? Yanıt oldukça basit: Taşıma düzeneğini (move semantics) sağlamak için. Aşağıdaki örneği inceleyiniz: std::vector<int> func() std::vector<int> a; // elemanlar ekleniyor return a; std::vector<int> b; b = func(); /* kopya başlangıç fonksiyonu ve kopya atama operatör fonksiyonu çağrılacak */ Burada b = func() işlemiyle önce fonksiyonun geri dönüş değeri için geçici nesne yaratılacak ve bu nesne için kopya başlangıç fonksiyonu (copy constructor) çağrılacak, daha sonra b ataması için bu kez kopya atama operatör fonksiyonu çağrılacaktır. Halbuki return işlemi ile yaratılacak geçici vector<int> nesnesi doğrudan yerel nesne tarafından tahsis edilmiş olan alanı kullanabilirdi ve benzer biçimde b ye atama işleminde yine taşıma yapılarak bu alan b ye aktarılabilirdi. Taşıma düzeneğini oluşturabilmek için X gibi bir sınıfa X && parametreli bir başlangıç fonksiyonunun ve atama operatör fonksiyonunun yerleştirilmesi gerekir. Sınıfın X && parametreli başlangıç fonksiyonuna taşıma başlangıç fonksiyonu (move constructor), X && parametreli operatör fonksiyonuna taşıma operatör fonksiyonu (move assignment operator) denilmektedir. Taşıma düzeneği için düşünülmüş olan move isimli fonksiyon şablonu da önemli bir işleve sahiptir: template <class T> T &&move(t &&a) return a; 5
Bu fonksiyonun yaptığı tek şey argüman olarak aldığı ifadeyi sağ taraf değeri olarak vermektir. Böylece elimizde bir sol taraf değeri varsa biz onu hiç değiştirmeden sağ taraf değerine dönüştürebiliriz. Örneğin: template <class T> void swap(t &a, T &b) T tmp(std::move(a)); a = std::move(b); b = std::move(tmp); Öncelikle taşıma mekanizmasının anlaşılması gerekiyor. Bunun için basit bir örnek vermek istiyoruz. Örneğin anlaşılabilir olması için özellikle şablon kullanmamayı tercih ediyoruz: #include <iostream> #include <cstdlib> #include <algorithm> #include <utility> using namespace std; class IntArray public: IntArray() : m_parray(0), m_size(0) IntArray(std::size_t size) : m_parray(new int[size]), m_size(size) ~IntArray() delete [] m_parray; // Kopya başlangıç fonksiyonu IntArray(const IntArray &r) m_parray = new int[r.m_size]; std::copy(r.m_parray, r.m_parray + r.m_size, m_parray); // Taşıma başlangıç fonksiyonu IntArray(IntArray &&r) m_parray = r.m_parray; r.m_parray = 0; 6
// Kopya atama operatör fonksiyonu IntArray &operator =(const IntArray &r) if (&r!= this) delete [] m_parray; m_parray = new int[r.m_size]; std::copy(r.m_parray, r.m_parray + r.m_size, m_parray); return *this; // Taşıma atama operatör fonksiyonu IntArray &operator =(IntArray &&r) std::swap(m_parray, r.m_parray); std::swap(m_size, r.m_size); return *this; int &operator [](std::size_t index) return m_parray[index]; int size() const return m_size; private: int *m_parray; std::size_t m_size; ; Örneğimizde int türden bir diziyi temsil eden IntArray isimli bir sınıf görüyorsunuz. Sınıfın kopya başlangıç fonksiyonu int türden dinamik bir dizi tahsis ederek parametresiyle aldığı sınıfın dizi elemanlarını bu alana kopyalamaktadır. IntArray(const IntArray &r) m_parray = new int[r.m_size]; std::copy(r.m_parray, r.m_parray + r.m_size, m_parray); IntArray nesnesini IntArray türünden bir sol taraf değeri ile ilkdeğer vererek yaratırsak bu başlangıç fonksiyonu çalıştırılacaktır. Şimdi taşıma başlangıç fonksiyonuna bakınız: 7
IntArray(IntArray &&r) m_parray = r.m_parray; r.m_parray = 0; taşıma işleminden sonra ilkdeğer verilen nesnenin m_parray elemanına NULL adres sabitinin atandığına dikkat ediniz. Şimdi taşıma başlangıç fonksiyonunu kullanalım: IntArray a(10); for (int i = 0; i < 10; ++i) a[i] = i; Bu işlemlerden sonra a nesnesi şöyle olacaktır: IntArray b = a; Şimdi olanları inceleyelim: Artık a nesnesinin m_parray elemanında NULL adres var. (NULL adrese delete işlemi uygulamanın bir sakıncası olmadığını anımsayınız.) Gördüğünüz gibi taşıma işlemi gerçekleştirilmiş, gereksiz bir biçimde tahsisat ve kopyalama yapılmamıştır. Şimdi atama operatör fonksiyonuna bakınız: 8
IntArray &operator =(const IntArray &r) if (&r!= this) delete [] m_parray; m_parray = new int[r.m_size]; std::copy(r.m_parray, r.m_parray + r.m_size, m_parray); Burada klasik işlemlerin yapıldığını görüyorsunuz. Önce eski dizi serbest bırakılmış, sonra yeni dizi için yer tahsis edilmiş ve yeni diziye kopyalama yapılmıştır. Şimdi de taşıma operatör fonksiyonunu inceleyiniz: IntArray &operator =(IntArray &&r) std::swap(m_parray, r.m_parray); std::swap(m_size, r.m_size); return *this; Burada ise std::swap fonksiyonuyla m_parray ve m_size değerlerinin yer değiştirildiğini görüyorsunuz. Bunun anlamı nedir? Aşağıdaki örnekle açıklayalım: IntArray foo() IntArray a(10); for (int i = 0; i < 10; ++i) a[i] = i; return a; int main(int argc, char** argv) IntArray b(5); for (int i = 0; i < 10; ++i) b[i] = -i; b = foo(); for (int i = 0; i < b.size(); ++i) cout << b[i] << endl; return 0; Burada b = foo() işlemine odaklanınız. Bu işlemle taşıma operator fonksiyonu çağrılacak değil mi? foo fonksiyonunun geri döndürdüğü nesneye temp diyelim. Önce main fonksiyonunda a nesnesi yaratıldığında şöyle bir durum oluşacaktır: 9
Şimdi foo fonksiyonunun çağrılmasıyla yaratılan geçici nesneyi betimleyelim: Şimdi de b = foo() işlemi sonucunda olanlara bakalım: Burada veri elemanlarının karşılıklı yer değiştirmesi ile b nin m_parray elemanı temp in m_parray elemanı haline gelmiştir. temp için çağrılacak bitiş fonksiyonu b nin gösterdiği eski alanı serbest bırakacaktır. Sağ taraf değerine bağlanan referaslar kopyalanamayan bazı türlerin taşınmasını (movable but not copyable types) mümkün hale de getirebilmektedir. Örneğin bir fstream nesnesinin kopyalanması anlamlı değildir fakat taşınması anlamlı olabilir. 10
Kaynaklar Hinnant, H. (2006). A Proposal to Add an Rvalue Reference to the C++ Language - Revision 3. http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2006/n2118. html adresinden alınmıştır. Hinnant, H., Abrahams, D., & Dimov, P. (2004). A Proposal to Add an Rvalue Reference to the C++ Language. http://www.open-std.org/jtc1/sc22/wg21/docs/ papers/2004/n1690.htm Hinnant, H., Stroustrup, B., & Kozicki, B. (2008). A Brief Introduction to RValue References. http://www.artima.com/cppsource/rvalue.html adresinden alınmıştır. Working Draft, Standard for Programming Language C++ (N2798=08-0308). (2008). http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2008/ adresinden alınmıştır. 11