C++ Dilinde Kopyalama Optimizasyonu — I

Serkan Eser
Nettsi Bilişim Teknoloji A.Ş.
25 min readAug 20, 2020

Bu yazı dizimizde C++ dilinde nesnelerin nasıl kopyalandığını detaylı olarak incelemeye çalışacağız. C++ dilinde nesnelerin nasıl kopyalanacağı önemli bir yer tutmaktadır. Özellikle dışsal kaynakları tutan büyük nesnelerin kopyalanması bellek ve işlemci yönünden maliyetli olabilmektedir. Bu amaçla kopyalama işlemlerinin daha etkin yapılabilmesi için hem dil hem de kod düzeyinde çeşitli yöntemler kullanılmaktadır. Bu yazımızda bu yöntemleri incelemeye çalışacağız. Sırasıyla kopyalama yöntemlerini, kopyalama işlemlerinin dil düzeyindeki araçlarla ve çeşitli kodlama yöntemleri kullanılarak nasıl etkinleştirilebildiğini inceleyeceğiz. Son olarak C++11 standartlarıyla dile bu amaçla eklenen sağ taraf referanslarının (rvalue reference) kullanımlarına bakacağız.

Kopyalama ve Taşıma İşlemleri

Birçok durumda nesnelerin orjinal halleri korunarak kopyaları üzerinde çalışılması gerekebilir. Örneğin bir fonksiyona argüman olarak bir nesnenin
geçirilmesi durumunda geçirilen nesnenin bir kopyası oluşturulmaktadır. Bu tür bir argüman geçirme yöntemi değerle çağırma (call by value) olarak isimlendirilmektedir. Bu durumda çağrılan fonksiyon kendisine geçirilen kopya nesne üzerinde, kendisini çağıran tarafta (call site) bir değişiklik yapmaksızın, çalışabilecektir. Kopyalama işleminin düzeyine göre iki farklı kopyalama yöntemi bulunmaktadır. Orjinal isimleri shallow copy ve deep copy olan bu yöntemleri yüzeysel ve derin kopyalama olarak isimlendireceğiz. Ayrıca, kopyalama işleminden farklı olarak, nesnelerin tuttukları dışsal kaynaklar taşınabilmektedir. Şimdi kopyalama ve taşıma işlemlerine daha yakından bakalım.

Yüzeysel Kopyalama (Shallow Copy)

Yüzeysel kopyalama yönteminde nesnenin tüm elemanlarının birebir kopyası çıkarılmaktadır. Bu yüzden bu yöntem bitwise copy olarak da
isimlendirilmektedir. Örnek bir sınıf üzerinden bu durumu inceleyelim.
Yazdığımız örnek kodu shallow.cpp adıyla saklayıp aşağıdaki gibi derleyebiliriz. Örnek sınıf ile bir banka hesabı temsil edilmektedir.

$ g++ -oshallow shallow.cpp#include <iostream>using namespace std;class Account
{
public:
Account() : m_no(0), m_balance(0) {}
Account(int no, double balance)
: m_no(no)
, m_balance(balance) {}
Account(const Account& rhs)
: m_no(rhs.m_no)
, m_balance(rhs.m_balance) {}
Account& operator=(const Account& rhs)
{
m_no = rhs.m_no;
m_balance = rhs.m_balance;
return *this;
}
void print() const
{
cout << "no: " << m_no << endl;
cout << "balance: " << m_balance << endl;
}
private:
int m_no; /*hesap numarası*/
double m_balance; /*bakiye*/
};
int main()
{
Account acc1(123456789, 666);
acc1.print();
Account acc2 = acc1;
acc2.print();
Account acc3;
acc3 = acc1;
acc3.print();
return 0;
}

Account sınıfı için öngörülen (default) ve parametre alan constructor fonksiyonlarına ek olarak copy constructor ve assignment operator fonksiyonlarını da yazdık. Bu sayede var olan bir nesne üzerinden yeni bir nesne oluşturabilecek ayrıca bir nesneye başka bir nesne ile değer atayabileceğiz. Örneğimizde acc2 nesnesi acc1 üzerinden kopyalanarak oluşturulmaktadır. Kopyalama işlemi sınıfın copy constructor fonksiyonu tarafından yapılmaktadır. acc3 nesnesine ise assignment operator fonksiyonu ile değer atanmaktadır. Her üç nesne için de print fonksiyonu aynı değerleri döndürmektedir.

Derin Kopyalama (Deep Copy)

Sınıfların dışsal bir kaynağı ellerinde bulundurmaları durumunda yüzeysel kopyalama işlemi sorunlara yol açmaktadır. Yüzeysel kopyalamada dışsal kaynağın bir kopyası oluşturulmak yerine aynı dışsal kaynağa başka referanslar oluşturulmaktadır. Buradaki referanstan kastımız C++ dilindeki özel anlamından ziyade göstericilerin kopyalanmasıdır. Basit bir yazı sınıfı üzerinden bu durumu inceleyelim.

#include <iostream>
#include <cstring>
using namespace std;class String
{
public:
String();
String(const char *str);
String(const String& rhs);
~String();
void print() const;
private:
char *m_p;
size_t m_len;
};
String::String()
{
m_len = 0;
m_p = NULL;
}
String::String(const char *str)
{
m_len = strlen(str);
m_p = new char[m_len + 1];
strcpy(m_p, str);
}
String::String(const String& rhs)
{
m_len = rhs.m_len;
m_p = rhs.m_p;
}
String::~String()
{
delete[] m_p;
}
void String::print() const
{
cout << m_p << endl;
}
int main()
{
String s1("Mary had a little lamb");
String s2 = s1;
s2.print();
return 0;
}

Kopyalayan başlangıç fonksiyonu içinde sınıf elemanları birebir kopyalanmakta yani yüzeysel kopyalama yapılmaktadır. Kodu
derleyip çalıştırdığımızda double free or corruption şeklinde bir hata almaktayız.

