C++ Dilinde Çalışma Zamanı Çokbiçimliliği (Runtime Polymorphism)

Serkan Eser
Nettsi Bilişim Teknoloji A.Ş.
15 min readJun 8, 2020

Çalışma zamanı çokbiçimliliği C++, Java ve C# gibi dillerde önemli bir konu olarak karşımıza çıkmaktadır. Bu yazımızda C++ dilinde çalışma zamanı çokbiçimliliğini incelemeye çalışacağız. Bu özelliğin dil tarafından nasıl sağlandığına ve alternatif yöntemlerine bakacağız. Çalışma zamanı çokbiçimliliği performans ve kodun tasarımı açısından dilin içerisinde tartışmalı konulardan birini oluşturmaktadır. Bu yönteme neden ihtiyaç duyulduğunu ve mekanizmasını anlamaya çalışacağız. Bu sayede bu aracı daha doğru kullanmamız mümkün olacaktır.

Çalışma zamanı çokbiçimliliğine geçmeden önce resmin bütününü görebilmek adına dil içerisindeki diğer çokbiçimlilik yöntemlerine de kısaca bakmamız faydalı olacaktır.

Çokbiçimlilik nedir?

Çokbiçimlilik tanım olarak birden çok biçime sahip olmayı ifade ediyor. Bu özellik sayesinde bir türetme hiyerarşisi içindeki farklı türleri tek bir tür gibi ele alan arayüzleri yazmak ya da türden bağımsız kodlama yapmak mümkün olmaktadır. İlk olarak kısaca dil içerisindeki diğer çokbiçimlilik yöntemlerine bakalım.

Ad-hoc Çokbiçimliliği (Ad-hoc Polymorphism)

Ad hoc çokbiçimliliği C++ dilinde fonksiyon ve operatör yükleme özelliğine (function overloading, operator overloading) karşılık gelmektedir. Bir fonksiyonun ismi ve parametrik yapısı o fonksiyonun imzasını (signature) belirlemektedir. C++ dili aynı isimli fakat farklı parametrik yapılara sahip birden çok fonksiyonun tanımlanmasına izin vermektedir. Bu durumda fonksiyona geçirilen argümanların türü çağrılacak olan fonksiyonu belirler (overload resolution). Bu işlem derleme zamanında (compile-time) yapıldığından dolayı fazladan herhangi bir çalışma zamanı maliyeti getirmemektedir. Basit bir örnek üzerinden bu durumu inceleyelim.

#include <iostream>using namespace std;void foo(int)
{
cout << __PRETTY_FUNCTION__ << endl;
}
void foo(double)
{
cout << __PRETTY_FUNCTION__ << endl;
}
int main()
{
foo(0);
foo(0.0);
return 0;
}

Kodu derleyip çalıştırdığımızda fonksiyona geçirilen argüman türünün çağrılacak olan fonksiyonu belirlediğini görmekteyiz.

$ ./test 
void foo(int)
void foo(double)

C dilinde olmayan bu özellik sayesinde farklı türler üzerinde benzer işlemleri yapan fonksiyonlara ayrı isimler vermek zorunda kalmıyoruz.

Parametrik Çokbiçimlilik (Parametric Polymorphism)

Parametrik çokbiçimlilik türden bağımsız (generic) kodlamaya karşılık gelmektedir. Fonksiyon ve veri yapıları gerçek bir tür için yazılmak yerine parametrik olarak değiştirilebilen türe karşılık gelen semboller kullanılarak yazılırlar. C++ dili bu çokbiçimlilik türünü şablonlarla (template) sağlamaktadır. Basit bir örnek üzerinden bu durumu inceleyelim.

#include <iostream>using namespace std;template<typename T>
void foo(T)
{
cout << __PRETTY_FUNCTION__ << endl;
}
int main()
{
foo(0);
foo(0.0);
return 0;
}

Örneği derleyip çalıştıralım.

$ ./test 
void foo(T) [with T = int]
void foo(T) [with T = double]

Bu örnekte int ve double türleri için ayrı ayrı fonksiyonlar yazmadık. İlgili fonksiyonlar derleyici tarafından otomatik olarak yazıldı. Parametrik çokbiçimlilik de yine derleme zamanında gerçeklendiğinden herhangi bir çalışma zamanı maliyeti getirmemektedir.

