ООП не существует без классовой инкапсуляция
Для начала ответьте мне на вопрос: какой пример (1 или 2) использует ООП?
// пример #1 class Email { public id: string public value: string public isActivated: boolean public main: boolean } class User { public id: string public password: string public emails: Email[] } class UserService { registerNewUser(password: string, email: string) { const mainEmail = new Email( new UUID(), email, false, true, ) const user = new User( new UUID(), hashPassword(password), [mainEmail] ) return user } changeUserEmail(user: User, newEmail: string) { const oldMainEmail = user.emails.find(email => email.main) oldMainEmail.main = false const newMainEmail = new Email( new UUID(), newEmail, false, true, ) user.emails.push(newMainEmail) } } // пример #2 function Email ( id: string, value: string, isActivated: boolean, main: boolean ) { const state = { id, value, isActivated, main } return { makeMain() { state.main = true }, unmain() { state.main = false }, isMain() { return state.main } } } function User = ( id: string, password: string, emails: Email[], ) { const state = { id, password, emails, } const getMainEmail = () => { return state.emails.find(email => email.isMain()) } return { changeUserEmail(email: string) { const oldMainEmail = getMainEmail() oldMainEmail.unmain() const newMainEmail = Email( new UUID(), newEmail, false, false, ) newMainEmail.main() user.emails.push(newMainEmail) } } } function registerNewUser = (password: string, email: string): User => { const mainEmail = Email( new UUID(), newEmail, false, false, ) mainEmail.main() const user = User( new UUID(), hashPassword(password), [mainEmail] ) return user }
Хоть я здесь и упростил некоторые детали, но можно с уверенностью говорить, что если вы ответили: «1» – то у меня для вас 2 новости, хорошая и плохая:
- Плохая – вы не понимаете, что такое ООП
- Хорошая – возможно все это время вы использовали нечто похожее на ФОП
Этот код правда использует “ООП синтаксис”, но вот только он совершенно не соответствует “ООП методологии”, которую мы обсуждали в предыдущей главе.
Каноничная ООП методология
Основная идея каноничного ООП:
“Программа состоит из “объектов”, инкапсулирующих данные и поведение и общающихся сообщениями”
Проблема примера #1 в том, что «объекты»
User
и Email
абсолютно ничего не инкапсулируют, ни данные, ни поведение. Они просто являются носителями публичных данных, это обычные POJO / DTO / Record / Struct, а поведение существует вообще отдельно ...Service
.Если говорить, про каноничные ООП, то каждый из этих «объектов» должен скрывать данные и отдавать наружу только разрешённое поведение:
class User { // Для начала скрываем все свойства, потому что // публичные свойства в ООП – bad practise private id: string private password: string private emails: Email[] // Опять же, логику я не дописываю, но ее можно интуитивно понять private getMainEmail() { // ... } static registerNewUser(password: string, email: string): User { // ... } changeUserEmail(user: User, newEmail: string) { // ... } } class Email { private id: string private value: string private isActivated: boolean private isMain: boolean makeMain() { // ... } unmain() { // ... } }
Теперь каждый объект инкапсулирует все относящиеся к нему данные и поведение. При этом, чтобы дать возможность “объектам общаться” мы создаем граф связей, благодаря которым “объекты” могут вызывать методы друг друга, передавая “сообщения”, как аргументы.
Этот подход еще называется Fat / Rich Model и именно он является каноничным для ООП.
Здесь же мы можем понять, что пример #2, хоть и не использует синтаксис ООП, по своей сути отвечает нашему представлению каноничного ООП, потому что инкапсулирует данные и поведение и имеет связи объектами (перечитайте пример #2 еще раз).
Это тот самый пример, который показывает, что использование “синтаксиса ООП” ≠ “ООП методологии”.
Но почему мы так не делаем?
Самые основные причины 2:
Мы не знали что так надо делать
Вы можете увидеть огромное количество кода в интернете, который заявляет свою причастность к ООП, но при это повсеместно допускает публичные свойства классов и операции над ними вне этого класса.
Но просто задумайтесь: мы все это время говорим, что самая сильная сторона ООП – это инкапсуляция и при этом делаем наши свойства публичными и даже доступ на изменение вне класса? Разве это инкапсуляция?
Мы начинаем учиться и передавать знания следующим разработчикам, основываясь на чужих работах, которые в свое время не до конца разобрались в вопросе и создали искажение, которое передается по принципу “испорченного телефона”
Cross Cutting Concern
Проблема, с который сталкивается абсолютно каждый разработчик на абсолютно любом проекте: что если мы не можем выбрать с какого «объекте» начать писать логику, потому что она равноправно затрагивает сразу несколько «объектов»?
Например, у нас есть Курьер, Газета и Покупатель. Нам нужно написать логику «купли-продажи газеты». Будем ли создавать функцию
sellPaper
у Курьера или создадим функцию buyPaper
у Покупателя?Большинство решит сделать отдельный класс
SellPaperService
и передавать туда Курьера и Покупателя, оперируя над их свойствами / методами. Но SellPaperService
это не «объект», у него нет состояния, он всего лишь функция, написанная в ООП-ном синтаксисе, которая оперирует над «объектами» чего в ООП архитектуре быть не должно.А возможно ли следовать каноничному ООП?
Да. Для этого вам придется следовать SOLID, использовать все ООП паттерны (то есть добавить тонну абстракций) и, по-хорошему, использовать Domain Driven Design (DDD).
Если пресловутый SOLID и паттерны более известны, то DDD для многих остается загадкой, а поскольку эта книга не про DDD я поверхностно перечислю вам правила, которые нужно в нем соблюдать:
- Вместо Model, тут используется понятие Агрегат (Aggregate), который является более строгой Model
- Агрегат состоит из Сущностей (Entity), имеющих id, содержащие на другие Сущности (Entity) и неизменяемые Объекты значений (Value Object)
- Самая верхняя Entity в Агрегате, содержащая другие Entity и Value Object, называется Root Entity
- Вся логика приложения находиться в Агрегатах, который оперирует над Entity и Value Object
- Агрегаты могут иметь ссылки друг на друга, но только по ID
- Агрегаты не имеют право ссылаться на Entity и Value Object друг друга, только на Root Entity
- 1 бизнес-процесс (считайте endpoint API) === 1 метод Агрегата
- 1 метод Агрегата === 1 транзакций
- Вы должны доставать Агрегат из БД полностью, перед тем, как производить логику и сохранять также полностью под конец логики
И это только вершина айсберга, потому для работы всего этого туда же докидывается Clean Architecture. Если хотите погрузиться в DDD (чего я вам не советую), то можете почитать The Red Book от Vaughn Vernon, а потом догнаться The Blue Book от Eric Evans.
Все это приводит нас к тому, что вся программа делится на Аггрегаты, которые содержат в себе кучу объектов (Entity / Value Object), при этом из-за древовидности Агрегатов и правила сылки граф нашего приложения остается достатчно чистым, а для одного бизнес-процесса достаточно запустить один метод конкретного Аггрегата, потому что он будет содержать все нужные данные.
И чтобы достигнуть этого нам потребуется провести месяцы в изучении DDD, привнести в код невероятное количество абстракций, провести часы за проектированием подходящих Аггрегатов и в 2 раза больше времени на рефакторинг, когда новые фичи потребуют новых связей между существующими Аггрегатами и все это во имя поддержания каноничного ООП.
“А что тогда я все это время использовал?”
Ок, если каноничный ООП это умные «объекты», у которых все свойства приватные и все бизнес-процессы начинаются с вызова метода одного из них, то тогда что все это время использовал я и куча других разработчиков?
Предположу, что вы делали нечто следующее:
Вы писали классы своих
Models
, делали как минимум половину свойств публичными (если не все) + добавляли им некоторое количество методов бизнес-логики, но как только что-то становилось неудобно описывать в самой модели, для этого вы создавать какой-нибудь …Service
, куда выносили оставшуюся.Примерно так:
class User { public id: string public password: string public emails: Email[] getMainEmail() { // ... } changeEmail(user: User, newEmail: string) { // ... } } class Email { public id: string public value: string public isActivated: boolean public main: boolean } class UserService { registerNewUser(password: string, email: string) { // ... } changeUserEmail(user: User, newEmail: string) { // ... } deleteUser(user: User) { // ... } }
Интересно то, что в мире ООП паттерн с вынесением части логики наружу даже имеет название: Slim / Anemic Model, что на деле ближе к процедурному программированию, чем к ООП.
Это название появилось потому что программисты слишком часто сталкиваются с кейсами, когда им неудобно добавлять поведение на модели, поэтому им приходится создавать
…Service
классы, делать свойства публичными и оперировать над моделями через них.Ну а в чем проблема?
Получается что у нас с вами остается всего 3 пути:
- Использовать каноничный ООП
- Воспользоваться гибридом ООП с каким-нибудь функциональным или процедурным программированием
- Не использовать ООП вообще
В первом случае, вы получите невероятно быстро растущую “искуственную сложность” (accidental complexity) кода, то есть усложнение программы, выдуманными разработчиком абстракциями, которые уже могли изжить себя (даже корневые столпы ООП уже давно подвергаются критике).
И вместо решения задач, будете часами сидеть и пытаться понять за что отвечает каждая из этих абстракций и какую новую вам надо создать, чтобы это придерживалось ООП.
Во втором же случае, вы потеряете преимущества ООП, потому что любая другая парадигма требует нарушения инкапсуляции, но при этом вы получите все недостатки и большинство абстракций ООП, превращая код в мешанину (погуглите OOP Anemic Model и поймете почему его называют anti-pattern в контексте ООП).
А вот третий… третий самый хитрый пункт… но не буду забегать вперед и дам вам возможность продолжить читать дальнейшие главы, где мы будем подводить итоги.
Что дальше
Ок, предположим, что я вас не убедил и вы по-прежнему считаете, что используете все это время вполне себе каноничный ООП.
Я как минимум хотел подсветить вам, что у вашего подхода есть название (Slim / Anemic Model) и что с ним вы уходите от устоев ООП (как минимум полноценной инкапсуляции).
В следующей главе, я хочу рассказать вам о проблемах, с которыми вы столкнётесь, не важно какой стиль ООП вы используете, и единственным решением которых будет перестать использовать ООП как таковое:
Полезные ссылки
👈 Предыдущая глава