UniLecs. SOLID — принципы объектно-ориентированного программирования

UniLecs. SOLID — принципы объектно-ориентированного программирования

UniLecs

Определение

SOLID - это аббре­ви­а­тура 5-ти основ­ных прин­ци­пов про­ек­ти­ро­ва­ния в объ­ектно-ори­ен­ти­ро­ван­ном про­грам­ми­ро­ва­нии:

Аббре­ви­а­тура SOLID была пред­ло­жена Робер­том Мар­ти­ном, авто­ром несколь­ких книг, широко извест­ных в сооб­ще­стве разработчиков. Использование в совокупности данных принципов позволяет повысить вероятность того, что программист создаст систему, которую будет легко поддерживать и расширять в течение долгого времени.

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

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

Давайте разберем на примере:

Пример:

namespace SOLID
{
  public class Developer
  {
    public int ID { get;set; }
    public string Name { get;set; }

    public void Add(Developer dev)
    {
      // TODO: добавляем разработчика в базу данных
    }

    public void GenerateWorkingHoursReport()
    {
      // TODO: генерируем рабочие часы разработчика за посл.месяц
    }
  }
}

В данном примере класс Developer не соот-т принципу SRP, т.к. класс несет две ответственности:

  1. Добавление разработчика в базу данных
  2. Генерация отчета по рабочим часам за посл. месяц

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

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

namespace SOLID
{
  public class Developer
  {
    public int ID { get;set; }
    public string Name { get;set; }

    public void Add(Developer dev)
    {
      // TODO: добавляем разработчика в базу данных
    }
  }

  public class DeveloperReport
  {
    public void GenerateWorkingHoursReport()
    {
      // TODO: генерируем рабочие часы разработчика за посл. месяц
    }
  }
}


Open-closed (OCP, принцип открытости/закрытости)