Çalışma Zamanı Çokbiçimliliği

Bu bölümde asıl konumuzu oluşturan çalışma zamanı çokbiçimliliğine bakacağız. Çalışma zamanı çokbiçimliliği temelde türetme ilişkisine dayanmaktadır. Bu yöntem kullanılarak türetme hiyerarşisi içindeki türlerin ortak özelliklerine dayanılarak arayüzler oluşturulabilmektedir. Bu sayede geliştirmeye açık fakat değişimi kapalı (open-closed principle) kodlar yazılabilir. Yani eski kodlar değiştirilmeden yeni kodlar eklenebilir. Bu özellik kütüphane kodlarının yazımını kolaylaştırmaktadır.

Çalışma zamanı çokbiçimliliği dilin içsel bir özelliği olup direkt dil düzeyindeki araçlarla sağlanmakta. Bu özelliğin dilin araçlarıyla nasıl sağlandığına geçmeden önce, alternatif yöntemlerine bakarak, hangi problemi çözmeye çalıştığına daha yakından bakalım.

Örneğin elimizde geometrik şekillere ilişkin square, rectangle, circle gibi türler olsun ve bu türlere ilişkin örnekleri (instance) bir konteyner içerisinde saklayıp sonrasında topluca işlemek isteyelim. Bu aşamada virtual fonksiyonları kullanmayacağız. Peki bu durumda bu gereksinimi nasıl karşılayabiliriz? Benzer bir işlemi C dilinde de yapmak isteyebilirdik.

Bu amaçla, C dilinde de kullanılan, yöntemlerden bir tanesi tagged union ya da discriminated union olarak isimlendirilmektedir.

Tagged Union

Bu yöntemde herhangi bir türetme işlemi kullanmıyoruz. Tüm türlere ilişkin örnekler bir union içerisinde tutulmakta ve üzerinde işlem yapılmak istenen örneğin türü bir tip bilgisi ile etiketlenmekte. Özünde, C++17 ile dile eklenen, std::variant şablonu da bir union kullanmaktadır. union içerisindeki tüm değişkenler aynı adresten başlamaktadır. Dolayısıyla union için içerisindeki en geniş tür kadar yer ayrılmaktadır.

İlk olarak ilgili geometrik türleri aşağıdaki gibi tanımlayabiliriz.

struct square 
{
double side;
};
struct rectangle
{
double height;
double width;
};
struct circle
{
double radius;
};

Tüm bu türlere ilişkin örnekleri saklayacak bir shape türünü de aşağıdaki gibi tanımlayabiliriz.

class shape
{
enum class shape_type { square, rectangle, circle } type;
public:
union {
square s;
rectangle r;
circle c;
};

void square_init(double side);
void rectangle_init(double height, double width);
void circle_init(double radius);

double area();
};

shape türü içerisinde square, rectangle ve circle türlerine ait örneklerden herhangi biri tutulabilir. Gerçekte tutulan tür type isimli bir enum değerinde saklanmaktadır. Sonu init ile biten fonksiyonlar union içindeki ilgili alanları ilklendirmeye yarayan fonksiyonlardır. area fonksiyonu ise çokbiçimli bir davranış kazandırmak istediğimiz, geometrik şeklin alanını dönen, fonksiyondur.

square türüne ait örnekleri ilklendirme için kullanacağımız fonksiyonu aşağıdaki gibi yazabiliriz. Diğer türler için de benzer şekilde fonksiyonları yazacağız.

shape türü içerisinde square, rectangle ve circle türlerine ait örneklerden herhangi biri tutulabilir. Gerçekte tutulan tür type isimli bir enum değerinde saklanmaktadır. Sonu init ile biten fonksiyonlar union içindeki ilgili alanları ilklendirmeye yarayan fonksiyonlardır. area fonksiyonu ise çokbiçimli bir davranış kazandırmak istediğimiz, geometrik şeklin alanını dönen, fonksiyondur.

square türüne ait örnekleri ilklendirme için kullanacağımız fonksiyonu aşağıdaki gibi yazabiliriz. Diğer türler için de benzer şekilde fonksiyonları yazacağız.

void shape::square_init(double side)
{
type = shape_type::square;
s.side = side;
}

