İçeriğe geç
academia.sh

Ders 04 / 14

RAII

Bir koruyucunun aldığı ve bıraktığı üyenin, koruyucu nesnenin kendisi değil içindeki bir parça olduğunu ölçer.

Yayın Güncelleme

Bu derste

  • RAII'nin bir kaynağı yapıcıda alıp yıkıcıda bıraktığını tanımlar
  • Bir koruyucunun dokunduğu nesnenin kendisi değil içindeki üye olduğunu ölçer
  • Erken çıkış yollarında bile yıkıcının koştuğunu gösterir
İçindekiler

Önceki ders bir nesnenin ne zaman kurulduğunu ve yok olduğunu, adı olsun ya da olmasın, ölçtü. Bu ders o bilgiyi bir amaç için kullanır: bir kaynağı — burada basit bir sayaç kaynağı, gerçek programlarda bellek, dosya tanıtıcısı ya da kilit olabilir — güvenle serbest bırakmak. Ölçüm sınıfı bu kez parça: bir koruyucunun aldığı ve bıraktığı şey, koruyucunun kendisi değil, içindeki bir veri üyesidir.

RAII Tanımı

RAII (Resource Acquisition Is Initialization — kaynak edinimi ilk değerlemedir), bir kaynağın bir nesnenin yapıcısında alınıp yıkıcısında bırakılmasını öngören bir tasarım kalıbıdır. Kaynağı tutan nesne yaşadığı sürece kaynak da geçerlidir; nesne — adlandırılmış ya da geçici, önceki derste görüldüğü gibi — herhangi bir yoldan yaşam süresinin sonuna geldiğinde yıkıcı çalışır ve kaynağı bırakır. Bu, kaynağı elle bırakmayı (serbest_birak() gibi bir işlevi her çağıran kodda tek tek çağırmayı) gereksiz kılar: kaynağı tutan nesne hangi yoldan yok olursa olsun (işlevin sonuna gelme, erken return, istisna fırlatma), yıkıcı her zaman çalışır.

RAII’nin karşıtı, kaynağı bir yapıcı/yıkıcı çiftine değil, çağıranın disiplinine bırakmaktır: bir kaynak_ac() işlevi çağrılır, kullanılır, ve kodun her çıkış noktasında elle bir kaynak_kapat() çağrılması beklenir. Bu yaklaşımın kırılganlığı, kod büyüdükçe ortaya çıkar — bir işleve yeni bir erken return eklendiğinde, o return’den önce kaynak_kapat() çağrısının da eklenmesi gerekir; eklenmezse kaynak sızar (leak — kaynağın hiç bırakılmadan programın geri kalanında geçerli kalmaya devam etmesi). RAII bu riski, bırakma kararını kaynağı tutan kodun değil, kaynağı tutan nesnenin türünün sorumluluğuna vererek ortadan kaldırır: Koruyucu’yu kullanan kod hiçbir zaman bir bırakma çağrısı yazmaz, çünkü bu çağrı zaten Kaynak::~Kaynak() içinde, bir kez, sınıfın kendisinde yazılıdır. Bu dersin ilk bölümünde iki sınıf tanımlanır: kaynağı gerçekten tutan Kaynak, ve onu bir üye olarak saran Koruyucu.

// kaynak.hpp — sayilarla temsil edilen basit bir kaynak
#ifndef KAYNAK_HPP
#define KAYNAK_HPP

#include <cstdio>

class Kaynak {
public:
    explicit Kaynak(int kimlik) : kimlik_(kimlik) {
        std::printf("kaynak alindi kimlik=%d\n", kimlik_);
    }

    ~Kaynak() {
        std::printf("kaynak birakildi kimlik=%d\n", kimlik_);
    }

    int kimlik() const { return kimlik_; }

private:
    int kimlik_;
};

#endif
// koruyucu.hpp — kaynagi tutan uyeyi alan ve birakan koruyucu
#ifndef KORUYUCU_HPP
#define KORUYUCU_HPP

#include "kaynak.hpp"

class Koruyucu {
public:
    explicit Koruyucu(int kimlik) : kaynak_(kimlik) {}

    int kimlik() const { return kaynak_.kimlik(); }

private:
    Kaynak kaynak_;
};

#endif

Koruyucunun Bıraktığı, Kendisi Değil İçindeki Üyedir

Koruyucu’nun kendi bir yapıcısı ve derleyicinin ürettiği bir yıkıcısı vardır; Koruyucu kaynak_ üyesini kendi kurduğunda Kaynak yapıcısı çalışır, kendi yok olduğunda kaynak_ yıkılır ve Kaynak yıkıcısı çalışır. Buradaki soru: koruyucunun aldığı ve bıraktığı şey, Koruyucu nesnesinin kendisi mi, yoksa içindeki kaynak_ üyesi mi?

