Переход от знания к истинному получению

Вероятно, вы встречали принципы SOLID в бесчисленных интервью, тренингах и статьях, посвященных разработке программного обеспечения. Аббревиатуры знакомы: SRP, OCP, LSP, ISP и DIP. Но вот вопрос: действительно ли вы их понимаете?

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

Начинаем со скучных основ

SOLID — это аббревиатура, обозначающая набор из пяти принципов проектирования для написания удобного в обслуживании и масштабируемого программного обеспечения.

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

  • Принцип единой ответственности (SRP)
  • Принцип открытости-закрытости (OCP)
  • Принцип замены Лискова (LSP)
  • Принцип разделения интерфейсов (ISP)
  • Принцип инверсии зависимостей (DIP)

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

Кодовая база

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

Младший разработчик или кто-то, кто не знаком с принципами SOLID, может быстро придумать что-то вроде этого:

// Separate storage for blog posts
const blogPostStorage: BlogPost[] = [];

class BlogPost {
    private title: string;
    private content: string;

    constructor(title: string, content: string) {
        this.title = title;
        this.content = content;
    }

    getTitle(): string {
        return this.title;
    }

    getContent(): string {
        return this.content;
    }

    // Method to add a post to the storage
    addToStorage(): void {
        blogPostStorage.push(this);
    }

    // Static method to retrieve all posts from storage
    static getPosts(): BlogPost[] {
        return [...blogPostStorage];
    }

    // Method to print a post
    printPost(): void {
        console.log(`Title: ${this.getTitle()}`);
        console.log(`Content: ${this.getContent()}`);
        console.log('------------------------');
    }
}

// Usage
const post1 = new BlogPost("First Post", "This is the content of the first post.");
const post2 = new BlogPost("Second Post", "This is the content of the second post.");

// Adding posts to storage
post1.addToStorage();
post2.addToStorage();

// Printing posts
for (const post of BlogPost.getPosts()) {
    post.printPost();
}

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

Принцип единой ответственности (SRP)

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

Наш кодекс нарушает этот принцип. Класс BlogPost отвечает не только за представление сообщения в блоге, но также за хранение сообщений и их печать. Давайте исправим это:

class BlogPost {
    private title: string;
    private content: string;

    constructor(title: string, content: string) {
        this.title = title;
        this.content = content;
    }

    getTitle(): string {
        return this.title;
    }

    getContent(): string {
        return this.content;
    }
}

class BlogPostRepository {
    private posts: BlogPost[] = [];

    addPost(post: BlogPost): void {
        this.posts.push(post);
    }

    getPosts(): BlogPost[] {
        return this.posts;
    }
}

class BlogPostPrinter {
    printPost(post: BlogPost): void {
        console.log(`Title: ${post.getTitle()}`);
        console.log(`Content: ${post.getContent()}`);
        console.log('------------------------');
    }
}

// Usage
const post1 = new BlogPost("First Post", "This is the content of the first post.");
const post2 = new BlogPost("Second Post", "This is the content of the second post.");

const postRepository = new BlogPostRepository();
const postPrinter = new BlogPostPrinter();

postRepository.addPost(post1);
postRepository.addPost(post2);

const posts = postRepository.getPosts();

for (const post of posts) {
    postPrinter.printPost(post);
}

Хотя результат включает в себя немного большую кодовую базу и может показаться несколько более сложным, он значительно упрощает задачи поддержки, расширения или изменения кода в будущем. Честно говоря, это мой любимый принцип из ТВЕРДОЙ пятерки, поэтому я рад, что он прямо здесь! 😃

  • Класс BlogPost несет единственную ответственность — представление сообщения в блоге.
  • Класс BlogPostRepository отвечает за хранение и извлечение сообщений в блоге, сохраняя хранилище данных отдельно от самого сообщения.
  • Класс BlogPostPrinter отвечает за печать сообщений в блоге.

Принцип открытости-закрытости (OCP)

OCP предполагает, что программные объекты (такие как классы, модули или функции) должны быть открыты для расширения, но закрыты для модификации. Проще говоря, это означает, что вы должны иметь возможность добавлять новые функции или возможности в свое программное обеспечение, не изменяя существующий код. Этот принцип поощряет модульную и гибкую конструкцию, что упрощает расширение вашего программного обеспечения и снижает риск появления новых ошибок при внесении изменений.

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

// Abstract class for printing
abstract class BlogPostPrinter {
    abstract print(post: BlogPost): void;
}

// Concrete printer class for plain text
class PlainTextBlogPostPrinter extends BlogPostPrinter {
    print(post: BlogPost): void {
        console.log(`Title: ${post.getTitle()}`);
        console.log(`Content: ${post.getContent()}`);
        console.log('------------------------');
    }
}

// Concrete printer class for HTML
class HTMLBlogPostPrinter extends BlogPostPrinter {
    print(post: BlogPost): void {
        console.log(`<h1>${post.getTitle()}</h1>`);
        console.log(`<p>${post.getContent()}</p>`);
        console.log('<hr/>');
    }
}

...

console.log("Printing blog posts using PlainTextBlogPostPrinter:");
for (const post of posts) {
    plainTextPrinter.print(post);
}

console.log("Printing blog posts using HTMLBlogPostPrinter:");
for (const post of posts) {
    htmlPrinter.print(post);
}
  1. Мы вводим абстрактный класс BlogPostPrinter, который определяет общий интерфейс для печати сообщений в блоге.
  2. Конкретные классы принтеров (PlainTextBlogPostPrinter и HTMLBlogPostPrinter) расширяют BlogPostPrinter, предоставляя конкретные реализации для печати в форматах обычного текста и HTML.
  3. Мы можем легко добавить больше классов принтеров для разных форматов, не изменяя существующий код, придерживаясь принципа открытости-закрытости (OCP). Это делает систему открытой для расширения, но закрытой для модификации.

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