Bir sonraki aşamada asıl ilgilendiğimiz area fonksiyonunu yazabiliriz.

double shape::area() 
{
double area = -1;

switch (type) {
case shape_type::square:
area = s.side * s.side;
break;
case shape_type::rectangle:
area = r.height * r.width;
break;
case shape_type::circle:
area = M_PI * c.radius * c.radius;
break;
}

return area;
}

area fonksiyonu içerisinde etiket bilgisine göre bir hesaplama yapılmaktadır. Bu aşamadan sonra geometrik şekillere karşılık gelen örnekleri oluşturabilir, ilklendirebilir ve sonrasında bir konteyner içinde tutarak topluca işleyebiliriz. Kodun tamamı aşağıdadır.

#include <iostream>
#include <cmath>
using namespace std;struct square
{
double side;
};
struct rectangle
{
double height;
double width;
};
struct circle
{
double radius;
};
class shape
{
enum class shape_type { square, rectangle, circle} type;
public:
union {
square s;
rectangle r;
circle c;
};
void square_init(double side);
void rectangle_init(double height, double width);
void circle_init(double radius);
double area();
};
void shape::square_init(double side)
{
type = shape_type::square;
s.side = side;
}
void shape::rectangle_init(double height, double width)
{
type = shape_type::rectangle;
r.height = height;
r.width = width;
}
void shape::circle_init(double radius)
{
type = shape_type::circle;
c.radius = radius;
}
double shape::area()
{
double area = -1;
switch (type) {
case shape_type::square:
area = s.side * s.side;
break;
case shape_type::rectangle:
area = r.height * r.width;
break;
case shape_type::circle:
area = M_PI * c.radius * c.radius;
break;
}
return area;
}
int main()
{
shape square;
square.square_init(10);
shape rectangle;
rectangle.rectangle_init(10, 20);
shape circle;
circle.circle_init(10);
shape* shapes[] = {&square, &rectangle, &circle}; double area;
for (auto shape : shapes) {
area = shape->area();
cout << "area: " << area << '\n';
}
return 0;
}
$ ./test
area: 100
area: 200
area: 314.159

Aralarında bir türetme ilişkisi olmamasına karşın ayrı türleri ortak özelliklerine dayanarak topluca işleyebildik. Fakat bu yöntem temel olarak iki problem içermekte. İlk problem bu şekilde bir union kullanmanın güvenli olmaması. Bir shape örneğinin temsil ettiği geometrik şekil çalışma zamanı boyunca değişebilir. Örneğin bir kareyi temsil ederken daha sonra bir dikdörtgene karşılık gelebilir. Bu durumda shape içinde bir rectangle örneği tutulmak istenirse öncesinde square örneğinin tuttuğu kaynaklar varsa geri verilmeli yani destruct edilmelidir. Örneğimizde bu sorumluluk bize ait. std::variant şablonu ise bu özelliği kendisi sunmaktadır.

İkinci ve daha ciddi olan problem ise kodun tasarımına yani gelişmeye kapalı olmasına ilişkin. Örneğin yeni bir geometrik şekil eklememiz gerekseydi bu amaçla area fonksiyonu içerisine bir case daha eklemek zorunda kalacaktık. Bu yapıda shape sınıfının diğer geometrik şekillere karşılık gelen türlere bir bağımlılığı bulunmakta. Bu durum da bu tür fonksiyonların kütüphane fonksiyonu olarak yazılmasını güçleştirmektedir.

Türetme ve Tür Bilgisinin Beraber Kullanılması

Bu yöntemde de yine bir tür bilgisi tutacağız fakat union yerine türetme yapacağız. shape türünü aşağıdaki gibi tanımlayabiliriz.

class shape
{
protected:
enum class shape_type {square, rectangle, circle};
shape_type type_;
public:
double area();
};

shape gerçek geometrik türü gösteren type_ isimli protected bir alana sahiptir. Diğer geometrik şekilleri shape türünden aşağıdaki gibi türetebiliriz.

