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

Принципы SOLID были впервые представлены Робертом С. Мартином (он же дядя Боб) как пять основных принципов объектно-ориентированного программирования. Следуя этим принципам, вы избавляетесь от плохих проектов, избегаете рефакторинга и создаете более эффективный и удобный для сопровождения код.

SOLID — это аббревиатура следующих понятий:

  • S:принцип единой ответственности
  • O: принцип открытого-закрытого
  • L: Принцип замены Лисков
  • I: принцип разделения интерфейса
  • D: принцип инверсии зависимостей

В этой статье будет дано вводное описание принципов вместе с некоторыми примерами на языке C#, но эти концепции могут быть применены к любому языку ООП.

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

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

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

Но почему так важно разделить задачи и функции в разных классах? Чтобы избежать дублирования функций.

Допустим, у нас есть класс OrderService, реализующий службу управления заказами в системе электронной коммерции.

public class OrderService : IOrderService
{    
    public async Task CreateOrder(Order order)    
    {  
        // code for creating order    
    }  
      
    public async Task CreateInvoice(Order order)    
    {  
        // code for creating invoice  
    }
}

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

Косу можно реорганизовать, включая функциональные возможности в отдельные классы, поэтому мы можем изменить логику InvoiceService, оставив нетронутой OrderService.

public class OrderService : IOrderService
{    
    public async Task CreateOrder(Order order)    
    {  
        // code for creating order    
    }
}

public class InvoiceService : IInvoiceService
{
    public async Task CreateInvoice(Order order)    
    {  
        // code for creating invoice  
    }
}

Принцип Открыто-Закрыто

Объекты или сущности должны быть открыты для расширения, но закрыты для модификации.

Это предложение говорит само за себя: класс должен быть расширяемым без необходимости модификации самого класса.

Возьмем в качестве примера класс, способный сериализовать сущность отдела разными способами (пока только JSON и XML). Приведенная ниже реализация класса Serializer явно нарушает принцип Open-Closed: если вы хотите добавить новую опцию сериализатора, скажем, например, CSV, вам нужно изменить сам класс.

public class Department
{    
    public string Code { get; set; }    
    public string Name { get; set; }
} 

public class SerializerClass{    
    public string Serialize(Department dept, string format)
    {        
        if(format == "json"){            
            return string.Format("{ \"Code\": \"{0}\", \"Name\": \"{1}\" ", dept.Code, dept.Name);        
        }else if(format == "xml"){            
            return string.Format("<Department><Code>{0}</Code><Name>{1}</Name></Department>", dept.Code, dept.Name);        
        }    
    }
}

После некоторого рефакторинга новая реализация выглядит так:

public class SerializerClass{    
    public virtual string Serialize(Department dept)    
    {        
        return string.Format("Code: {0}, Name: {1}", dept.Code, dept.Name);    
    }
} 

public class JsonSerializer : SerializerClass
{    
    public override string Serialize(Department dept)    
        {        
             return string.Format("{ \"Code\": \"{0}\", \"Name\": \"{1}\" ", dept.Code, dept.Name);    
        }
} 

public class XmlSerializer : SerializerClass
{    
    public override string Serialize(Department dept)    
    {        
        return string.Format("<Department><Code>{0}</Code><Name>{1}</Name></Department>", dept.Code, dept.Name);    
    }
} 

public class CsvSerializer : SerializerClass
{    
    public override string Serialize(Department dept)    
    {        
        return string.Format("{0};{1}", dept.Code, dept.Name);    
    }
}

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

Лисков Принцип замещения

Пусть Φ(x) — доказуемое свойство объектов x типа T. Тогда Φ(y) должно быть истинным для объектов y типа S, где S — подтип T.

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

public class MediaPlayer
{
    public void PlayAudio()
    {
        Console.Writeline("Audio");
    }

    public void PlayVideo()
    {
        Console.Writeline("Video");
    }
}
public class VideoPlayer : MediaPlayer
{
    // a video player is capable to play both audio and video
}

public class AudioPlayer : MediaPlayer
{
    // an audio player is only able to play audio
    public override void PlayVideo()
    {
        throw new RuntimeException("Unable to play video");
    }
}