Этот принцип означает, что программные объекты (классы, модули, функции и т.п.) должны быть открыты для расширения, но закрыты для модификации/изменения. Т.е. эти объекты могут менять свое поведение без изменения их исходного кода. В терминах ООП можно также сказать, что принцип закрытости означает, что уже разработанный класс, прошедший модульное тестирование не должен подвергаться изменениям. Если же нужно расширить функциональность вашего класса, то в ООП языках используется наследование классов, а также методы расширения (например в C#).

Пример:

namespace SOLID
{
  public class DeveloperReport
  {
    public string TimeReportType { get; set; }

    public void GenerateWorkingHoursReport()
    {
      switch (TimeReportType)
      {
        case "CSV":
          // TODO: генерируем отчет в формате CSV
          break;
        case "WORD":
          // TODO: генерируем отчет в формате WORD
          break;
      }
    }
  }
}

Согласно принципу OCP, этот класс составлен неверно, т.к. в случае необходимости добавления нового функционала, например, генерации отчета в формате Excel, нужно будет модифицировать класс. А принцип OCP гласит, что класс закрыт от модификаций, но открыт для расширения. Давайте посмотрим, как следует это делать.

namespace SOLID
{
  public class DeveloperReport
  {
    public virtual void GenerateWorkingHoursReport()
    {
      // Базовая реализация
    }
  }
  
  public class DeveloperCSVReport: DeveloperReport
  {
    public override void GenerateWorkingHoursReport()
    {
      // Генерация отчета в формате CSV
    }
  }

  public class DeveloperPDFReport: DeveloperReport
  {
    public override void GenerateWorkingHoursReport()
    {
      // Генерация отчета в формате PDF
    }
  }
}

В данном случае функционал легко расширяется путем создания нового класса и наследования его от базового.


Liskov Substitution Principle (LSP, Принцип подстановки Лисков)

В фор­му­ли­ровке Роберта Мар­ти­на: «функ­ции, кото­рые исполь­зуют базо­вый тип, должны иметь воз­мож­ность исполь­зо­вать под­типы базо­вого типа не зная об этом». Также можно сказать, что поведение наследуемых классов не должно противоречить поведению, заданному базовым классом, то есть поведение наследуемых классов должно быть ожидаемым для кода, использующего переменную базового типа.

Пример:

namespace SOLID
{
  public abstract class Developer
  {
    public int Id { get;set; }
    public string Name { get;set; }
    public virtual string[] GetProgramerCertifications(int Id)
    {
      return new string[] { "Base Cert" };
    }
    
    public virtual string GetWorkAlias(int Id)
    {
      return "Base Dev alias";
    }
  }

  public class JuniorDeveloper : Developer
  {
    public override string[] GetProgramerCertifications(int Id)
    {
      throw new NotImplementedException();
    }

    public override string GetWorkAlias(int Id)
    {
      return "Junior Dev alias";
    }
  }

  public class SeniorDeveloper : Developer
  {
    public override string[] GetProgramerCertifications(int Id)
    {
      return new string[] { "Senior Java Cert", "Senior C# Cert" };
    }

    public override string GetWorkAlias(int Id)
    {
      return "Senior Dev alias";
    }
  }
}

Вроде выглядит вполне прилично. Но ...

List<Developer> list = new List<Developer>()
{
  new JuniorDeveloper(),
  new SeniorDeveloper()
};

string[] allDevCerts = new string[] {};
foreach (var dev in list)
{
  allDevCerts.Add(dev.GetProgramerCertifications(dev.Id))
}

Тут у нас проблема, т.к. у класса JuniorDeveloper не определен метод, возвращающий сертификаты разработчика. И в данном примере мы получим необработанное исключение. Как раз потому, что данный пример не соот-т принципу подстановки Лисков. Давайте приведем все в порядок.

public interface IWork
{
  string GetWorkAlias(int Id);
}

public interface IDeveloperCert
{
  string[] GetProgramerCertifications(int Id);
}

public class JuniorDeveloper : IWork
{
  public string GetWorkAlias(int Id)
  {
    return "Junior Dev alias";
  }
}

public class SeniorDeveloper : IWork, IDeveloperCert
{
  public string[] GetProgramerCertifications(int Id)
  {
    return new string[] { "Senior Java Cert", "Senior C# Cert" };
  }

  public string GetWorkAlias(int Id)
  {
    return "Senior Dev alias";
  }
}

Решение очень простое - мы просто разбили функционал на два интерфейса IWork и IDeveloperCert. Теперь класс JuniorDeveloper требует реализации только IWork. И в таком случае мы соблюдаем принцип LSP.


Interface Segregation Principle (ISP, принцип разделения интерфейсов)

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

Пример:

public interface IDeveloper
{
  void AddDeveloper();
}

Предположим, что все объекты класса Developer наследуют этот интерфейс для сохранения данных в базу данных. Но появилось новое бизнес требование, выдавать данные по senior-разработчикам.

public interface IDeveloper
{
  void AddDeveloper();
  void DisplaySeniorDeveloper();
}

И тут явно неверная логика. Класс JuniorDeveloper наследует этот интерфейс, но не использует метод DisplaySeniorDeveloper(). Поэтому правильным решением будет разделение логики на 2 интерфейса и разделение ответственности.

public interface IDeveloper
{
  void AddDeveloper();
}

public interface ISeniorDeveloper
{
  void DisplaySeniorDeveloper();
}

Таким образом, обеспечивается разделение интерфейсов.


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

Означает, что модули верх­них уров­ней не должны зави­сеть от моду­лей ниж­них уров­ней, а оба типа моду­лей должны зави­сеть от абстрак­ций; сами абстрак­ции не должны зави­сеть от дета­лей, а вот детали должны зави­сеть от абстракций.

Пример:

public class ExcelReport
{
  public void Generate()
  {
    // генерация excel-отчета
  }
}

public class DeveloperReport
{
  private ExcelReport excelReport;
  public DeveloperReport()
  {
    excelReport = new ExcelReport();
  }

  public void GenerateExcelReport()
  {
    excelReport.Generate();
  }
}

Как мы видим, класс DeveloperReport имеет сильную зависимость от класса ExcelReport, потому что он генерирует только отчеты типа excel. Если нам нужно будет добавить генерацию отчетов другого типа, то нужно переделать эту систему, так как данный пример показывает сильную зависимость и не соот-т принципу инверсии зависимостей.

public interface IReport
{
  void Generate();
}

public class ExcelReport : IReport
{
  public void Generate()
  {
    // генерация excel отчета
  }
}

public class WordReport : IReport
{
  public void Generate()
  {
    // генерация word отчета
  }
}

public class DeveloperReport
{
  private IReport report;
  public DeveloperReport()
  {
    report = new ExcelReport();
  }

  public void GenerateReport()
  {
    report.Generate();
  }
}

Мы все еще имеем зависимость класса DeveloperReport от класса ExcelReport, но она уже не такая сильная. Полное решение проблемы устраняется принципом внедрения зависимостей (DI), ктр реализуется, например, с помощью различных библиотек.


Ссылки:

Report Page