class square : public shape
{
double side_;
public:
square(double side) : side_{side} {
type_ = shape_type::square;
}
double area() { return side_ * side_;}
};
class rectangle : public shape
{
double height_;
double width_;
public:
rectangle(double height, double width)
: height_{height}, width_{width} {
type_ = shape_type::rectangle;
}
double area() { return height_ * width_; }
};
class circle : public shape
{
double radius_;
public:
circle(double radius) : radius_{radius} {
type_ = shape_type::circle;
}
double area() { return M_PI * radius_ * radius_; }
};

Her tür constructor içerisinde type_ alanına kendi türünü gösteren uygun değeri atamaktadır. area fonksiyonu da aşağıdaki gibi yazılabilir.

double shape::area()
{
double area = -1;
switch (type_) {
case shape_type::square:
area = static_cast<square*>(this)->area();
break;
case shape_type::rectangle:
area = static_cast<rectangle*>(this)->area();
break;
case shape_type::circle:
area = static_cast<circle*>(this)->area();
break;
}
return area;
}

area fonksiyonu içerisinde statik bir cast işlemi yapıldığını görüyoruz. Bu durumda main fonksiyonunu aşağıdaki gibi yazabiliriz. Bir türemiş sınıf adresini taban sınıf türünden bir göstericide tutabildiğimizi (Liskov Substitution Principle) hatırlayınız.

int main()
{
square s{10};
rectangle r{10, 20};
circle c{10};
shape* shapes[] = {&s, &r, &c}; double area;
for (auto shape : shapes) {
area = shape->area();
cout << "area: " << area << '\n';
}
return 0;
}
$ ./test
area: 100
area: 200
area: 314.159

Bu yöntemdeki temel problem de yine union örneği ile aynı. area fonksiyonu içerisindeki koşul ifadeleri aslında bize bu tasarımda bir hata olduğunu gösteriyor. Yeni bir tür eklemek istediğimizde area fonksiyonu içerisinde değişiklik yapmak yani yeni bir koşul ifadesi daha eklemek zorundayız. Bu durumda area fonksiyonunu bir kütüphane fonksiyonu olarak yazmak pek olası değildir.

Alternatif diğer bir yöntem ise, gerçekte derleyici tarafından yapılan işleme belli ölçüde benzeyen, fonksiyon göstericileri kullanmak olabilir.

Taban Sınıf İçerisinde Fonksiyon Göstericileri Kullanmak

shape sınıfını aşağıdaki gibi tanımlayabiliriz.

class shape
{
public:
double (*area)(shape* self);
};

shape türü içerisinde, üye fonksiyon göstericisi (member function pointer) değil, normal bir fonksiyon göstericisi kullanıyoruz. Bu yüzden tür içerisinde this anahtar sözcüğü ile eriştiğimiz adresi fonksiyona self parametresi ile dışarıdan geçireceğiz.

square türünü aşağıdaki gibi tanımlayabiliriz.

class square : public shape
{
double side_;
public:
square(double side) : side_{side} {
shape::area = &area;
}
static double area(shape* self) {
double side = ((square*)self)->side_;
return side * side;
}
};

square türü kendi statik area fonksiyonu adresini shape içerisindeki area fonksiyon göstericisine geçirmektedir. Bu sayede, diğer incelediğimiz iki yöntemin aksine, sonradan yazılan kod önceden yazılan kod üzerinde bir değişiklik yapabilmektedir. Son durumda kodun tamamı aşağıdaki gibi olacaktır.

#include <iostream>
#include <cmath>
using namespace std;class shape
{
public:
double (*area)(shape* self);
};
class square : public shape
{
double side_;
public:
square(double side) : side_{side} {
shape::area = &area;
}
static double area(shape* self) {
double side = ((square*)self)->side_;
return side * side;
}
};
class rectangle : public shape
{
double height_;
double width_;
public:
rectangle(double height, double width)
: height_{height}, width_{width} {
shape::area = &area;
}
static double area(shape* self) {
double width = ((rectangle*)self)->width_;
double height = ((rectangle*)self)->height_;
return width * height;
}
};
class circle : public shape
{
double radius_;
public:
circle(double radius) : radius_{radius} {
shape::area = &area;
}
static double area(shape* self) {
double radius = ((circle*)self)->radius_;
return M_PI * radius * radius;
}
};
int main()
{
square s{10};
rectangle r{10, 20};
circle c{10};
shape* shapes[] = {&s, &r, &c}; double area;
for (auto shape : shapes) {
area = shape->area(shape);
cout << "area: " << area << '\n';
}
return 0;
}

