Mert Özen Her Satırda Daha İleri
Backend C# ile Nesne Yönelimli Programlama — #03

C#'ta Encapsulation ve Access Modifier'lar: public, private, protected, internal

Mert Özen 7 Kas 2026 9 dk 4 görüntülenme
C#'ta Encapsulation ve Access Modifier'lar: public, private, protected, internal

Get-only bir List property'sinin neden hiçbir şeyi korumadığı, invariant'ları object'in içinde tutmak, IReadOnlyList ve AsReadOnly farkı, altı access modifier ve varsayılanları, private'ın tipe ait olması, internal ve InternalsVisibleTo ile assembly sınırı.

Sürüm notu: Bu yazıdaki kodlar C# 10 ve .NET 6 API'leriyle yazılmıştır; .NET 10 SDK ile derlenip denenmiştir.

Önce Tahmin Et

Önceki yazıda get-only property'nin değerini sonradan kimsenin değiştiremeyeceğini söyledik. Şimdi bir sipariş class'ı yazalım: kalemleri get-only bir List<OrderItem> property'sinde tutuyor, toplamı da onlardan hesaplıyor. Aşağıdaki kod derlenir mi? Derlenirse ne yazdırır?

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

Order order = new Order();
order.Items.Add(new OrderItem("Dune", 45.90m, 2));
Console.WriteLine(order.Total);

order.Items.Clear();
order.Items.Add(new OrderItem("Dune", -100m, 1));
Console.WriteLine(order.Total);

public class Order
{
    public List<OrderItem> Items { get; } = new();
    public decimal Total => Items.Sum(item => item.UnitPrice * item.Quantity);
}

public class OrderItem
{
    public string Title { get; }
    public decimal UnitPrice { get; }
    public int Quantity { get; }

    public OrderItem(string title, decimal unitPrice, int quantity)
    {
        Title = title;
        UnitPrice = unitPrice;
        Quantity = quantity;
    }
}

Get-only Liste Neden Korumadı

Kod tek uyarı vermeden derlenir ve şunu yazdırır:

91.80
-100

Toplamı eksi 100 olan bir sipariş elde ettik. Items { get; } yalnızca property'nin gösterdiği referansı kilitler: order.Items = new List<OrderItem>(); yazamazsın. Ama referansın gösterdiği list'in kendisi tamamen açık; Add, Clear, Remove dışarıdaki herkesin elinde. Kapıyı kilitleyip anahtarı paspasın altına koymak gibi.

Encapsulation tam olarak bu sorunla ilgilenir. Bir object'in kendi kuralları vardır, literatürdeki adıyla invariant'ları: siparişteki her kalemin fiyatı pozitiftir, onaylanmış bir sipariş değişmez, toplam kalemlerin toplamıdır. Encapsulation bu kuralları object'in içinde tutmak ve durumu yalnızca kuralları uygulayan yollardan değiştirmeye izin vermektir. Field'ları private yapmak bunun aracıdır, amacı değil.

Encapsulation: Kuralları Verinin Yanında Tutmak

Siparişi baştan yazalım. List artık private bir field; dışarıya yalnızca okunabilir bir görüntü açılıyor ve değişiklik niyeti anlatan method'lardan geçiyor:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

Order order = new Order();
order.AddItem("Dune", 45.90m, 2);
order.AddItem("Clean Code", 39.50m, 1);
Console.WriteLine($"{order.Items.Count} items, total {order.Total}");

try
{
    order.AddItem("Refactoring", -100m, 1);
}
catch (ArgumentOutOfRangeException ex)
{
    Console.WriteLine(ex.Message);
}

order.Submit();

try
{
    order.AddItem("Refactoring", 52.00m, 1);
}
catch (InvalidOperationException ex)
{
    Console.WriteLine(ex.Message);
}

Console.WriteLine($"{order.Items.Count} items, total {order.Total}, submitted: {order.IsSubmitted}");

public class Order
{
    private readonly List<OrderItem> _items = new();

    public IReadOnlyList<OrderItem> Items => _items;
    public bool IsSubmitted { get; private set; }
    public decimal Total => _items.Sum(item => item.UnitPrice * item.Quantity);

