Nesne yönelimli programlama (OOP) paradigmasının temel taşlarından biri kalıtımdır. Kalıtım, kodun yeniden kullanılabilirliğini sağlar ve “is-a” (bir tür) ilişkisi kurarak kodun daha iyi organize edilmesine yardımcı olur. Ancak, kalıtımın yanlış kullanılması, birçok tasarım sorununa ve kırılgan kod yapılarına yol açabilir. İşte tam da bu noktada, SOLID prensiplerinin üçüncü bileşeni olan Liskov Yerine Geçme Prensibi (Liskov Substitution Principle – LSP) devreye girer.

Bu makalede, LSP’nin ne olduğunu, neden önemli olduğunu, nasıl uygulanacağını ve kalıtım hiyerarşilerinizi tasarlarken bu prensibi nasıl kullanabileceğinizi derinlemesine inceleyeceğiz.

Liskov Yerine Geçme Prensibi Nedir?

Liskov Yerine Geçme Prensibi, 1987 yılında bilgisayar bilimcisi Barbara Liskov tarafından tanıtılmıştır ve daha sonra Robert C. Martin tarafından SOLID prensiplerinin bir parçası olarak benimsenmiştir. Prensip şöyle ifade edilir:

“Bir programda, bir üst sınıfın nesneleri, programın doğruluğunu etkilemeden alt sınıflarının nesneleriyle değiştirilebilir olmalıdır.”

Daha teknik bir ifadeyle, bir alt sınıf S, bir üst sınıf T’nin yerine geçebiliyorsa, herhangi bir T türündeki nesne kullanılan program, S türündeki bir nesne kullanıldığında da aynı şekilde çalışmalıdır.

Bu tanım biraz soyut görünebilir, bu yüzden daha basit bir şekilde ifade edelim: Bir alt sınıf, üst sınıfın davranışını değiştirmemeli, sadece genişletmelidir.

Neden Liskov Yerine Geçme Prensibi Önemlidir?

LSP’nin uygulanması, yazılım sistemlerinizde çeşitli faydalar sağlar:

1. Kod Güvenilirliği

LSP’ye uyan kod, alt sınıfların üst sınıfların yerini alabilmesini garanti eder, bu da kodun daha öngörülebilir ve güvenilir olmasını sağlar.

2. Daha İyi Polimorfizm

Polimorfizm, OOP’nin temel ilkelerinden biridir ve LSP, polimorfizmin sağlıklı bir şekilde kullanılmasını destekler. LSP sayesinde, kodunuz farklı nesnelerle tutarlı bir şekilde çalışır.

3. Daha Az Koşullu Mantık

LSP’ye uygun tasarlanmış sistemlerde, nesne türüne bağlı koşullu ifadelere daha az ihtiyaç duyulur. Bu, kodun daha temiz ve bakımı daha kolay olmasını sağlar.

4. Test Edilebilirlik

LSP’ye uygun kod, daha kolay test edilebilir çünkü testler üst sınıf arayüzünü kullanabilir ve bu testler tüm alt sınıflar için de geçerli olmalıdır.

5. Daha Modüler Kod

LSP, daha modüler ve bağımsız kod bileşenleri oluşturmanıza yardımcı olur, bu da kodun bakımını ve genişletilmesini kolaylaştırır.

Liskov Yerine Geçme Prensibinin İhlali Nasıl Olur?

LSP ihlalleri, genellikle alt sınıfların üst sınıflarının davranışını değiştirdiği durumlarda ortaya çıkar. İşte bazı yaygın LSP ihlalleri:

1. Metot Önkoşullarını Güçlendirme

Bir alt sınıf, üst sınıfın metodunun önkoşullarını daha katı hale getirdiğinde LSP ihlal edilmiş olur.

Örnek – LSP İhlali:

class Rectangle {
    protected int width;
    protected int height;
    
    public void setWidth(int width) {
        this.width = width;
    }
    
    public void setHeight(int height) {
        this.height = height;
    }
    
    public int getArea() {
        return width * height;
    }
}

class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        // Karede genişlik ve yükseklik aynı olmalıdır
        this.width = width;
        this.height = width;
    }
    
    @Override
    public void setHeight(int height) {
        // Karede genişlik ve yükseklik aynı olmalıdır
        this.width = height;
        this.height = height;
    }
}

Bu örnekte, Square sınıfı Rectangle sınıfından türetilmiştir, ancak bir karenin genişliği ve yüksekliği aynı olmalıdır. Bu nedenle, setWidth ve setHeight metodları her iki boyutu da aynı değere ayarlar. Bu, LSP’yi ihlal eder çünkü aşağıdaki kod Rectangle için çalışırken, Square için çalışmaz:

void testRectangle(Rectangle r) {
    r.setWidth(5);
    r.setHeight(4);
    // Rectangle için bu assert geçerli olmalıdır
    assert(r.getArea() == 20);
}