Yine shape türünden bir koyteyner üzerinden ilgilendiğimiz geometrik türlerin alanlarını hesaplayabildik. Bu yöntem diğer iki yöntemdeki yeni türlerin eklenmesiyle ilgili problemi içermemekte. Yani shape türünden yeni tür türetilebilir ve shapes dizisine eklenebilir. shape sınıfı içerisinde herhangi bir değişiklik gerekmemektedir. Aslında gerçekte derleyici tarafından yapılan işlem de burada yaptığımız işleme bir hayli benzemekte. Şimdi dilin sağladığı araçlarla aynı işlemi yapalım.

Sanal (Virtual) Fonksiyonlar

Dilin çalışma zamanı çokbiçimliliğiyle ilgili sunduğu araçlar çok yakından bildiğimiz türetme, virtual ve pure virtual fonksiyonlardır. Şimdi aynı örneği virtual fonksiyonlar kullanarak yazalım.

#include <iostream>
#include <cmath>
using namespace std;class shape
{
public:
virtual double area() = 0;
};
class square : public shape
{
double side_;
public:
square(double side) : side_{side} {}
double area() override { return side_ * side_; }
};
class rectangle : public shape
{
double height_;
double width_;
public:
rectangle(double height, double width)
: height_{height}, width_{width} {}
double area() override { return width_ * height_; }
};
class circle : public shape
{
double radius_;
public:
circle(double radius) : radius_{radius} {}
double area() override { return M_PI * radius_ * radius_; }
};
int main()
{
square s{10};
rectangle r{10, 20};
circle c{10};
shape* shapes[] = {&s, &r, &c}; double area;
for (auto shape : shapes) {
area = shape->area();
cout << "area: " << area << '\n';
}
return 0;
}

Dilin sağladığı bu araçları kullanarak artık diğer incelediğimiz yöntemlere göre daha temiz ve yönetilebilir bir koda sahip olduk. shape sınıfını abstract olarak tanımladık. Bu durumda area fonksiyonunu tanımlamak shape türünden türeyen sınıfların sorumluluğundadır. Kod içerisinde türe ilişkin herhangi bir kontrol yapılmamakta. Peki bu durumda fonksiyon çağrıları nasıl yapılmaktadır? Tür ile ilgili bir kontrol yapılmıyorsa derleyici shape türünden bir gösterinin gerçekte hangi türden bir örneğe ait adres tuttuğunu nasıl bilebilir? Cevabımız bilemez, ayrıca bilmesini de istemiyoruz. shape türünün hem kendisi hem de hem de shape sınıfını kullanan kodlar bir tür bilgisi sorgusu yapmamalı sadece bir fonksiyon çağrısı yapmalıdır. Bu durum İngilizce’de “Tell-Don’t-Ask principle” olarak ifade edilmekte.

Sorularımızın cevabına geçmeden önce ilk olarak çokbiçimli olmayan bir fonksiyon çağrısının nasıl yapıldığına aşağıdaki basit örnek üzerinden bakalım.

#include <iostream>using namespace std;class B
{
public:
void foo() { cout << __PRETTY_FUNCTION__ << endl; }
};
class D : public B
{
void foo() { cout << __PRETTY_FUNCTION__ << endl; }
};
int main()
{
D d;
B& b = d;
b.foo();
return 0;
}

b referansı aslında D türünden bir örneğe karşılık gelmesine karşın, beklediğimiz gibi, B sınıfının foo fonksiyonu çağrıldı.

$ ./test 
void B::foo()

Kodu derleyip üretilen sembolik makine kodlarına baktığımızda derleyicinin foo fonksiyonu çağrısına ilişkin aşağıdaki sembolik makine komutunu ürettiğini görmekteyiz.

call    _ZN1B3fooEv

c++filt ile bir sembolün dekore edilmemiş halini görebiliriz.

$ c++filt -t _ZN1B3fooEv

B::foo()

Bu sembol daha sonra bağlayıcı (linker) tarafından bir fonksiyon adresine dönüştürülecektir. Derleyici foo fonksiyonu çağrısıyla karşılaştığında amaç kod (object code) içerisine fonksiyonun adresini yazmakta yani hangi fonksiyonun çağrılacağı derleme zamanında belirlenmektedir (static binding). Şimdi foo fonksiyonunu virtual yapalım.

