Часто я встречаю разработчиков, которые пишут код на объектно-ориентированном языке программирования, но не понимают принципов ООП. Это могут быть начинающие девелоперы, которые еще на собеседованиях сталкиваются с проблемами объяснения принципов. А также это могут быть, казалось бы, опытные программисты, которые не понимают принципов, заложенных в язык программирования, на котором они пишут. Второй случай хотелось бы встречать реже, но на практике это не так. Часто разработчики смотрят на наследование или полиморфизм, как на особенности языка, как на какой-то технический инструмент и не думают, о вещах, которые лежат в основе этих механизмов.
Все, что будет изложено ниже — сугубо мои размышления, я не претендую на статус истины в последней инстанции и не жду, что все примут мою точку зрения. Я надеюсь, эта статья натолкнет на размышления и даст толчок к развитию собственного понимания у каждого читателя.
Примеры кода буду приводить из iOS разработки.
Я считаю, если ты пишешь код на объектно-ориентированном языке программирования, ты обязан не только знать определения, но и понимать суть, которая вложена в эту парадигму.
На мой взгляд, если функциональное или структурное программирование — принципы которые больше относятся к написанию именно кода, то ООП это уже не про код, а моделирование сложных систем. Наш мозг воспринимает мир как набор объектов, которые взаимодействуют друг с другом. Если функция — просто действие без контекста, то метод класса это уже действие в определенном контексте, это действие, которое относится к определенному объекту. И система приобретает вид взаимодействия различных объектов между собой. Таким образом, ООП позволяет сделать описание системы более понятным для восприятия.
Также заранее хочу добавить, это описание идеального сферического программирования в вакууме и в реальности множество вещей нарушаются в угоду практичности. Но стремление к идеалу только улучшит качество кода. И не стоит забывать, что гонка за крайностью — тоже плохо.
Наследование
Что такое программирование в целом — это написание детальной инструкции, которую должна выполнить машина. К тому же эта инструкция должна быть понятной как машине, так и человеку, который будет вносить изменения в инструкцию в будущем.
Так как ООП — это про моделирование, то код мы пишем начиная с абстракции частей системы и взаимодействия между этими частями, которые мы должны записать в виде кода. Например, социальная сеть, которая состоит из пользователей, взаимодействующих друг с другом. Помимо пользователей, система состоит из более мелких компонентов, таких как сообщения, посты, лайки, комментарии. Даже сам пользователь может являть собой подсистему в системе. Как человек состоит из различных органов и частей тела: сердце, мозг, руки, пальцы, так и пользователь в системе может состоять из более мелких составляющих. Но уже не руки или глаза, а адрес, интересы, записи об образовании, которые можно выносить как отдельные объекты. Уже на этапе анализа можно проследить принципы объектно-ориентированного подхода. У нас есть пользователь — абстракция реального человека. Но у пользователя могут быть разные роли: админ, обычный пользователь, VIP пользователь, анонимный посетитель. Они все являются абстракциями реальных людей и пользователями данной системы. Но каждая из вышеперечисленных ролей имеет свои особенности и при этом все имеют общее — они пользуются системой и должны зарегистрироваться в системе (у каждого может быть свой способ) и они все должны пройти процедуру входа в систему.
Это и есть принцип наследования, где каждый админ/VIP-клиент/аноним являются пользователями, но не каждый пользователь должен быть админом или VIP-пользователем.
Неправильное понимание принципа приводит к ошибкам в коде. Типичная ошибка — если есть общие поля или методы, значит нужно делать базовый класс, хотя классы наследники по логике не имеют ничего общего.
Еще пример ошибочной трактовки принципа наследования, это когда базовый класс и наследник являются представителями разных логических групп. Выглядит это следующим образом, реализуем MVC в iOS проекте, где UIViewController это Controller с абстрактными методами, которые должен реализовать наследник. А наследник — это уже Model. Там где по логике проектирования должно быть взаимодействие между двумя группами классов, один класс становиться одновременно и Model и Controller. Не говорим уже о том, что UIViewController в реалиях iOS разработки еще и берет на себя роль View. В итоге мы получаем один объект, который делает все сам. Если у нас есть пользователь (User), то он будет и Controller и View одновременно.
/**
Нужно сделать экран профиля пользователя, в котором отображаются имя и фамилия. Этот экран должен переиспользоваться.
*/
// INCORRECT
class UserProfileViewController: UIViewController {
// MARK: - IBOutlets
@IBOutlet private var firstNameLabel: UILabel!
@IBOutlet private var lastNameLabel: UILabel!
/**
Заполнение данных оставляем классу наследнику в виде абстрактных методов
*/
// MARK: - Abstract methods
func firstName() -> String? {
return nil
}
func lastName() -> String? {
return nil
}
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
firstNameLabel.text = firstName()
lastNameLabel.text = lastName()
}
}
/**
Создаем класс-наследник, который отвечает за функцию заполнения данных. В таком случае, наследник будет выполнять роль не только UIViewController, а и роль модели, которая предоставляет данные для отображения.
*/
class UserProfileModel: UserProfileViewController {
override func firstName() -> String? {
return "Name"
}
override func lastName() -> String? {
return "Last name"
}
}
// CORRECT
/**
Корректней будет, добавить новый класс-модель, которая будет предоставлять данные.
*/
class UserProfileModel {
func firstName() -> String? {
return "Name"
}
func lastName() -> String? {
return "Last name"
}
}
/**
В таком случае у нас будут два отдельных класса, каждый из которых имеет свою зону ответственности.
*/
class UserProfileViewController: UIViewController {
var model: UserProfileModel?
// MARK: - IBOutlets
@IBOutlet private var firstNameLabel: UILabel!
@IBOutlet private var lastNameLabel: UILabel!
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
setupUserInfo()
}
// MARK: - Private
private func setupUserInfo() {
firstNameLabel.text = model?.firstName()
lastNameLabel.text = model?.lastName()
}
}
// PERFECT
/**
Еще лучше, взаимодействие между двумя типами классов, контроллер и модель, сделать через протокол, чтобы можно было создавать и использовать разные модели.
*/
protocol UserProfileProtocol {
func firstName() -> String?
func lastName() -> String?
}
class UserProfileViewController: UIViewController {
var model: UserProfileProtocol?
// MARK: - IBOutlets
@IBOutlet private var firstNameLabel: UILabel!
@IBOutlet private var lastNameLabel: UILabel!
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
setupUserInfo()
}
// MARK: - Private
private func setupUserInfo() {
firstNameLabel.text = model?.firstName()
lastNameLabel.text = model?.lastName()
}
}
class UserProfileModel: UserProfileProtocol {
// MARK: - UserProfileProtocol
func firstName() -> String? {
return "Name"
}
func lastName() -> String? {
return "Last name"
}
}
Представляю эту картину в реальной жизни, у нас есть регистратура, где некий администратор ведет записи о посетителях в тетрадку. Также в этой тетради можно читать данные о посетителях. Мы получаем посетителя — Model, человека в регистратуре — Controller и тетрадь View. И в вышеуказанной интерпретации принципа наследования мы получаем, что посетитель всегда является регистратором и тетрадкой. Уже дико выглядит то, что человек и тетрадка одно целое. И логика наследования нарушается. Если рассматривать, что посетитель и регистратор — люди и принять тот факт, что представитель класса регистратора может стать посетителем, то логичней сказать, что регистратор является наследником посетителя.
Это пример, когда непонимание принципа наследования приводит к сложности понимания системы.
По принципу наследования, базовый класс содержит общие свойства для некоторой группы наследников, которая связана общим логическим смыслом. Если наследники не имеют общего смысла, а просто имеют случайные общие свойства, то скорей всего наследование неправильно реализовано.
Абстракция
Я уже затронул принцип наследования, хотя хотелось бы начать с такого принципа как абстракция. На мой взгляд это базовый принцип ООП (также абстракция относится и к остальным парадигмам) и он незаслуженно перешел в разряд опционального принципа.
Абстракция гласит — останавливаем внимание на важных и необходимых аспектах объекта и игнорируем ненужные для нас.
Как это выглядит: когда мы описываем что-то, мы упоминаем только о тех вещах, которые важны в нашем повествовании. Например, когда парень рассказывает другу о том, как в салоне видел крутую машину, он говорит о важных для них вещах: мощность двигателя, систему тормозов, диаметр колес. Хотя особенностей автомобиля безграничное множество. В своем разговоре ребята не упоминают о молекулярном составе автомобиля, хотя такая характеристика определенно существует у физического тела. Незнание законов физики заставляет упустить такой показатель как сила трения между определенными деталями. Даже дело не в образовании, в данном разговоре все эти детали не важны, они упускаются.
В этом примере используется модель описания автомобиля с набором только необходимых качеств.Так же и в программировании. Когда будет создаваться мобильное приложения для продажи автомобилей, программисту определенно нужно будет описать модель этого автомобиля. Естественно разработчик не будет писать модель у которой 100500 полей и методов.
Наверно пренебрежением этим принципом в реальной разработке, является добавление ненужных методов и свойств классу. Впоследствии, получаем непонимание и нарушение принципа единственной ответственности с SOLID.
Также к абстракции я бы отнес декомпозицию, когда сложный объект разбивается на систему. Мы абстрагируемся от некоторых особенностей и переносим их в отдельный компонент. Пример: пользователь у которого есть место проживания, то есть адрес. Адрес в свою очередь состоит из города, улицы, номера дома и т. д. В этот момент мы думаем, а нужно ли указывать страну или регион? Если это приложения для пользования администрацией конкретного района города, то можно упустить такие детали. В итоге мы получаем пользователя, который абстрагируется от некоторых деталей адреса. Опять-таки, непонимание того, что мы не только пишем код, но и занимаемся моделированием, приводит к тому, что у нас есть, допустим, MenuViewController, который состоит из 5000+ строк кода.
/**
Распространенная ситуация: создаем класс, к примеру, простую модель пользователя. Но с добавлением функционала, все больше появляется полей и методов в этом классе.
*/
// INCORRECT
class User {
let firstName: String
let lastName: String
let fullName: String
let age: Int
let birthday: Date
let street: String
let postalCode: Int
let city: String
var phoneNumber: String?
var phoneCode: String?
var phoneFlag: UIImage?
var isLoggined: Bool = false
var isAdmin: Bool = false
// MARK: - Init
init(firstName: String,
lastName: String,
fullName: String,
age: Int,
birthday: Date,
street: String,
postalCode: Int,
city: String) {
self.firstName = firstName
self.lastName = lastName
self.fullName = fullName
self.age = age
self.birthday = birthday
self.street = street
self.postalCode = postalCode
self.city = city
}
// MARK: - Admin functionality
func createNewReport() {
guard isAdmin else { return }
print("New report created")
}
func updateReport(for user: User) {
guard isAdmin else { return }
print("Update report for \(user.fullName)")
}
}
// CORRECT
/**
Правильней будет, декомпозировать код, абстрагируя части большого сложного класса на маленькие компоненты.
*/
class Address {
let street: String
let postalCode: Int
let city: String
init(street: String,
postalCode: Int,
city: String) {
self.street = street
self.postalCode = postalCode
self.city = city
}
}
class Name {
let firstName: String
let lastName: String
init(firstName: String,
lastName: String) {
self.firstName = firstName
self.lastName = lastName
}
var fullName: String {
firstName + " " + lastName
}
}
class PhoneNumber {
let phone: String
let code: String
let flag: UIImage
init(phone: String,
code: String,
flag: UIImage) {
self.phone = phone
self.code = code
self.flag = flag
}
}
class User {
/**
В результате, класс User уменьшился в размерах, при этом мы абстрагируемся от деталей имени и адреса.
*/
let name: Name
let address: Address
let birthday: Date
var phoneNumber: PhoneNumber?
init(name: Name,
address: Address,
birthday: Date) {
self.name = name
self.address = address
self.birthday = birthday
}
}
/**
Так как после логина система получает залогиненого Пользователя, то класс User не должен отвечать за состояния системы. За статус логина будет отвечать новая сущность, тем самым система абстрагируется от деталей логики этого статуса.
*/
class LoginSession {
var user: User?
var isLoggined: Bool {
user != nil
}
}
/**
Дополнительные свойства Администратора выносяться в класс-наследник Пользователя.
*/
class Admin: User {
func createNewReport() {
print("New report created")
}
func updateReport(for user: User) {
print("Update report for \(user.fullName)")
}
}
Полиморфизм
Следующим стоило бы написать об инкапсуляции, принцип, наверное, самый спорный и интересный. Так что я его оставлю на закуску. Наследование уже упомянул, так что можно перейти к полиморфизму.
Полиморфизм плавно вытекает из наследования. Гласит он следующее: можно создавать классы наследники, которые будут имитировать интерфейс базового класса, но со своей собственной реализацией. Этот принцип отражается в таком принципе SOLID как принцип Барбары Лисков: мы можем подставлять объекты классов наследников там, где предполагается использование базового класса, при этом замена не должна никак себя проявлять.
Если взять пример с регистратурой, когда у нас есть просто человек (посетитель, не будем абстрагироваться и делать посетителя наследником человека) и есть регистратор. Должна быть возможность регистратору из другого отдела пройти через текущую регистратуру как обычному посетителю. В жизни это вполне реальный пример.
Пример в коде: множество наследников UIViewController, которые пушатся, презентятся и добавляются в UITabBarController как обычные UIViewController.
Как по мне, самый простой принцип. Но он довольно часто нарушается, когда класс наследник превращается в что-то новое и его уже нельзя использовать там, где использовался базовый класс. Также к нарушению относятся многочисленные опциональные методы и поля, которые в ходе неправильного наследования не нужны классу-наследнику. В этот момент, когда предполагается выполнение определенного метода базового класса, ничего не происходит. В лучшем случае ничего не произойдет, но может получиться так, что приложение либо неадекватно начинает себя вести либо крашится вовсе.
Полиморфизм — когда наследники делают все по своему, но результат работы такой же как у базового класса. Если наследник занимается чем-то своим и не дает результат такой, который ожидается от базового класса — значит с наследованием и полиморфизмом что-то не так.
Пример: есть базовый класс автомобиль, который предположительно должен заехать в гараж. Создаем летающий автомобиль-наследник (как в фильме «Назад в будущее 2»). Какой будет результат при попытке загнать этого монстра в гараж? Да, он может залететь в гараж, если функция езды будет полностью заменена на функцию полета. А если функция полета была новым функционалом, а функция езды вообще заблокирована? То мы не сможем спрятать нашего летуна от непогоды. Это уже не будет автомобиль, это будет что-то новое. Результат — из-за неправильного моделирования и наследования был нарушен принцип полиморфизма и получился нежелательный результат.
Добавлю, что полиморфизм тесно связан с наследованием и проблемы в наследовании отзываются еще большими проблемами в полиморфизме.
Инкапсуляция
И вот пришла очередь инкапсуляции. Ходит много дискуссий, что же собой представляет инкапсуляция. И существует как минимум два определения:
Инкапсуляция — это сокрытие методов и полей класса, которые не нужны при использовании объектов этого класса.
Инкапсуляция — это обьединение данных и методов, которые обрабатывают эти данные.
Первый вариант иногда воспринимается как инкапсуляция = сокрытие, что, как я считаю, не совсем верное понимание.
Для меня инкапсуляция — это проектирование класса таким образом, чтобы скрыть те методы и поля, которые могут нарушить логику работы класса, заложенную в этот класс. То есть, инкапсуляция != сокрытие. Сокрытие это механизм, который попросту скрывает часть возможностей класса, тогда как инкапсуляция отвечает за то, какие возможности будут доступны, а какие могут нарушить абстракцию этого класса и, соответственно, должны быть скрыты.
К примеру, я разрабатываю кнопку, которая в момент нажатия становится немного светлее. Проще говоря, когда кнопка в состоянии “нажата”, цвет фона становится светлее.
Для этого я создаю класс наследник UIButton и в наследнике добавляю метод, который устанавливает цвет кнопки в цвет с альфа каналом 50% от оригинального. А также я добавляю метод, который возвращает бэкграунд цвет кнопки в оригинальный, без альфа канала.
Если эти два метода будут доступны при использовании моей кастомной кнопки, то может нарушиться поведение этой кнопки. К примеру в момент нажатия можно вызвать метод, который устанавливает оригинальный цвет. Или же вдруг сделать кнопку светлее, как будто она нажата, хотя это не так.
/**
Создаем кнопку, у которой при нажатии цвет бэкграунда устанавливается в оригинальный цвет но с альфаканалом 0,5
*/
// INCORRECT
class Button: UIButton {
/**
Добавляем два метода, которые устанавливают цвет бекграунда для состояния нажатой кнопки и нормального состояния кнопки
*/
func decorateSelected() {
backgroundColor = backgroundColor?.withAlphaComponent(0.5)
}
func decorateDeselected() {
backgroundColor = backgroundColor?.withAlphaComponent(1)
}
override var isSelected: Bool {
didSet {
if isSelected {
decorateSelected()
} else {
decorateDeselected()
}
}
}
}
// SAMPLE
/**
Проблемой будет то, что методы, декорирующие кнопку в разных состояних, являются публичными. А это значит, что можно нарушить логику работы кнопки, вызвав метод в неправильный момент.
*/
let button = Button()
button.decorateSelected()
// CORRECT
class Button: UIButton {
override var isSelected: Bool {
didSet {
if isSelected {
decorateSelected()
} else {
decorateDeselected()
}
}
}
/**
Мы сделали методы, настраивающие внешний вид кнопки, приватными, тем самым обеспечили правильную логику отображения.
*/
// MARK: - Private
private func decorateSelected() {
backgroundColor = backgroundColor?.withAlphaComponent(0.5)
}
private func decorateDeselected() {
backgroundColor = backgroundColor?.withAlphaComponent(1)
}
}
// PERFECT
/**
Но! У кнопки остаеться возможнось измененить цвет через базовое поле var backgroundColor: UIColor?. Поэтому, немного заморочившись, делаем невозможным менять цвет в момент, когда кнопка нажата.
*/
class Button: UIButton {
override var backgroundColor: UIColor? {
get {
super.backgroundColor
}
set {
if isHighlighted == false {
super.backgroundColor = newValue
}
}
}
override var isHighlighted: Bool {
willSet {
if newValue {
decorateSelected()
}
}
didSet {
if isHighlighted == false {
decorateDeselected()
}
}
}
// MARK: - Private
private func decorateSelected() {
backgroundColor = backgroundColor?.withAlphaComponent(0.5)
}
private func decorateDeselected() {
backgroundColor = backgroundColor?.withAlphaComponent(1)
}
}
Инкапсуляция — не бездумное сокрытие каких-то полей или методов, а проектирование класса с определенным набором возможностей, которые не должны нарушаться.
По поводу объединения данных и методов, которые обрабатывают эти данные, я с этим согласен, но с оговоркой. В моем понимании ООП — моделирование объектов в коде, что само собой подразумевает, наличие состояния и действий у этого объекта.
Если просуммировать, то инкапсуляция — проектирование самостоятельной единицы (объекта), которая выполняет некую роль в системе, имеет набор параметров и методов. При этом все, что может нарушить роль этого объекта, скрыто.
Заключение
Что в целом я хотел сказать? Я считаю, что программирование сложных систем, которые состоят из множества компонентов, начинается с моделирования, а не кодинга. А для моделирования лучше всего подходит ООП парадигма, так как она вобрала в себя принципы, позволяющие упростить моделирование и дальнейшее написание кода. Поэтому правильное понимание принципов ООП ключ к грамотному моделированию. В свою очередь хорошо спроектированная система проще переноситься в код и этот код проще поддерживается в будущем.
eandr_67
ООП через наследование — всего лишь самый модный из множества разных вариантов ООП. И ООП через композицию обеспечивает такую же мощность кода, но без геморроя, вызванного наследованием.
А то, что полноценное наследование — большой геморрой, стало понятно после появления C++: мало того, что понимание работы класса требует изучить весь граф предков, так ещё и внесение изменений в один из классов-предков может самым неожиданным образом сломать работу потомков — код становится крайне хрупким.
Именно потому в последующих ООП-языках наследование было предельно кастрировано (до линейного списка предков, что сгладило, но никак не решило проблемы), а основой полиморфизма стали интерфейсы, не создающие вышеназванных проблем.
Единственная реальная причина использования наследования в современных ООП-языках — уменьшение дублирования кода. Но и это можно решить без наследования: например, через типажи (trait) и обобщённое программирование (generic).
P.S. Для моделирования многокомпонентных систем лучше всего подходит парадигма компонентно-ориентированного программирования. ООП-же лишь имитирует модульность.
adictive_max
anton19286
Ну почему же. Задача ровно та же самая, переиспользование кода.
А будет там бойлерплейт или нет — зависит от языка. На плюсах будет, да.
Но даже на них я предпочту избыточность неопределенности.
AnthonyMikh
Если этот бойлерплейт генерируется, скажем, макросами, то не будут.
fireSparrow
Я правильно понимаю — вы предлагаете использовать макросы для генерации бойлерплейт-кода, чтобы эмулировать наследование, лишь бы не использовать настоящее наследование?
VolCh
Наследование нужно для моделирования отношений "является" между объектами и классами.
tmnhy
Зря вы так.
Вы не поверите, но в «начале» в ООП вообще не было никаких классов. Классы появились после появления отдельных реализаций парадигмы.
И в этих реализациях наследование описывает иерархию классов и только. Причём здесь отношения между классами и объектами?
ArsenAbakarov
Отношения играют роль, потому что как показывает моя практика (не мировая статистика, но все же), если связать наследованием классы только по общему поведению, то потом непременно в дочернем классе какой-то метод родителя делает не то что нужно и его нужно переопределять полностью, либо глушить (а тут уже SOLID нарушился), кое кто выделяет базовые классы, но такая иерархия однобокая и создана она лишь для удовлетворения нужд одного дочернего класса. Это потом приведет к проблеме, если появится еще один ребенок (а он появится, поверьте). Но тут есть проблема пострашнее, если изменятся безнес требования, и их нужно будет внести в базовый класс, вот тут вы и поймете что жизнь — боль, особенно если дочерних классов уже много. Вобщем и отношение «является» не всегда серебряная пуля, я бы тут наследовался если бы понимал что оно «является» и класс по моей бизнес модели уточняющий. Иначе я бы предпочел композицию.
VolCh
В каком "начале"?
Это уже не начало?
Alex_ME
Это можно сделать с помощью интерфейсов или трейтов. Более того, так можно делать несколько отношений "является" без типичных проблем множественного наследования.
VolCh
Можно, кто же спорит, но как по мне код типа
более читабельный и поддерживаемый в большинстве случаев чем
Alex_ME
Зато вы можете иметь несколько независимых интерфейсов с методами по-умолчанию или трейтов, которые реализуют поведение, и имплементировать их одновременно. И при этом быть уверенными, что с множественным наследованием проблем не будет — компилятор не позволит
VolCh
Это язык такие фичи должен поддерживать. Не во всех даже простые интерфейсы есть, а если есть, то, например, поддерживают только методы