Предыдущие части:
Мы продолжаем рассматривать подходы к тому, чтобы сделать наш код чистым, гибким и удобным в сопровождении. А теперь давайте начнем исследовать чистые архитектурные решения.
Принципы SOLID были представлены Робертом К. Мартином и широко считаются лучшими практиками в объектно-ориентированном программировании и разработке программного обеспечения.
В этой статье мы рассмотрим три первых принципа SOLID: принцип единой ответственности, принцип открытости/закрытости и принцип замены Лискова на практических примерах.
У класса должна быть только одна причина для изменения
Другими словами, у класса должна быть одна обязанность или работа. Это важно, поскольку класс с единственной ответственностью легче понять, изменить и поддерживать. Изменения в одной области кода не будут распространяться на всю систему, что снижает риск появления ошибок.
Давайте рассмотрим практический пример и способы соблюдения принципа единой ответственности:
// Bad class UserSettingsService { constructor(user: IUser) { this.user = user; } changeSettings(settings: IUserSettings): void { if (this.isUserValidated()) { // ... } } getUserInfo(): Promise<IUserSettings> { // ... } async isUserValidated(): Promise<boolean> { const userInfo = await this.getUserInfo(); // ... } }
В этом примере наш класс выполняет действия в разных направлениях: устанавливает контекст, изменяет его и проверяет его.
Чтобы следовать SRP, нам необходимо разделить эти разные обязанности.
// Better class UserAuth { constructor(user: IUser) { this.user = user; } getUserInfo(): Promise<IUserSettings> { // ... } async isUserValidated(): boolean { const userInfo = await this.getUserInfo(); // ... } } class UserSettings { constructor(user: IUser) { this.user = user; this.auth = new UserAuth(user); } changeSettings(settings: IUserSettings): void { if (this.auth.isUserValidated()) { // ... } } }
SRP направлен на повышение модульности кода, сводя к минимуму проблемы, возникающие из-за взаимозависимостей. Организуя код в функции, отдельные классы и продвигая модульность, он становится более пригодным для повторного использования, что экономит время, которое в противном случае могло бы быть потрачено на перекодирование существующей функциональности.
Программные объекты (классы, модули, функции) должны быть открыты для расширения, но закрыты для модификации.
У вас должна быть возможность добавлять новые функции без изменения существующего кода. Это важно, поскольку, придерживаясь этого принципа, вы можете внедрять в свою систему новые функции или компоненты, не рискуя стабильностью существующего кода.
Это способствует повторному использованию кода и снижает потребность в значительных изменениях при расширении функциональности.
// Bad class Product { id: number; name: string[]; price: number; protected constructor(id: number, name: string[], price: number) { this.id = id; this.name = name; this.price = price; } } class Ananas extends Product { constructor(id: number, name: string[], price: number) { super(id, name, price); } } class Banana extends Product { constructor(id: number, name: string[], price: string) { super(id, name, price); } } class HttpRequestCost { constructor(product: Product) { this.product = product; } getDeliveryCost(): number { if (product instanceOf Ananas) { return requestAnanas(url).then(...); } if (product instanceOf Banana) { return requestBanana(url).then(...); } } } function requestAnanas(url: string): Promise<ICost> { // logic for ananas } function requestBanana(url: string): Promise<ICost> { // logic for bananas }
В данном примере проблема в классе HttpRequestCost
, который в методе getDeliveryCost
содержит условия для расчета разных типов товаров, а для каждого типа товаров мы используем отдельные методы. Итак, если нам нужно добавить новый тип продукта, нам следует изменить класс HttpRequestCost
, а это небезопасно; мы могли бы получить неожиданные результаты.
Чтобы этого избежать, нам следует создать request
абстрактного метода в классе Product
без реализаций. Конкретная реализация унаследует классы: Ананы и Бананы. Они реализуют запрос для себя.
HttpRequestCost
примет параметр product
, следующий за интерфейсом класса Product
, и когда мы передадим определенные зависимости в HttpRequestCost
, он уже реализует метод request
для себя.
// Better abstract class Product { id: number; name: string[]; price: string; constructor(id: number, name: string[], price: string) { this.id = id; this.name = name; this.price = price; } abstract request(url: string): void; } class Ananas extends Product { constructor(id: number, name: string[], price: string) { super(id, name, price); } request(url: string): void { // logic for ananas } } class Banana extends Product { constructor(id: number, name: string[], price: string) { super(id, name, price); } request(url: string): void { // logic for bananas } } class HttpRequestCost { constructor(product: Product) { this.product = product; } request(): Promise<void> { return this.product.request(url).then(...); } }
Следуя этому принципу, вы уменьшите связанность кода и убережете систему от непредсказуемого поведения.
Объекты суперкласса должны быть заменены объектами его подклассов без нарушения работы приложения.
Чтобы понять этот принцип, давайте посмотрим на этот пример:
// Bad class Worker { work(): void {/../} access(): void { console.log('Have an access to closed perimeter'); } } class Programmer extends Worker { createDatabase(): void {/../} } class Seller extends Worker { sale(): void {/../} } class Designer extends Worker { access(): void { throwError('No access'); } }
В этом примере у нас проблема с классом Contractor
. Designer
, Programmer
и Seller
— все Workers, и они унаследованы от родительского класса Worker
. Но при этом Проектировщики не имеют доступа к закрытому периметру, поскольку они Подрядчики, а не Сотрудники. И мы переопределили метод access
и нарушили принцип подстановки Лискова.
Этот принцип говорит нам, что если мы заменим суперкласс Worker
его подклассом, например классом Designer
, функциональность не должна быть нарушена. Но если мы это сделаем, функциональность класса Programmer
будет нарушена — метод access
будет иметь неожиданные реализации из класса Designer
.
Следуя принципу подстановки Лискова, нам не следует переписывать реализации в подклассах, а необходимо создавать новые уровни абстракции, где мы определяем конкретные реализации для каждого типа абстракции.
Давайте поправим:
// Better class Worker { work(): void {/../} } class Employee extends Worker { access(): void { console.log('Have an access to closed perimeter'); } } class Contractor extends Worker { addNewContract(): void {/../} } class Programmer extends Employee { createDatabase(): void {/../} } class Saler extends Employee { sale(): void {/../} } class Designer extends Contractor { makeDesign(): void {/../} }
Мы создали новые уровни абстракций Employee
» и Contractor
, переместили метод access
в класс Employee
и определили конкретную реализацию. Если мы заменим класс Worker
подклассом Contractor
, функциональность Worker
не будет нарушена.
Если вы придерживаетесь LSP, вы можете заменить любой экземпляр подкласса там, где ожидается экземпляр базового класса, и программа все равно должна работать по назначению. Это способствует повторному использованию кода, модульности и делает ваш код более устойчивым к изменениям.
В следующей статье мы рассмотрим принципы разделения интерфейсов и инверсии зависимостей SOLID.