#include <iostream>using namespace std;class B
{
public:
virtual void foo() { cout << __PRETTY_FUNCTION__ << endl; }
};
class D : public B
{
void foo() override { cout << __PRETTY_FUNCTION__ << endl; }
};
int main()
{
D d;
B& b = d;
b.foo();
return 0;
}

Bu sefer, bir önceki örneğin aksine, fonksiyon çağrısı B türünden bir referans üzerinden yapılmasına karşın D sınıfının foo fonksiyonunun çağrıldığını görüyoruz.

$ ./test 
virtual void D::foo()

Bu örnek için aklınıza derleyici zaten b referansında D türünden bir örneğinin tutulduğu çıkarımını yapabilir gibi bir soru gelebilir. Aşağıdaki örneği inceleyelim.

#include <iostream>
#include <random>
using namespace std;class B
{
public:
virtual void foo() { cout << __PRETTY_FUNCTION__ << endl; }
virtual void bar() { cout << __PRETTY_FUNCTION__ << endl; }
};
class D : public B
{
void foo() override { cout << __PRETTY_FUNCTION__ << endl; }
};
double random_gen()
{
random_device rd;
mt19937 mt(rd());
uniform_real_distribution<double> dist(0, 10);
return dist(mt);
}
int main()
{
D d;
B b;
B& br = random_gen() > 5 ? d : b;
br.foo();
return 0;
}

Bu örnekte br referansına hangi türden örneğin atanacağı çalışma zamanında belirleniyor dolayısıyla derleyicinin herhangi bir çıkarım yapması mümkün değil. Baştaki sorumuza geri dönecek olursak derleyici bunu nasıl başarıyor?

Derleyici aslında bir önceki incelememize benzer bir yöntem uyguluyor. Derleyici her, en az bir adet virtual ya da pure virtual fonksiyon barındıran, çokbiçimli tür için fonksiyon göstericilerinden oluşan bir tablo oluşturmaktadır. virtual method table ya da virtual dispatch table olarak isimlendirilen bu tablodaki fonksiyon göstericileri virtual fonksiyonları göstermektedir. Ayrıca derleyici çokbiçimli türe ait her bir örneğin içine bu tabloyu gösteren, genel olarak virtual table pointer olarak isimlendirilen, bir gösterici eklemektedir.

Yukarıdaki örneğimizdeki B ve D türleri için derleyici aşağıdakine benzer birer dispatch tablosu hazırlamakta ve örnek içerisine bu tablonun başlangıç adresini geçirmektedir.

B ve D türleri için dispatch tabloları

Örnek içerisinde dispatch tablosunun nerede tutulacağı standartlarda belirtilmemiş olmasına karşın g++ ve clang++ derleyicileri bu adresi örneğin başında tutmaktadırlar. Örnekte D türü foo fonksiyonunu override etmesine karşın bar fonksiyonunu override etmemiştir. Bu durumda D türünün dispatch tablosunda B sınıfının bar fonksiyonunun adresi tutulmaktadır.

Çok biçimli türlerin örneklerinde ayrıca bir adres tutulmasından dolayı boyutları artmaktadır. Bir örnek üzerinden bu durumu doğrulayalım.

#include <iostream>using namespace std;class B
{
int i;
public:
~B() = default;
};
int main()
{
cout << sizeof(B{}) << '\n';
return 0;
}
$ ./test
4

Şimdi sınıfın destructor fonksiyonunu virtual yapıp yeniden derleyelim.

#include <iostream>using namespace std;class B
{
int i;
public:
virtual ~B() = default;
};
$ ./test
16

64 bitlik sitemlerde adres genişliğinin 8 byte olduğu hatırlayınız. Derleyici performans gerekçesiyle ayrıca hizalama (alignment) yaptığı için B türünün genişliği 16 byte olmaktadır.

Sanal fonksiyonlara yapılan çağrılar çalışma zamanında çözümlendiği için ayrıca bir çalışma zamanı maliyeti getirmektedir. Bir sanal fonksiyon çağrısı fazladan iki indirection işlemine neden olmaktadır. İlk önce dispatch tablosuna sonrasında da ilgili fonksiyon adresine erişilerek indirekt (indirect function call) bir çağrı yapılmaktadır. Basit bir örnek üzerinden bu durumu inceleyelim.

