Когда дело доходит до объектно-ориентированного программирования, работа с системой наследования не всегда является легкой задачей, особенно когда речь идет о гибкости. На конференции WWDC 2015 Apple представила Swift, первый протокольно-ориентированный язык программирования. В этой статье мы рассмотрим эту новую парадигму и поймем, как этот подход может привести к более гибким и повторно используемым кодовым базам.

Что вы узнаете:

  • Проблемы с наследованием объектно-ориентированного программирования;
  • Что такое протоколы;
  • Что такое протокольно-ориентированное программирование.

Проблемы с объектно-ориентированным программированием

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

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

Предположим, мы разрабатываем игру и работаем над персонажами. Изначально мы знаем, что персонаж может двигаться и атаковать, поэтому создадим для него базовый класс:

class Character {
  func move() {
    // Basic move implementation
  }
  
  func attack() {
  // Basic attack implementation 
 }
}

На данный момент нам сообщили, что будет два особых типа персонажей, лучники и волшебники:

class Archer: Character {
 override func atack() {
  // Shoot an arrow
 }
}

class Wizard: Character {
 override func atack() {
  // Fireball!
 }
}

Наши персонажи — люди, они двигаются одинаково, поэтому нам нужно было реализовать только определенные атаки, но мы повторно использовали базовый код движения. Пока у нас есть такая структура класса:

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

Сложность конструкции уже значительно возросла — а мы добавили только одного нового персонажа. Из-за этого решения нам пришлось изменить базовый класс и, как следствие, все остальные:

class Character {
  func move() {
    // Basic move implementation
  }
}

class AttackCharacters: Character {
  func attack() {
  // Basic attack implementation 
 }
}

Теперь наши специальные символы:

class Archer: AttackCharacters {
 override func atack() {
  // Shoot an arrow
 }
}

class Wizard: AttackCharacters {
 override func atack() {
  // Fireball!
 }
}

class Healer: Character {
 func heal() {
  // I need cover here!!
 }
}

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

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

  1. Жесткая связь. Наследование может привести к жесткой связи между классами, когда изменения в одном классе могут иметь каскадный эффект на другие связанные классы. Изменение реализации базового класса может повлиять на его подклассы.
  2. Отсутствие гибкости. Наследование — это статическая связь, определяемая во время компиляции. Подклассы наследуют весь интерфейс и реализацию своего родительского класса, даже если им нужно только подмножество или другое поведение.
  3. Отсутствие композиции. Наследование связано с отношениями «есть-а», где подкласс является специализированным типом своего родительского класса. Однако во многих случаях требуется отношение «имеет-а», в котором приоритет отдается композиции, а не наследованию.
  4. Дублирование кода. Наследование может иногда приводить к дублированию кода, если сходное поведение или свойства должны быть общими для нескольких подклассов, которые не являются частью одной и той же иерархии наследования.

Что такое протоколы?

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

1. Определение протокола

protocol Shape {

  // Properties
  var area: Double { get }
  var perimeter: Double { get }

  // Methods
  func description() -> String
}

В этом примере протокол Shape определяет требования к геометрическим формам. Он требует, чтобы соответствующие типы имели area вычисляемое свойство типа Double и perimeter вычисляемое свойство типа Double. Кроме того, протокол указывает, что тип должен реализовывать метод с именем description(), возвращающий String.

2. Соответствие протоколу

Во-первых, мы создадим структуру Circle, которую мы хотим согласовать с протоколом:

struct Circle {
  let radius: Double
}

Чтобы соответствовать протоколу Shape, нам нужно реализовать необходимые свойства и методы:

struct Circle: Shape {
  let radius: Double

  var area: Double {
    return Double.pi * radius * radius
  }

  var perimeter: Double {
    return 2 * Double.pi * radius
  }

  func description() -> String {
    return "Circle with radius \(radius)"
  }
}

Протокольно-ориентированное программирование

Протокольно-ориентированное программирование (POP) — это парадигма программирования, которая делает упор на использование протоколов для разработки компонентов. Это побуждает нас работать с составом функций, где мы определяем протоколы и имеем типы, соответствующие им, что является более гибким, чем цепочки наследования классов.

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

protocol Movable {
  func move()
}

protocol Attackable {
  func attack()
}

protocol Healable {
  func heal()
}

Теперь мы можем создать наших персонажей:

class Archer: Movable, Attackable {
  func attack() {
    // Shoot an arrow
  }

  func move() {
    // Run!!
  }
}

class Wizard: Movable, Attackable {
  func attack() {
    // Fireball!
  }

  func move() {
    // Run!!
  }
}

class Healer: Movable, Healable {
  func heal() {
    // I need cover here!!
  }

  func move() {
    // Run!!
  }
}

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

extension Movable {
  func move() {
    // Default Run!!
  }
}

Теперь мы можем удалить реализации перемещения:

class Archer: Movable, Attackable {
  func attack() {
    // Shoot an arrow
  }
}

class Wizard: Movable, Attackable {
  func attack() {
    // Fireball!
  }
}

class Healer: Movable, Healable {
  func heal() {
    // I need cover here!!
  }
}

Если нам нужна конкретная реализация, просто добавьте ее в класс, и реализация по умолчанию будет переопределена:

class Healer: Movable, Healable {
  func heal() {
    // I need cover here!!
  }

  // Override default method "move"
  func move() {
    // Fly
  }
}

Теперь у нас есть немного другая диаграмма, где структуры соответствуют поведению, определенному протоколами:

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

Заключение

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

Я надеюсь, что эта статья была полезной. Если вы могли бы мне помочь, подписывайтесь на меня на Medium и хлопайте статье, спасибо! 🤠

Повышение уровня кодирования

Спасибо, что являетесь частью нашего сообщества! Перед тем, как ты уйдешь:

🔔 Подписывайтесь на нас: Twitter | ЛинкедИн | "Новостная рассылка"

🚀👉 Присоединяйтесь к коллективу талантов Level Up и найдите прекрасную работу