Принципы SOLID: принцип замещения Лискова

Знаете, когда я впервые услышал название принципа замещения Лискова, я подумал, что это будет самый сложный принцип SOLID. Название принципа показалось мне очень странным. Я судил о книге по обложке и убедил себя, что не возьму ее в руки. В конце концов оказалось, что это один из самых простых и понятных принципов SOLID.

Итак, давайте начнем наше путешествие с простого определения принципа подстановки Лискова:

Это возможность заменить любой объект родительского класса любым объектом одного из его дочерних классов без ущерба для правильности программы.

Я знаю, это звучит странно для вас, но давайте разобьем это на части. Предположим, у нас есть программа, у которой есть родительский класс. Родительский класс имеет несколько дочерних классов, от которых он наследуется. Если мы решили создать какие-то объекты из родительского класса в нашей программе, мы должны иметь возможность заменить любой из них на любой объект любого дочернего класса, и программа должна работать как положено, без каких-либо ошибок.

Другими словами, мы должны иметь возможность заменять объекты родительского класса объектами дочерних классов, не вызывая прерывания работы программы. Вот почему в названии этого принципа есть ключевое слово замещение. Что касается Лисков, то это имя ученого Варвары Лисков, которая разработала научное определение этого принципа. Вы можете прочитать эту статью Принцип подстановки Лисков в Википедии для получения дополнительной информации об этом определении.

Теперь давайте попробуем связать определение, которое мы только что обсудили, с известным примером, чтобы понять принцип.

Bird — это класс, который имеет два метода eat() и fly(). Он представляет собой базовый класс, который может расширять любой тип птицы.

public class Bird {
    
    public void eat() {
        System.out.println("I can eat.");
    }
    
    public void fly() {
        System.out.println("I can fly.");
    }
}

Swan — птица, которая может есть и летать. Следовательно, он должен расширить класс Bird.

public class Swan extends Bird {

    @Override
    public void eat() {
	System.out.println("OMG! I can eat pizza!");
    }

    @Override
    public void fly() {
	System.out.println("I believe I can fly!");
    }
}

Main — это основной класс нашей программы, содержащий ее логику. У него есть два метода: letBirdsFly(List<Bird> birds) и main(String[] args). Первый метод принимает в качестве параметра список птиц и вызывает их методы fly. Второй создает список и передает его первому.

public class Main { 
  
    public static void letBirdsFly(List<Bird> birds) {
        for(Bird bird: birds) { 
            bird.fly(); 
        } 
    } 
    
    public static void main(String[] args) { 
        List<Bird> birds = new ArrayList<Bird>();
        birds.add(new Bird()); 
        letBirdsFly(birds);
    }
}

Программа просто создает список птиц и позволяет им летать. Если вы попытаетесь запустить эту программу, она выведет следующее утверждение:

Теперь попробуем применить определение этого принципа к нашему основному методу и посмотрим, что получится. Мы собираемся заменить объект Bird на объект Swan.

public static void main(String[] args) {
    List<Bird> birds = new ArrayList<Bird>();
    birds.add(new Swan()); 
    letBirdsFly(birds); 
}

Если мы попытаемся запустить программу после применения изменений, она выведет следующее утверждение:

I believe I can fly!

Мы видим, что этот принцип прекрасно применим к нашему коду. Программа работает как положено, без ошибок и проблем. Но что, если мы попытаемся расширить класс Bird новым типом птиц, которые не умеют летать?

public class Penguin extends Bird {
    @Override
    public void eat() {
        System.out.println("Can I eat taco?");
    } 
    @Override 
    public void fly() {
        throw new UnsupportedOperationException("Help! I cannot fly!"); 
    } 
}

Мы можем проверить, применим ли этот принцип к нашему коду или нет, добавив объект Penguin в список птиц и запустив код.

public static void main(String[] args) { 
    List<Bird> birds = new ArrayList<Bird>();
    birds.add(new Swan());
    birds.add(new Penguin());
    letBirdsFly(birds);
}
I believe I can fly! Exception in thread "main" java.lang.UnsupportedOperationException: Help! I cannot fly!

Опс! это не сработало, как ожидалось!

Мы видим, что с объектом Swan код работал отлично. Но с объектом Penguin код выбросил UnsupportedOperationException. Это нарушает принцип подстановки Лискова, поскольку у класса Bird есть дочерний элемент, который неправильно использовал наследование, что вызвало проблему. Penguin пытается расширить логику полета, но не может летать!

Мы можем решить эту проблему, используя следующую проверку if:

public static void letBirdsFly(List<Bird> birds) { 
    for(Bird bird: birds) { 
        if(!(bird instanceof Penguin)) { 
            bird.fly(); 
        } 
    } 
}

Но это решение считается плохой практикой и нарушает принцип открытого-закрытого. Представьте, если мы добавим еще три вида птиц, которые не умеют летать. Код превратится в беспорядок. Заметьте также, что одно из определений принципа замещения Лискова, разработанное Робертом К. Мартином, звучит так:

Функции, использующие указатели или ссылки на базовые классы, должны иметь возможность использовать объекты производных классов, не зная об этом.

Это не относится к нашему решению, поскольку мы пытаемся узнать тип объекта Bird, чтобы избежать неправильного поведения нелетающих птиц.

Одним из чистых решений для решения этой проблемы и повторного следования принципу является выделение летающей логики в другой класс.

public class Bird { 
    public void eat() { 
        System.out.println("I can eat."); 
    } 
}
public class FlyingBird extends Bird { 
    public void fly() { 
        System.out.println("I can fly."); 
    }
}
public class Swan extends FlyingBird { 
    @Override
    public void eat() { 
        System.out.println("OMG! I can eat pizza!"); 
    }
    @Override
    public void fly() { 
        System.out.println("I believe I can fly!");
    } 
}
public class Penguin extends Bird { 
    @Override 
    void eat() {
        System.out.println("Can I eat taco?");
    }
}

Теперь мы можем отредактировать метод letBirdsFly, чтобы он поддерживал только летающих птиц.

public class Main {
    public static void letBirdsFly(List<FlyingBird> flyingBirds) {
        for(FlyingBird flyingBird: flyingBirds) { 
            flyingBird.fly();
        } 
    }
    public static void main(String[] args) {
        List<FlyingBird> flyingBirds = new ArrayList<FlyingBird>();
        flyingBirds.add(new Swan());
        letBirdsFly(flyingBirds); 
    }
}

Причина, по которой мы заставили метод letBirdsFly принимать только летающих птиц, состоит в том, чтобы гарантировать, что любая замена FlyingBird сможет летать. Теперь программа работает как положено и выводит следующие операторы:

I believe I can fly!

Вы можете видеть, что принцип замещения Лискова заключается в правильном использовании отношения наследования. Вы должны создавать подтипы какого-либо родителя тогда и только тогда, когда они собираются правильно реализовать его логику, не вызывая никаких проблем.

Мы подошли к концу этого пути, но нам еще предстоит рассмотреть еще два принципа. Так что не торопитесь читать об этом принципе и убедитесь, что вы понимаете его, прежде чем двигаться дальше. Быть в курсе!

Первоначально опубликовано на https://amrsaeed.com 25 октября 2020 г.