#include <iostream>class B
{
public:
virtual void foo() { }
};
class D : public B
{
void foo() override { }
};
int main()
{
D d;
B& br = d;
br.foo();
return 0;
}

foo fonksiyon çağrısına ilişkin sembolik makine kodları aşağıdaki gibidir.

main:

movq %rax, -8(%rbp)
xorl %eax, %eax
leaq 16+_ZTV1D(%rip), %rax
movq %rax, -24(%rbp)
leaq -24(%rbp), %rax
movq %rax, -16(%rbp)
movq -16(%rbp), %rax
movq (%rax), %rax
movq (%rax), %rax
movq -16(%rbp), %rdx
movq %rdx, %rdi
call *%rax

Sembolik makine kodları içinde foo fonksiyonun ismine herhangi bir atıf görmüyoruz. Bu fonksiyon virtual olmasaydı kod içerisinden aşağıdaki gibi bir makine kodu görecektik.

call    _ZN1B3fooEv

_ZTV1D, D türünün dispatch tablosunu göstermektedir.

$ c++filt -t _ZTV1D

vtable for D

Derleyicinin oluşturduğu dispatch tablosu ise aşağıdaki gibidir. foo fonksiyon adresinin tablonun ikinci slotunda yani tablonun başlangıcından 16 byte ilerde olduğunu görüyoruz.

_ZTV1D:
.quad 0
.quad _ZTI1D
.quad _ZN1D3fooEv
.weak _ZTI1D

leaq 16+_ZTV1D(%rip), %rax makine komutu ile D türünün dispatch tablosunda foo fonksiyonunun adresinin tutulduğu yerin adresi rax yazmacına (register) atanmıştır. Sonrasında bu değer yığın (stack) alanında geçici bir alana kopyalanmış ve bu geçici alanın adresi tekrar rax yazmacına atanmıştır. Bu durumda virtual fonksiyon adresine erişmek için iki adet indirection işlemi yapılmalıdır. Aşağıdaki sembolik makine kodları bu işlemlere karşılık gelmektedir.

movq    (%rax), %rax
movq (%rax), %rax

Parantez içerisinde yapılan işlemler AT&T sentaksında indirection işlemini göstermektedir. C dilindeki pointer dereference işlemlerinde de benzer makine kodları üretilmektedir. Bu işlemlerden sonra rax yazmacında foo fonksiyonunu adresi bulunmaktadır. Aşağıdaki makine komutuyla da indirekt bir fonksiyon çağrısı yapılarak nihayetinde virtual foo fonksiyonu çağrılmaktadır.

call    *%rax

Çokbiçimli bir sınıfa ait bir örnek içinde dispatch tablosunun adresinin nerede bulunduğunu biliyorsak bir sanal fonksiyon çağrısını aşağıdaki gibi de yapabiliriz. g++ ve clang++ derleyicilerinin dispatch tablosunun adresini örneğin başında tuttuğunu biliyoruz.

#include <iostream>using namespace std;class B
{
public:
virtual void foo() { cout << __PRETTY_FUNCTION__ << '\n'; }
};
class D : public B
{
public:
void foo() override { cout << __PRETTY_FUNCTION__ << '\n'; }
};
int main()
{
D d;
B& br = d;
void* vptr = *(*(int***)(&br));
void(*p)() = (void(*)())vptr;
p();
return 0;
}

Tabii biz fonksiyon çağrılarımızı bu şekilde yapmayacağız burada amacımız sadece sanallık mekanizmasını anlamak. vptr değerini oluştururken iki defa indirection yaptığımıza dikkat edin bu sayede foo fonksiyonunun adresine erişiyor ve sonrasında bir fonksiyon göstericisi üzerinden çağırıyoruz. Çalışma zamanı çokbiçimliliğinin mekanizmasıyla ilgili incelemelerimizi burada tamamlayalım.

Çalışma zamanı çokbiçimliliğinin avantaj ve dezavantajlarını ise aşağıdaki gibi maddeleyebiliriz;