public class Program
{
    static void Main(string[] args)
    {
         MediaPlayer player = new VideoPlayer();
         Console.Writeline(player.PlayAudio()); // output "Audio"
         Console.Writeline(player.PlayVideo()); // output "Video"
         player = new AudioPlayer();
         Console.Writeline(player.PlayAudio()); // output "Audio"
         Console.Writeline(player.PlayVideo()); // throws RuntimeException
    {
}

В приведенном выше примере у нас есть универсальный класс MediaPlayer, который предоставляет два метода: PlayAudio и PlayVideo. Мы видим, что и AudioPlayer, и VideoPlayer расширяют суперкласс, но здесь проблема заключается в переопределении AudioPlayer.

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

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

public class MediaPlayer
{
    public void PlayAudio()
    {
        Console.Writeline("Audio");
    }
}

public class VideoPlayer : MediaPlayer
{
    // a video player is capable to play both audio and video

    public void PlayVideo()
    {
        Console.Writeline("Video");
    }
}

public class AudioPlayer : MediaPlayer
{
    // an audio player is only able to play audio
}

public class Program
{
    static void Main(string[] args)
    {
         MediaPlayer player = new VideoPlayer();
         Console.Writeline(player.PlayAudio()); // output "Audio"
         Console.Writeline(player.PlayVideo()); // output "Video"
         player = new AudioPlayer();
         Console.Writeline(player.PlayAudio()); // output "Audio"
         //Console.Writeline(player.PlayVideo());  compile-time error
    {
}

В приведенном выше рефакторинге кода класс MediaPlayer теперь содержит только метод PlayAudio, общий для всех подклассов, а метод PlayVideo перемещен в класс VideoPlayer. В основной программе, если вы посмотрите на последнюю строку (закомментированную), вы увидите, что теперь, если мы попытаемся вызвать метод PlayVideo в AudioPlayer, мы получим ошибку времени компиляции, потому что AudioPlayer больше не реализует метод.

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

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

Концепция, выраженная в предложении дяди Боба (Robert C. Martin), довольно прямолинейна: она гласит, что вместо одного толстого интерфейса мы должны предпочесть иметь несколько тонких интерфейсов, которые лучше соответствуют потребностям пользователя. клиент.

Давайте посмотрим на приведенный ниже пример, чтобы понять необходимость следовать принципу разделения интерфейса.

public interface IDocumentService
{
    public void CreateQuotation();
    public void CreateOrder();
    public void CreateInvoice();
}

Это упомянутый выше «толстый» интерфейс, который представляет собой службу документов, способную создавать несколько типов документов. Реализация может быть следующей.

public class QuotationService : IDocumentService
{
    public override void CreateQuotation()
    {
        Console.Writeline("Quotation created");
    }

    public override void CreateOrder()
    {
        throw new UnsupportedOperationException("Cannot create order");
    }

    public override void CreateInvoice()
    {
        throw new UnsupportedOperationException("Cannot create invoice");
    }
    ...
}

public class OrderService : IDocumentService
{
    public override void CreateQuotation()
    {
        throw new UnsupportedOperationException("Cannot create quotation");
    }

    public override void CreateOrder()
    {
        Console.Writeline("Order created");
    }

    public override void CreateInvoice()
    {
        throw new UnsupportedOperationException("Cannot create invoice");
    }
    ... 
}

public class InvoiceService : IDocumentService
{
    public override void CreateQuotation()
    {
        throw new UnsupportedOperationException("Cannot create quotation");
    }

    public override void CreateOrder()
    {
        throw new UnsupportedOperationException("Cannot create order");
    }

    public override void CreateInvoice()
    {
        Console.Writeline("Invoice created");
    }
    ...
}

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

Это один из признаков того, что что-то в вашем коде нарушает требования провайдера. Другой симптом — это когда вам нужно передать null или эквивалентное значение методу или конструктору, чтобы «удовлетворить» зависимость, которая, однако, никогда не используется.

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

public interface IQuotationService
{
    public void CreateQuotation();
}

public interface IOrderService
{
    public void CreateOrder();
}

public interface IInvoiceService
{
    public void CreateInvoice();
}

public class QuotationService : IQuotationService
{
    public override void CreateQuotation()
    {
        Console.Writeline("Quotation created");
    }
    ...
}

public class OrderService : IOrderService
{
    public override void CreateOrder()
    {
        Console.Writeline("Order created");
    }
    ... 
}

public class InvoiceService : IInvoiceService
{
    public override void CreateInvoice()
    {
        Console.Writeline("Invoice created");
    }
    ...
}

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

Принцип инверсии зависимости

Сущности должны зависеть от абстракций, а не от конкретики. В нем говорится, что модуль высокого уровня не должен зависеть от модуля низкого уровня, но они должны зависеть от абстракций.

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

public class Logger
{
    public void Log(string message)
    {
        Console.WriteLine(message);
    }
}

public class System
{
    private Logger _logger;

    public System(Logger logger)
    {
        _logger = logger;
    }

    public void DoSomething()
    {
        ...
        _logger.Log("I'm doing something");
        ... 
    }
}

Класс System, являющийся модулем высокого уровня, напрямую использует реализацию класса Logger, модуля низкого уровня. Это явное нарушение приведенного выше предложения "Сущности должны зависеть от абстракций, а не от конкретики", поскольку класс System вынужден зависеть от класс Logger.

Но что такое абстракция?

Абстракция является одной из ключевых концепций языков объектно-ориентированного программирования (ООП). Его основная цель — справляться со сложностью, скрывая от пользователя ненужные детали. Взяв в качестве примера конструкцию c# interface, ее можно считать абстракцией, поскольку показаны только методы (также называемые Contract), которым должен удовлетворять объект, а не реальная реализация.

Другими словами, абстракции описывают ЧТО объект способен делать, а не КАК он это делает.

Изменить поведение на реализацию

Теперь, если мы хотим добавить функциональность для входа в файл вместо стандартной консоли, мы можем создать новый класс FileLogger. Поскольку модуль более высокого уровня зависит от модуля более низкого уровня, если мы хотим использовать класс FileLogger вместо Logger, мы должны изменить System объект.

public class FileLogger
{
    public void Log(string message)
    {
        File.WriteAllTextAsync("log.txt", message);
    }
}

public class System
{
    private FileLogger _logger;

    public System(FileLogger logger)
    {
        _logger = logger;
    }

    public void DoSomething()
    {
        ...
        _logger.Log("I'm doing something");
        ... 
    }
}

Рефакторинг кода и применение инверсии зависимостей

Чтобы избежать проблемы, описанной выше, мы можем ввести абстракцию, то есть интерфейс, чтобы передать объекту System сам интерфейс вместо реальной реализации.

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

public class Logger : ILogger
{
    public void Log(string message)
    {
        Console.WriteLine(message);
    }
}

public class FileLogger : ILogger
{
    public void Log(string message)
    {
        File.WriteAllTextAsync("log.txt", message);
    }
}

public class System
{
    private ILogger _logger;

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

    public void DoSomething()
    {
        ...
        _logger.Log("I'm doing something");
        ... 
    }
}

Теперь класс System игнорирует реальную реализацию интерфейса Ilogger, тем самым выполняя инверсию зависимостей.

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

Спасибо за прочтение.