Eğer testRectangle metoduna bir Square nesnesi geçirilirse, alanın 20 yerine 16 olacağı görülür çünkü setHeight(4) çağrısı genişliği de 4 yapar.

2. Metot Sonkoşullarını Zayıflatma

Bir alt sınıf, üst sınıfın metodunun sonkoşullarını daha zayıf hale getirdiğinde LSP ihlal edilmiş olur.

Örnek – LSP İhlali:

class Bird {
    public void fly() {
        System.out.println("Kuş uçuyor...");
    }
}

class Penguin extends Bird {
    @Override
    public void fly() {
        throw new UnsupportedOperationException("Penguenler uçamaz!");
    }
}

Bu örnekte, Penguin sınıfı Bird sınıfından türetilmiştir, ancak penguenler uçamaz. Bu nedenle, fly metodu bir istisna fırlatır. Bu, LSP’yi ihlal eder çünkü Bird türünde bir nesne bekleyen kod, bir Penguin nesnesi alırsa çalışmaz.

3. İstisnaları Değiştirme

Bir alt sınıf, üst sınıfın metodunun fırlattığı istisnaları değiştirdiğinde LSP ihlal edilmiş olur.

Örnek – LSP İhlali:

class FileProcessor {
    public void processFile(String path) throws IOException {
        // Dosya işleme mantığı
    }
}

class NetworkFileProcessor extends FileProcessor {
    @Override
    public void processFile(String path) throws IOException, NetworkException {
        // Ağ dosyası işleme mantığı
    }
}

Bu örnekte, NetworkFileProcessor sınıfı, FileProcessor sınıfının processFile metoduna yeni bir istisna (NetworkException) ekler. Bu, LSP’yi ihlal eder çünkü FileProcessor bekleyen kod bu ilave istisnayı beklemiyor olabilir.

4. Değişmezleri (Invariants) Korumama

Bir alt sınıf, üst sınıfın değişmezlerini korumazsa LSP ihlal edilmiş olur.

Örnek – LSP İhlali:

class Account {
    protected double balance;
    
    public void deposit(double amount) {
        if (amount > 0) {
            balance += amount;
        }
    }
    
    public void withdraw(double amount) {
        if (amount > 0 && balance >= amount) {
            balance -= amount;
        }
    }
}

class OverdraftAccount extends Account {
    private double overdraftLimit;
    
    public OverdraftAccount(double overdraftLimit) {
        this.overdraftLimit = overdraftLimit;
    }
    
    @Override
    public void withdraw(double amount) {
        if (amount > 0 && balance + overdraftLimit >= amount) {
            balance -= amount;
        }
    }
}

Bu örnekte, Account sınıfı, bakiyenin negatif olmaması gerektiği değişmezini korur. Ancak, OverdraftAccount sınıfı bu değişmezi ihlal eder ve bakiyenin negatif olmasına izin verir (overdraftLimit dahilinde). Bu, LSP’yi ihlal eder çünkü Account türünde bir nesne bekleyen kod, bakiyenin her zaman pozitif olacağını varsayabilir.

Liskov Yerine Geçme Prensibinin Doğru Uygulanması

LSP’yi doğru bir şekilde uygulamak için, kalıtım hiyerarşilerinizi tasarlarken belirli kurallara ve pratiklere uymanız gerekir. İşte bazı temel yaklaşımlar:

1. Doğru Soyutlama Seviyesi Kullanma

Sınıfları doğru soyutlama seviyesinde tasarlayın. Eğer bir davranış tüm alt sınıflar için uygun değilse, o davranışı üst sınıfa koymak yerine, daha spesifik bir alt grup için bir ara soyut sınıf veya arayüz oluşturun.

Örnek – LSP’ye Uygun Tasarım:

interface Bird {
    void eat();
    void sleep();
}

interface FlyingBird extends Bird {
    void fly();
}

class Sparrow implements FlyingBird {
    @Override
    public void eat() {
        System.out.println("Serçe yem yiyor...");
    }
    
    @Override
    public void sleep() {
        System.out.println("Serçe uyuyor...");
    }
    
    @Override
    public void fly() {
        System.out.println("Serçe uçuyor...");
    }
}

class Penguin implements Bird {
    @Override
    public void eat() {
        System.out.println("Penguen balık yiyor...");
    }
    
    @Override
    public void sleep() {
        System.out.println("Penguen uyuyor...");
    }
}

Bu tasarımda, tüm kuşlar eat ve sleep metodlarına sahiptir, ancak sadece uçabilen kuşlar fly metoduna sahiptir. Bu, LSP’ye uygun bir tasarımdır çünkü herhangi bir Bird nesnesi eat ve sleep metodlarını çağırabilir, ve herhangi bir FlyingBird nesnesi de ek olarak fly metodunu çağırabilir.

