Liskov Substitution Principle: Behavioral Subtyping ve .NET'teki NotSupportedException'lar
LSP, bir subtype'ın derleyiciyi değil base type'ın contract'ını da karşılamasını ister: precondition güçlenmez, postcondition zayıflamaz, invariant ve history constraint korunur. Bookstore stok modülünde üç gerçek ihlal, T[] ve ReadOnlyCollection'ın IList<T>.Add'de fırlattığı NotSupportedException, Stream.CanSeek ile capability query ve contract'ı abstract bir xUnit test class'ıyla bütün implementation'lara karşı doğrulamak.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore'un ana sayfasındaki çok satanlar listesi iki yerden gelebiliyor: veritabanından okunan bir List<string> ya da cache'ten gelen bir string[]. Editörün seçtiği kitabı listeye ekleyen method, parametre olarak ikisinin de implement ettiği IList<string>'i alıyor. Kod derleniyor, uyarı yok. İki çağrı da aynı çıktıyı mı üretir?
List<string> fromDatabase = ["Dune", "Emma"];
string[] fromCache = ["Dune", "Emma"];
AddBestseller(fromDatabase, "The Hobbit");
AddBestseller(fromCache, "The Hobbit");
static void AddBestseller(IList<string> titles, string title)
{
if (!titles.Contains(title))
titles.Add(title);
Console.WriteLine($"{titles.Count}: {string.Join(", ", titles)}");
}
Derleyici Kabul Etti, Contract Kabul Etmedi
Çıktı:
3: Dune, Emma, The Hobbit
Unhandled exception. System.NotSupportedException: Collection was of a fixed size.
İlk çağrı çalıştı, ikincisi exception fırlattı. string[] derleyiciye göre bir IList<string>; IList<T>'nin Add method'unu da "implement ediyor". Ama implementation'ı tek bir şey yapıyor: NotSupportedException fırlatmak. AddBestseller'ı yazan kişi, IList<T>'nin verdiği söze güvendi: bu bir listeyse eleman eklenebilir. Array bu sözü tutmuyor.
Bu yazının konusu tam olarak bu: bir tipin başka bir tipin yerine geçebilmesi için derleyicinin onayı yetmez; davranışın da uyması gerekir. SOLID'in (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) L harfi, LSP (Liskov Substitution Principle), bu davranışsal uyumun kurallarını koyar. Bu yazıda LSP'nin orijinal tanımını, behavioral subtyping'in dört kuralını Bookstore'un stok modülü üzerinde, .NET'in kendi koleksiyonlarındaki ve stream'lerindeki gerçek ihlalleri ve bir contract'ı bütün implementation'lara karşı testle doğrulamayı göreceğiz. C# OOP (Object-Oriented Programming) serisindeki "Composition ve SOLID'e Giriş" yazısında e-kitabın stoğu örneğiyle LSP'ye kısaca girmiştik; burada kuralları tek tek açıyoruz.
Tanım: Liskov 1987, Liskov ve Wing 1994
Barbara Liskov (Massachusetts Institute of Technology'de çalışan, 2008 Turing Ödülü sahibi bilgisayar bilimci) prensibi ilk kez 1987'de OOPSLA (Object-Oriented Programming, Systems, Languages and Applications) konferansındaki "Data Abstraction and Hierarchy" başlıklı açılış konuşmasında dile getirdi. Fikir şuydu: S tipi T'nin subtype'ı ise, T tipindeki object'leri kullanan bir program, onlar yerine S tipindeki object'ler verildiğinde davranışını değiştirmemeli. 1994'te Jeannette Wing ile birlikte ACM'nin (Association for Computing Machinery) TOPLAS (Transactions on Programming Languages and Systems) dergisinde yayımladıkları "A Behavioral Notion of Subtyping" makalesi bunu formal hâle getirdi: T tipindeki her object için kanıtlanabilen bir özellik, S tipindeki her object için de doğru olmalı.
Robert C. Martin (Clean Code ve Clean Architecture kitaplarının yazarı) 1990'larda bu fikri object-oriented tasarımın pratik diline çevirdi: base class'ı kullanan kod, ona bir derived class verildiğinde de doğru çalışmaya devam etmeli. Martin ayrıca prensibin Bertrand Meyer'in (Eiffel dilinin tasarımcısı) Design by Contract fikriyle bağını vurgular: bir method'un contract'ı, çağıranın sağlaması gerekenler (precondition) ve method'un garanti ettikleri (postcondition) ile tanımlanır.
Klasik ders kitabı örneği Rectangle ve Square'dir: matematikte her kare bir dikdörtgendir, ama genişliği ve yüksekliği ayrı ayrı ayarlanabilen bir Rectangle'dan türeyen Square, genişliği değiştirildiğinde yüksekliği de değiştirmek zorunda kalır ve "genişliği 5, yüksekliği 4 yap, alan 20 olsun" diye düşünen client'ı şaşırtır. Örnek doğru ama tek başına yanıltıcı olabiliyor; çünkü insanlar "bu yalnızca geometri problemi" deyip geçiyor. O yüzden kuralları gerçek bir Bookstore örneğinde görelim.
Dört Kural: Precondition, Postcondition ve Invariant
Liskov ve Wing'in kuralları method imzasının ötesine geçer. Bir subtype'ın method'ları için: precondition'ları güçlendiremezsin (base'in kabul ettiği her girdiyi kabul etmelisin), postcondition'ları zayıflatamazsın (base'in garanti ettiği her şeyi garanti etmelisin), base type'ın invariant'larını korumalısın. Dördüncü kural, history constraint, bir sonraki bölümün konusu. Ayrıca subtype, base type'ın fırlatmadığı yeni exception türleri fırlatmamalı.
Bookstore'un stok modülü bir IInventory interface'i üzerinden çalışıyor. Contract'ı kodun yanına yorum olarak yazdık; gerçek bir projede bu XML (Extensible Markup Language) doc comment'te ve testlerde durur. Dört implementation'dan üçü contract'ı farklı bir yerden deliyor. Inventory.cs:
namespace Bookstore.Stock;
// Contract:
// - quantity 0 veya negatifse ArgumentOutOfRangeException (precondition)
// - quantity <= Available ise true döner ve Available tam quantity kadar azalır (postcondition)
// - aksi hâlde false döner ve Available değişmez (postcondition)
// - Available hiçbir zaman negatif olmaz (invariant)
public interface IInventory
{
int Available(string isbn);
bool TryReserve(string isbn, int quantity);
}
public sealed class InMemoryInventory(Dictionary<string, int> stock) : IInventory
{
public int Available(string isbn) => stock.GetValueOrDefault(isbn);
public bool TryReserve(string isbn, int quantity)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);
if (quantity > Available(isbn))
return false;
stock[isbn] = Available(isbn) - quantity;
return true;
}
}
// Precondition'ı güçlendiriyor: depo yalnızca 5'li paket gönderiyor
public sealed class WarehouseInventory(Dictionary<string, int> stock) : IInventory
{
private readonly InMemoryInventory _inner = new(stock);
public int Available(string isbn) => _inner.Available(isbn);
public bool TryReserve(string isbn, int quantity)
{
if (quantity % 5 != 0)
throw new ArgumentException("Quantity must be a multiple of 5.", nameof(quantity));
return _inner.TryReserve(isbn, quantity);
}
}
// Invariant'ı bozuyor: ön siparişte stok eksiye düşebiliyor
public sealed class PreorderInventory(Dictionary<string, int> stock) : IInventory
{
public int Available(string isbn) => stock.GetValueOrDefault(isbn);
public bool TryReserve(string isbn, int quantity)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);
stock[isbn] = Available(isbn) - quantity;
return true;
}
}
// Postcondition'ı zayıflatıyor: rezervasyon başarılı ama Available eski değeri gösteriyor
public sealed class CachedInventory(IInventory inner) : IInventory
{
private readonly Dictionary<string, int> _cache = [];
public int Available(string isbn)
{
if (!_cache.TryGetValue(isbn, out int available))
_cache[isbn] = available = inner.Available(isbn);
return available;
}
public bool TryReserve(string isbn, int quantity) => inner.TryReserve(isbn, quantity);
}
Aynı client kodunu dördüne de verelim. Stokta 2 kopya var; önce 3, sonra 1 kopya ayırtmaya çalışıyoruz. Program.cs:
using Bookstore.Stock;
const string Dune = "978-0441013593";
IInventory[] inventories =
[
new InMemoryInventory(new() { [Dune] = 2 }),
new WarehouseInventory(new() { [Dune] = 2 }),
new PreorderInventory(new() { [Dune] = 2 }),
new CachedInventory(new InMemoryInventory(new() { [Dune] = 2 }))
];
foreach (IInventory inventory in inventories)
{
Console.Write($"{inventory.GetType().Name,-19}");
foreach (int quantity in (int[])[3, 1])
{
try
{
bool reserved = inventory.TryReserve(Dune, quantity);
Console.Write($" | reserve {quantity}: {reserved,-5} available: {inventory.Available(Dune),2}");
}
catch (ArgumentException ex)
{
Console.Write($" | reserve {quantity}: {ex.GetType().Name}");
}
}
Console.WriteLine();
}
InMemoryInventory | reserve 3: False available: 2 | reserve 1: True available: 1
WarehouseInventory | reserve 3: ArgumentException | reserve 1: ArgumentException
PreorderInventory | reserve 3: True available: -1 | reserve 1: True available: -2
CachedInventory | reserve 3: False available: 2 | reserve 1: True available: 2
İlk satır contract'ın kendisi: 3 kopya yetmiyor, false; 1 kopya ayrılıyor, kalan 1. Diğer üç satır, her biri farklı bir kuralı bozan üç subtype.
WarehouseInventory precondition'ı güçlendiriyor. Base contract "pozitif her miktar" diyor; bu implementation "5'in katı olan pozitif miktar" diyor. Client hiçbir şeyi yanlış yapmadı ama IInventory'nin hiç bahsetmediği bir exception aldı. Depo partnerinin kuralı gerçek bir iş kuralı olabilir, ama yeri burası değil: ya contract'a açıkça eklenmeli (ve bütün client'lar bunu bilmeli), ya da miktarı 5'e yuvarlayan ayrı bir katmanda çözülmeli.
PreorderInventory invariant'ı ve postcondition'ı bozuyor: stok -1'e, sonra -2'ye düştü ve yetersiz stokta true döndü. Ön sipariş gerçek bir ihtiyaç, ama "stok eksiye düşebilir" diyen bir şey IInventory değildir. Bu, aynı interface'in arkasına sığdırılmaya çalışılan farklı bir kavram.
CachedInventory postcondition'ı zayıflatıyor. Rezervasyon başarılı oldu ama Available hâlâ 2 diyor. Client'ın gözünden bu, "rezervasyon yaptım, stok azalmadı" demek; aynı kitabı iki müşteriye satmanın en kısa yolu. Bu ihlal en sinsi olanı, çünkü bir exception yok; her şey çalışıyor gibi görünüyor.
History Constraint: Subtype'ın Eklediği Method'lar
Liskov ve Wing'in asıl katkısı dördüncü kural. Bir object'in durumu yalnızca kendi method'larıyla değişir; subtype yeni method'lar ekleyebilir, ama bu method'lar base type'ın izin vermediği bir durum değişikliğine izin vermemeli. Klasik örnek, değiştirilemez bir nokta tipinden türeyen değiştirilebilir bir nokta. Bookstore'da bir fiyat teklifi:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
AdjustableQuote quote = new("978-0441013593", 12.99m);
Checkout checkout = new();
// Sepet ekranı fiyatı gösterip quote'u saklıyor
checkout.Show(quote);
// Fiyat ekibi kampanya bitince fiyatı güncelliyor
quote.Adjust(15.99m);
checkout.Pay();
// Contract: bir quote oluşturulduktan sonra değişmez; sepet ona güvenir
public class PriceQuote(string isbn, decimal price)
{
public string Isbn { get; } = isbn;
public virtual decimal Price => price;
}
// Yeni bir method ekliyor ve base type'ın yasakladığı bir durum değişikliğine izin veriyor
public sealed class AdjustableQuote(string isbn, decimal price) : PriceQuote(isbn, price)
{
private decimal _price = price;
public override decimal Price => _price;
public void Adjust(decimal newPrice) => _price = newPrice;
}
public sealed class Checkout
{
private PriceQuote? _quote;
public void Show(PriceQuote quote)
{
_quote = quote;
Console.WriteLine($"Shown: {quote.Price} EUR");
}
public void Pay() => Console.WriteLine($"Charged: {_quote!.Price} EUR");
}
Shown: 12.99 EUR
Charged: 15.99 EUR
Müşteri 12.99 gördü, 15.99 ödedi. Checkout yalnızca PriceQuote'u tanıyor ve onun contract'ına göre bir quote değişmez; o yüzden quote'u saklayıp ödeme anında tekrar kullanması mantıklı. AdjustableQuote hiçbir base method'u bozmadı: Price hâlâ bir fiyat döndürüyor. Bozduğu şey, base type'ın zaman içindeki davranışına dair söz. Precondition ve postcondition tek bir çağrıya bakar; history constraint, çağrılar arasındaki zamana bakar. C#'ta bu sözü dile dökmenin en sağlam yolu tipi gerçekten değiştirilemez yapmak: sealed bir record ya da ImmutableArray<T> gibi tipler, subtype'ın bu sözü delmesine izin vermez.
.NET'teki NotSupportedException'lar
Yazının başındaki array örneği bir kaza değil, .NET'in bilinçli bir tasarım kararı. Aynı IList<string> interface'ini implement eden dört tipe aynı soruları soralım:
using System.Collections;
using System.Collections.Immutable;
using System.Collections.ObjectModel;
Describe("List<T>", new List<string> { "Dune" });
Describe("T[]", new[] { "Dune" });
Describe("ReadOnlyCollection<T>", new ReadOnlyCollection<string>(["Dune"]));
Describe("ImmutableArray<T>", ImmutableArray.Create("Dune"));
static void Describe(string name, IList<string> list)
{
// Capability query: interface'in kendisi "bunu yapabilir misin?" diye sormaya izin veriyor
bool fixedSize = list is IList nonGeneric && nonGeneric.IsFixedSize;
Console.WriteLine($"{name,-22} IsReadOnly={list.IsReadOnly,-5} IsFixedSize={fixedSize,-5} Add: {Try(() => list.Add("Emma"))} | this[0]: {Try(() => list[0] = "Emma")}");
}
static string Try(Action action)
{
try
{
action();
return "ok";
}
catch (NotSupportedException ex)
{
return ex.Message;
}
}
List<T> IsReadOnly=False IsFixedSize=False Add: ok | this[0]: ok
T[] IsReadOnly=True IsFixedSize=True Add: Collection was of a fixed size. | this[0]: ok
ReadOnlyCollection<T> IsReadOnly=True IsFixedSize=True Add: Collection is read-only. | this[0]: Collection is read-only.
ImmutableArray<T> IsReadOnly=True IsFixedSize=True Add: Specified method is not supported. | this[0]: Specified method is not supported.
Dört tipin dördü de IList<T>, ama yalnızca List<T> interface'in bütün sözünü tutuyor. ReadOnlyCollection<T> IList<T>'yi implement ediyor ve her yazma işleminde exception fırlatıyor. Bunun tarihsel bir nedeni var: ReadOnlyCollection<T> .NET Framework 2.0'dan beri var, ama IReadOnlyList<T> ve IReadOnlyCollection<T> ancak .NET Framework 4.5'te eklendi. Arada geçen yıllarda "salt okunur bir liste" verebilmenin tek yolu, IList<T>'yi implement edip yazma method'larında hayır demekti. Bunun interface tasarımı tarafını bir sonraki yazıda, Interface Segregation'da konuşacağız.
.NET tasarımcılarının bu ihlali yönetmek için seçtiği araç capability query: ICollection<T>.IsReadOnly ve non-generic IList.IsFixedSize property'leri, client'ın işlemi denemeden önce sormasını sağlar. Böylece contract "her liste eleman ekler" değil, "IsReadOnly false ise eleman ekler" olur. Bu, LSP'yi kurtarmanın meşru bir yolu: subtype'ın davranışı base type'ın contract'ına uyuyor, çünkü contract zaten zayıflatılmış. Ama çıktıdaki ikinci satıra dikkat: array için IsReadOnly True diyor, yine de indexer ile yazma çalışıyor. Bayrağın anlamı "yapı değiştirilemez" ile "içerik değiştirilemez" arasında belirsiz kalmış. Capability query'nin bedeli bu: her client'ın soruyu sormayı hatırlaması ve cevabı doğru yorumlaması gerekiyor. Hiçbiri derleyici tarafından kontrol edilmiyor.
Capability Query: Stream.CanSeek
Aynı tasarımın daha tutarlı bir örneği Stream. Bazı stream'ler (bellek, dosya) başa sarılabilir ve uzunluğu bilinir; bazıları (sıkıştırma, network) tek yönlü akar. .NET bunu üç ayrı tip yerine tek bir base class ve CanRead, CanWrite, CanSeek property'leriyle çözer:
using System.IO.Compression;
using System.Text;
using MemoryStream file = new(Encoding.UTF8.GetBytes("978-0441013593;Dune;12.99"));
using GZipStream gzip = new(new MemoryStream(), CompressionMode.Compress);
PrintSize(file);
PrintSize(gzip);
try
{
Console.WriteLine(gzip.Length);
}
catch (NotSupportedException ex)
{
Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
}
// Base class'ın contract'ı: Length'i yalnız CanSeek true ise çağır
static void PrintSize(Stream stream) =>
Console.WriteLine(stream.CanSeek
? $"{stream.GetType().Name}: {stream.Length} bytes"
: $"{stream.GetType().Name}: size unknown (CanSeek = false)");
MemoryStream: 25 bytes
GZipStream: size unknown (CanSeek = false)
NotSupportedException: This operation is not supported.
Stream'in dokümantasyonu bu davranışı contract'ın parçası olarak yazar: seek desteklemeyen bir stream'de Length, SetLength, Position ve Seek çağrıları NotSupportedException fırlatır. Yani GZipStream burada LSP'yi ihlal etmiyor; contract'ı bilerek çağıran PrintSize doğru çalışıyor. CanSeek'i sormadan Length'e uzanan kod ise contract'ı okumamış demektir.
Peki hangisi daha iyi: capability query mi, ayrı tipler mi? Capability query, interface sayısını düşük tutar ve aynı pipeline'da farklı stream'leri birlikte kullanmayı kolaylaştırır; .NET'in input/output API'si (Application Programming Interface) yirmi yılı aşkın süredir bu sayede tek bir Stream tipi etrafında dönüyor. Ayrı tipler ise hatayı derleme zamanına taşır. Kendi kodunda karar verirken şunu sor: bu yeteneğe sahip olmayan implementation'lar istisna mı, yoksa olağan mı? İstisnaysa capability query makul; olağansa ayrı interface'ler daha dürüst.
Contract'ı Testle Doğrulamak
Contract yorumda yazdığı sürece ihlaller ancak üretimde fark edilir. Daha iyisi, contract'ı bir kez test olarak yazıp bütün implementation'lara karşı çalıştırmak. xUnit'te bunun en sade yolu abstract bir test class'ı: test method'ları base class'ta durur, her implementation için yalnızca object'i oluşturan bir method override edilir:
using Bookstore.Stock;
namespace Bookstore.Stock.Tests;
// IInventory'nin contract'ı bir kez yazılır, her implementation için koşar
public abstract class InventoryContract
{
protected const string Dune = "978-0441013593";
protected abstract IInventory Create(int stock);
[Fact]
public void Reserve_within_stock_decreases_available_by_quantity()
{
IInventory inventory = Create(stock: 10);
Assert.True(inventory.TryReserve(Dune, 3));
Assert.Equal(7, inventory.Available(Dune));
}
[Fact]
public void Reserve_beyond_stock_returns_false_and_keeps_available()
{
IInventory inventory = Create(stock: 2);
Assert.False(inventory.TryReserve(Dune, 3));
Assert.Equal(2, inventory.Available(Dune));
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void Non_positive_quantity_is_rejected(int quantity)
{
IInventory inventory = Create(stock: 2);
Assert.Throws<ArgumentOutOfRangeException>(() => inventory.TryReserve(Dune, quantity));
}
}
public sealed class InMemoryInventoryTests : InventoryContract
{
protected override IInventory Create(int stock) => new InMemoryInventory(new() { [Dune] = stock });
}
public sealed class WarehouseInventoryTests : InventoryContract
{
protected override IInventory Create(int stock) => new WarehouseInventory(new() { [Dune] = stock });
}
public sealed class PreorderInventoryTests : InventoryContract
{
protected override IInventory Create(int stock) => new PreorderInventory(new() { [Dune] = stock });
}
public sealed class CachedInventoryTests : InventoryContract
{
protected override IInventory Create(int stock)
{
CachedInventory inventory = new(new InMemoryInventory(new() { [Dune] = stock }));
inventory.Available(Dune); // cache'i ısıt: gerçek kullanımda ekran önce stoku okur
return inventory;
}
}
dotnet test çıktısından ilgili satırlar:
Failed Bookstore.Stock.Tests.WarehouseInventoryTests.Reserve_within_stock_decreases_available_by_quantity [1 ms]
Error Message:
System.ArgumentException : Quantity must be a multiple of 5. (Parameter 'quantity')
Failed Bookstore.Stock.Tests.PreorderInventoryTests.Reserve_beyond_stock_returns_false_and_keeps_available [< 1 ms]
Error Message:
Assert.False() Failure
Expected: False
Actual: True
Failed Bookstore.Stock.Tests.CachedInventoryTests.Reserve_within_stock_decreases_available_by_quantity [< 1 ms]
Error Message:
Assert.Equal() Failure: Values differ
Expected: 7
Actual: 10
Failed Bookstore.Stock.Tests.WarehouseInventoryTests.Non_positive_quantity_is_rejected(quantity: -1) [< 1 ms]
Error Message:
Assert.Throws() Failure: Exception type was not an exact match
Expected: typeof(System.ArgumentOutOfRangeException)
Actual: typeof(System.ArgumentException)
Failed Bookstore.Stock.Tests.WarehouseInventoryTests.Reserve_beyond_stock_returns_false_and_keeps_available [< 1 ms]
Error Message:
System.ArgumentException : Quantity must be a multiple of 5. (Parameter 'quantity')
Failed! - Failed: 5, Passed: 11, Skipped: 0, Total: 16, Duration: 18 ms - Bookstore.Stock.Tests.dll (net10.0)
On altı testten beşi kırmızı ve her biri, yukarıda elle bulduğumuz ihlallerden birini adıyla gösteriyor: WarehouseInventory'nin 5'in katı kuralı üç teste takılıyor, PreorderInventory yetersiz stokta true dönüyor, CachedInventory 7 yerine 10 diyor. InMemoryInventory'nin bütün testleri yeşil. Yeni bir implementation yazan kişinin yapması gereken tek şey, üç satırlık bir test class'ı eklemek; contract'ı yeniden düşünmek zorunda kalmıyor. xUnit'in temelleri için C# İleri serisindeki "Unit Test" yazısına bakabilirsin. Bu teknik özellikle repository, cache ve dış servis adapter'ları gibi aynı interface'in birden fazla implementation'ı olan yerlerde değerlidir: in-memory implementation'ın testlerde gerçek implementation gibi davrandığından ancak böyle emin olabilirsin.
LSP İhlali OCP'yi de Bozar
PreorderInventory devreye girdikten bir süre sonra checkout kodunda şöyle bir satır belirir: if (inventory is PreorderInventory) { ... }. Ya da bir type switch: inventory switch { PreorderInventory p => ..., CachedInventory c => ..., _ => ... }. Client, abstraction'a güvenemediği için alttaki somut tipi sormaya başlamıştır.
Martin 2000 tarihli makalesinde bunu net bir cümleyle bağlar: LSP ihlalleri, gizli OCP (Open/Closed Principle) ihlalleridir. Client artık her yeni IInventory implementation'ı geldiğinde yeniden açılıp "bunu da özel olarak ele almam gerekir mi?" diye kontrol edilmek zorunda. Önceki yazıda OCP için kurduğumuz "yeni davranış = yeni implementation" denklemi, implementation'lar abstraction'ın sözünü tuttuğu sürece geçerli. Kodda subtype'a özel bir is kontrolü gördüğünde, ilk soru şu olmalı: bu tip gerçekten bu abstraction'ın bir türü mü?
Not: Her is ihlal işareti değil. Bir collection'ın ICollection<T> olup olmadığına bakıp Count'u doğrudan almak, LINQ'in (Language Integrated Query) Count()'unun da yaptığı bir performans optimizasyonu; davranışı değiştirmiyor, yalnızca hızlandırıyor. Sorun, tipe göre farklı doğruluk kurallarının uygulandığı yerde başlar.
Sık Düşülen Tuzaklar
LSP'yi gerçek dünyadaki "is-a" ilişkisiyle karıştırmak. Ön sipariş de bir stok işlemi, kare de bir dikdörtgen, e-kitap da bir kitap. Ama yazılımda soru, iki kavramın gerçek dünyada nasıl ilişkili olduğu değil, client'ların base type'tan ne beklediği. "Is-a" yerine "behaves-like-a" diye düşünmek daha az yanıltır.
Override'ı boş bırakmak ya da NotImplementedException fırlatmak. public override void Ship() { } gibi sessizce hiçbir şey yapmayan bir override, exception fırlatandan bile tehlikelidir; client işin yapıldığını sanır. NotImplementedException ise "henüz yazılmadı" demektir ve üretime ulaşan bir subtype'ta olmamalı; bilinçli bir "desteklenmiyor" kararı için NotSupportedException ve bir capability query kullan.
Exception türünü değiştirmek. WarehouseInventory'nin -1 için ArgumentOutOfRangeException yerine ArgumentException fırlatması küçük bir fark gibi görünüyor, ama catch (ArgumentOutOfRangeException) yazan client için hata yakalanmadan yukarı çıkıyor. Exception tipi de contract'ın parçası.
Contract'ı yazmamak. LSP, var olmayan bir contract'a göre ölçülemez. IInventory'nin "stok eksiye düşmez" dediği hiçbir yer yoksa, PreorderInventory'yi yazan kişi bir kuralı bozduğunu bilemez. XML doc comment'te birkaç cümle ve abstract bir test class'ı, bu belirsizliği kapatmanın en ucuz yolu.
Küçük Bir Deneme
★☆☆ İlk örnekteki AddBestseller'ı, array ile de çalışacak şekilde değiştir. İki yol var: parametre tipini değiştirmek ya da IsReadOnly'yi sormak. Hangisini seçtin ve neden? Çıktıdaki IsReadOnly=True ama indexer yazılabilir tuhaflığı senin seçimini etkiledi mi?
★★☆ CachedInventory'yi contract testlerinin hepsini geçecek şekilde düzelt. Ardından contract'a yeni bir test ekle: "iki ayrı ISBN (International Standard Book Number) birbirinin stoğunu etkilemez". Bu test hangi implementation'larda kırıldı?
★★★ PreorderInventory'yi IInventory'den çıkar ve ön siparişi ayrı bir kavram olarak modelle (IPreorderBook gibi). Checkout kodu iki durumu nasıl ayırt ediyor? Bu ayrım bir is kontrolüne dönüşmeden nasıl yapılabilir?
Bir sonraki yazıda bu yazıda iki kez kenarından geçtiğimiz konuya giriyoruz: Interface Segregation Principle (ISP). 14 method'luk bir IOrderRepository'yi role interface'lere bölmeyi, IReadOnlyList<T>'nin hikâyesini ve default interface method'larla ISP'yi delmenin bedelini konuşacağız.