Принципы SOLID — это набор правил, которые каждый разработчик должен знать и применять при написании кода, потому что они уменьшают «запах кода», делают его более читабельным и помогают масштабировать проект в любое время с минимальным количеством ошибок.
Итак, что же это за принципы?
- Единственная ответственность
- Принцип открытости/закрытости
- Принцип подстановки Лисков
- Разделение интерфейсов
- Инверсия зависимостей
Итак, давайте подробно разберем каждое из этих важных правил.
1. Единственная ответственность
У класса должна быть только одна причина для изменения.
Это означает, что у класса должна быть только одна ответственность. Это не только поможет сделать ваш код более организованным, но и в больших командах сократит количество разработчиков, желающих внести изменения в ваш класс.
Пример:
class UserViewModel {
func getUser() -> User {
// fetch user from repo
let userData = fetchUserData()
// convert dto to UImodel
let decoder = JSONDecoder()
let userModel: User = try! decoder.decode(User.self, from: userData)
// save the user object to local storage
saveUserToDB(userModel)
return userModel
}
}
Итак, здесь есть пара ошибок.
Во-первых, на уровне классов этот класс выполняет три задачи: получение пользовательских данных, их анализ и сохранение в локальной базе данных.
Во-вторых, на уровне методов, обратите внимание на метод getUser(). Он выполняет три задачи:
- получение пользовательских данных из API
- анализ данных в соответствующую модель
- сохранение данных в базу данных
Другой разработчик даже может запутаться, увидев метод getUser и обнаружив, что он сохраняет объекты в базе данных.
Давайте перепишем этот класс, чтобы применить принцип единственной ответственности для исправления этих проблем:
class UserViewModel {
let repo = UserRepository()
let uiMapper = UserUIMapper()
let dbClient: DataBaseClient()
func saveUserModelToDataBase() {
let userData = repo.getUser()
let userModel = uiMapper.parseDataToJson(data: userData)
dbClient.saveDataToDB(users: userModel)
}
}
class UserRepository {
func getUser() -> Data {
//Send API request and wait for a response
}
}
class UserUIMapper {
func parseDataToJson(data: Data) -> User {
// parse the data and convert it to array
}
}
class DataBaseClient {
func saveDataToDB(users: [String]) {
// save that array into CoreData...
}
}
Теперь вы видите, что у каждого метода и каждого класса есть одна обязанность.
Также имя метода отражает обязанность метода.
Важно отметить, что одна обязанность не означает одну задачу; как видите, метод saveUserModelToDataBase() выполняет несколько задач, но все они связаны с одной и той же обязанностью.
2. Принцип открытости/закрытости
Этот принцип означает, что каждый написанный вами класс или фрагмент кода открыт для расширения (т.е. добавления новых функций), но закрыт для модификации (т.е. он окончательный и не требует добавления кода для адаптации к новому коду или бизнес-процессам).
Запутались? Давайте рассмотрим пример, чтобы лучше понять принцип.
enum PaymentType {
case creditCard
case cash
}
class PaymentGateway {
func processPayment(amount: Double, type: PaymentType) {
switch type {
case .cash:
payWithCash(amount: amount)
case .creditCard:
payWithCreditCard(amount: amount)
}
}
func payWithCash(amount: Double) {
//handle cash payment
}
func payWithCreditCard(amount: Double) {
//handle credit card payment
}
}
Пример выглядит хорошо, но что, если вас попросят добавить новый способ оплаты в класс PaymentGateway?
Вам придётся внести множество изменений, чтобы добавить новый тип оплаты. Кроме того, ваш класс не открыт для расширения и, конечно же, не закрыт для изменения, поскольку вам нужно вносить изменения для каждого типа оплаты, который вы хотите добавить в метод processPayment.
Давайте изменим класс, чтобы всё работало правильно.
protocol PaymentHnadler {
func handlePayment(with amount: Double)
}
class PaymentGateway {
var handlers: [String: PaymentHnadler] = [:]
func addHnadler(key: String, handler: PaymentHnadler) {
self.handlers[key] = handler
}
func processPayment(amount: Double, key: String) {
guard let handler = handlers[key] else {
fatalError("No Payment handler associated with the provided key")
}
handler.handlePayment(with: amount)
}
}
class CashHandler: PaymentHnadler {
func handlePayment(with amount: Double) {
//handle cash payment
}
}
class CreditCardHandler: PaymentHnadler {
func handlePayment(with amount: Double) {
//handle credit card payment
}
}
В данном случае вместо использования enum и switch для определения способа обработки платежа мы преобразовали обработку платежа в поведение внутри протокола, и каждый способ оплаты может реализовать этот протокол и предоставить реализацию.
Теперь добавление нового способа оплаты не повлияет на наш основной класс и не изменит его, поскольку мы создадим новый класс, реализующий наш протокол, и добавим его в словарь обработчиков.
Внеся это изменение, мы добавили возможность расширения нашего основного класса PaymentGateway, поскольку любой способ оплаты, который мы хотим добавить в словарь обработчиков, может просто реализовать наш протокол PaymentHandler, поэтому у нас нет ограничений на количество добавляемых типов платежей.
3. Принцип подстановки Лисков
Дочерний класс не должен изменять поведение своего родительского класса. Это означает, что если у вас есть родительская ссылка, указывающая на реализацию дочернего класса, вы не должны заметить разницы в конечном поведении.
Давайте рассмотрим пример нарушения этого правила и исправим его.
class User {
let firstName: String
let lastName: String
let age: Int
let address: String
init(firstName: String, lastName: String, age: Int, address: String) {
self.firstName = firstName
self.lastName = lastName
self.age = age
self.address = address
}
func getUserFullName() -> String {
return "\\(firstName) \\(lastName)"
}
}
class VIPUser: User {
override func getUserFullName() -> String {
return "\\(lastName) \\(firstName)"
}
}
Проблема здесь очевидна! Вы же не ожидаете, что возвращаемое значение метода getUserFullName дочернего класса VIPUser будет иметь другую реализацию, чем у обычного класса User. Это поведение должно быть унифицировано во всём приложении.
Решение также очевидно:
class User {
let firstName: String
let lastName: String
let age: Int
let address: String
init(firstName: String, lastName: String, age: Int, address: String) {
self.firstName = firstName
self.lastName = lastName
self.age = age
self.address = address
}
func getUserFullName() -> String {
return "\\(firstName) \\(lastName)"
}
}
class VIPUser: User {
func getFullUserData() -> String {
return "full name: \\(getUserFullName()), age: \\(age), address: \\(address)"
}
}
Обратите внимание, что дочерний класс может иметь новые методы или перегружать родительский метод, но самое главное, чтобы дочерний класс не вёл себя иначе, чем родительская реализация того же метода.
4. Разделение интерфейсов
Класс не следует заставлять реализовывать функциональность, которая ему не нужна. Другими словами, избегайте громоздких интерфейсов, содержащих множество методов, не связанных друг с другом.
Давайте рассмотрим неудачный пример:
protocol BaseViewModelProtocol {
var repo: Repository { get }
var errorHnadler: ErrorHnadler { get }
var router: Router { get }
var anlyticsManager: AnalyticsManager { get }
func getData()
func handleError()
func routeToScreen(route: String)
func logEvent(event: Event)
}
class WelcomeScreenViewModel: BaseViewModelProtocol {
var repo: Repository = Repository()
var errorHnadler: ErrorHnadler = ErrorHnadler()
var router: Router = Router()
var anlyticsManager: AnalyticsManager = AnalyticsManager()
func getData() {}
func handleError() {}
func routeToScreen(route: String) {
router.gotToRoute(route)
}
func logEvent(event: Event) {}
}
Посмотрите, как WelcomeScreenViewModel вынужден реализовывать функции, которые ему не нужны, поскольку у него нет вызова API и необходимости регистрировать какие-либо события.
Как можно улучшить его, применив разделение интерфейсов?
protocol BaseViewModelProtocol {
var router: Router { get }
func routeToScreen(route: String)
}
protocol BaseViewModelAPIProtocol {
var repo: Repository { get }
var errorHnadler: ErrorHnadler { get }
func getData()
func handleError()
}
protocol BaseViewModelEventsProtocol {
var anlyticsManager: AnalyticsManager { get }
func logEvent(event: Event)
}
class WelcomeScreenViewModel: BaseViewModelProtocol {
var router: Router = Router()
func routeToScreen(route: String) {
router.gotToRoute(route)
}
}
Как мы видим, WelcomeScreenViewModel реализует только необходимые функции в соответствии со своей ответственностью, а также не содержит пустых методов только потому, что они есть в базовом протоколе.
5. Инверсия зависимостей
Классы должны зависеть от абстракции, а не от фактической реализации. Другими словами, высокоуровневые классы не должны ничего знать о низкоуровневых классах, они должны зависеть от протоколов, а не от реальных классов.
Таким образом, наши классы будут слабосвязанными и их будет легче поддерживать.
Это одна из важнейших ролей, которая выделяет ваш код и значительно упрощает его поддержку другими разработчиками в вашей команде.
А теперь время для плохого примера:
class FavouriteOrders {
let onlineRepo: OnlineRepository()
let offlineRepo: OfflineRepository()
func getFavouriteOrders() -> [Order] {
if connectedToTheInternet() {
return onlineRepo.getFavouriteOrders()
} else {
return offlineRepo.getFavouriteOrders()
}
}
}
Видите ли вы здесь проблемы?
1. Логика здесь непонятна, поскольку извне класса при вызове метода getFavouriteOrders() вы не знаете, какой тип заказов получите.
2. Что, если мы захотим добавить ещё одну стратегию для получения избранных заказов или для объединения локальных и удалённых сохранённых заказов? Для этого нам придётся внести множество изменений в наш класс, чтобы удовлетворить бизнес-требования.
3. Применить модульное тестирование к этому классу будет очень сложно, поскольку мы не сможем предоставить фиктивный класс с предопределёнными данными для тестирования.
Итак, давайте посмотрим, что можно сделать, чтобы всё исправить:
protocol RepoProtocol {
func getFavouriteOrders()
}
class FavouriteOrders {
let repo: RepoProtocol
init(repo: RepoProtocol) {
self.repo = repo
}
func getFavouriteOrders() -> [Order] {
repo.getFavouriteOrders()
}
}
class onlineRepo: RepoProtocol {
func getFavouriteOrders() {
// get orders from api
}
}
class offlineRepo: RepoProtocol {
func getFavouriteOrders() {
// get orders from local storage
}
}
class combinedRepo: RepoProtocol {
func getFavouriteOrders() {
// get orders from local storage + api
}
}
В данном случае класс FavouriteOrders ничего не знает о реализации внедренного репозитория.
Любой класс, реализующий протокол RepoProtocol, можно внедрить в класс FavouriteOrders и получить данные в соответствии с его типом.
Ещё один важный момент: теперь этот класс проще тестировать, поскольку мы можем предоставить фиктивный файл, реализующий RepoProtocol, и протестировать поведение класса FavouriteOrders.
На этом всё. Надеюсь, вам понравилась эта статья.

