20 Şubat 2025

C# Kodunu C++'a Taşıma: Akıllı İşaretçiler

C# kodunu Java'ya çeviren bir araç geliştirirken, kullanılmayan nesnelerin silinmesiyle ilgili herhangi bir sorun yaşanmaz: Java, C#'daki mekanizmaya oldukça benzer bir çöp toplama (garbage collection) mekanizması sağlar ve sınıfları kullanan çevrilmiş kod derlenir ve çalışır. C++ ise farklı bir hikaye. Açıkça görülüyor ki referansları ham işaretçilerle eşleştirmek istenen sonucu vermeyecek çünkü bu şekilde çevrilmiş kod hiçbir şeyi silmeyecektir. Buna karşın, GC ortamında çalışmaktan alışkın olan C# geliştiricileri, birçok geçici nesne oluşturan kod yazmaya devam edeceklerdir.

Çevrilmiş kodda nesnelerin zamanında silinmesini sağlamak için üç seçenek arasından seçim yapmak zorunda kaldık:

  1. Örneğin akıllı işaretçiler aracılığıyla nesneler için referans sayımı kullanmak;
  2. Örneğin Boehm GC gibi bir C++ çöp toplayıcı uygulaması kullanmak;
  3. Nesnelerin silinmesi gereken noktaları belirlemek için statik analiz kullanmak.

Üçüncü seçenek hemen elendi: Taşınan kütüphanelerdeki algoritmaların karmaşıklığı engelleyici düzeydeydi. Ayrıca, statik analizin bu çevrilmiş kütüphaneleri kullanan istemci kodlarını da kapsaması gerekecekti.

İkinci seçenek de pratik bulunmadı: Kütüphaneler yerine uygulamalar taşınıyor olduğundan, bir çöp toplayıcıyı dayatmak bu kütüphaneleri kullanan istemci kodlarına kısıtlamalar getirecekti. Bu yöndeki deneyler başarısız olarak kabul edildi.

Böylece, geriye kalan son seçenek olan referans sayımı kullanan akıllı işaretçiler seçeneğine ulaştık, bu da C++ için oldukça tipiktir. Bu durum, dairesel referanslar sorununu çözmek adına güçlü referanslara ek olarak zayıf referanslar kullanmamız gerektiği anlamına geliyordu.

Akıllı İşaretçi Türleri

Akıllı işaretçilerin bilinen birkaç türü vardır:

  • shared_ptr en açık seçenek gibi görünebilir, ancak önemli bir dezavantaja sahiptir: enable_shared_from_this bile olsa referans sayacı nesneden ayrı olarak yığın üzerinde tutulur. Referans sayacı için bellek ayırmak ve serbest bırakmak göreceli olarak pahalı bir işlemdir.
  • intrusive_ptr, bu açıdan daha iyidir çünkü her geçici nesne için ekstra bir ayırma maliyetinin önüne geçmek adına yapı içinde kullanılmayan 4/8 baytlık bir alan önemsiz bir dezavantajdır.

Şimdi aşağıdaki C# kodunu ele alalım:

class Document
{
    private Node node;
    public Document()
    {
        node = new Node(this);
    }
    public void Prepare(Node n) { ... }
}

class Node
{
    private Document document;
    public Node(Document d)
    {
        document = d;
        d.Prepare(this);
    }
}

intrusive_ptr kullanıldığında, bu kod şu şekilde çevrilecektir:

class Document : public virtual System::Object
{
    intrusive_ptr<Node> node;
public:
    Document()
    {
        node = make_shared_intrusive<Node>(this);
    }
    void Prepare(intrusive_ptr<Node> n) { ... }
};

class Node : public virtual System::Object
{
    intrusive_ptr<Document> document;
public:
    Node(intrusive_ptr<Document> d)
    {
        document = d;
        d->Prepare(this);
    }
};