// parca-olcumu.cpp — koruyucunun tuttugu kaynak koruyucunun kendisi mi ici mi
#include <cstdio>

#include "koruyucu.hpp"

int main() {
    std::printf("blok basladi\n");
    {
        Koruyucu k(7);
        std::printf("kimlik %d\n", k.kimlik());
    }
    std::printf("blok bitti\n");
    return 0;
}
blok basladi
kaynak alindi kimlik=7
kimlik 7
kaynak birakildi kimlik=7
blok bitti

SK8. Kaynak’ın yapıcısı Koruyucu k(7) satırında, Koruyucu’nun kendi yapıcı gövdesi çalışmadan önce, üye ilk değer listesindeki kaynak_(kimlik) ile tetiklenir — kaynak alindi satırı bu yüzden blok basladi’dan hemen sonra, Koruyucu’nun kendi gövdesi hiçbir şey basmadan önce görülür. Kaynağı gerçekte alan Kaynak nesnesidir, ve bu nesne Koruyucu’nun kendisi değil, onun bir üyesidir — kaynak_, Koruyucu’nun bellek aralığının içinde bir alt nesnedir (subobject), ayrı, bağımsız bir nesne değildir. Bunun kanıtı sıralamadadır: k blok sonunda yok olurken önce Koruyucu’nun (derleyicinin ürettiği, hiçbir şey basmayan) yıkıcısı çalışır, ardından Koruyucu’nun üyesi olduğu için kaynak_’ın yıkıcısı çalışır — kaynak birakildi satırı bu yüzden blok bitti’den önce görülür. Eğer kaynak_ Koruyucu’dan bağımsız, ayrı bir nesne olsaydı, onun yıkımı Koruyucu’nun yıkımına bağlı olmazdı; oysa burada ikisi aynı anda, aynı kapsamın kapanmasıyla tetiklenir — çünkü biri diğerinin parçasıdır.

Burada dikkat edilmesi gereken bir isim çakışması yoktur: Kaynak ile Koruyucu’nun ikisi de kendi başlarına birer sınıftır, biri diğerini üye olarak barındırır. Koruyucu’nun kendi yapıcısı explicit Koruyucu(int kimlik) : kaynak_(kimlik) {} gövdesizdir — tüm işi üye ilk değer listesi yapar, kaynak_’ı kimlik ile kurar. Koruyucu’nun kendi bir yıkıcısı yazılmamıştır; derleyici bir tane üretir, ve bu üretilen yıkıcı hiçbir şey basmadan yalnız kaynak_’ın yıkıcısını tetikler — bu, önceki dersteki Ciftlik örneğinde görülen aynı davranıştır: bir sınıf kendi yıkıcısını yazmasa bile, üyelerinin yıkıcıları yine de çalışır.

Erken Çıkış Yollarında Yıkıcı Yine Çalışır

RAII’nin asıl değeri, kaynağı bırakan kodu tek bir yere (yıkıcıya) yazıp, o kodun her çıkış yolunda çalışmasını derleyiciye bırakmaktır — işlevin normal sonu, erken return, ya da bir istisna fırlatıldığında bile.

// erken-cikis.cpp — koruyucu erken return yolunda da birakiyor mu
#include <cstdio>

#include "koruyucu.hpp"

static void calistir(bool erkenCik) {
    Koruyucu k(9);
    std::printf("calistir basladi kimlik=%d\n", k.kimlik());
    if (erkenCik) {
        std::printf("erken cikiliyor\n");
        return;
    }
    std::printf("normal bitiyor\n");
}

int main() {
    calistir(true);
    std::printf("main devam ediyor\n");
    return 0;
}
kaynak alindi kimlik=9
calistir basladi kimlik=9
erken cikiliyor
kaynak birakildi kimlik=9
main devam ediyor

SK9. calistir(true) if bloğu içindeki return’e ulaştığında, k’nin kapsamı (işlev gövdesi) sona erer ve derleyici k’nin yıkıcısını return’den hemen önce çalıştırır — kaynak birakildi satırı erken cikiliyor’dan sonra, ama main devam ediyor’dan önce görülür. Bu davranış if dalına bakılmaksızın aynıdır: calistir(false) çağrılsaydı normal bitiyor satırından sonra yine aynı yıkıcı çalışırdı. Kaynağı bırakan kod yalnız Kaynak::~Kaynak() içinde bir kez yazılıdır; calistir’in kendisi hiçbir bırakma çağrısı yapmaz.