2. Kompozisyon Kullanımı

Kalıtımın yanlış kullanımını önlemek için, bazen kompozisyon (composition) daha iyi bir alternatif olabilir. Kompozisyon, bir nesnenin başka bir nesneyi içermesi ve onun davranışlarını kullanmasıdır.

Örnek – Kompozisyon Kullanarak LSP’ye Uygun Tasarım:

interface Shape {
    double area();
}

class Rectangle implements Shape {
    private int width;
    private int height;
    
    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }
    
    public void setWidth(int width) {
        this.width = width;
    }
    
    public void setHeight(int height) {
        this.height = height;
    }
    
    @Override
    public double area() {
        return width * height;
    }
}

class Square implements Shape {
    private int side;
    
    public Square(int side) {
        this.side = side;
    }
    
    public void setSide(int side) {
        this.side = side;
    }
    
    @Override
    public double area() {
        return side * side;
    }
}

Bu tasarımda, Square sınıfı Rectangle sınıfından türetilmek yerine, her ikisi de Shape arayüzünü uygular. Bu, LSP’ye uygun bir tasarımdır çünkü her iki sınıf da kendi içinde tutarlı davranır ve Shape türünde bir nesne bekleyen kod her iki sınıf için de çalışır.

3. Savunmacı Programlama

Metodlarınızı, alt sınıfların davranışlarını değiştirmeyeceği şekilde tasarlayın. Bu, önkoşulları, sonkoşulları ve değişmezleri belgelemek ve uygulamak anlamına gelir.

Örnek – Savunmacı Programlama:

abstract class Vehicle {
    // Değişmez: speed >= 0
    protected int speed = 0;
    
    // Önkoşul: amount > 0
    // Sonkoşul: yeni speed = eski speed + amount
    public void accelerate(int amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("Hızlanma miktarı pozitif olmalıdır.");
        }
        speed += amount;
    }
    
    // Önkoşul: amount > 0 ve speed >= amount
    // Sonkoşul: yeni speed = eski speed - amount
    public void decelerate(int amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("Yavaşlama miktarı pozitif olmalıdır.");
        }
        if (speed < amount) {
            throw new IllegalStateException("Araç o kadar yavaşlayamaz.");
        }
        speed -= amount;
    }
    
    public int getSpeed() {
        return speed;
    }
}

class Car extends Vehicle {
    @Override
    public void accelerate(int amount) {
        // Car sınıfı, Vehicle sınıfının önkoşullarını ve sonkoşullarını korur
        super.accelerate(amount);
    }
    
    @Override
    public void decelerate(int amount) {
        // Car sınıfı, Vehicle sınıfının önkoşullarını ve sonkoşullarını korur
        super.decelerate(amount);
    }
}

Bu tasarımda, Vehicle sınıfı, hızın negatif olmaması gerektiği değişmezini korur ve metodların önkoşullarını ve sonkoşullarını belirtir. Car sınıfı, bu kuralları korur ve üst sınıfın davranışını değiştirmez.

4. Kontrat Tasarımı (Design by Contract)

Kontrat tasarımı, bir metodun davranışını tanımlayan formal bir spesifikasyon yaklaşımıdır. Metodun önkoşulları, sonkoşulları ve değişmezleri belirlenir. Alt sınıflar, üst sınıfın kontratına uymak zorundadır.

Örnek – Kontrat Tasarımı:

// Kontrat: Bu sınıf, banka hesaplarını temsil eder.
// Değişmez: balance >= 0 (hesap bakiyesi her zaman pozitif veya sıfır olmalıdır).
class BankAccount {
    protected double balance = 0;
    
    // Kontrat: Para yatırma işlemi.
    // Önkoşul: amount > 0 (yatırılan miktar pozitif olmalıdır).
    // Sonkoşul: yeni balance = eski balance + amount (bakiye yatırılan miktar kadar artmalıdır).
    public void deposit(double amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("Yatırılan miktar pozitif olmalıdır.");
        }
        balance += amount;
    }
    
    // Kontrat: Para çekme işlemi.
    // Önkoşul: amount > 0 ve balance >= amount (çekilen miktar pozitif ve yeterli bakiye olmalıdır).
    // Sonkoşul: yeni balance = eski balance - amount (bakiye çekilen miktar kadar azalmalıdır).
    public void withdraw(double amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("Çekilen miktar pozitif olmalıdır.");
        }
        if (balance < amount) {
            throw new IllegalStateException("Yetersiz bakiye.");
        }
        balance -= amount;
    }
    
    public double getBalance() {
        return balance;
    }
}

class SavingsAccount extends BankAccount {
    private double interestRate;
    
    public SavingsAccount(double interestRate) {
        this.interestRate = interestRate;
    }
    