$ ./shallow
Mary had a little lamb
*** Error in `./shallow': double free or corruption (fasttop): 0x00000000019c4c20 ***

Neden böyle bir hata aldığımızı inceleyelim. s2 nesnesi oluşturulduktan sonra her iki nesne de aynı kaynağı yani dinamik olarak tahsis edilmiş bellek alanını göstermektedir. copy constructor tarafından s1 nesnesi içindeki m_p göstericisinin değeri direkt olarak s2 nesnesine kopyalanmıştır. main fonksiyonu sonlanırken otomatik ömürlü s1 ve s2 nesneleri için sınıfın destructor fonksiyonu çağrılmaktadır. İlk olarak, son oluşturulan, s2 nesnesi üzerinde sınıfın destructor fonksiyonu çağrılmakta ve ortaklaşa tutulan kaynak sisteme geri verilmektedir. Sonrasında s1 nesnesi için de benzer işlem yapılmaya çalışıldığında daha önce geri verilen yer yeniden
verilmeye çalışılmakta ve bu durum uygulamanın sonlanmasına sebep olmaktadır. Benzer hata s1 nesnesi bir fonksiyona geçirildiğinde de oluşacaktır. Bu durumu görmek için yukarıdaki koda foo isimli bir
fonksiyon ekleyelim.

void foo(String param)
{
param.print();
}
int main()
{
String s1("Mary had a little lamb");
foo(s1);
s1.print();
return 0;
}

Program yine benzer hatayı vererek sonlanacaktır. s1 ve foo fonksiyonunun parametresi ortak bir kaynağı göstermektedir. foo fonksiyonu sonlanmadan önce otomatik ömürlü param değişkeni için destructor çağrılacak ve tuttuğu kaynak geri verilecektir. main fonksiyonu sonunda da s1 için benzer işlem yapılmaya çalışıldığında yine double free hatası oluşacaktır. Bu durumun çözümü olarak direkt bir gösterici kopyalaması yapmak yerine göstericinin gösterdiği bellek alanının bir kopyası oluşturulmakta ve bu alanın başlangıç adresi kopyalanmaktadır. Bu sayede kopya nesnelerin tuttukları kaynaklar farklı olmaktadır. Bu tür kopyalama işlemi derin kopyalama (deep copy) olarak isimlendirilmektedir. Yazdığımız yazı sınıfının copy constructor ve operator assignment fonksiyonlarını yeniden yazalım. copy constructor fonksiyonunu aşağıdaki gibi yazabiliriz.

String::String(const String& rhs)
{
m_len = rhs.m_len;
m_p = new char[m_len];
for (int i = 0; i < m_len; ++i) {
m_p[i] = rhs.m_p[i];
}
}

Kopyalan başlangıç fonksiyonu, direkt dışsal kaynağın adresini kopyalamak yerine, yeni bir dinamik alan tahsis etmekte ve bu alana kendisine argüman olarak geçirilen nesnenin içeriğini kopyalamaktadır. Şimdi benzer şekilde bir atama operatör fonksiyonu yazalım.

String& String::operator=(const String& rhs)
{
if (this != &rhs) // nesnenin kendisine atanıp
// atanmadığı kontrol ediliyor
{
// eski tutulan kaynak geri veriliyor
delete [] m_p;
m_p = NULL;
m_len = rhs.m_len;
m_p = new char[m_len];
for (int i = 0; i < m_len; ++i) {
m_p[i] = rhs.m_p[i];
}
}
return *this;
}

Atama operatör fonksiyonunda ilk olarak nesnenin kendine atanıp atanmadığı kontrol edilmiş, sonrasında eski kaynak geri verilmiş ve
kopyalayan başlangıç fonksiyonunda olduğu gibi yeni dışsal kaynak oluşturulmuştur. Son durumda örnek yazı sınıfımıza ilişkin kod
aşağıdaki gibidir.

#include <iostream>
#include <cstring>
using namespace std;class String
{
public:
String();
String(const char *str);
String(const String& rhs);
String& operator=(const String& rhs);
~String();
void print() const;
private:
char *m_p;
size_t m_len;
};
String::String()
{
m_len = 0;
m_p = NULL;
}
String::String(const char *str)
{
m_len = strlen(str);
m_p = new char[m_len + 1];
strcpy(m_p, str);
}
String::String(const String& rhs)
{
m_len = rhs.m_len;
m_p = new char[m_len];
for (int i = 0; i < m_len; ++i) {
m_p[i] = rhs.m_p[i];
}
}
String& String::operator=(const String& rhs)
{
if (this != &rhs) // nesnenin kendisine atanıp atanmadığı kontrol ediliyor
{
// eski tutulan kaynağı geri veriyoruz
delete [] m_p;
m_p = NULL;
m_len = rhs.m_len;
m_p = new char[m_len];
for (int i = 0; i < m_len; ++i) {
m_p[i] = rhs.m_p[i];
}
m_p[m_len] = '\0';
}
return *this;
}
String::~String()
{
delete[] m_p;
}
void String::print() const
{
cout << m_p << endl;
}
void foo(String param)
{
param.print();
}
int main()
{
String s1("Mary had a little lamb");
foo(s1);
s1.print();
String s2;
s2 = s1;
s2.print();
return 0;
}

Kodu derleyip çalıştırdığımızda bir sorun olmaksızın kopyalama ve atama işlemlerinin gerçekleştiğini görmekteyiz.

Not: Bu tip bir kodlama hedeflerimiz için doğru bir sonuç üretse de kodlama yönünden bazı problemleri bulunmaktadır. Daha sonra bu konuya tekrar bakacağız.

Örnek yazı sınıfımız üzerinden yüzeysel ve derin kopyalama işlemleri sonucunda nesnelerin bellekteki yerleşimini aşağıdaki şekillerle
temsil edebiliriz.

Taşıma

Taşıma işlemi dil içerisinde move semantics olarak isimlendirilmektedir. C++11 standartlarıyla beraber, sağ taraf referanslarının eklenmesiyle, taşıma semantiği dil içinde daha belirgin ve yoğun bir kullanıma sahip olmuştur. İlerleyen bölümlerde sağ taraf referanslarını incelerken taşıma işlemine tekrar değineceğiz. Bu bölümde C++11 öncesi taşıma semantiğine örnekler vererek konuyu açıklamaya çalışacağız.

Taşıma işlemi esasında ancak dışsal bir kaynağı elinde tutan sınıflar için anlamlıdır. Taşıma işleminde, kopyalamadan farklı olarak, tutulan kaynağın sahipliği (ownership) aktarılmaktadır. Kopyalama işleminde kopyası oluşturulan nesne bu işlemden etkilenmezken taşıma işlemininde taşıma yapılan nesnenin durumu değişmektedir. Taşıma işleminin kaynağı olan nesne artık daha önce sahip olduğu kaynağı göstermez. Taşıma işleminin sonucunda taşıma yapılan nesne geçerli (valid but unspecified state) bir durumda bırakılmalıdır yani bu nesne için sınıfın destructor fonksiyonu bir hata üretmemeli ayrıca bu nesneye yeni bir değer atanabilmelidir. Kopyalama işlemini incelerken kullandığımız örnek yazı sınıfına ait bir nesnenin taşıma öncesi ve sonrası durumunu aşağıdaki gibi temsil edebiliriz.

Taşıma işleminden sonra s1 nesnesinin tuttuğu kaynak s2 nesnesine aktarılmıştır. Taşıma işleminde, derin kopyalama yapılmaksızın,
nesnenin tuttuğu kaynaklar transfer edilmektedir. Taşıma işlemini yüzeysel kopyalama sonrasında kopyası çıkarılan nesnenin tuttuğu kaynağı bırakması buna karşın geçerli bir duruma getirilmesi olarak da tarif edebiliriz. Yukarıdaki şekilde taşıma işleminden sonra s1 nesnesinin m_p elemanının NULL değer gösterdiğine dikkat ediniz. Bu sayede s1 nesnesi üzerinde sınıfın destructor fonksiyonu çalıştırıldığında herhangi bir problem çıkmayacaktır. NULL adresin delete edilmeye çalışılması durumunda herhangi bir işlem yapılmamaktadır.

Taşıma işlemi bir optimizasyon tekniği olarak kopyalama yerine kullanılabilmektedir. Bu kullanıma örnek olarak takas (swap) işlemini verebiliriz. Takas işleminde 2 nesnenin değeri karşılıklı olarak birbirine atanmaktadır. Bir T türü için örnek bir takas fonksiyonu aşağıdaki gibi yazılabilir.

void swap(T& lhs, T& rhs)
{
T temp(lhs);
lhs = rhs;
rhs = temp;
}

Takas işlemi için üçüncü bir nesneye daha ihtiyaç duyulmaktadır. İlk olarak nesnelerden biri için geçici bir kopya oluşturulmakta sonrasında 2 atama işlemi yapılmaktadır. Kopya oluşturma ve atama işlemlerinde dışsal referansa sahip sınıflar için derin kopyalama yapılmaktadır. Takas işleminde nesnelerden birinin kopyası oluşturulduktan sonra kopyası oluşturulan bu nesnenin değeri bir daha kullanılmamakta ve sonrasında kendisinin de değeri değişmektedir. Dolayısıyla nesnesin değerini korumaya gerek kalmaksızın kopyalama yerine taşıma işlemi yapılabilir. Bu yüzden bir takas fonksiyonu kopyalama yerine taşıma yapacak şekilde özelleştirilebilir.

C++11 öncesi standart swap fonksiyon şablonu aşağıdaki gibi tanımlanmaktaydı. C++11 sonrası yapılan değişikliğe konunun sonunda bakacağız.

template<typename T>
void swap(T& t1, T& t2)
{
T temp = t1;
t1 = t2;
t2 = temp;
}

Örnek sınıfımız için takas işlemi yapmak istediğimizde standart swap şablonu kullanılarak yazı sınıfımız için bir swap fonksiyonu üretilecektir (template instantiation). Derleyici tarafından üretilen swap fonksiyonu yazı sınıfının derin kopyalama yapan copy constructor ve operator assignment fonksiyonlarını kullanacaktır. Gereksiz kopyalama işleminden kurtularak takas işlemini daha etkin yapabilmek için yazı sınıfımıza bir takas fonksiyonu ekleyebiliriz. Burada birkaç yol izlenebilir.

Standart swap şablonunu kendi sınıfımız için özelleştirebilir (explicit template specialization), sınıfın üye swap fonksiyonunu sarmalayan global bir swap fonksiyonu yazabilir veya bir üye fonksiyon yazmaksızın sınıfın arkadaşlık (friendship) verdiği bir swap fonksiyonu yazabiliriz. Standart vector ve string sınıfları swap şablonunu özelleştirmekte ve takas işleminde kopyalama yerine taşıma yapan kendi swap fonksiyonlarını kullanmaktadır. Biz burada yazdığımız sınıf için arkadaşlık verdiğimiz bir swap fonksiyonu yazacağız. swap fonksiyonunun bildirimini ve tanımını aşağıdaki gibi yapabiliriz.

class String
{
public:
//...
friend void swap(String& first, String& second);
};
void swap(String& first, String& second)
{
cout << __PRETTY_FUNCTION__ << endl;
using std::swap;
swap(first.m_p, second.m_p);
swap(first.m_len, second.m_len);
}

Sınıfımızın swap fonksiyonu içinde derin kopyalama yapmaksızın standart swap fonksiyonu kullanılarak sınıfın elemanları karşılıklı olarak birine atanmış yani kaynaklar karşılıklı olarak aktarılmıştır. Son durumda yazı sınıfımız ve test kodumuz aşağıdaki gibidir.

#include <iostream>
#include <cstring>
using namespace std;class String
{
public:
String();
String(const char *str);
String(const String& rhs);
String& operator=(const String& rhs);
~String();
void print() const;
friend void swap(String& first, String& second);
private:
char *m_p;
size_t m_len;
};
String::String()
{
m_len = 0;
m_p = NULL;
}
String::String(const char *str)
{
m_len = strlen(str);
m_p = new char[m_len + 1];
strcpy(m_p, str);
}
String::String(const String& rhs)
{
cout << __PRETTY_FUNCTION__ << endl;
m_len = rhs.m_len;
m_p = new char[m_len];
for (int i = 0; i < m_len; ++i) {
m_p[i] = rhs.m_p[i];
}
}
String& String::operator=(const String& rhs)
{
cout << __PRETTY_FUNCTION__ << endl;
if (this != &rhs) // nesnenin kendisine atanıp atanmadığı kontrol ediliyor
{
// eski tutulan kaynağı geri veriyoruz
delete [] m_p;
m_p = NULL;
m_len = rhs.m_len;
m_p = new char[m_len];
for (int i = 0; i < m_len; ++i) {
m_p[i] = rhs.m_p[i];
}
}
return *this;
}
String::~String()
{
delete[] m_p;
}
void String::print() const
{
cout << m_p << endl;
}
void swap(String& first, String& second)
{
cout << __PRETTY_FUNCTION__ << endl;
using std::swap;
swap(first.m_p, second.m_p);
swap(first.m_len, second.m_len);
}
int main()
{
String s1("Mary had a little lamb");
String s2("Jupiter");
swap(s1, s2);
s1.print();
s2.print();
return 0;
}

Kodu derleyip çalıştırdığımızda copy constructor ve operator assignment fonksiyonları çağrılmaksızın takas işleminin daha etkin bir şekilde yapıldığını görmekteyiz. Bazı durumlarda atama işlemi yerine taşıma işlemi yapan swap fonksiyonu kullanılabilir. Örneğin yazı sınıfımızdan bir vektörün n. elemanını silmek istediğimizi düşünelim. Sonrasında vektörü yeniden organize etmek istediğimizde n. elemandan sonraki elemanların bir önceki elemana kopyalanması ve vektörün resize edilmesi gerekmektedir. Böyle bir kod aşağıdaki gibi yazılabilir.

for (int i = n + 1; i < vect.size(); ++i) {
vect[i - 1] = vect[i];
}
vect.resize(vect.size() - 1);

Vektördeki n. elemandan sonraki son eleman hariç tüm elemanlar kopyalandıktan sonra kendi değerleri de değişmektedir. Bu durumda
swap fonksiyonu kullanılarak kopyalama yerine taşıma işlemi yapılabilir.

for (int i = n + 1; i < vect.size(); ++i) {
swap(vect[i - 1], vect[i]);
}
vect.resize(vect.size() - 1);

Taşıma işlemi bazı durumlarda ise zorunlu olarak yapılır. C++ dilindeki bazı türler taşınabilir fakat kopyalanamaz (movable but not copyable types) olarak tanımlanmıştır. Bu türlere fstream ve auto_ptr sınıfları örnek olarak verilebilir. fstream sınıfı bir dosyayı temsil etmekte ve bu türe ait nesne sonlandığında temsil ettiği dosya da kapatılmaktadır. Bu yüzden fstream nesnesinin kopyasının çıkarılmasına izin verilmez. Aksi taktirde kopyalardan birinin sonlanması durumunda dosya kapatılacak ve diğer kopyalar hatalı sonuç üretecektir. C++11 ile fstream sınıfının kopyasının çıkarılamaz olma durumu korunmuş fakat kaynakları aktarılabilir yani taşınabilir hale getirilmiştir. Sağ taraf referanslarını incelediğimiz bölümde bu duruma değineceğiz.

Bir akıllı gösterici (smart pointer) şablonu olan auto_ptr ise kopyalanmak istendiğinde kopyalama yerine taşıma işlemi yapmaktadır. auto_ptr şablonunun bu davranışı dışardan bakıldığında belirgin olmadığından C++11 ile deprecated olmuş ve C++17 ile tamamen kaldırılmıştır. C++11 ile birlikte auto_ptr yerine, sağ taraf referanslarının sağladığı özellikleri kullanan, unique_ptr şablonu eklenmiştir. auto_ptr şablonu artık dilden kaldırılmış olmasına karşın C++11 öncesi taşıma işleminin kullanımına örnek olduğundan kısaca değineceğiz.

auto_ptr şablonu ile dinamik alanların güvenli olarak yönetilmesi hedeflenmektedir. auto_ptr bir gösterici tutmakta ve gösterici ile yapılacak işlemler bu sınıf üzerinden yapılmaktadır. auto_ptr türünden bir nesne sonlanırken tuttuğu dinamik alanı serbest bırakmaktadır. Bu yüzden, fstream sıfınında olduğu gibi, auto_ptr türünden bir nesnenin de kopyasının çıkarılması istenmez. Fakat fstream sınıfından farklı olarak auto_ptr sınıfında kopyalama yerine taşıma işlemi yapan bir atama operatör fonksiyonu tanımlanmıştır. GNU standart C++ kütüphanesinde auto_ptr.h dosyasında tanımlanan auto_ptr sınıfına ait ilgili kodlar aşağıdaki gibidir.

template<typename _Tp>
class auto_ptr
{
private:
_Tp* _M_ptr;
public:
/// The pointed-to type.
typedef _Tp element_type;
//...
}

auto_ptr sınıfı _M_ptr isimli bir gösterici tutmaktadır. Atama operatör fonksiyonu ise aşağıdaki gibi tanımlanmıştır.

auto_ptr&
operator=(auto_ptr& __a) throw()
{
reset(__a.release());
return *this;
}

release ile taşıma yapılacak nesnenin tuttuğu kaynağın adresi elde edilmekte ve reset ile eski kaynak serbet bırakılıp yeni kaynak elde edilmektedir. release ve reset fonksiyonlarının tanımları aşağıdaki gibidir. release tutulan kaynağın adresini dönmekte ve kaynak göstericisine 0 değerini atamaktadır.

element_type*
release() throw()
{
element_type* __tmp = _M_ptr;
_M_ptr = 0;
return __tmp;
}

reset eski kaynağı serbest bırakıp yeni kaynağın adresini saklamaktadır.

void
reset(element_type* __p = 0) throw()
{
if (__p != _M_ptr)
{
delete _M_ptr;
_M_ptr = __p;
}
}

C++ dilinde kopyalama optimizasyonu çoğunlukla geçici nesnelere (temporary object) ilişkindir. Bu yüzden kopyalama optimizasyonuyla ilgili yöntemlere geçmeden önce geçici nesne kavramını inceleyeceğiz.

Geçici Nesneler (Temporary Objects)

Geçici nesneler gerektiğinde derleyici tarafından otomatik olarak veya açık bir şekilde oluşturulan isimsiz (anonymous) nesnelerdir. Geçici nesneler oluşturulmalarına neden olan ifade (expression) sonunda ömürlerini tamamlarlar. Örneğin bir sınıf türünden geçici bir nesne oluşturulmuşsa ifadenin sonunda bu nesne için sınıfın destructor fonksiyonu çağrılır. Ancak const referanslar geçici nesnelere bağlanabilir ve bu sayede geçici
nesnelerin ömürleri uzatılabilir. C++11 ile birlikte dile ayrıca sağ taraf referansları eklenmiştir bu sayede const olmayan referanslar da geçici nesnelere bağlanabilmektedir. Konunun sonunda bu durumu ayrıca inceleyeceğiz. Geçici nesneler genellikle tür dönüşümü (type conversion) veya değer dönen fonksiyonların çağrılması sonucunda oluşurlar. Şimdi bu durumları ayrı ayrı inceleyelim.

Tür Dönüşümü Sonucunda Geçici Nesnelerin Oluşturulması

Bir türden diğer bir türe örtülü (implicit) veya açık (explicit) tür dönüşümü yapılabilmektedir. Bu tür dönüşümü sırasında bellekte geçici bir nesne oluşturulabilmektedir. Örneğin const bir referansa kendi türünden olmayan bir ifadeyle ilk değer verilmek istendiğinde, eğer bir tür dönüşümü yapılabiliyorsa, ilk olarak bellekte geçici bir nesne oluşturulur ve referans bu nesneye bağlanır. Örneğin;

double d = 4.9;
const int& r = d;

Yukarıdaki kod derleyici tarafından aşağıdaki koda benzer şekilde ele alınmaktadır.

double d = 4.9;
const int __0 = d;
const int &r = __0;

Geçici nesne __0 ismiyle temsil edilmiştir. r referansı artık, bizim dışarıdan erişemediğimiz, int türünden geçici bir nesneyi göstermektedir. Benzer şekilde bir sınıf türünden const bir referans başka bir türden bir değer ile ilklendirilmek istendiğinde, iki tür arasında dönüşüm mevcut ise, o sınıf türünden geçici bir değişken oluşturulur. Ayrıca bir sınıf türünden bir nesne başka türden bir değer ile ilklendirildiğinde veya mevcut bir nesneye başka türden bir değer ile atama yapıldığında da geçici bir nesne oluşturulabilir. Örnek bir sınıf üzerinden bu durumları inceleyelim.

class Account {
public:
Account() : m_no(0), m_balance(0) {}
Account(int no, double balance = 0)
: m_no(no)
, m_balance(balance) {}
Account(const Account& rhs)
: m_no(rhs.m_no)
, m_balance(rhs.m_balance) {}
Account& operator=(const Account& rhs)
{
m_no = rhs.m_no;
m_balance = rhs.m_balance;
return *this;
}
private:
int m_no;
double m_balance;
};

Account sınıfı bir hesap numarası ve ilişkili bakiyeyi temsil etmektedir. Account sınıfına ilişkin bir nesneyi aşağıdaki şekillerde oluşturabiliriz.

int main()
{
Account acc1; //1
Account acc2(1234, 666); //2
Account acc3(1234); //3
Account acc4 = 1234; //4
Account acc5 = Account(1234); //5
Account acc6 = (Account)1234; //6
return 0;
}

Not: 1. durumda ilklendirme işleminin Account acc1() şeklinde yapılmadığına dikkat ediniz. Böyle bir ifade derleyici tarafından parametresi olmayan geri dönüş türü Account olan acc1 isimli bir fonksiyon protitipi olarak ele alınır (most vexing parse). C++11 ile beraber Account acc1{} şeklinde bir ilklendirme yapılabilir.

İlk 3 durumda nesneler uygun constructor fonksiyonları ile direkt olarak ilklendirilmiştir. Bir nesne direkt olarak (direct initialization) veya başka bir nesne üzerinden (copy initialization), sınıfın copy constructor fonkiyonu kullanılarak, kopyalama yoluyla ilklendirilebilir. Diğer 3 durumda ise eşitliğin sağında Account türünden bir nesne belirtilmemiştir. Bu durumda derleyici uygun bir dönüşüm olması durumunda geçici bir nesne oluşturabilir ve yeni oluşturulan nesneyi bu geçici nesne ile ilklendirebilir. Böyle bir durumda derleyiciler, kod optimizasyonu yaparak, geçici bir nesne oluşturmak yerine direkt olarak nesneyi oluşturmaktadırlar. Örneğin GNU C++ derleyisinde derleyicinin bu davranışı değiştirilebilmektedir. Bu konuya daha sonra değineceğiz. Aşağıdaki anlatımlarımız derleyicinin bu optimizasyonu yapmadığı durumlar için olacaktır. Sırasıyla örnek durumları inceleyelim.
4. durumda Account türünden bir nesne int türden bir değer ile ilklendirilmek isteniyor. Bu durumda derleyici sınıfın Account(int no, double balance = 0) constructor fonksiyonunu kullanarak geçici bir nesne oluşturur ve bu nesne Account(const Account& rhs) constructor fonksiyonuyla kopyalanarak acc4 nesnesi ilklendirilir. Sınıfın tek bir argüman ile çağrılabilen constructor fonksiyonu tür dönüşümü için kullanılabilmekte ve conversion constructor olarak isimlendirilmektedir. Tür dönüşüm fonksiyonu int türden bir değeri sınıf türüne dönüştürebilmektedir. 4. durum için derleyici aşağıdakine benzer bir kod yazacaktır.

Account __0(1234);
Account acc4 = __0;
__0.~Account();

İlk olarak 1234 değeri ile __0 ile gösterdiğimiz bir geçici değişken yaratılacak, sonrasında bu değişken üzerinden sınıfın copy constructor fonksiyonu çağrılarak acc4 ilklendirilecek ve sonrasında geçici değişken sonlandırılacaktır. Bu durumu gözleyebilmek için constructor içine fonksiyonun adını ve this değerini yazan aşağıdaki satırı ekleyelim.

Örneğin;

Account(int no, double balance = 0) : m_no(no), m_balance(balance)
{
cout << __PRETTY_FUNCTION__
<< " { this: " << this << " }" << endl;
}

4. durum için örnek kodu temp.cpp adıyla saklayıp derleyelim.

int main()
{
Account acc4 = 1234;
cout << "***" << endl;
return 0;
}

Örnek kodu aşağıdaki gibi derleyebiliriz.

g++ -m32 --save-temps -otemp temp.cpp -fno-elide-constructors

fno-elide-constructors anahtarı derleyiciye kopyalamaya ilişkin bir optimizasyon yapmaması gerektiğini bildirmektedir. Kodu çalıştırdığımızda aşağıdaki gibi bir çıktı almaktayız.

Account::Account(int, double) { this: 0xffaf5044 }
Account::Account(const Account&) { this: 0xffaf5038 }
Account::~Account() { this: 0xffaf5044 }
***
Account::~Account() { this: 0xffaf5038 }

İlk olarak geçici bir nesne oluşturulmuş ve geçici nesne kopyalandıktan sonra sonlandırılmıştır. Sonrasında main fonksiyonu sonlanmadan önce acc4'de sonlandırılmıştır.

Not: Sınıf türüne yapılan bu tür dönüşümün otomatik olarak yapılması istenmiyorsa tür dönüştüren başlangıç fonksiyonunun bildiriminin başına explicit anahtar sözcüğü getirilmelidir.

5. ve 6. durumlar ise 4. duruma benzemektedir. Yalnız tür dönüşümü otomatik olarak yapılmak yerine açık bir şekilde yapılmıştır. 5. ve 6. durumlarda tür dönüşümü için sırasıyla fonksiyon çağrı biçiminde ve C dili yazım biçiminde tür dönüşüm operatörleri kullanılmıştır.

//Fonksiyon çağrı biçiminde (Functional notation) yazılmış açık //(explicit) tür dönüşümü
Account acc5 = Account(1234);
//C yazım biçiminde (C-like notation) yazılmış
//açık (explicit) tür dönüşümü
Account acc6 = (Account)1234;

Benzer bir durum Account türden bir nesne alan bir fonksiyon çağrıldığında da oluşacaktır. Örneğimize aşağıdaki gibi bir fonksiyon daha ekleyelim ve tür dönüşümüne sebep olacak şekilde bir çağrı yapalım.

void foo(Account param)
{
}
int main()
{
foo(1234);
return 0;
}

Kodu yukarıdakine benzer şekilde derleyip çalıştırdığımızda benzer sonucu üretecektir. Bir sınıf türü için ilklendirme işleminde olduğu gibi atama işleminde de bir tür dönüşümü yapılacaksa yine geçici bir nesne oluşturulacaktır.

int main()
{
Account acc1;
acc1 = 1234;
return 0;
}

Kodu derleyip çalıştırdığımızda yine bir geçici nesnenin oluşturulduğunu görmekteyiz.

//acc1 oluşturuluyor
Account::Account() { this: 0xff895dd8 }
//geçici nesne oluşturuluyor
Account::Account(int, double) { this: 0xff895de4 }
//geçici nesne acc1'e atanıyor
Account& Account::operator=(const Account&) { this: 0xff895dd8 }
//geçici nesne sonlandırılıyor
Account::~Account() { this: 0xff895de4 }
//acc1 sonlandırılıyor
Account::~Account() { this: 0xff895dd8 }

Not: Kodu -fno-elide-constructors anahtarı olmaksızın derlediğimizde de aynı sonucu üretecektir. Atama işleminde derleyici ilklendirme işleminin aksine bir optimizasyon yapamamaktadır. Bu durumu daha sonra inceleyeceğiz.

Sınıf türünden const bir referansa başka türden bir değer atandığında, dönüşüm yapılabiliyorsa, yine bir geçici nesnenin oluşturulduğunu söylemiştik. Şimdi son olarak bu duruma bakalım. Aşağıdaki kodu derleyip aldığımız sonuca bakalım.

int main()
{
const Account& r1 = 1234;
cout << "***" << endl;
return 0;
}
Account::Account(int, double) { this: 0xffa10534 }
***
Account::~Account() { this: 0xffa10534 }

Derleyici r1 referansına adresi geçirilmek üzere ilk olarak geçici bir nesne yaratır ve main fonksiyondan çıkılırken r1 referansı sonlanmadan önce geçici nesne için sınıfın destructor fonksiyonunu çağırır. Bu sayede geçici nesnenin ömrü genişletilmiş (life extension) olur. Yine -fno-elide-constructors anahtarının kullanılıp kullanılmaması durumu değiştirmeyecektir, her durumda geçici nesne yaratılır. Diğer benzer kullanımlar da aşağıdaki gibidir.

void bar(const Account& param)
{
}
int main()
{
const Account& r1 = 1234;
const Account& r2 = Account(1234);
const Account& r3 = (Account)1234;
bar(1234);
return 0;
}

Nesne Dönen Fonksiyon Çağrıları Sonucunda Geçici
Nesnelerin Oluşturulması

Fonksiyonlar doğal türlerden değerleri genellikle yazmaç (register) yoluyla dönmektedir. Bir örnek üzerinden bu durumu inceleyelim.

#include <iostream>int foo()
{
return 111;
}
int main()
{
int result = foo();
return 0;
}

foo fonksiyonu sonlandıktan sonra geri dönüş değeri result değişkenine aktarılmaktadır. foo fonksiyonu sonlanmadan önce geri dönüş
değerini eax yazmacına yazmakta ve fonksiyon sonlandığında eax yazmacındaki değer result değişkenine atanmaktadır. Geri dönüş değerinin nasıl döneceği ve çağrılan fonksiyona argümanların nasıl geçirileceğinin belirlenmesi çağırma biçimi (calling convention) olarak isimlendirilmektedir. Örnek koda ilişkin sembolik makina kodu çıktısını üreterek bu durumu gözleyebiliriz.

g++ -S test.cpp

main ve foo fonksiyonlarına ilişkin sembolik makina kodları aşağıdaki gibidir.

_Z3foov:
pushq %rbp
movq %rsp, %rbp
movl $111, %eax
popq %rbp
ret
main:
.LFB972:
.cfi_startproc
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp
call _Z3foov
movl %eax, -4(%rbp)
movl $0, %eax
leave
ret

Geri dönüş değerinin eax yazmacı ile aktarıldığını görüyoruz. Geri dönüş değerinin işlemci yazmaçlarına sığmadığı durumlarda ise çağıran tarafta geri dönüş değeri genişliğinde bir alan ayrılmakta ve bu alanın başlangıç adresi çağrılan fonksiyona gizlice geçirilmektedir. Yani çağıran tarafta geri dönüş değerinin kopyalanacağı geçici bir nesne yaratılmaktadır. Örnek bir kod üzerinden bu durumu inceleyelim.

#include <iostream>using namespace std;class Data
{
public:
Data() {cout << __PRETTY_FUNCTION__ << endl;}
Data(const Data& param) {cout << __PRETTY_FUNCTION__ << endl;} void fill(char c)
{ std::fill(bytes, bytes + sizeof(bytes), c);}
~Data() {cout << __PRETTY_FUNCTION__ << endl;}private:
char bytes[16];
};
Data foo(char c)
{
Data result;
result.fill(c);
return result;
}
int main()
{
Data d = foo('a');
return 0;
}

Fonksiyonun geri dönüş değerinin bir nesneye atanması durumunda derleyiciler genellikle optimizasyon yaparak bir geçici değişken
oluşturmazlar. Bu durumu daha sonra inceleyeceğiz. Yine önceki örneklerde olduğu gibi derleyiciye -fno-elide-constructors anahtarını geçirerek bir optimizasyon yapmasını engelleyeceğiz. Kodu aşağıdaki gibi derleyip çalıştıralım.

g++ -m32 --save-temps -otest test.cpp -fno-elide-constructorsData::Data() //1
Data::Data(const Data&) //2
Data::~Data() //3
Data::Data(const Data&) //4
Data::~Data() //5
Data::~Data() //6

Kodu çalıştırdığımızda copy constructor fonksiyonun 2 kez çağrıldığını görüyoruz. foo fonksiyonu çağrıldığında oluşturulan Data alanı foo fonksiyonu sonlanmadan önce main fonksiyonunun yığın (stack) alanındaki geçici bir alana kopyalanır. Kontrol main fonksiyonuna döndüğünde ise d nesnesi foo fonksiyonunun geri dönüş değerinin tutulduğu bu geçici nesne üzerinden ilklendirilir ve geçici alan sonlandırılır. foo fonksiyonunun geri dönüş değerinin kopyalanmasıyla ilgili işlemleri aşağıdaki gibi özetleyebiliriz

  1. foo foksiyonunun çağrılmasıyla yığın alanında result nesnesi oluşturulur
  2. foo sonlanmadan önce result kopyalanarak geçici bir nesne oluşturulur
  3. foo sonlanmadan önce result nesnesi sonlandırılır
  4. main yığın alanındanki d nesnesi geçici nesneden kopyalanarak oluşturulur
  5. geçici nesne sonlandırılır
  6. main sonlanmadan önce d nesnesi sonlandırılır

foo fonksiyonu çağrıldığında yığının durumunu aşağıdaki gibi temsil edebiliriz.

Son olarak yazdığımız kodun derleyici tarafından nasıl ele alındığına bakalım. Örneğimiz için derleyici aslında aşağıdaki gibi bir kod yazılmış gibi davranmaktadır.

void foo(void* __0, char c)
{
Data result;
result.fill(c);
new(__0) Data(result);
}
int main()
{
char __0[sizeof(Data)];
foo(__0, 'a');
Data d = *reinterpret_cast<Data*>(__0);
reinterpret_cast<Data*>(__0)->~Data();
return 0;
}

Kodu bu haliyle -fno-elide-constructors anahtarı olmaksızın derlediğimizde bir önceki ile aynı sonucu ürettiğini görmekteyiz. Derleyicinin kodu nasıl ele aldığı daha sonra derleyici tarafından uygulanan optimizasyonu incelediğimizde faydalı olacaktır. main içerisinde Data türü genişliğinde
__0 isimli bir alan ayrıldığını ve bu alanın başlangıç adresinin foo fonksiyonuna geçirildiğini görüyoruz. __0 geçici değişken için ayrılan alanı göstermektedir. foo fonksiyonunun geri dönüş türünün void olduğuna ve fazladan bir parametresi daha olduğuna dikkat ediniz. foo içerisinde, placement new operatörü kullanılarak, yeni bir alan tahsis edilmeksizin
__0 adresinden başlayarak result nesnesi kopyalanmıştır. foo foksiyonu sonlandıktan sonra, main içerisinde __0 ile gösterilen alan bir Data nesnesi olarak ele alınarak d nesnesi kopyalama yoluyla ilklendirilmiş ve sonrasında __0 ile ile temsil edilen geçici nesne sonlandırılmıştır. Bir sınıfın constructor fonksiyonunun aksine destructor fonksiyonunun açık bir şekilde çağrılabildiğini hatırlayınız.

Bu bölüme kadar geçici nesnelerin sebep olduğu problemlerden bahsettik fakat bazı durumlarda geçici nesneler bir performans kaybına neden olmaksızın kullanılabilir. Geçici nesneler ismi olan nesneler yerine kullanılabilir. Bir nesne oluşturulduktan sonra bir takım işlemler için kullanıldıktan sonra bir daha erişilmeyecek ve sonlandırılacaksa bu durumda geçici nesneler anonim nesneler olarak kullanılabilir. Örnek Data sınıfımız için geçici nesne oluşturan aşağıdaki örnekleri verebiliriz.

foo().fill('0');
Data().fill('0');

İlk kullanımda foo ile dönen nesne başka bir nesneye kopyalanmak yerine oluşan geçici nesne üzerinden işlem yapılmıştır. İkinci durumda ise Data türünden geçici bir nesne açık bir şekilde oluşturulmuş ve kullanılmıştır. Örneklerdeki fill fonksiyonu yalnız nesnenin durumunu değiştirdiği için anlamlı bir kullanıma karşılık gelmemektedir fakat fill yerine bir dosyaya bilgi yazan veya ağ üzerinden bilgi gönderen fonksiyon kullanımları olabilirdi.

Bu aşamadan sonra kopyalama optimizasyonuyla ilgili kullanılan yöntemlere geçebiliriz. Konunun başında kopyalama optimizasyonuyla ilgili kullanılan yöntemlerin dil ve kodlama düzeyinde olduğunu söylemiştik. Şimdi sırasıyla bu yöntemlere bakalım.

İlk olarak bazı durumlarda geçici değişken oluşturulmasının nasıl önlenebileceğini ve derleyici tarafından yapılan optimizasyonu inceleyeceğiz. Sonrasında özellikle C++11 öncesi kullanılan optimizasyon yöntemlerine ve C++11 ile dile eklenen sağ taraf referanslarıyla beraber yapılan optimizasyonlara bakacağız.

Kopyalama Optimizasyonu İçin Kullanılan Yöntemler

Kopyalama optimizasyonuyla ilgili yöntemler çoğunlukla geçici nesnelerin oluşturulmaması ve gereksiz yere kopyalanmamaları üzerinedir. Buradaki kopyalamadan kastımız yeni bir bellek tahsisatına neden olacak derin kopyalamadır. Şimdi bu yöntemlere sırasıyla bakalım.

Kodun Geçici Değişken Oluşturmayacak Biçimde
Düzenlenmesi

Kodun yazım şekli değiştirilerek fonksiyon çağrıları sonucunda geçici nesnelerin oluşturulması engellenebilir. Bu duruma aşağıdaki iki
örnek kullanımı verebiliriz. Fonksiyonun geri dönüş değeri parametre yoluyla alınabilir yani fonksiyona geri dönüş değerini oluşturacağı alan const olmayan referans yoluyla geçirilebilir. Örneğimiz için Data dönen foo fonksiyonumuzu aşağıdaki gibi yeniden yazabiliriz.

void foo(Data& result, char c)
{
result.fill(c);
}
int main()
{
Data d;
foo(d, 'a');
return 0;
}

foo fonksiyonu geri dönüş türü void ve 2 parametreye sahip bir fonksiyon olarak yazılmıştır. Bu yazım şeklinde geri dönüş değerinin oluşturulacağı alan fonksiyona açık bir şekilde geçirilmiştir. Bu durumda derleyici geçici bir
nesne oluşturacak bir kod üretmeyecektir. Öte yandan bu kullanım kodun anlaşılabilirliğini azaltmaktadır. main içerisinde foo fonksiyonunu aşağıdaki gibi çağırmak zorunda kaldık.

Data d;
foo(d, 'a');

Bu durumda foo fonksiyonunun geri dönüş değerinin d nesnesinden alınacağı açık değildir. Ayrıca her durumda bu yöntemi kullanmak mümkün değildir. Örneğin operatör işlemlerinde sonucun döneceği fazladan bir argüman geçiremeyiz. Bu durumda geçici nesne oluşturmamak için uygulanan bir diğer yöntem ise nesneye dönen fonksiyonları kullanmak yerine aynı sonucu başka operatörler ile elde etmek şeklindedir. Örneğin iki yazı nesnesini aşağıdaki gibi toplamak isteyelim.

#include <iostream>using namespace std;int main()
{
string s1("Mary had a little ");
string s2("lamb");
string sum = s1 + s2;
cout << sum << endl;
return 0;
}

s1 + s2 işlemi için sınıfın üye olmayan global operator+ fonksiyonu çağrılacaktır.

string operator+ (const string& lhs, const string& rhs)

operator+ fonksiyonu 2 yazının eklenmiş halini içeren yeni bir yazı nesnesi oluşturmakta ve bu nesneyi dönmektedir. Bu kullanımda derleyicinin optimizasyon yapamadığı durumlarda geçici bir nesne oluşacaktır. Aynı işlem aşağıdaki gibi de yazılabilir.

#include <iostream>using namespace std;int main()
{
string s1("Mary had a little ");
string s2("lamb");
string sum(s1);
sum += s2;
cout << sum << endl;
return 0;
}

Sonucu tutacak olan sum nesnesi s1 nesnesinden kopyalanarak oluşturulmuş ve daha sonra s2 ile toplanmıştır. Bu durumda çağrılacak olan operator+= fonksiyonunun prototipi aşağıdaki gibidir.

string& operator+= (const string& str);

operator+= fonksiyonu çağrıldığı nesnenin tuttuğu yazıyı genişletmekte yine aynı nesneyi referans yoluyla dönmektedir. Bu durumda geçici bir nesne oluşturulmayacaktır. Bu kullanım da, geri dönüş değerinin referans yoluyla alındığı bir önceki örnek gibi, yine kodun yazımını zorlaştırmaktadır.

Derleyici Tarafından Yapılan Optimizasyon

C++ standartlarında bir programın gözlemlenebilir davranışı (observable behavior) değişmediği sürece derleyicilerin optimizasyon yapmasına izin verilmiştir. Bu kural as-if rule olarak isimlendirilmektedir. Bu kuralın bir istinası olarak yine standartlarda kopyalama işleminin, bir yan etkisi (side effect) olsa dahi, elimine edilmesine izin verilmiştir. Bu optimizasyon copy elison olarak isimlendirilmektedir. C++11 standartlarıyla beraber kopyalama işlemine ek olarak taşıma işleminin de elimine edilmesine izin verilmiştir. Ancak derleyicinin optimizasyon yaptığı durumlarda dahi sınıfın copy constructor fonksiyonuna sahip olması gerekmektedir. Derleyici ancak belli durumlarda kopyalama işlemini elimine etmektedir. C++11 öncesinde dil içerisinde geçici nesneleri ayırt etmek için bir araç bulunmamaktaydı. Buna karşın derleyici bu bilgiye sahiptir ve bazı durumlarda daha etkin makine kodu üretebilir. Daha önce geçici nesnelerden bahsettiğimiz bölümde geçici nesnelerin tür dönüşümü sonucunda ve nesnenin değerine dönen fonksiyonların geri dönüş değerlerini elde etmek için oluşturulduğunu söylemiştik. İncelemelerimizde derleyiciye -fno-elide-constructors anahtarını geçirerek kopyalama eliminasyonu yapmasını engellemiştik. Şimdi derleyicinin kopyalama eliminasyonu yapabildiği ve yapamadığı durumlara bakalım. İncelemelerimizde yine önceki örneklerde kullandığımız Data sınıfını kullanacağız.

Tür Dönüşümüne İlişkin Optimizasyon

Data sınıfına yazı alan bir constructor fonksiyonu daha ekleyelim. Data sınıfımız son durumda aşağıdaki gibi olacaktır.

class Data {
public:
Data() {cout << __PRETTY_FUNCTION__ << endl;}
Data(const char* param) {cout << __PRETTY_FUNCTION__ << endl;}
Data(const Data& param) {cout << __PRETTY_FUNCTION__ << endl;}
void fill(char c)
{ std::fill(bytes, bytes + sizeof(bytes), c); }
~Data() {cout << __PRETTY_FUNCTION__ << endl;}
private:
char bytes[16];
};
void foo(Data param)
{
}
int main()
{
Data d1 = Data();
Data d2 = Data("text");
Data d3 = "text";
foo("text");
foo(Data());
foo(Data("text"));
return 0;
}

Derleyiciye -fno-elide-constructors anahtarını geçirmediğimizde yukarıdaki örnekler için derleyici kopyalama eliminasyonu yapacak yani geçici bir nesne oluşturmayacaktır. Derleyici geçici bir nesne oluşturup o nesneyi kopyalamak yerine direkt sonucu tutacak nesneyi ilklendirecektir. Yani copy initialization yerine direct initialization yapılacaktır. Aşağıdaki kodu derleyicinin optimizasyon yaptığı ve yapmadığı durumlar için derleyerek sonucu karşılaştıralım.

int main()
{
Data d = "text";
return 0;
}

İlk olarak derleyicinin optimizasyon yapmasını engelleyelim, kodu test.cpp adıyla saklayıp aşağıdaki gibi derleyebiliriz.

g++ -otest test.cpp -fno-elide-constructors

Sonuç aşağıdaki gibi olacaktır.

Data::Data(const char*)
Data::Data(const Data&)
Data::~Data()
Data::~Data()

İlk olarak sınıfın tür dönüştüren constructor fonksiyonu ile geçici bir nesnenin oluşturulduğunu ve bu nesnenin kopyalandıktan sonra sonlandığını görüyoruz. Aynı kodu -fno-elide-constructors anahtarı olmaksızın yeniden derleyip çalıştıralım.

Data::Data(const char*)
Data::~Data()

Bu durumda geçici bir nesne oluşturulmaksızın d nesnesinin oluşturulduğunu ve main sonlanmadan önce sonlandırıldığını görüyoruz. copy constructor fonksiyonunun bir yan etkisi dahi olsaydı, örneğin sınıfın bir elemanının değerini değiştirseydi veya bir fonksiyon çağrısı yapsaydı, yine de çağrılmıyacaktı. Bu durumu copy constructor fonksiyonunu değiştirerek deneyebilirsiniz. Daha önce de söylediğimiz gibi derleyici kopyalama eliminasyonu yapsa dahi sınıf kopyalama başlangıç fonksiyonu sahip olmalıdır. Data sınıfı için copy constructor fonksiyonu yazmasaydık derleyici bizim için birebir kopyalama (memberwise copy) yapan bir
kopyalama fonksiyonu yazacaktır. Sınıfın copy constructor fonksiyonunu sınıfın private bölümüne almamız durumunda ise derleyici herhangi bir kopyalama işlemi yapmamasına rağmen derleme hatası üretecektir.

Geri Dönüş Değerine İlişkin Optimizasyon

Kopyalama eliminasyonuna ilişkin bir diğer optimizasyon fonksiyonların geri dönüş değerlerine ilişkindir. Daha önce nesnenin değerini dönen fonksiyonların geri dönüş değerleri alınırken, derleyici optimizasyon yapmıyor ise, geçici nesnelerin oluşturulduğunu incelemiştik. Derleyiciler bazı durumlarda fonksiyon çağrıları sonucunda oluşan geçici nesneleri elimine edebilir. Fonksiyonların geçici veya isimli bir nesne dönmelerine göre bu optimizasyon return value optimization (RVO) veya named return value optimization (NRVO) olarak isimlendirilmektedir. İlk olarak fonksiyonun geçici bir nesne döndüğü duruma bakalım.

Data foo()
{
return Data();
}
int main()
{
Data d = foo();
return 0;
}

Kodu aşağıdaki gibi derleyip çalıştıralım.

g++ --save-temps -otest test.cppData::Data()
Data::~Data()

Daha önceki incelemelerimizde derleyicinin optimizasyon yapmadığı durumda 2 adet kopyalama işlemi yapıldığını hatırlayınız. İlk olarak foo geri dönüş değeri geçici bir nesneye kopyalanmakta ardından geçici nesne kopyalanarak d nesnesi oluşturulmaktaydı. Derleyici geri dönüş değerine ilişkin optimizasyon yaptığında ise foo fonksiyonuna geçici bir alanın adresini geçirmek yerine doğrudan d nesnesi için ayrılan alanın adresini geçirmekte ve foo içerisinde geçici bir nesne oluşturmak yerine direkt d nesnesini ilklendirmektedir. Bu optimizasyon return value optimization (RVO) olarak isimlendirilmektedir. foo fonksiyonun geçici bir nesne yerine ismi olan bir nesneyi dönmesi durumunda ise derleyicinin işi zorlaşacaktır. Aşağıdaki örneği inceleyelim.

Data foo(char c)
{
Data result;
cout << "result addr: " << &result << endl;
result.fill(c);
return result;
}
int main()
{
Data d = foo('a');
cout << "d addr: " << &d << endl;
return 0;
}

Kodu yine -fno-elide-constructors anahtarı olmaksızın derleyip çalıştırdığımızda bir önceki ile aynı sonucu aldığımızı görmekteyiz.

Data::Data()
result addr: 0xfff124bc
d addr: 0xfff124bc
Data::~Data()

Bu durumda derleyici foo içinde yerel bir değişken yaratmak yerine main içindeki d nesnesi için ayrılan yerin adresini foo fonksiyonuna geçirmiş ve foo içinde ilklendirmiştir. result ve d adreslerinin aynı olduğuna dikkat ediniz. Bu optimizasyon türünü de named return value optimization (NRVO) denilmektedir. Daha önce derleyicinin optimizasyon yapmadığı durumda nesnenin değerine dönen fonksiyonlara ilişkin kodu nasıl ele aldığını incelemiştik. Optimizasyon yapıldığında ise yukarıdaki kod derleyici tarafından aşağıdaki gibi ele alınmaktadır.

void foo(void* __0, char c)
{
Data& d = *new(__0) Data();
d.fill(c);
}
int main()
{
char __0[sizeof(Data)];
foo(__0, 'a');
Data& d = *reinterpret_cast<Data*>(__0);
d.~Data();
return 0;
}

foo fonksiyonuna geçici bir alanın değil d nesnesi için ayrılan alanın adresi geçirilmektedir. Ayrıca foo fonksiyonu içinde yerel bir nesne oluşturulmak yerine direkt olarak d nesnesi ilklendirilmektedir. Öte yandan kopyalama eliminasyonu her durumda uygulanamamaktadır. Ancak bir nesne ilklendiriliyor ise kopyalama eliminasyonu yapılabilir. Bunun dışındaki atama işlemlerinde kopyalamaya ilişkin bir optimizasyon yapılamamaktadır. Daha önce incelediğimiz aşağıdaki örneğe yeniden bakalım.

int main()
{
Data d = "text";
return 0;
}

Kodu -fno-elide-constructors anahtarı olmaksızın derleyip çalıştırdığımızda yalnız d nesnesinin oluşturulduğunu görüyoruz.

Data::Data(const char*)
Data::~Data()

Örneğimizi ilklendirme yerine atama yapacak şekilde değiştirelim. Data sınıfımıza test amaçlı aşağıdaki gibi bir atama operator fonksiyonu ekleyelim.

Data& operator=(const Data& param) 
{
cout << __PRETTY_FUNCTION__ << endl;
return *this;
}
int main()
{
Data d;
d = "text";
return 0;
}

Kodu tekrar derleyip çalıştıralım.

Data::Data()
Data::Data(const char*)
Data& Data::operator=(const Data&)
Data::~Data()
Data::~Data()

Atama işleminden önce geçici bir Data nesnesi oluşmakta ve bu nesne d nesnesine atanmaktadır. Benzer şekilde bir nesneye bir fonksiyonun geri dönüş değeri ile atama yapıldığında da derleyici geri dönüş değerine ilişkin bir optimizasyon yapamayacaktır. Daha önce incelediğimiz aşağıdaki örnek için derleyicinin optimizasyon yaptığını biliyoruz.

Data foo()
{
return Data();
}
int main()
{
Data d = foo();
return 0;
}

main içinde ilklendirme yerine atama işlemi yaptığımızda ise sonuç tamamen değişecektir.

Data foo()
{
return Data();
}
int main()
{
Data d;
d = foo();
return 0;
}

Derleyici yine geçici bir nesne oluşturacak kod üretecektir.

Data::Data()
Data::Data()
Data& Data::operator=(const Data&)
Data::~Data()
Data::~Data()

Atama ve ilklendirme işlemleri benzer gibi görünselerde aslında tamamen farklı işlemlerdir. Örneğin atama yapılacak nesnenin kopyalama işleminden önce tuttuğu kaynakları geri vermesi gerekebilir. Bu işlemler atama operatör fonksiyonu içinde yapılmaktadır. Standartlarda derleyicinin yalnız kopyalama ve taşıma yapan constructor fonksiyonlarını elimine etmesine izin verilmiştir.

Bazı durumlarda derleyicinin geri dönüş değerine ilişkin optimizasyon yapması zorlaşmaktadır. Aşağıdaki örneği inceleyelim.

#include <cstdlib> // rand fonksiyonu içinData foo()
{
Data d1;
Data d2;
if (rand() % 2) {
return d2;
}
return d1;
}
int main()
{
Data d = foo();
return 0;
}

Daha önceki incelemelerimizde derleyicinin kodu optimize ederken foo fonksiyonuna d nesnesinin adresini geçirdiğini ve foo içinde yerel bir nesne oluşturmak yerine d nesnesini ilklendirdiğini görmüştük. Bu sayede yerel nesne üzerinde yapılacak işlemler direkt olarak d nesnesi üzerinde yapılır. Bu örnekte ise foo fonksiyonu içinde 2 farklı nesne tanımlanmıştır ve foo fonksiyonu bu nesnelerden biri ile dönmektedir. Derleyici, derleme aşamasında, hangi nesnenin değerinin döneceğini bilemediği için ancak kısmi olarak bir optimizasyon yapabilmektedir. Bu durumda foo fonksiyonuna geçici bir alanın adresi geçirilmek yerine yine d nesnesinin adresi geçirilir. Fakat foo
içinde geri dönüş değerine ilişkin yapılan işlemler d nesnesi üzerinde yapılmak yerine d1 ve d2 üzerinde yapılır. Sonuç d nesnesine
kopyalanır. Sonuçta bir geçici nesne oluşturulmamakta fakat yine de bir kopyalama işlemi yapılmaktadır. Kodu derleyip çalıştırdığımızda 1 adet kopyalama işleminin yapıldığını görüyoruz.

Data::Data()
Data::Data()
Data::Data(const Data&)
Data::~Data()
Data::~Data()
Data::~Data()

Kodu -fno-elide-constructors anahtarı ile derlediğimizde ise 2 adet kopyalama yapılmaktadır.

Data::Data()
Data::Data()
Data::Data(const Data&)
Data::~Data()
Data::~Data()
Data::Data(const Data&)
Data::~Data()
Data::~Data()

Derleyicinin optimizasyon yapmadığı ve kısmi olarak yapılabildiği durumlardaki bellekteki yerleşimini aşağıdaki gibi temsil edebiliriz.

Özetleyecek olursak bir fonksiyonun geri dönüş değerinin alınması işleminde derleyici hiçbir kopyalama yapılmayacak şekilde bir kod yazabilir ya da fonksiyonun karmaşıklığına ve yapılacak işlemin atama veya ilklendirme olmasına göre 1 veya 2 kopyalama işlemi yapılabilir. Derleyici tarafından yapılan kopyalama eliminasyonu her durumda kullanılamamaktadır bu yüzden ek olarak başka yöntemler de önerilmiştir. C++11 standartlarından sonra bu yöntemlerin bazıları artık kullanım dışı kalmıştır.

Bir sonraki blog yazımızda ise bu yöntemlere bakacağız.

--

--