    public void AddItem(string title, decimal unitPrice, int quantity)
    {
        if (IsSubmitted)
        {
            throw new InvalidOperationException("A submitted order cannot be changed.");
        }

        if (unitPrice <= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(unitPrice), "Unit price must be positive.");
        }

        if (quantity <= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity must be positive.");
        }

        _items.Add(new OrderItem(title, unitPrice, quantity));
    }

    public void Submit()
    {
        if (_items.Count == 0)
        {
            throw new InvalidOperationException("An empty order cannot be submitted.");
        }

        IsSubmitted = true;
    }
}

public class OrderItem
{
    public string Title { get; }
    public decimal UnitPrice { get; }
    public int Quantity { get; }

    public OrderItem(string title, decimal unitPrice, int quantity)
    {
        Title = title;
        UnitPrice = unitPrice;
        Quantity = quantity;
    }
}
2 items, total 131.30
Unit price must be positive. (Parameter 'unitPrice')
A submitted order cannot be changed.
2 items, total 131.30, submitted: True

Siparişin üç kuralı artık Order'ın içinde yazılı ve onları atlatmanın sıradan bir yolu yok. IReadOnlyList<OrderItem> tipinde Add ya da Clear bulunmadığı için ilk örnekteki order.Items.Add(...) satırı artık derlenmez; derleyici CS1061 ile "'IReadOnlyList<OrderItem>' does not contain a definition for 'Add'" der. IsSubmitted'ın private set olması da önemli: siparişin onaylandığını yalnızca Submit söyleyebilir ve o da boş siparişi onaylamaz.

Dikkat et, Order dışarıya "list'e element ekle" demiyor, "siparişe kalem ekle" diyor. Bu fark küçük görünür ama encapsulation'ın özü budur. Çağıran kod siparişin içini bilmek zorunda değil; yarın kalemleri list yerine dictionary'de tutmaya karar verirsen AddItem'ı kullanan hiçbir satır değişmez. Bunun bilinen adı "tell, don't ask": object'ten verisini isteyip kararı dışarıda vermek yerine, object'e ne istediğini söylersin ve kararı o verir.

Okuma Görüntüsü Bir Kilit Değil

IReadOnlyList<OrderItem> döndürmek kötü niyetli koda karşı bir kalkan değildir. Property hâlâ aynı List<OrderItem> object'ini döndürüyor, yalnızca daha dar bir tip olarak. Cast eden biri list'e ulaşır:

Order order = new Order();
order.AddItem("Dune", 45.90m, 2);

List<OrderItem> hacked = (List<OrderItem>)order.Items;
hacked.Clear();
Console.WriteLine(order.Items.Count);

public class Order
{
    private readonly List<OrderItem> _items = new();

    public IReadOnlyList<OrderItem> Items => _items;

    public void AddItem(string title, decimal unitPrice, int quantity)
    {
        _items.Add(new OrderItem(title, unitPrice, quantity));
    }
}

public class OrderItem
{
    public string Title { get; }
    public decimal UnitPrice { get; }
    public int Quantity { get; }

    public OrderItem(string title, decimal unitPrice, int quantity)
    {
        Title = title;
        UnitPrice = unitPrice;
        Quantity = quantity;
    }
}
0

Sipariş boşaldı. Aynı uygulamanın içinde yazılmış kodda bu genelde bir sorun değildir; bu cast'i yazan kişi kuralı bilerek çiğniyordur ve code review'da yakalanır. Ama bir library yazıyorsan ya da kalemler dış bir sisteme gidiyorsa, list'i gerçek bir read-only wrapper'la dışarı ver. Tek satırlık değişiklik:

public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
Unhandled exception. System.InvalidCastException: Unable to cast object of type 'System.Collections.ObjectModel.ReadOnlyCollection`1[OrderItem]' to type 'System.Collections.Generic.List`1[OrderItem]'.