    // Kontrat: Faiz ekleyen metod.
    // Önkoşul: Yok.
    // Sonkoşul: yeni balance = eski balance * (1 + interestRate) (bakiye faiz oranı kadar artmalıdır).
    public void addInterest() {
        balance = balance * (1 + interestRate);
    }
}

Bu tasarımda, BankAccount sınıfı, hesap bakiyesinin negatif olmaması gerektiği değişmezini korur ve metodların önkoşullarını ve sonkoşullarını belirtir. SavingsAccount sınıfı, bu kuralları korur ve üst sınıfın davranışını değiştirmez.

LSP’nin Gerçek Dünya Uygulamaları

LSP’nin gerçek dünya uygulamaları arasında şunlar bulunur:

1. Framework ve API Tasarımı

LSP, framework ve API tasarımında özellikle önemlidir. Kullanıcılar, bir framework veya API’nin davranışına güvenirler ve alt sınıfların bu davranışı değiştirmemesi beklenir.

Örnek – Java Collections Framework:

List<String> list = new ArrayList<>();  // ArrayList, List arayüzünü uygular
list.add("Hello");
list.add("World");

// LinkedList de List arayüzünü uygular ve aynı davranışı gösterir
List<String> linkedList = new LinkedList<>();
linkedList.add("Hello");
linkedList.add("World");

// Her iki liste türü de aynı şekilde kullanılabilir
for (String s : list) {
    System.out.println(s);
}

for (String s : linkedList) {
    System.out.println(s);
}

Bu örnekte, ArrayList ve LinkedList sınıfları, List arayüzünü uygular ve aynı davranışı gösterir. Bu, LSP’ye uygun bir tasarımdır çünkü her iki sınıf da List türünde bir nesne bekleyen kod için çalışır.

2. Çok Katmanlı Mimariler

Çok katmanlı mimarilerde, her katmanın arayüzü, bu arayüzü uygulayan tüm sınıflar için tutarlı bir davranış sağlamalıdır. Bu, LSP’nin bir uygulamasıdır.

Örnek – Veri Erişim Katmanı:

interface UserRepository {
    User findById(long id);
    List<User> findAll();
    void save(User user);
    void delete(User user);
}

class DatabaseUserRepository implements UserRepository {
    // Veritabanı kullanarak UserRepository arayüzünü uygular
    // ...
}

class CachedUserRepository implements UserRepository {
    // Önbellek kullanarak UserRepository arayüzünü uygular
    // ...
}

// Her iki repository türü de aynı şekilde kullanılabilir
class UserService {
    private UserRepository userRepository;
    
    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }
    
    public User getUserById(long id) {
        return userRepository.findById(id);
    }
    
    // Diğer metodlar...
}

Bu tasarımda, DatabaseUserRepository ve CachedUserRepository sınıfları, UserRepository arayüzünü uygular ve aynı davranışı gösterir. Bu, LSP’ye uygun bir tasarımdır çünkü her iki sınıf da UserRepository türünde bir nesne bekleyen kod için çalışır.

3. Test Dublörleri (Test Doubles)

Test dublörleri, test edilecek birimin bağımlılıklarının yerini alan nesnelerdir. LSP, test dublörlerinin gerçek nesnelerin yerini alabilmesini garanti eder.

Örnek – Test Dublörleri:

interface PaymentGateway {
    boolean processPayment(double amount);
}

class RealPaymentGateway implements PaymentGateway {
    @Override
    public boolean processPayment(double amount) {
        // Gerçek ödeme işleme mantığı
        return true;
    }
}

class MockPaymentGateway implements PaymentGateway {
    @Override
    public boolean processPayment(double amount) {
        // Test için ödeme simülasyonu
        return amount > 0;
    }
}

class PaymentService {
    private PaymentGateway paymentGateway;
    
    public PaymentService(PaymentGateway paymentGateway) {
        this.paymentGateway = paymentGateway;
    }
    
    public boolean makePayment(double amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("Ödeme miktarı pozitif olmalıdır.");
        }
        return paymentGateway.processPayment(amount);
    }
}

// Test
public void testMakePayment() {
    PaymentGateway mockGateway = new MockPaymentGateway();
    PaymentService service = new PaymentService(mockGateway);
    
    assertTrue(service.makePayment(100));
    // Negatif miktarla ödeme yapmaya çalışıldığında bir istisna fırlatılmalıdır
    assertThrows(IllegalArgumentException.class, () -> service.makePayment(-100));
}

Bu örnekte, MockPaymentGateway sınıfı, RealPaymentGateway sınıfının yerine kullanılabilir ve PaymentService sınıfı için aynı davranışı gösterir. Bu, LSP’ye uygun bir tasarımdır.

LSP’nin Uygulanmasında Karşılaşılan Zorluklar ve Çözümleri

LSP’yi uygulamak her zaman kolay değildir ve bazı zorluklar beraberinde gelebilir:

