Композиция поверх наследования, принцип разделения интерфейса, диспетчеризация методов и модульное тестирование

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

  • Композиция по принципам наследования и разделения интерфейсов.
  • Отправка метода для протоколов.
  • Модульное тестирование.

Реализация протокола по умолчанию

Как упоминалось в документации Swift:

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

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

Давайте посмотрим на пример:

protocol SampleProtocol {
  func foo()
}
extension SampleProtocol {
  func foo() {
    print("foo")
  }
  func bar() {
    print("bar")
  }
}
class SampleClass: SampleProtocol {}
let sample: SampleProtocol = SampleClass()
sample.foo() // prints "foo"
sample.bar() // prints "bar"

Предпочитайте композицию наследованию

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

Если вы не знакомы с этим термином, запах кода - это то, что возможно указывает на более глубокую проблему.

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

В соответствии с принципами ООП для этой цели вы должны отдавать предпочтение композиции перед наследованием.

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

О теме рассказываю в другой статье.

Принцип разделения интерфейса

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

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

Вкратце: если это так, разделите свой протокол на более мелкие.

Метод отправки

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

protocol SampleProtocol {
    func foo()
}
extension SampleProtocol {
    func foo() {
        print("protocol foo")
    }
    func bar() {
        print("protocol bar")
    }
}
class SampleClass: SampleProtocol {
    func foo() {
        print("class foo")
    }
    func bar() {
        print("class bar")
    }
}
let sample: SampleProtocol = SampleClass()
sample.foo() // prints "class foo"
sample.bar() // prints "protocol bar"

Повторяю: Sample.bar() печатает “protocol bar”.

Если вы или кто-либо, читающий ваш код, не знаком с тем, как диспетчер методов работает в Swift, это довольно нелогично.

И это также источник труднодоступных ошибок.

Вот почему это происходит:

SampleProtocol определяет два метода: foo() ,, который определен в протоколе как требование, и bar(), который определен в расширении.

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

Методы, определяемые расширением, используют статическую отправку, которая выбирает реализацию метода для выполнения во время сборки. Это означает, что единственной используемой реализацией будет реализация расширения. И вы не можете отменить это.

Приоритет отправки и ограничения

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

protocol SampleProtocol {
    func foo()
}
extension SampleProtocol {
    func foo() {
        print("SampleProtocol")
    }
}
protocol BarProtocol {}
extension SampleProtocol where Self: BarProtocol {
    func foo() {
        print("BarProtocol")
    }
}
class SampleClass: SampleProtocol, BarProtocol {}
let sample: SampleProtocol = SampleClass()
sample.foo() // prints "BarProtocol"

Вы можете переопределить реализации по умолчанию (если они требуются протоколом), используя ограничения. И реализации по умолчанию с ограничениями имеют приоритет над неограниченными.

Таким образом, приоритет будет следующим: класс / структура / перечисление, соответствующий протоколу - ›ограниченное расширение протокола -› простое расширение протокола.

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

Модульное тестирование

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

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

protocol DependencyProtocol {}
extension DependencyProtocol {
    func foo() -> Int {
        return 0
    }
}
class SampleClass {
    let dependency: DependencyProtocol
    init(dependency: DependencyProtocol) {
        self.dependency = dependency
    }
    
    // You will never be able to mock dependency.foo()
    func sampleMethod() {
        let dependencyValue = dependency.foo()
        print(dependencyValue)
    }
}

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

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

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

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

protocol SampleProtocol {
    func same(input: Int) -> Int
}
extension SampleProtocol {
    func double(input: Int) -> Int {
        return input * input
    }
}
// You need a dummy class so you can call protocol default implementations
class SampleProtocolDummy: SampleProtocol {
    // You need to implement notimplemented-required-methods with dummy code
    func same(input: Int) -> Int {
        return 0
    }
}
let sut: SampleProtocol = SampleProtocolDummy()
let result = sut.double(input: 2)
XCTAssertEqual(expectedResult, result)

Спасибо за прочтение!
Если статья понравилась, пожалуйста, аплодируйте :)