AsReadOnly() bir ReadOnlyCollection<T> döner: altta yine aynı list'i okur, kopya almaz, ama kendisi bir List<T> değildir ve değiştirme method'ları exception fırlatır. Her okumada küçük bir sarmalayıcı object oluşur; sık okunan bir property'deysen onu bir kez oluşturup bir field'da tutabilirsin.

Altı Access Modifier

Şimdiye kadar public ve private'ı kullandık. C#'ta toplam altı seviye var ve hepsi tek class'ta şöyle görünür:

Book dune = new Book();
Console.WriteLine(dune.Title);
Console.WriteLine(dune._stock);

class Book // belirleyici yok: internal
{
    public string Title { get; } = "Dune"; // her yerden
    int _stock = 3; // belirleyici yok: private, yalnızca Book içinden
    protected decimal BasePrice { get; set; } // Book ve onun derived class'ları
    internal string ShelfCode { get; set; } = "A-12"; // aynı assembly içindeki her yer
    protected internal string Supplier { get; set; } = ""; // aynı assembly YA DA derived class
    private protected decimal Cost { get; set; } // aynı assembly içindeki derived class
}
Program.cs(3,24): error CS0122: 'Book._stock' is inaccessible due to its protection level
Program.cs(8,9): warning CS0414: The field 'Book._stock' is assigned but its value is never used

İki varsayılanı ezberle. Class member'larında hiçbir şey yazmazsan member private'tır. Bir namespace'in doğrudan içindeki tiplerde hiçbir şey yazmazsan tip internal'dır. Yani C# en kapalı seçenekten başlar; açmak senin kararındır. Yine de belirleyiciyi her zaman açıkça yazmak yaygın bir ekip kuralıdır, çünkü okuyan kişi varsayılanı hatırlamak zorunda kalmaz.

protected ve iki parçalı kombinasyonlar inheritance ile anlam kazanıyor; onları beşinci yazıda kullanacağız. protected internal bir "veya", private protected bir "ve" ilişkisidir; ikincisi C# 7.2 ile geldi ve nadiren gerekir. Uyarı satırı da ilginç: derleyici _stock'a atama yapıldığını ama hiç okunmadığını fark etti. Private bir member'ın kullanılıp kullanılmadığını kesin bilebilir, çünkü onu görebilecek tek yer bu class.

private Object'e Değil Tipe Aittir

Erişim kuralları tip seviyesinde işler, object seviyesinde değil. Bir Book method'u, başka bir Book object'inin private field'ına dokunabilir:

Book shelf = new Book("Dune", 5);
Book window = new Book("Dune", 0);

shelf.MoveStockTo(window, 2);
Console.WriteLine($"shelf: {shelf.Stock}, window: {window.Stock}");

public class Book
{
    private int _stock;

    public string Title { get; }
    public int Stock => _stock;

    public Book(string title, int stock)
    {
        Title = title;
        _stock = stock;
    }

    public void MoveStockTo(Book other, int quantity)
    {
        if (quantity > _stock)
        {
            throw new InvalidOperationException("Not enough stock.");
        }

        _stock -= quantity;
        other._stock += quantity;
    }
}
shelf: 3, window: 2

other._stock derlenir, çünkü kod Book'un içinde yazılı. Bu bir açık değil, bilinçli bir tasarım: aynı class'ın kodu tipin kurallarını zaten bilir. Eşitlik karşılaştırması, kopyalama ya da bu örnekteki gibi iki object arasında aktarım yazarken işine yarar. Encapsulation'ın sınırı "bu object" değil, "bu tipin kodu"dur.

internal ve Assembly Sınırı

internal'ın anlamı ancak birden çok proje olduğunda ortaya çıkar. Her proje derlendiğinde bir assembly'ye, yani bir .dll ya da .exe dosyasına dönüşür. internal "bu assembly'nin içinde public, dışında görünmez" demektir. Bookstore'u bir class library ve onu kullanan bir console uygulaması olarak ikiye böldüm. Library tarafı:

namespace Bookstore.Core;

public class PriceCalculator
{
    public decimal WithTax(decimal price)
    {
        return price + price * TaxTable.BookRate;
    }
}

internal static class TaxTable
{
    public const decimal BookRate = 0.10m;
}