1. Doğru Soyutlama Seviyesini Bulma

Zorluk: Doğru soyutlama seviyesini belirlemek zor olabilir. Çok genel bir soyutlama, alt sınıfların LSP’yi ihlal etmesine neden olabilir; çok spesifik bir soyutlama ise kod tekrarına yol açabilir.

Çözüm:

  • Domain analizine zaman ayırın
  • İş gereksinimlerini ve kullanım senaryolarını dikkatlice incelemeli
  • Gereksinimlere göre soyutlamaları iteratif olarak güncellenebilir
  • Arayüz Ayrımı Prensibi’ni (Interface Segregation Principle) kullanarak arayüzleri daha küçük ve spesifik hale getirebilirsin.

2. Önceden Var Olan Koddaki LSP İhlallerini Düzeltme

Zorluk: Önceden var olan kodda LSP ihlalleri olabilir ve bunları düzeltmek, mevcut kodu kullanan diğer kodları etkileyebilir.

Çözüm:

  • Aşamalı bir yaklaşım benimseni
  • Önce testler yaz ve güveni sağla
  • Kalıtımı kompozisyonla değiştir ya da yeniden soyutlama yapdış
  • “Adapter” veya “Decorator” gibi tasarım desenlerini kullan

3. Pratikte Katı Bir Kontrat Tanımlama

Zorluk: Metodların önkoşullarını, sonkoşullarını ve değişmezlerini katı bir şekilde tanımlamak, pratikteki tüm kullanım senaryolarını öngörmek zor olabilir.

Çözüm:

  • Kontratları mümkün olduğunca açık ve belgelenmiş yap
  • Birim testlerini kontratları doğrulamak için kullan
  • “Defensive programming” tekniklerini kullan
  • Design by Contract (DbC) araçları ve kütüphaneleri kullan

LSP’nin Diğer SOLID Prensipleriyle İlişkisi

LSP, diğer SOLID prensipleriyle yakından ilişkilidir ve genellikle bunlarla birlikte uygulanır:

Tek Sorumluluk Prensibi (SRP) ile İlişkisi

SRP, bir sınıfın sadece bir değişim nedeni olması gerektiğini söyler. LSP ile birlikte uygulandığında, her sınıf hem tek bir sorumluluk üstlenir hem de üst sınıfların davranışını korur.

Açık/Kapalı Prensibi (OCP) ile İlişkisi

OCP, yazılım varlıklarının genişletmeye açık, değiştirmeye kapalı olması gerektiğini belirtir. LSP, OCP’yi destekler çünkü alt sınıflar, üst sınıfların davranışını değiştirmeden genişletirse, OCP daha etkili bir şekilde uygulanabilir.

Arayüz Ayrımı Prensibi (ISP) ile İlişkisi

ISP, büyük arayüzlerin daha küçük ve spesifik olanlar lehine parçalanmasını önerir. LSP ile birlikte uygulandığında, daha küçük arayüzler, her alt sınıfın tüm arayüzü doğru bir şekilde uygulamasını kolaylaştırır.

Bağımlılığın Tersine Çevrilmesi Prensibi (DIP) ile İlişkisi

DIP, üst düzey modüllerin alt düzey modüllere bağımlı olmaması gerektiğini belirtir. LSP ile birlikte uygulandığında, üst düzey modüller, alt sınıfların üst sınıfların yerini alabilmesine güvenebilir.

LSP ve Tasarım Desenleri

LSP, birçok tasarım deseni ile doğal olarak uyumludur:

1. Strateji Deseni (Strategy Pattern)

Strateji deseni, algoritmaları (stratejileri) kapsüller ve bunları değiştirilebilir hale getirir. Bu desen, LSP’yi destekler çünkü her strateji, bir strateji arayüzünü uygular ve aynı davranışı gösterir.

interface SortStrategy {
    void sort(int[] array);
}

class QuickSortStrategy implements SortStrategy {
    @Override
    public void sort(int[] array) {
        // QuickSort algoritması
    }
}

class MergeSortStrategy implements SortStrategy {
    @Override
    public void sort(int[] array) {
        // MergeSort algoritması
    }
}

class Sorter {
    private SortStrategy strategy;
    
    public Sorter(SortStrategy strategy) {
        this.strategy = strategy;
    }
    
    public void sort(int[] array) {
        strategy.sort(array);
    }
}

2. Fabrika Deseni (Factory Pattern)

Fabrika deseni, nesne oluşturma mantığını istemci kodundan ayırmanızı sağlar. Bu desen, LSP’yi destekler çünkü fabrika, belirli bir arayüzü uygulayan nesneler döndürür ve istemci kodu, bu arayüzün davranışına güvenir.

interface Product {
    void operation();
}

class ConcreteProductA implements Product {
    @Override
    public void operation() {
        System.out.println("ConcreteProductA işlemi gerçekleştiriyor...");
    }
}