Burada üç sorun hemen belirginleşiyor:

  1. Dairesel referansı kırmak için bir mekanizmaya ihtiyaç var; bu durumda Node::document'i zayıf bir referans haline getirmek gerekiyor.
  2. this'i bir intrusive_ptr'ye dönüştürmek için bir yol olmalı (shared_from_this benzeri). Eğer bunun yerine yöntem imzalarını değiştirmeye başlarsak (örneğin, Document::Prepare'nin Node* yerine intrusive_ptr<Node> kabul etmesini sağlarsak), aynı yöntemlerin zaten oluşturulmuş nesnelerle çağrılması veya nesne ömürlerinin yönetilmesinde sorunlar ortaya çıkacaktır.
  3. this'i bir intrusive_ptr'ye çevirme işlemi, referans sayısını sıfıra düşürme ile takip eder (örneğin, Node yapıcısından Document::Prepare çıktığında), henüz dış referanslara sahip olmayan kısmen inşa edilmiş nesneyi hemen silmemelidir.

İlk sorun manuel olarak ele alındı, çünkü insanlar bile hangi referansların zayıf olması gerektiğini belirlemekte zorlanabilir. Bazı durumlarda net bir yanıt yoktur ve C# kodunda değişiklik yapılmasını gerektirir.
Örneğin, bir projede “yazdırma eylemi” ve “yazdırma eylemi parametreleri” adlı iki sınıf vardı. Her birinin yapıcısı eşleştirilmiş nesneyi oluşturdu ve çift yönlü referanslar kurdu. Açıkça, bu referanslardan birini zayıf yapmak kullanım senaryosunu bozacaktı. Sonuç olarak, çevirmenin ilgili alanı zayıf bir referans içermesi gerektiğini belirtmek için [CppWeakPtr] niteliğini kullanmaya karar verdik.

İkinci sorun, intrusive_ptr'nin ham işaretçiden dönüşüme izin vermesi durumunda kolayca çözülür; this bir ham işaretçidir ve Boost uygulaması bu özelliği sağlar.

Son olarak, üçüncü sorun, oluşturucuda yerel bir RAII koruyucusu değişkeni tanıtılarak çözüldü. Bu koruyucu, nesne oluşturulduğunda referans sayısını artırır ve yok edildiğinde azaltır. Önemli olan, koruyucu içinde referans sayısını sıfıra düşürmenin korunan nesneyi silmemesidir.

Bu değişikliklerle, çeviri öncesi ve sonrası kod kabaca şu şekilde görünür:

class Document
{
    private Node node;
    public Document()
    {
        node = new Node(this);
    }
    public void Prepare(Node n) { ... }
}

class Node
{
    [CppWeakPtr] private Document document;
    public Node(Document d)
    {
        document = d;
        d.Prepare(this);
    }
}
class Document : public virtual System::Object
{
    intrusive_ptr<Node> node;
public:
    Document()
    {
        System::Details::ThisProtector guard(this);
        node = make_shared_intrusive<Node>(this);
    }
    void Prepare(intrusive_ptr<Node> n) { ... }
};

class Node : public virtual System::Object
{
    weak_intrusive_ptr<Document> document;
public:
    Node(intrusive_ptr<Document> d)
    {
        System::Details::ThisProtector guard(this);
        document = d;
        d->Prepare(this);
    }
};

Dolayısıyla, intrusive_ptr uygulaması gereksinimlerimizi karşılamakta ve zayıf bir weak_intrusive_ptr sınıfıyla tamamlanmakta olduğu sürece yeterli olacaktır. İkincisi, nesnenin dışında yığın üzerinde bulunan bir referans sayacı üzerine dayanmalıdır. Zayıf referansların oluşturulması, geçici nesnelerin oluşturulmasına kıyasla nispeten nadir bir işlem olduğundan, referans sayacını güçlü (nesne içinde) ve zayıf (nesne dışında) olarak ayırmak gerçek dünya kodlarında performans artışı sağlamıştır.

Şablonlar

Durum, genel sınıflar ve yöntemler için kod çevrildiğinde, tür parametrelerinin değer türleri veya referans türleri olabilmesi nedeniyle önemli ölçüde daha karmaşık hale gelir. Örneğin, aşağıdaki C# kodunu ele alalım:

class MyContainer<T>
{
    public T field;
    public void Set(T val)
    {
        field = val;
    }
}
class MyClass {}
struct MyStruct {}

var a = new MyContainer<MyClass>();
var b = new MyContainer<MyStruct>();

Doğrudan bir çeviri yaklaşımı şu sonucu verir:

template <typename T> class MyContainer : public virtual System::Object
{
public:
    T field;
    void Set(T val)
    {
        field = val;
    }
};
class MyClass : public virtual System::Object {};
class MyStruct : public System::Object {};

auto a = make_shared_intrusive<MyContainer<MyClass>>();
auto b = make_shared_intrusive<MyContainer<MyStruct>>();

Açıkça, bu kod orijinalle aynı şekilde davranmayacak çünkü MyContainer<MyClass> örneği alındığında field nesnesi yığından MyContainer alanına taşınır ve referans kopyalama semantiğini bozar. Aynı zamanda, MyStruct yapısının alan içinde yer alması tamamen doğrudur çünkü bu, C# davranışına uygun olarak hareket eder.

Bu durum iki şekilde çözülebilir:

  1. MyContainer<MyClass> semantiğinden MyContainer<intrusive_ptr<MyClass>> semantiğine geçiş yapmak:
auto a = make_shared_intrusive<MyContainer<intrusive_ptr<MyClass>>>();
  1. Her şablon sınıfı için iki uzmanlaştırma oluşturmak: biri tür argümanının değer türü olduğu durumlar için, diğeri referans türü olduğu durumlar için:
template <typename T, bool is_T_reference_type = is_reference_type_v<T>> class MyContainer : public virtual System::Object
{
public:
    T field;
    void Set(T val)
    {
        field = val;
    }
};

template <typename T> class MyContainer<T, true> : public virtual System::Object
{
public:
    intrusive_ptr<T> field;
    void Set(intrusive_ptr<T> val)
    {
        field = val;
    }
};

Ek olarak, her bir ek tür parametresiyle birlikte karmaşıklık katlanarak artar ve ikinci yaklaşımda her MyContainer<T> kullanan bağlamın T'nin bir değer türü mü yoksa referans türü mü olduğunu bilmesi gerekir ki bu çoğu zaman istenmez. Örneğin, dahil edilen başlık dosyalarının sayısını azaltmak veya belirli dahili türler hakkında bilgiyi tamamen gizlemek istediğimizde.
Ayrıca, referans türünün (güçlü veya zayıf) seçimi her bir kap için yalnızca bir kez yapılabilir. Bu, dönüştürülmüş ürünlerin kodunun her iki varyantı da gerektirse bile hem güçlü referansların hem de zayıf referansların listelerine sahip olmanın imkansız olduğu anlamına gelir.

Bu faktörleri göz önünde bulundurarak, MyContainer<T>'yi güçlü referanslar için MyContainer<intrusive_ptr<T>> semantiğiyle veya zayıf referanslar için MyContainer<weak_intrusive_ptr<T>> semantiğiyle taşımaya karar verdik. En popüler kütüphaneler gerekli özelliklere sahip işaretçiler sağlamadığından, kendi uygulamalarımızı geliştirdik: nesne içinde referans sayacı kullanan System::SharedPtr—güçlü bir referans—ve harici referans sayacı kullanan System::WeakPtr—zayıf bir referans. System::MakeObject işlevi ise std::make_shared tarzında nesneler oluşturmakla sorumludur.

İlgili Haberler

İlgili videolar

İlgili makaleler