Bu güvence yalnız if/return gibi düzenli kontrol akışı için değil, C++’ın istisna mekanizması için de geçerlidir: bir işlev gövdesinde bir istisna fırlatılırsa (throw), yığıt o istisna bir catch bloğu tarafından yakalanana kadar çözülür (yığıt çözülmesi) — bu çözülme sırasında, çözülen her kapsamdaki yerel nesnelerin yıkıcıları, tıpkı normal bir return’de olduğu gibi sırayla çalıştırılır. calistir içinde k’den sonra bir throw satırı olsaydı, k’nin yıkıcısı yine de istisna calistir‘in dışına çıkmadan önce çalışırdı. Bu kurs istisna fırlatma ve yakalamayı öğretmez — o başka bir konudur — ama RAII’nin gerçek gücü tam olarak buradan gelir: kaynak bırakma kodu, kontrol akışının hangi yoldan çıktığını bilmek zorunda değildir, çünkü yıkıcı her yoldan çalışır.

Kaynağı Referansla Tutmanın Riski

RAII’nin sağlamlığı, koruyucunun kaynağı değerle (bir üye olarak, kendi ömrüyle bağlı) tutmasına dayanır. Bir koruyucu kaynağı referansla ya da işaretçiyle tutarsa, koruyucunun kendi ömrü ile kaynağın gerçek sahibinin ömrü ayrışabilir — koruyucu hâlâ yaşarken kaynak zaten yok olmuş olabilir. Koruyucu bu riski taşımaz çünkü kaynak_ bir referans ya da işaretçi değil, doğrudan bir Kaynak değeridir; Koruyucu yaşadığı sürece kaynak_ de yaşar, çünkü aynı nesnenin bir parçasıdır. Bir koruyucu kaynağı değerle değil de bir işaretçiyle tutacak şekilde yanlış tasarlansaydı — Kaynak* kaynak_; gibi — koruyucunun kopyalanması yalnız işaretçiyi kopyalardı, gerçek Kaynak nesnesini değil; iki koruyucu aynı kaynağı işaret eder, biri yıkıcısında kaynağı bırakınca diğeri artık geçersiz bir kaynağa işaret eder hâle gelirdi. Bu ders bu hatayı koşturmaz — yalnız düzyazıda anılır — çünkü kaynağın iki kez bırakılması tanımsız davranıştır.

Bu paylaşım sorununu standart kütüphanenin hazır RAII sarmalayıcılarıyla çözmek K05’in konusudur; bu kurs kaynağı kendi yazdığı sınıflarla yönetir. M10/K02’nin (Rust) sahiplik sistemi ve M11/K02’nin kaynak yönetimi aynı fikrin — bir kaynağın tam olarak bir sahibi olması ve sahibin ömrü bittiğinde kaynağın otomatik bırakılması — farklı dillerdeki karşılıklarıdır; bu ders o karşılaştırmayı derinleştirmez.

Koruyucu’nun kendi tasarımı bu riskten nasıl kaçındığını daha ayrıntılı görmek faydalıdır. kaynak_ bir Kaynak değeridir, bir Kaynak* ya da Kaynak& değildir; bu yüzden Koruyucu nesnesinin bellek düzeninde kaynak_’ın kendisi (yalnız ona işaret eden bir adres değil) yer alır. Bir Koruyucu kopyalandığında — bu kursun bir sonraki dersinin konusu — kaynak_ de kopyalanır, çünkü o da Koruyucu‘nun bir parçasıdır; iki Koruyucu iki ayrı Kaynak taşır, aynı kaynağı paylaşmazlar. Bir işaretçiyle tutulan bir kaynakta bu doğru olmaz: işaretçinin kopyalanması yalnız adresi kopyalar, gösterilen nesneyi değil, bu yüzden iki koruyucu aynı kaynağa işaret etmeye başlar. Değerle tutmanın bu sonucu — kopyalamanın kaynağı da beraberinde kopyalaması — RAII’nin güvenilirliğinin bir parçasıdır ve bir sonraki derste ayrıntılı ölçülür.

Bir kaynağı değerle tutmanın bir sınırı da vardır: kaynak, tutan nesnenin bellek düzeninde yer aldığı için, kaynağın boyutu (ya da en azından kaynağı temsil eden tanıtıcının boyutu) derleme zamanında bilinmelidir. Kaynak burada yalnız bir int taşıdığı için bu sorun görünmez; gerçek bir kaynak — açık bir dosya, bir ağ bağlantısı — genellikle işletim sisteminin verdiği sabit boyutlu bir tanıtıcı (handle, örneğin bir tamsayı ya da bir işaretçi) ile temsil edilir, bu yüzden aynı değerle-tutma stratejisi gerçek kaynaklar için de çalışır.

Çoklu Kaynak Tutan Bir Koruyucu