class ConcreteProductB implements Product {
    @Override
    public void operation() {
        System.out.println("ConcreteProductB işlemi gerçekleştiriyor...");
    }
}

interface Factory {
    Product createProduct();
}

class ConcreteFactoryA implements Factory {
    @Override
    public Product createProduct() {
        return new ConcreteProductA();
    }
}

class ConcreteFactoryB implements Factory {
    @Override
    public Product createProduct() {
        return new ConcreteProductB();
    }
}

// İstemci kodu
class Client {
    private Factory factory;
    
    public Client(Factory factory) {
        this.factory = factory;
    }
    
    public void doSomething() {
        Product product = factory.createProduct();
        product.operation();
    }
}

Bu örnekte, istemci kodu sadece Product arayüzüne bağımlıdır ve herhangi bir Product implementasyonu ile çalışabilir. Bu, LSP’ye uygun bir tasarımdır.

3. Şablon Metod Deseni (Template Method Pattern)

Şablon metod deseni, bir algoritmanın iskeletini tanımlar ve bazı adımları alt sınıflara bırakır. Bu desen, LSP’yi destekler çünkü alt sınıflar, üst sınıfın davranışını değiştirmeden algoritmanın belirli adımlarını uygular.

abstract class AbstractClass {
    // Şablon metod
    public final void templateMethod() {
        // Değişmez adımlar
        step1();
        step2();
        // Alt sınıflar tarafından değiştirilebilir adım
        step3();
        // Değişmez adımlar
        step4();
    }
    
    // Değişmez adımlar
    private void step1() {
        System.out.println("AbstractClass: Adım 1");
    }
    
    private void step2() {
        System.out.println("AbstractClass: Adım 2");
    }
    
    // Alt sınıflar tarafından uygulanacak adım
    protected abstract void step3();
    
    private void step4() {
        System.out.println("AbstractClass: Adım 4");
    }
}

class ConcreteClass1 extends AbstractClass {
    @Override
    protected void step3() {
        System.out.println("ConcreteClass1: Adım 3");
    }
}

class ConcreteClass2 extends AbstractClass {
    @Override
    protected void step3() {
        System.out.println("ConcreteClass2: Adım 3");
    }
}

Bu örnekte, ConcreteClass1 ve ConcreteClass2 sınıfları, AbstractClass sınıfının şablon metodunu değiştirmeden step3 metodunu uygular. Bu, LSP’ye uygun bir tasarımdır.

4. Komut Deseni (Command Pattern)

Komut deseni, bir isteği bir nesne olarak kapsüller, bu da farklı istekleri parametrize etmenizi, istekleri sıraya koymanızı ve istekleri geri almanızı sağlar. Bu desen, LSP’yi destekler çünkü her komut, bir komut arayüzünü uygular ve aynı davranışı gösterir.

interface Command {
    void execute();
}

class LightOnCommand implements Command {
    private Light light;
    
    public LightOnCommand(Light light) {
        this.light = light;
    }
    
    @Override
    public void execute() {
        light.turnOn();
    }
}

class LightOffCommand implements Command {
    private Light light;
    
    public LightOffCommand(Light light) {
        this.light = light;
    }
    
    @Override
    public void execute() {
        light.turnOff();
    }
}

class Light {
    public void turnOn() {
        System.out.println("Işık açıldı");
    }
    
    public void turnOff() {
        System.out.println("Işık kapandı");
    }
}

// İstemci kodu
class RemoteControl {
    private Command command;
    
    public void setCommand(Command command) {
        this.command = command;
    }
    
    public void pressButton() {
        command.execute();
    }
}

Bu örnekte, istemci kodu sadece Command arayüzüne bağımlıdır ve herhangi bir Command implementasyonu ile çalışabilir. Bu, LSP’ye uygun bir tasarımdır.

LSP’nin Programlama Dillerindeki Uygulanması

Farklı programlama dilleri, LSP’yi desteklemek için farklı mekanizmalar sunar:

Java’da LSP

Java, LSP’yi desteklemek için güçlü bir tür sistemi ve arayüz mekanizması sunar. Java’da, alt sınıflar üst sınıfların metodlarını override edebilir, ancak üst sınıfın davranışını değiştirmemelidir.

class Bird {
    public void eat() {
        System.out.println("Kuş yem yiyor...");
    }
}

class Sparrow extends Bird {
    @Override
    public void eat() {
        // Üst sınıfın davranışını koruyor
        System.out.println("Serçe yem yiyor...");
    }
    
    public void fly() {
        System.out.println("Serçe uçuyor...");
    }
}

// İstemci kodu
void feedBird(Bird bird) {
    bird.eat();
}

// Bu kod her iki sınıf için de çalışır
Bird bird = new Bird();
feedBird(bird);  // "Kuş yem yiyor..." yazdırır

