Разделы портала

Онлайн-тренинги

.
Четыре столпа объектно-ориентированного программирования, часть 4: абстракция
05.09.2023 00:00

Автор: Баз Дейкстра (Bas Dijkstra)
Оригинал статьи
Перевод: Ольга Алифанова

В этой серии статей я углублюсь в четыре столпа (фундаментальных принципа ) объектно-ориентированного программирования:

  • Инкапсуляция
  • Наследование
  • Полиморфизм
  • Абстракция (эта статья)

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

Примеры, которые я даю, в основном будут на Java, но в ходе этой серии статей я упомяну, как внедрять эти концепции, по возможности, в C# и Python.

Что такое абстракция?

Абстракция – это создание абстрактных репрезентаций или схем для конкретных концепций, как правило, классов.

Абстракция позволяет разработчикам навязать общую структуру группе связанных классов – или через использование интерфейсов, или через абстрактные классы.

Абстракция: пример

Чтобы лучше понять, как выглядит абстракция и каковы ее преимущества для объектно-ориентированного программирования, рассмотрим пример. В предыдущей статье серии мы закончили на том, что класс SavingsAccount, который наследуется от родительского класса Account, но имеет свое собственное внедрение отдельных методов.

Если, однако, пристальнее посмотреть на отношения между Account и SavingsAccount, то родительско-дочерние отношения, возможно, не лучший выбор. Предпочтительным вариантом моделирования разных типов классов в нашем коде будет репрезентация каждого типа счета (текущего, сберегательного, инвестиционного…) своим собственным классом без каких-либо родительско-дочерних отношений между ними.

Нам, однако, нужно убедиться, что все классы следуют некоей общей структуре – или что они, скорее, содержат определенные свойства и методы, общие для всех типов счетов. Применение абстракции поможет нам добиться именно этого.

Сначала посмотрим, как это делается с применением интерфейсов.

Интерфейсы в действии

Интерфейсы в Java можно рассматривать, как форму контракта, которого должны придерживаться все внедряющие этот интерфейс классы. Он, как минимум, содержит список методов, которые должны присутствовать во всех классах, внедряющих этот интерфейс. Вот так может выглядеть интерфейс Account:

public interface Account {
    void withdraw(double amount);
    void deposit(double amount);
}

Этот интерфейс сообщает нам, что все типы счетов должны, как минимум, внедрять метод withdraw(), а также метод deposit(). Классы также могут содержать другие методы, не определенные в интерфейсе, но обязаны внедрять эти.

Класс SavingsAccount теперь можно определить, внедрив интерфейс Account примерно так:

public class SavingsAccount implements Account { 
    private final int number;
private final double interestRate;
    private double balance;
    public SavingsAccount(int number, double interestRate) {
this.number = number;
this.balance = 0;
this.interestRate = interestRate;
    @Override
public void withdraw(double amountToWithdraw) {
if (amountToWithdraw <= this.balance) {
this.balance -= amountToWithdraw;
}
}
    @Override
public void deposit(double amount) {
this.balance += amount;
}
}

Заметьте, что ключевое слово implements используется тут для объявления, что наш класс SavingsAccount следует структуре, заданной интерфейсом Account. Отсутствие внедрения методов, заданных в интерфейсе класса, вызовет ошибку компилятора.

Другие классы вроде CheckingAccount теперь тоже могут внедрять интерфейс Account, и мы можем даже инстанцировать новые объекты, используя тип данных интерфейса:

public static void main(String[] args) {
    // Текущий счет требует только задания номера счета
Account myCheckingAccount = new CheckingAccount(9876);
    // Сберегательный счет требует номера счета и процентной ставки
Account mySavingsAccount = new SavingsAccount(1234, 0.03);
}

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

Один из методов решения этой проблемы – это использование методов по умолчанию в вашем интерфейсе. Метод по умолчанию определяется на уровне интерфейса и автоматически доступен для всех классов, использующих этот интерфейс:

public interface Account {
    void withdraw(double amount);
    void deposit(double amount);
    default void printBankInfo() {
System.out.println("This account belongs to OhOhBank");
}
}
public static void main(String[] args) {
    Account account = new CheckingAccount(9876);
account.printBankInfo();
}

Решение, однако, имеет свои пределы, так как у интерфейсов в Java нет состояний – то есть вы не можете определять, получать доступ к или модифицировать свойства интерфейса. Если, скажем, мы хотим задать общее внедрение для метода deposit() для всех типов счетов в абстракции, этого нельзя добиться при помощи интерфейсов. Вместо этого нужно использовать абстрактный класс.

Абстрактные классы в действии

Как и интерфейсы, абстрактные классы дают возможность навязать общую структуру группе связанных классов. В отличие от интерфейсов, однако, абстрактные классы могут иметь состояние, и могут иметь внедрение методов, которые получают доступ к состоянию объекта и модифицируют его – то есть его свойства. Вот как может выглядеть абстрактный класс Account, задающий общее внедрение для метода deposit():

public abstract class Account {
    protected double balance;
    abstract void withdraw(double amount);
    public void deposit(double amount) {
this.balance += amount;
}
    public void printBankInfo() {
System.out.println("This account belongs to OhOhBank");
}
}

А так наш класс SavingsAccount теперь расширяет Account (вы расширяете абстрактный класс, вы не внедряете его):

public class SavingsAccount extends Account {
    private final int number;
private final double interestRate;
    public SavingsAccount(int number, double interestRate) {
this.number = number;
this.balance = 0;
this.interestRate = interestRate;
}
    @Override
public void withdraw(double amountToWithdraw) {
if (amountToWithdraw <= this.balance) {
this.balance -= amountToWithdraw;
}
}
}

Как можно видеть, SavingsAccount больше не нуждается во внедрении метода deposit(), так как он уже поддерживается абстрактным классом, но метод можно вызвать для объекта с типом SavingsAccount без проблем:

public static void main(String[] args) {
    Account mySavingsAccount = new SavingsAccount(1234, 0.03);
mySavingsAccount.deposit(100);
}

Более того, так как свойство balance уже определено в Account, SavingsAccount может получить к нему доступ и использовать его, не нуждаясь в явном повторном его определении. Все это, конечно, работает при условии, что ваши модификаторы доступа позволяют это.

Абстракция на других языках

В C# абстракция работает практически так же, как в Java, с некоторыми мелкими отличиями. Основное из них заключается в том, что в C# в интерфейсе можно определить даже больше, чем в Java. Вы можете определить интерфейсы, а интерфейсы могут содержать внедрения методов, как в Java, но в C# (как минимум в последних версиях языка) интерфейсы могут также определять состояния и получать к ним доступ (и снова – свойства).

По этой причине различия между интерфейсами и абстрактными методами в C# даже меньше, чем в Java. Крупнейшие, по моему мнению, оставшиеся различия:

  • Интерфейсы не могут содержать конструкторы, а абстрактные методы могут.
  • Класс может расширять только один абстрактный класс, но может внедрять множество интерфейсов.

Эти различия применимы как к Java, так и к C#.

Python не имеет концепции абстрактного класса, а также не знает концепции интерфейса. Технически вы можете сконструировать что-то, что некоторым образом похоже на интерфейс (показано в этой статье), но я считаю, что это выглядит очень натужно – не так, как нормальный интерфейс в Java и C#.

Абстракция в автоматизации

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

Распространенный пример применения абстракции в коде автоматизации (но не самостоятельное внедрение) – это интерфейс WebDriver в Selenium. Факт, что в коде можно сделать

WebDriver driver = new ChromeDriver();

и

WebDriver driver = new FirefoxDriver();

- что позволяет создавать тесты, которые можно запускать в разных браузерах, не жонглируя разными объектами драйвера – это демонстрация силы абстракции.

Я с радостью посмотрел бы на опровергающие меня примеры, демонстрирующие, что определение и использование интерфейсов и абстрактных классов в автоматизации – хорошая идея. Если у вас есть примеры того, как абстракция сильно помогла с кодом автоматизации, пожалуйста, пришлите мне их – я с удовольствием сменю свою точку зрения.

Обсудить в форуме