Bir koruyucunun tek bir kaynak yerine birden çok kaynak tutması da aynı kuralı izler: her kaynak, koruyucunun bir başka üyesi olur, ve her üyenin kurulma/yıkılma sırası önceki derste ölçülen bildirim sırası kuralına uyar. İki Kaynak üyesi olan bir CiftKoruyucu tasarlansaydı, üyeler bildirim sırasıyla kurulur, ters sırada yıkılırdı — tıpkı Ciftlik örneğindeki birinci_/ikinci_ gibi. Bu ders CiftKoruyucu‘yu koşturmaz, çünkü tek bir kaynağın alınıp bırakılması RAII’nin çekirdek fikrini göstermek için yeterlidir; ama bu genellemenin doğrudan önceki dersten geldiğini görmek önemlidir — RAII yeni bir dil kuralı eklemez, yalnız yapıcı/yıkıcı sırasını bir amaç için kullanır.

Sınırlayıcı Ölçüm

Bu dersin ölçtüğü ifade parça sınıfındadır: kaynak_, Koruyucu’nun kendisi değil, onun içindeki bir alt nesnedir, ve Koruyucu‘nun yaşam süresiyle aynı yaşam süresine sahiptir. Ama RAII’nin güvencesi koruyucunun kaynağı değerle tutmasına bağlıdır — bir koruyucu kaynağa yalnız bir referans tutuyorsa (kaynağın gerçek sahibi başka bir nesneyse), koruyucu korunan nesneden uzun yaşamamalıdır; koruyucu hâlâ yaşarken korunan nesne yok olursa, koruyucunun referansı sarkan hâle gelir ve koruyucunun yıkıcısı (varsa) geçersiz bir nesneye erişmeye çalışabilir. Bu ders bu riski koşturmaz, çünkü koşturmak tanımsız davranışı tetikler; Koruyucu burada kaynağı her zaman değerle tuttuğu için bu risk zaten yoktur.

Bu sınırın pratik bir sonucu, bir koruyucu tasarlarken “bu kaynağın gerçek sahibi kim?” sorusunun her zaman açıkça yanıtlanması gerektiğidir. Koruyucu’da yanıt nettir: Koruyucu kendisi sahiptir, çünkü kaynak_ onun bir değer üyesidir. Bir koruyucu bunun yerine “başka bir yerde yaşayan bir kaynağı yalnız gözlemliyorum” demek isteseydi — örneğin bir işlevin parametresinde alınan bir Kaynak&’i saklamak isteseydi — bu artık RAII değil, yalnız bir referans tutmak olurdu, ve kaynağı bırakma sorumluluğu koruyucuda olmazdı. İki tasarım da C++’ta geçerlidir, ama birbirine karıştırılmamalıdır: bir sınıfın yıkıcısı yalnız kendi sahip olduğu kaynakları bırakmalıdır, ödünç aldığı bir kaynağı değil.

Bu ders boyunca ölçülen üç örnek — kurulma sırasının kanıtı, erken çıkışta yıkıcının çalışması, kaynağın değerle tutulması — birlikte RAII’nin neden bir “en iyi uygulama”dan fazlası olduğunu gösterir: bu, dilin yapıcı/yıkıcı ve bildirim sırası kurallarının doğrudan bir sonucudur, ayrı bir mekanizma değildir. Bir sonraki dersin kopyalama sorusu da aynı temelin üstüne kurulur — bir Koruyucu kopyalandığında kaynak_ de kopyalanacaktır, ama bu kopyanın “derin” mi “sığ” mı olduğu, kaynağın türüne bağlı olarak farklı sonuçlar doğurabilir.

Özet

  • Bu dersin ölçtüğü ifade parça sınıfındadır: kaynak_, Koruyucu nesnesinin kendisi değil, onun bir alt nesnesidir.
  • RAII bir kaynağı yapıcıda alır, yıkıcıda bırakır; kaynağı elle bırakan kod gerekmez.
  • Yıkıcı, kapsamın nasıl sona erdiğine bakılmaksızın çalışır — normal bitiş, erken return, ya da istisna fırlatma, hepsinde aynıdır.
  • Kaynağı değerle tutan bir koruyucu, koruyucunun ömrüyle kaynağın ömrünü birleştirir; referansla tutan bir koruyucu bu güvenceyi kaybeder.
  • Akıllı işaretçiler RAII’yi paylaşılan kaynaklara genişletir; bu kursun kapsamı dışındadır (K05).

Sonraki Adım

Bu ders bir kaynağın yapıcı/yıkıcı çiftiyle nasıl güvenle tutulduğunu ölçtü; bir sonraki ders bir nesne kopyalandığında bu güvencenin ne olduğunu sorar — kopyalayan yapıcının kendi kaynağını mı aldığını, yoksa orijinaliyle paylaştığını mı ölçer.

İlerlemeni kaydetmek ve not almak için Giriş yap

Notlarım

Not almak için giriş yapmalısın.

Aramak için yazmaya başlayın.

↑↓ ↵ Esc gezin · aç · kapat