Bird sparrow = new Sparrow();
feedBird(sparrow);  // "Serçe yem yiyor..." yazdırır

C#’da LSP

C#, Java’ya benzer şekilde LSP’yi destekler ve ek olarak covariant ve contravariant jenerik tür parametreleri sunar.

class Bird {
    public virtual void Eat() {
        Console.WriteLine("Kuş yem yiyor...");
    }
}

class Sparrow : Bird {
    public override void Eat() {
        // Üst sınıfın davranışını koruyor
        Console.WriteLine("Serçe yem yiyor...");
    }
    
    public void Fly() {
        Console.WriteLine("Serçe uçuyor...");
    }
}

// İstemci kodu
void FeedBird(Bird bird) {
    bird.Eat();
}

// Bu kod her iki sınıf için de çalışır
Bird bird = new Bird();
FeedBird(bird);  // "Kuş yem yiyor..." yazdırır

Bird sparrow = new Sparrow();
FeedBird(sparrow);  // "Serçe yem yiyor..." yazdırır

Python’da LSP

Python, dinamik tipli bir dil olduğu için LSP’yi desteklemek için tür kontrolleri yapmaz. Ancak, “duck typing” prensibi sayesinde LSP benzeri bir davranış elde edilebilir: “Eğer bir kuş gibi ses çıkarıyorsa ve bir kuş gibi uçuyorsa, o bir kuştur.”

class Bird:
    def eat(self):
        print("Kuş yem yiyor...")

class Sparrow(Bird):
    def eat(self):
        # Üst sınıfın davranışını koruyor
        print("Serçe yem yiyor...")
    
    def fly(self):
        print("Serçe uçuyor...")

# İstemci kodu
def feed_bird(bird):
    bird.eat()

# Bu kod her iki sınıf için de çalışır
bird = Bird()
feed_bird(bird)  # "Kuş yem yiyor..." yazdırır

sparrow = Sparrow()
feed_bird(sparrow)  # "Serçe yem yiyor..." yazdırır

Python’da, bir nesnenin belirli bir arayüzü uygulaması beklenmez; sadece beklenen metodlara sahip olması yeterlidir. Bu, LSP’nin bir tür gevşek uygulamasıdır.

LSP’nin Karmaşık Dünya Senaryoları

Bazen, gerçek dünya durumları LSP’ye uymakta zorlanabilir. Bu durumlar için bazı stratejiler şunlar olabilir:

1. İş Kuralları ve Teknik Tasarım Arasındaki Çelişkiler

İş dünyasında, “is-a” ilişkisi teknik tasarımın “is-a” ilişkisine tam olarak uymayabilir. Örneğin, iş kurallarına göre bir kare, bir dikdörtgendir, ancak OOP’de bunları modellerken LSP’yi ihlal edebiliriz.

Çözüm:

  • İş modelini ve teknik modeli ayırın
  • Kompozisyonu kullanın
  • İş modelini daha soyut bir seviyede tanımlayın
// İş modeli
interface Shape {
    double area();
}

class Rectangle implements Shape {
    private double width;
    private double height;
    
    public Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }
    
    @Override
    public double area() {
        return width * height;
    }
}

class Square implements Shape {
    private double side;
    
    public Square(double side) {
        this.side = side;
    }
    
    @Override
    public double area() {
        return side * side;
    }
}

2. Miras Alınan Sistemlerdeki LSP İhlalleri

Önceden var olan sistemlerde LSP ihlalleri olabilir ve bunları düzeltmek, sistem değişikliği olmadan mümkün olmayabilir.

Çözüm:

  • Adaptör deseni kullanın
  • Facade deseni kullanın
  • Dekoratör deseni kullanın
// LSP'yi ihlal eden miras alınan sınıf
class LegacyRectangle {
    protected int width;
    protected int height;
    
    public void setWidth(int width) {
        this.width = width;
    }
    
    public void setHeight(int height) {
        this.height = height;
    }
    
    public int getArea() {
        return width * height;
    }
}

// LSP'yi ihlal eden miras alınan sınıf
class LegacySquare extends LegacyRectangle {
    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width;
    }
    
    @Override
    public void setHeight(int height) {
        this.width = height;
        this.height = height;
    }
}

// LSP'ye uygun adaptör
interface Shape {
    double area();
}

class RectangleAdapter implements Shape {
    private LegacyRectangle rectangle;
    
    public RectangleAdapter(LegacyRectangle rectangle) {
        this.rectangle = rectangle;
    }
    
    @Override
    public double area() {
        return rectangle.getArea();
    }
}

// İstemci kodu
void printArea(Shape shape) {
    System.out.println("Alan: " + shape.area());
}

