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

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

.
Четыре столпа объектно-ориентированного программирования, часть 3: полиморфизм
31.08.2023 00:00

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

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

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

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

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

Что такое полиморфизм?

Полиморфизм – способность объекта в объектно-ориентированном программировании принимать разные формы. Слово это греческое, и термин «полиморфизм» дословно переводится как «множество форм».

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

Полиморфизм: пример

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

Замещение в действии

Сначала посмотрим на замещение. В предыдущей статье мы определили класс SavingsAccount, наследующий свойства и методы от родительского класса Account.

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

Можно сказать, что второй кусочек логики должен быть частью класса SavingsAccount вместо класса Account, так как относится только к сберегательным счетам. В результате в обоих классах будет своя собственная реализация метода withdraw().

Это именно то, что позволяет нам замещение: замещать определение метода из родительского класса (в данном случае Account) в дочернем классе (в данном случае SavingsAccount).

Это значит, что теперь у нас будет реализация withdraw() в классе Account, которая выглядит так:

public void withdraw(double amount) throws WithdrawException {
if (amount < 0) {
throw new WithdrawException("You cannot withdraw a negative amount!");
}
this.balance -= amount;
}

и другое определение для  withdraw() в классе SavingsAccount, замещающее определение в Account:

@Override
public void withdraw(double amount) throws WithdrawException {
if (amount < 0) {
throw new WithdrawException("You cannot withdraw a negative amount!");
}
if (amount > this.balance) {
throw new WithdrawException("You cannot overdraw on a savings account!");
}
this.balance -= amount;
}

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

Итак, применяя полиморфизм путем замещения, мы получаем ситуацию, когда мы можем делать как это:

Account account = new Account(AccountType.CHECKING);
account.deposit(20);
account.withdraw(30); // это не вызывает ошибок, так как овердрафт текущего счета разрешен

Так и это:

SavingsAccount account = new SavingsAccount();
account.deposit(20);
account.withdraw(30); // это вызывает ошибку, так как овердрафт сберегательного счета запрещен

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

Переопределение в действии

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

В качестве примера определим второй конструктор (это особый тип метода) для класса SavingsAccount:

public class SavingsAccount extends Account {
private final double interestRate;
public SavingsAccount() {
super(AccountType.SAVINGS);
this.interestRate = 0.03;
}
public SavingsAccount(double interestRate) {
super(AccountType.SAVINGS);
this.interestRate = interestRate;
}

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

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

Полиморфизм в других языках

В C# замещение работает практически так же, как в Java. Вам, однако, нужно разрешить замещение для метода в родительском классе явным образом, используя ключевое слово virtual:

public class Employee
{
protected decimal _baseSalary;
public Employee(decimal baseSalary)
{
_baseSalary = baseSalary;
}
public virtual decimal GetSalary()
{
return _baseSalary;
}
}
public class SalesEmployee : Employee
{
protected decimal _targetBonus;
public SalesEmployee(decimal baseSalary, decimal targetBonus) : base(baseSalary)
{
_targetBonus = targetBonus;
}
public override decimal GetSalary()
{
return _baseSalary + _targetBonus;
}
}

Python тоже поддерживает замещение метода довольно прямым образом:

class Employee:
def __init__(self, base_salary):
self.base_salary = base_salary
def get_salary(self):
return self.base_salary
class SalesEmployee(Employee):
def __init__(self, base_salary, target_bonus):
Employee.__init__(self, base_salary)
self.target_bonus = target_bonus
def get_salary(self):
return self.base_salary + self.target_bonus

Переопределение методов в C# и Python даже проще, чем в Java, так как оба языка поддерживают опциональные аргументы метода в дополнение к значениям аргумента по умолчанию. К примеру, два конструктора, которые мы разбирали для класса Account в Java, можно воссоздать единым конструктором в C# и Python, используя значение аргумента по умолчанию:

public class SavingsAccount : Account
{
protected double _interestRate;
public SavingsAccount(double interestRate = 0.03) : base(AccountType.SAVINGS)
{
_interestRate = interestRate;
}
class SavingsAccount(Account):
def __init__(self, interest_rate = 0.03):
self.interest_rate = interest_rate

В обоих случаях теперь можно или создать SavingsAccount с процентной ставкой 3% по умолчанию, или же создать счет с выбранной вами процентной ставкой.

Полиморфизм в автоматизации

Лично я нечасто применяю полиморфизм в своих автоматизационных решениях, но в некоторых случаях он может быть полезен – особенно если мы смотрим на переопределение методов. К примеру, у меня есть метод-помощник в Selenium, который должен или пользоваться таймаутом по умолчанию, или использовать кастомный (и, как правило, более длительный) таймаут в отдельных случаях.

Вот как могут выглядеть такие методы – например, в случае ожидания кликабельности кнопки перед попыткой клика по ней:

protected void click(By locator) {
click(locator, 10);
}
protected void click(By locator, int timeoutInSeconds) {
try {
new WebDriverWait(this.driver, Duration.ofSeconds(timeoutInSeconds)).until(ExpectedConditions.elementToBeClickable(locator));
driver.findElement(locator).click();
}
catch (TimeoutException te) {
Assertions.fail(String.format("Exception in click() (timeout was %d seconds): %s", timeoutInSeconds, te.getMessage()));
}
}

Таким образом, можно или вызвать click(By.Id("someElement")) в сценариях, используя значение таймаута по умолчанию (10 секунд), или задать, скажем, 20-секундный таймаут для отдельных случаев, вызывая `click(By.Id(“someSlowLoadingElement”), 20);

Другой случай, где можно выиграть от переопределения, даже если вы не внедряли его самостоятельно – это выбор одного из переопределений метода assertEquals() в JUnit (или TestNG):


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

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

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