В разработке программного обеспечения сопровождение кода похоже на бесконечный марафон — оно требует выдержки, терпения и много кофе, я имею в виду МНОГО КОФЕ ☕️️️. Так что, если вы устали от того, что ваша кодовая база превратилась в беспорядок, возьмите свой любимый напиток для кодирования и приготовьтесь НАДЁЖНО совершенствовать свою кодовую базу, как босс.

Итак, что такое принципы SOLID 🤔

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

Принципы SOLID — это набор принципов проектирования для написания чистого, поддерживаемого и расширяемого кода. Эти принципы были введены Робертом С. Мартином (также известным как дядя Боб), чтобы помочь разработчикам создавать код, который легко читать, понимать и поддерживать. Принципы SOLID состоят из пяти принципов, а именно:

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

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

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

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

// Bad approach

class Customer
{
    public void AddCustomer()
    {
        //Add customer to database
    }

    public void SendEmail()
    {
        //Send email to customer
    }

    public void GenerateInvoice()
    {
        //Generate invoice for customer
    }
}

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

// Good approach - using SRP

class Customer
{
    public void AddCustomer()
    {
        //Add customer to database
    }
}

class EmailSender
{
    public void SendEmail()
    {
        //Send email to customer
    }
}

class InvoiceGenerator
{
    public void GenerateInvoice()
    {
        //Generate invoice for customer
    }
}

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

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

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

Давайте рассмотрим пример класса, нарушающего OCP.

// Bad approach

public class Superhero
{
    public string Name { get; set; }
    public string Power { get; set; }

    public void UsePower()
    {
        if (Power == "Fly")
            Console.WriteLine($"{Name} is flying!"); 
        else if (Power == "Invisibility")
            Console.WriteLine($"{Name} is invisible!");
        else if (Power == "Super Strength")
            Console.WriteLine($"{Name} is lifting a car!");
        else
            throw new Exception("Unknown superpower!");
    }
}

public class Superhero
{
    public string Name { get; set; }
    public string Power { get; set; }

    public void UsePower()
    {
        if (Power == "Fly")
            Console.WriteLine($"{Name} is flying!");
        else if (Power == "Invisibility")
            Console.WriteLine($"{Name} is invisible!");
        else if (Power == "Super Strength")
            Console.WriteLine($"{Name} is lifting a car!");
        else
            throw new Exception("Unknown superpower!");
    }
}

В этом примере класс Superhero нарушает OCP, потому что он не закрыт для модификации. Если мы хотим добавить новую сверхспособность, такую ​​как лазерное зрение или телепортацию, нам нужно изменить метод UsePower.

Вот как мы можем исправить это, чтобы следовать OCP:

// Good approach - using OCP

public abstract class Superhero
{
    public string Name { get; set; }
    public abstract void UsePower();
}

public class FlyingSuperhero : Superhero
{
    public override void UsePower()
    {
        Console.WriteLine($"{Name} is flying!");
    }
}

public class InvisibilitySuperhero : Superhero
{
    public override void UsePower()
    {
        Console.WriteLine($"{Name} is invisible!");
    }
}

public class StrengthSuperhero : Superhero
{
    public override void UsePower()
    {
        Console.WriteLine($"{Name} is lifting a car!");
    }
}

Теперь у нас есть отдельные классы для каждого типа сверхспособностей, каждый из которых реализует свой метод UsePower. Это позволяет нам добавлять новые сверхспособности без изменения класса Superhero. Кроме того, мы можем представить всевозможных забавных супергероев со странными способностями, такими как способность превращаться в гигантского хомяка! По этой идее мог бы получиться отличный фильм, я должен защитить его авторскими правами, я имею в виду, что у нас уже есть герой, который превращается в муравья.

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

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

public class Duck
{
    public virtual void Quack()
    {
        Console.WriteLine("Quack quack!");
    }
}

public class RubberDuck : Duck
{
    public override void Quack()
    {
        Console.WriteLine("Squeak squeak!");
    }
}

public class DecoyDuck : Duck
{
    public override void Quack()
    {
        throw new NotImplementedException();
    }
}

public void MakeDuckNoise(Duck duck)
{
    duck.Quack();
}

// Usage
MakeDuckNoise(new Duck()); // Outputs "Quack quack!"
MakeDuckNoise(new RubberDuck()); // Outputs "Squeak squeak!"
MakeDuckNoise(new DecoyDuck()); // Oops! NotImplementedException exception

В этом примере класс DecoyDuck наследуется от класса Duck, но переопределяет метод Quack для создания NotImplementedException. Это нарушает LSP, поскольку означает, что объект DecoyDuck нельзя использовать так же, как объект Duck. Если написан код, предполагающий, что все объекты Duck могут крякать, он рухнет при встрече с объектом DecoyDuck.

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

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

Чтобы проиллюстрировать это, представьте, что мы создаем игру-симулятор супергероя на C#. У нас есть интерфейс Superhero, который должны реализовать все супергерои в игре, включая такие методы, как Fly, Fight и UseSuperPower. Однако не все супергерои могут летать или использовать сверхспособности, поэтому принуждение их к использованию этих методов было бы нарушением ISP.