Console uygulaması library'ye project reference ile bağlı:

using System.Globalization;
using Bookstore.Core;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

PriceCalculator calculator = new PriceCalculator();
Console.WriteLine(calculator.WithTax(45.90m));
Console.WriteLine(TaxTable.BookRate);
Program.cs(8,19): error CS0122: 'TaxTable' is inaccessible due to its protection level

TaxTable içindeki BookRate public olsa da işe yaramıyor; dıştaki tip internal olduğu için onun member'ları da assembly dışına çıkamıyor. Bir member'ın gerçek erişilebilirliği, kendisinin ve onu içeren tiplerin en dar olanıdır. Library'nin dışarıya verdiği söz yalnızca PriceCalculator; vergi tablosunun nasıl tutulduğu library'nin iç meselesi ve yarın değişebilir.

Test projeleri internal tiplere erişmek istediğinde library'ye şu attribute eklenir:

using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("Bookstore.Core.Tests")]

Bu satır adı verilen assembly'ye internal member'ları açar. Aynı attribute'u Bookstore.App adıyla eklediğimde console uygulaması derlendi ve 50.4900 ile 0.10 yazdırdı. Bunu yalnızca test için kullan; üretim kodunda iki assembly birbirinin internal'ına muhtaçsa sınır yanlış çizilmiş demektir.

Sık Düşülen Tuzaklar

Her field için bir property, her property için bir set. Field'ı private yapıp yanına { get; set; } açmak encapsulation değildir; kapıyı söküp yerine aynı genişlikte bir kapı takmaktır. Soru "bu değer dışarıdan hangi kurallarla değişebilir?" olmalı.

Değiştirilebilir collection'ları olduğu gibi dışarı vermek. Get-only List<T> property'si yazının başındaki hatanın ta kendisi. İçeride List<T> tut, dışarıya IReadOnlyList<T> ya da IReadOnlyCollection<T> ver, gerekiyorsa AsReadOnly() ile sar.

internal'ı "biraz public" sanmak. Tek projelik bir uygulamada internal ile public arasında pratik fark yoktur; fark library yazdığında ortaya çıkar. Library'lerde varsayılan tercih internal olsun, dışarıya bilerek açtığın her tip bir sözdür.

Kuralları service class'larına dağıtmak. "Sipariş onaylandıysa kalem eklenemez" kuralı OrderService, CartController ve bir arka plan işinde ayrı ayrı yazılırsa biri mutlaka unutulur. Kural, verisi neredeyse oraya aittir. Kuralları dağıtmak, veriyi taşıyan ama hiçbir şeyi koruyamayan anemic modellere götürür.

Küçük Bir Deneme

★☆☆ Order'a RemoveItem(string title) method'u ekle. Onaylanmış siparişte exception fırlatsın, olmayan bir başlık gelirse false dönsün.

★★☆ Aynı başlıkla ikinci kez AddItem çağrıldığında yeni kalem eklemek yerine mevcut kalemin adedini artır. OrderItem'ın Quantity'si get-only olduğu için bunu nasıl yapacaksın: eski kalemi yeni bir kalemle mi değiştirirsin, yoksa OrderItem'a bir method mu eklersin? İki yolun encapsulation açısından farkını düşün.

★★★ Items'ı _items.AsReadOnly() yerine, constructor'da bir kez oluşturulan bir ReadOnlyCollection<OrderItem> field'ından döndür. Sonra AddItem ile kalem ekleyip daha önce aldığın Items referansının yeni kalemi görüp görmediğini kontrol et. Sonuç sana sarmalayıcının kopya mı yoksa görüntü mü olduğunu söyleyecek.

Bu yazıda kuralları object'in içine kapattık. Bazı bilgi ve davranışlar ise hiçbir object'e değil, tipin kendisine aittir: toplam kaç kitap oluşturulduğu, ISBN doğrulayan bir yardımcı, Math.Round gibi durumsuz işlemler. Bir sonraki yazıda static member'ları, static class'ları ve var olan tiplere method ekliyormuş gibi görünen extension method'ları ele alıyoruz.