Принцип замены Лискова (LSP)

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

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

В нашем случае это означает, что производные классы (PlainTextBlogPostPrinter и HTMLBlogPostPrinter) должны иметь возможность без каких-либо проблем заменить базовый класс (BlogPostPrinter).

// Interface for printing
interface BlogPostPrinter {
    print(post: BlogPost): void;
}

// Concrete printer class for plain text
class PlainTextBlogPostPrinter implements BlogPostPrinter {
    print(post: BlogPost): void {
        console.log(`Title: ${post.getTitle()}`);
        console.log(`Content: ${post.getContent()}`);
        console.log('------------------------');
    }
}

// Concrete printer class for HTML
class HTMLBlogPostPrinter implements BlogPostPrinter {
    print(post: BlogPost): void {
        console.log(`<h1>${post.getTitle()}</h1>`);
        console.log(`<p>${post.getContent()}</p>`);
        console.log('<hr/>');
    }
}

...

const plainTextPrinter: BlogPostPrinter = new PlainTextBlogPostPrinter();
const htmlPrinter: BlogPostPrinter = new HTMLBlogPostPrinter();


console.log("Printing blog posts using PlainTextBlogPostPrinter:");
for (const post of posts) {
    plainTextPrinter.print(post);
}

console.log("Printing blog posts using HTMLBlogPostPrinter:");
for (const post of posts) {
    htmlPrinter.print(post);
}

Принцип разделения интерфейсов (ISP):

Интернет-провайдер заявляет, что клиенты (классы или модули) не должны зависеть от интерфейсов, которые они не используют. Другими словами, интерфейс должен иметь конкретную, четко определенную цель, адаптированную к потребностям реализующих его классов, а не быть универсальным интерфейсом, который заставляет клиентов реализовывать методы, которые им не нужны. Этот принцип способствует созданию более чистого, модульного и удобного в сопровождении кода за счет уменьшения ненужных зависимостей и обеспечения того, чтобы каждый класс имел именно те методы, которые ему необходимы.

Чтобы сохранить пример принципа открытости-закрытости (OCP), одновременно придерживаясь принципа разделения интерфейса (ISP), вы можете определить общий интерфейс для всех принтеров, но предоставить реализации по умолчанию для определенных типов принтеров.

// Common interface for all printers
interface BlogPostPrinter {
    print(post: BlogPost): void;
}

// Default printer class with a generic implementation
class DefaultBlogPostPrinter implements BlogPostPrinter {
    print(post: BlogPost): void {
        console.log(`Title: ${post.getTitle()}`);
        console.log(`Content: ${post.getContent()}`);
        console.log('------------------------');
    }
}

// Concrete printer class for HTML
class HTMLBlogPostPrinter implements BlogPostPrinter {
    print(post: BlogPost): void {
        console.log(`<h1>${post.getTitle()}</h1>`);
        console.log(`<p>${post.getContent()}</p>`);
        console.log('<hr/>');
    }
}
  • У нас есть общий интерфейс BlogPostPrinter для всех принтеров, позволяющий поддерживать связь с интернет-провайдером.
  • Мы предоставляем класс принтера по умолчанию DefaultBlogPostPrinter, который реализует общее поведение печати, гарантируя, что мы следуем OCP. Этот класс может служить запасным вариантом, если конкретный принтер не нужен.
  • Класс HTMLBlogPostPrinter предоставляет специализированную реализацию печати HTML.

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

Принцип инверсии зависимостей (DIP)

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

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

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

class BlogManager {
    private repository: BlogPostRepository = new BlogPostRepository();
    private printer: HTMLBlogPostPrinter = new HTMLBlogPostPrinter();

    createAndPrintPost(title: string, content: string): void {
        const post = new BlogPost(title, content);
        this.repository.addPost(post);
        this.printer.print(post);
    }
}
  • Класс BlogManager, представляющий модуль высокого уровня, напрямую зависит от конкретных классов BlogPostRepository и HTMLBlogPostPrinter.
  • Если мы хотим изменить репозиторий или способ печати сообщений в блоге, нам придется изменить класс BlogManager, нарушая принцип открытости-закрытости и принцип инверсии зависимостей.

Давайте это исправим! Чтобы придерживаться принципа инверсии зависимостей (DIP), вы можете ввести абстракции (интерфейсы) и BlogManager зависеть от этих абстракций, а не от конкретных реализаций. Вот исправленная версия BlogManager:

interface PostPrinter {
    print(post: BlogPost): void;
}

class BlogManager {
    private repository: BlogPostRepository;
    private printer: PostPrinter;

    constructor(repository: BlogPostRepository, printer: PostPrinter) {
        this.repository = repository;
        this.printer = printer;
    }

    createAndPrintPost(title: string, content: string): void {
        const post = new BlogPost(title, content);
        this.repository.addPost(post);
        this.printer.print(post);
    }
}
  1. Мы представляем интерфейс PostPrinter для представления абстракции для печати сообщений в блоге.
  2. BlogManager теперь зависит от PostPrinter и BlogPostRepository через их абстракции, а не через конкретные классы. Это соответствует DIP путем инвертирования зависимостей.
  3. Конструктор BlogManager принимает экземпляры BlogPostRepository и PostPrinter, позволяя внедрять зависимости. Это упрощает смену реализаций или предоставление макетов для тестирования.

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

Это обертка! Спасибо за чтение. Я надеюсь, что эта статья пролила свет на эти важные понятия и сделала их немного понятнее для вас.

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

Приятного отдыха! 👋