Вот плохой пример нарушения ISP в C#:

// Bad approach

public interface ISuperhero
{
    void Fly();
    void UseSuperPower();
    void Fight();
}

public class Superman : ISuperhero
{
    public void Fly()
    {
        // Fly like Superman
    }

    public void UseSuperPower()
    {
        // He is called Superman for a reason!
    }

    public void Fight()
    {
        // Fight like Superman
    }
}

public class Batman : ISuperhero
{
    public void Fly()
    {
        // Batman doesn't fly!
    }

    public void UseSuperPower()
    {
        // Batman has no super power, even though he is super rich!
    }

    public void Fight()
    {
        // Fight like Batman
    }
}

Как видите, интерфейс Superhero включает в себя методы, которые могут использовать не все супергерои. В приведенном выше примере Бэтмен вынужден реализовать методы Fly и UseSuperPower, несмотря на то, что он не умеет летать и не обладает сверхспособностями. Это нарушает ISP, поскольку Бэтмен вынужден полагаться на методы, которые он не использует.

Вот лучший подход с использованием интернет-провайдера:

// Better approach - using ISP

public interface IFly
{
    void Fly();
}

public interface ISuperPower
{
    void UseSuperPower();
}

public interface IFight
{
    void Fight();
}

public class Superman : IFly, ISuperPower, IFight
{
    public void Fly()
    {
        // Fly like Superman
    }

    public void UseSuperPower()
    {
        // Use super power
    }

    public void Fight()
    {
        // Fight like Superman
    }
}

public class Batman : IFight
{
    public void Fight()
    {
        // Fight like Batman
    }
}

В приведенном выше примере мы создали три отдельных интерфейса для каждого типа способностей супергероев: IFly , ISuperPower и IFight. Таким образом, мы можем создавать супергероев, которые реализуют только те интерфейсы, которые имеют отношение к их способностям, и мы не заставляем супергероев зависеть от методов, которые они не используют.

Этот подход соответствует ISP, и он намного более гибкий и масштабируемый, чем предыдущий пример.

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

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

// Bad approach

public class FileLogger
{
    public void Log(string message)
    {
        // Log message to file
    }
}

public class Calculator
{
    private readonly FileLogger _logger;

    public Calculator()
    {
        _logger = new FileLogger();
    }

    public int Add(int a, int b)
    {
        int result = a + b;
        _logger.Log($"Addition of {a} and {b} is {result}");
        return result;
    }
}

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

Чтобы следовать принципу инверсии зависимостей (DIP), нам нужно инвертировать зависимость между классами Calculator и FileLogger. Мы можем сделать это, введя абстракцию, от которой зависят оба класса:

// Good approach - using DIP

public interface ILogger
{
    void Log(string message);
}

public class FileLogger : ILogger
{
    public void Log(string message)
    {
        // Log message to file
    }
}

public class ConsoleLogger : ILogger
{
    public void Log(string message)
    {
        // Log message to console
    }
}

public class Calculator
{
    private readonly ILogger _logger;

    public Calculator(ILogger logger)
    {
        _logger = logger;
    }

    public int Add(int a, int b)
    {
        int result = a + b;
        _logger.Log($"Addition of {a} and {b} is {result}");
        return result;
    }
}

В этом примере мы ввели интерфейс ILogger, представляющий любой тип механизма ведения журнала. Класс Calculator теперь зависит от этого интерфейса вместо класса FileLogger. Это означает, что мы можем передать регистратор любого типа, который реализует интерфейс ILogger, в класс Calculator.

Теперь мы можем создавать новые регистраторы, реализующие интерфейс ILogger, и использовать их с классом Calculator, не изменяя его:

ILogger logger = new ConsoleLogger(); // or new FileLogger();
Calculator calculator = new Calculator(logger);
calculator.Add(3, 4);

Инвертируя зависимость между классами Calculator и FileLogger, мы сделали наш код более гибким и простым в обслуживании. Теперь мы можем легко добавлять новые типы регистраторов, не изменяя класс Calculator.

Заключение:

Код может быть сложным,
но эти принципы сделают его менее липким.

Единая ответственность держит его в чистоте,
Одна работа для каждого класса, это мечта!

Принцип открытого-закрытого, вы можете быть смелым,
Новые функции могут быть добавлены без изменения кода старого.

Подстановка Лисков гарантирует, что все пойдет как надо.
Производные классы могут заменить базовые, это зрелище!

Разделение интерфейса избавляет вас от беспорядка.
Используйте только то, что вам нужно, и никакого лишнего кода.

Инверсия зависимостей — это основа,
Высокоуровневые модули и низкоуровневый интерфейс — все в порядке!

Так что запомните эти принципы, все до единого,
И ваш код будет стоять на высоте!

Если вы достигли этого момента «и вам понравилось рифмованное заключение 😬», рассмотрите возможность поаплодировать 👏 и подписаться на мой аккаунт, чтобы в будущем получать больше подобного контента.