avantajlar:

  • Eski kodlar üzerinde değişiklik yapmadan yeni kodlar eklenebilir. Bu yönüyle çalışma zamanı çokbiçimliliği open-closed prensibini gerçeklemek için kullanılan güçlü bir araçtır. Bu sayede daha sonra eklenecek yeni türler bilinmeksizin fonksiyonel kütüphane kodları geliştirilebilir.

dezavantajlar:

  • Çalışma zamanı çokbiçimliliği bir is-a yani türetme ilişki kurmayı gerektirmektedir. Bu yüzden türeyen sınıflar arasında katı bir bağımlılık (tight coupling) oluşturmaktadır.
  • Çokbiçimli türlerin örneklerinin hepsinde ayrıca dispatch tablosuna ilişkin bir adres tutulmaktadır. Bu da 64 bitlik sistemlerde fazladan 8 byte anlamına gelmektedir.
  • Çokbiçimli fonksiyon çağrıları için fazladan iki adet indirection işlemi yapılmaktadır. Ayrıca sanal fonksiyonlar, istisna durumları hariç, inline yapılamamaktadır.

Son olarak çalışma zamanı çokbiçimliliğine bazı durumlarda alternatif olabilecek bir yöntem olan statik çokbiçimliliğe bakalım.

Statik Çokbiçimlilik

C++ dilinde statik çokbiçimlilik şablonlar kullanılarak yapılabilmektedir. Bu yöntem CRTP (Curiously Recurring Template Pattern) olarak isimlendirilmektedir. İlk bakışta gerçekten kafa karıştıran bu yöntemin nasıl gerçeklendiğine adım adım bakalım.

template<typename T>
class B
{
public:
void foo() { static_cast<T*>(this)->foo_impl(); }
void foo_impl() { cout << __PRETTY_FUNCTION__ << endl; }
};

Yukarıda B isimli bir şablon tanımladık. Bu şablon kullanılarak T türü için yeni bir tür oluşturulacak. Fakat burada dikkat edilmesi gereken bir nokta var. foo fonksiyonu için D* türüne bir statik cast işlemi yapıldığını görmekteyiz. Bu cast işlemini yapabilmemiz için T türü ile B türü arasında bir türetme ilişkisi olmalı yani bu örnek için T türü B şablonundan oluşturalacak türden türemeli. Bu durumda uygun bir T türü aşağıdaki gibi tanımlanabilir.

class D : public B<D>
{
public:
void foo_impl() { cout << __PRETTY_FUNCTION__ << endl; }
};

Yukarıdaki türetme işlemi için ilk olarak D türü kullanılarak B şablonundan bir tür üretilmekte ve D türü bu türden türetilmektedir. D türü B şablonunda kullanılırken bir incomplete type durumundadır. Dolayısıyla ancak gösterici ya da referans olarak kullanılabilir. Bu aşamada kodun bütününe bakmak faydalı olabilir.

#include <iostream>using namespace std;template<typename T>
class B
{
public:
void foo() { static_cast<T*>(this)->foo_impl(); }
void foo_impl() { cout << __PRETTY_FUNCTION__ << endl; }
};
class D : public B<D>
{
public:
void foo_impl() { cout << __PRETTY_FUNCTION__ << endl; }
};
template<typename T>
void bar(B<T>& param)
{
param.foo();
}
int main()
{
D d;
bar(d);
return 0;
}
$ ./test
void D::foo_impl()

static_cast<T*>(this)->foo_impl() ifadesi ile uygun foo fonksiyonun çağrılması sağlanmaktadır. İlk olarak D sınıfının bir foo_impl fonksiyonunu barındırıp barındırmadığına bakılır. D türünün bir foo_impl fonksiyonu barındırmaması durumunda ise B şablonunun içindeki foo_impl fonksiyonu çağrılacaktır. Bu yöntemde fonksiyon çözümlemesi statik zamanda yapıldığından çalışma zamanı çokbiçimliliğinin aksine fazladan bir çalışma maliyeti getirmemektedir.

Burada amacımız daha ziyade çalışma zamanı çokbiçimliliğinin mekanizmasını anlamak olduğundan kullanım senaryolarının detayına girmeyeceğim. Başka bir blog yazımızda bu konuya ayrıca bakabiliriz.

Kaynaklar

--

--