Как SOLID применяется в мирах iOS и Swift

SOLID состоит из пяти принципов проектирования, призванных сделать код более понятным, гибким и поддерживаемым. Этот принцип впервые появился в 2000 году в статье Роберта Мартина «Принципы проектирования и шаблоны проектирования», но, согласно Википедии, сокращение SOLID было введено Майклом Фезерсом.

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

Принцип # 1: единоличная ответственность

У каждого класса должна быть только одна обязанность

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

  • Согласно Code Complete, исследование 1996 года показало, что классы с большим количеством процедур имели больше дефектов.
  • По словам Бена Наделя Художественная гимнастика, классы с более чем 50 строками обычно выполняют несколько задач, что затрудняет их понимание и повторное использование. У классов из пятидесяти строк есть дополнительное преимущество, заключающееся в том, что они видны на одном экране без прокрутки, что упрощает их понимание.
  • Согласно одному из обсуждений на Stack Exchange, 200 строк - хороший ориентир.
  • SwiftLint, инструмент, который помогает обеспечить соблюдение стиля Swift с помощью конфигурации по умолчанию, считает, что у класса должно быть менее 500 строк кода. (Я знаю, что вы можете изменить конфигурацию таких настроек.)

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

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

Пример кода

Давайте посмотрим на плохой способ сделать это, а затем посмотрим, что мы могли бы сделать лучше.

// You have two responsiblities in this class
struct Person {
let name: String
let age: Int
func checkAge() -> String {
    if age < 8 {
      return "Underage"
    } else {
      return "Qualified"
    }
  }
}

let person = Person(name: "Kelvin", age: 27)
person.checkAge()

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

// One responsibility in one class
struct Person {
  let name: String
  let age: Int
}
// another responsibility in another class
struct AgeVerifier {
    func checkAge(age: Int) -> String {
        if age < 8 {
          return "Underage"
        } else {
          return "Qualified"
        }
    }
}
let person = Person(name: "Lexton", age: 1)
let verifyAge = AgeVerifier()
verifyAge.checkAge(age: person.age)

Принцип № 2: Открыто-Закрыто

Класс должен быть открыт для расширения, но закрыт для изменения

Этот принцип способствует написанию поддерживаемого кода, имея две важные характеристики:

  • Открыто для продления
  • Закрыт на модификацию

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

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

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

Пример кода

protocol AnimalProtocol {
func makeSound() -> String
}
class Tiger: AnimalProtocol {
    func makeSound() -> String {
      return "Roar"
    }
}
struct Zoo {
let animals: [AnimalProtocol]
    func animalNoise() -> [String] {
      return animals.map { $0.makeSound() }
    }
}
let tiger = Tiger()
var zooAnimals = Zoo(animals: [tiger])
zooAnimals.animalNoise() // [roar]

Наконец-то у нас появилось новое животное, и мы хотим добавить его в зоопарк. Простой! Просто создайте новый класс и добавьте его в зоопарк. И мы в порядке. Возможно, в будущем, когда у нас появится больше животных, мы сможем делать то же самое.

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

class Horse: AnimalProtocol {
  func makeSound() -> String {
    return “Neigh”
  }
}
let horse = Horse()
zooAnimals = Zoo(animals: [tiger, horse])
zooAnimals.animalNoise() // [roar, neigh]

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

Дочерний класс не должен нарушать определения типов родительского класса

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

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

Пример кода

class Bird {
  func makeNoise() {
    print(“Chirp chirp”)
  }
}
class Eagle: Bird {
  override func makeNoise() {
    print(“Almight eagle roar”)
  }
}
// This is where it violates the Liskov Substitution as doing so will break the parent class
class Crow: Bird {
  override func makeNoise() {
    fatalError(“I forgot what my sound is”)
  }
}

Принцип # 4: разделение интерфейсов

Реализуйте только то, что вам нужно

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

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

Пример кода

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

protocol Action {
  func eat()
  func work()
}
class Adult: Action {
  func eat() {
    // adult eat
  }
  func work() {
    // adult work
  }
}
class Baby: Action {
  func eat() {
    // baby eat
  }
  func work() {
    // baby can’t work
  }
}

Приведенный выше код можно просто переписать, как показано ниже. Мы используем только то, что нам нужно.

protocol EatAction {
  func eat()
}
protocol WorkAction {
  func work()
}
class Adult: EatAction, WorkAction {
  func eat() {
    // adut eat
  }
  func work() {
    // adult work
  }
}
class Baby: EatAction {
  func eat() {
    // baby eat
  }
}

Принцип # 5: инверсия зависимостей

Полагаться на абстракции, а не на конкреции

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

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

Для большинства разработчиков iOS Firebase - любимый инструмент. Предположим, например, что Firebase закрывается завтра без предварительного уведомления. Мы должны иметь возможность быстро переключить его на Realm (например), чтобы легко развернуть наше приложение. Вся идея здесь заключается в возможности легко заменить нашу зависимость, избегая тесной связи.

Пример кода

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

class DatabaseController {
  private let networkRequest: NetworkRequest
  
  init(network: NetworkRequest) {
    self.networkRequest = network
  }
  func connectDatabase() {
    networkRequest.connect()
  }
}
class NetworkRequest {
  func connect() {
    // connect to the database
  }
}

Чтобы обойти это, вероятно, используйте protocol, чтобы избежать сильной связи.

protocol Database {
  func connect()
}

class DatabaseController {
  private let database: Database
  
  init(db: Database) {
    self.database = db
}
  func connectDatabase() {
    database.connect()
  }
}
class NetworkRequest: Database {
  func connect() {
    // Connect to the database
  }
}

Краткое резюме

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

  • У каждого класса должна быть только одна обязанность
  • Класс должен быть открыт для расширения, но закрыт для модификации
  • Дочерний класс не должен нарушать определения типов родительского класса.
  • Реализуйте только то, что вам нужно
  • Полагаться на абстракции, а не на конкреции