// Bu kod her iki adaptör için de çalışır
LegacyRectangle rectangle = new LegacyRectangle();
rectangle.setWidth(5);
rectangle.setHeight(4);
Shape rectangleAdapter = new RectangleAdapter(rectangle);
printArea(rectangleAdapter);  // "Alan: 20" yazdırır

LegacySquare square = new LegacySquare();
square.setWidth(5);
Shape squareAdapter = new RectangleAdapter(square);
printArea(squareAdapter);  // "Alan: 25" yazdırır

3. Çoklu Kalıtım ve LSP

Bazı programlama dilleri çoklu kalıtımı destekler (C++ gibi) ve bu, LSP uyumluluğunu daha karmaşık hale getirebilir.

Çözüm:

  • Arayüz kalıtımını tercih edin
  • Kompozisyonu kullanın
  • Mixin’leri kullanın
// C++ çoklu kalıtım örneği
class Swimmer {
public:
    virtual void swim() {
        std::cout << "Yüzüyor..." << std::endl;
    }
};

class Flyer {
public:
    virtual void fly() {
        std::cout << "Uçuyor..." << std::endl;
    }
};

class Duck : public Swimmer, public Flyer {
public:
    void swim() override {
        std::cout << "Ördek yüzüyor..." << std::endl;
    }
    
    void fly() override {
        std::cout << "Ördek uçuyor..." << std::endl;
    }
};

// İstemci kodu
void letSwim(Swimmer* swimmer) {
    swimmer->swim();
}

void letFly(Flyer* flyer) {
    flyer->fly();
}

// Bu kod Duck nesnesi için çalışır
Duck duck;
letSwim(&duck);  // "Ördek yüzüyor..." yazdırır
letFly(&duck);   // "Ördek uçuyor..." yazdırır

LSP ve Kod Kalitesi Metrikleri

LSP uyumluluğunu değerlendirmek için çeşitli kod kalitesi metrikleri kullanılabilir:

1. Kalıtım Derinliği (Depth of Inheritance Tree – DIT)

Kalıtım hiyerarşisinde çok fazla seviye olması, genellikle LSP ihlallerine yol açabilir. Sığ kalıtım hiyerarşileri genellikle daha yönetilebilir ve LSP’ye daha uyumludur.

2. Alt Sınıf Sayısı (Number of Children – NOC)

Bir sınıfın çok sayıda alt sınıfı olması, o sınıfın davranışının tüm alt sınıflar için uygun olduğundan emin olmayı zorlaştırabilir.

3. Metod Geçersiz Kılma Oranı (Method Override Ratio)

Alt sınıfların üst sınıfların metodlarını ne kadar sık geçersiz kıldığını ölçer. Yüksek bir oran, alt sınıfların üst sınıfların davranışını değiştiriyor olabileceğini gösterebilir.

4. LCOM (Lack of Cohesion of Methods)

Bir sınıfın içindeki metodların ne kadar ilişkili olduğunu ölçer. Yüksek bir LCOM, sınıfın birden fazla sorumluluğa sahip olabileceğini ve potansiyel olarak LSP’yi ihlal edebileceğini gösterebilir.

5. Coupling Metrics

Sınıflar arasındaki bağımlılıkları ölçer. Yüksek coupling, LSP ihlallerine yol açabilecek karmaşık bağımlılıklar olduğunu gösterebilir.

Sonuç

Liskov Yerine Geçme Prensibi, nesne yönelimli programlamadaki kalıtım hiyerarşilerinin sağlam ve tutarlı olmasını sağlayan temel bir prensiptir. Bu prensip, alt sınıfların üst sınıfların yerini alabilmesini ve üst sınıfların davranışını değiştirmemesini garanti eder. LSP’yi doğru bir şekilde uygulamak, daha modüler, bakımı kolay ve genişletilebilir kod elde etmenizi sağlar.

LSP’yi uygulamak için, doğru soyutlama seviyesi kullanma, kompozisyon, savunmacı programlama ve kontrat tasarımı gibi teknikler kullanabilirsiniz. Ancak, her tasarım prensibinde olduğu gibi, LSP’yi uygularken de pragmatik olmak, projenizin bağlamını ve ihtiyaçlarını göz önünde bulundurmak önemlidir.

SOLID prensiplerini bir bütün olarak uygulamak, daha modüler, test edilebilir, bakımı kolay ve genişletilebilir kod oluşturmanıza yardımcı olur. LSP, bu prensipler içinde özel bir yere sahiptir çünkü kalıtım hiyerarşilerinin doğru tasarlanmasını sağlar.

Unutmayın, yazılım geliştirme bir zanaat ve sanat karışımıdır. Prensipleri öğrenmek önemlidir, ancak ne zaman ve nasıl uygulanacaklarını bilmek tecrübe gerektirir. LSP’yi projenizde uygularken, dengeli bir yaklaşım benimseyin ve sürekli olarak kodunuzu iyileştirmeye çalışın.