Принцип инверсии зависимостей (DIP) — это один из пяти принципов SOLID, сформулированный следующим образом: сущности более высокого уровня не должны зависеть от сущностей более низкого уровня. И те и другие должны зависеть от абстракций. Что означает ISP и как он реализуется в C# — рассмотрим в этой части.
Принцип инверсии зависимостей (DIP) в C#
Ключевая идея DIP: классы более высокого уровня не должны знать ничего про реализацию классов более низкого уровня, но должны знать как воспользоваться возможностями этих классов.
Самое важное, что нужно помнить при разработке приложений — это стараться максимально ослабить связь между классами. Когда класс знает о структуре и реализации другого класса, повышается риск того, что если мы внесём какие-либо изменения в один класс (например, изменим сигнатуру метода или тип возвращаемого значения или даже переименуем имя класса)? Это нарушит работу другого класса (класса, использующего элементы другого класса путём создания экземпляра). Поэтому мы должны максимально ослабить такую связь. Для этого нам нужно сделать так, чтобы они оба зависели от абстракций и, при этом, не знали друг о друге.
Пример использования принципа инверсии зависимостей (DIP) в C#
Давайте разберём принцип инверсии зависимостей небольшого приложения на C#. Сначала мы рассмотрим пример без соблюдения принципа инверсии зависимостей. Затем мы выявим проблемы, связанные с несоблюдением принципа инверсии зависимостей и, далее, перепишем тот же пример с использованием принципа инверсии зависимостей, чтобы вам было проще понять эту концепцию.
Пример без использования принципа инверсии зависимостей (DIP) в C#
Итак, допустим мы решили написать свой супер-калькулятор, который будет выполнять всего четыре операции:
public class Calculator { public double Sum(double a, double b) => a + b; public double Multiply(double a, double b) => a * b; public double Difference(double a, double b) => a - b; public double Divide(double a, double b) => a / b; }
Теперь нам необходимо как-то выводить результаты вычислений, а также вести лог операций. Эти задачи не относятся к задачам калькулятора, поэтому мы должны, следуя принципу единственности ответственности, создать отдельную сущность, которая бы отвечала за ведение лога. В итоге мы разрабатываем вот такой класс:
public class Logger { public void Log(string text) { Console.WriteLine(text); } public void LogError(string text) { Console.WriteLine($"ERROR: {text}"); } public void LogInformation(string text) { Console.WriteLine($"INFO: {text}"); } }
и применяем этот класс в нашем калькуляторе:
public class Calculator { private Logger log; public Calculator() { log = new Logger(); } public double Sum(double a, double b) { log.LogInformation($"a + b = {a + b}"); return a + b; } public double Multiply(double a, double b) { log.LogInformation($"a * b = {a * b}"); return a * b; } public double Difference(double a, double b) { log.LogInformation($"a - b = {a - b}"); return a - b; } public double Divide(double a, double b) { log.LogInformation($"a / b = {a / b}"); return a / b; } }
Будет ли такая программа работать? Безусловно. Но что произойдет, если мы решим изменить класс Logger
или вообще его переименовать? Очевидно, что наша программа потребует внесения изменений в код класса Calculator
сразу же как только поменяется Logger
. Согласно определению принципа инверсии зависимостей, «класс высокого уровня не должен зависеть от класса низкого уровня. Оба должны зависеть от абстракции.» Класс высокого уровня — это класс, который всегда зависит от других модулей. В нашем приложении классом высокого уровня выступает Calculator
— он зависит от класса низкого уровня — Logger
. Оба наших класса при этом должны зависеть от абстракции.
Второе правило принципа инверсии зависимостей гласит, что «абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций». Прежде чем понять это, давайте сначала разберёмся, что такое абстракция.
Что такое абстракция в C#?
Абстракция в программировании означает, что мы должны создать либо интерфейс, либо абстрактный класс, который не является конкретным (не содержит никаких реализаций методов). В нашем примере Logger является конкретным классом, что означает, что мы можем создавать объекты этого класса и, следовательно, мы не следуем второму правилу Принципа инверсии зависимостей.
Согласно принципу инверсии зависимостей в C#, Calculator
(класс высокого уровня) не должен зависеть от конкретного класса Logger
(класс низкого уровня). Оба класса должны зависеть от абстракций, то есть оба класса должны зависеть от интерфейса или абстрактного класса.
Что должно быть в интерфейсе (или в абстрактном классе)?
Как вы можете видеть в приведенном выше примере, Calculator использует методы класса Logger. Следовательно, мы должны перенести эти методы в интерфейс. По умолчанию методы интерфейса являются абстрактными. Но если вы создаете абстрактный класс, вы должны явно объявить методы абстрактными, используя ключевое слово abstract
. Создадим интерфейс, так как он делает код более слабосвязанным, и мы можем добиться множественного наследования.
Пример с использованием принципа инверсии зависимостей (DIP) в C#
Объявим в нашем приложении следующий интерфейс:
interface ILogger { public void Log(string text); public void LogError(string text); public void LogInformation(string text); }
В этом интерфейсе мы объявили методы, которые необходимы для работы — вывод в лог различной информации. Теперь реализуем эти методы в классе Logger
:
public class Logger: ILogger { public void Log(string text) { Console.WriteLine(text); } public void LogError(string text) { Console.WriteLine($"ERROR: {text}"); } public void LogInformation(string text) { Console.WriteLine($"INFO: {text}"); } }
По сути, сейчас для нас в классе Logger ничего не поменялось с точки зрения функциональности класса — он также будет выводить в консоль необходимую нам информацию. Однако теперь этот класс реализует нашу абстракцию — интерфейс ILogger
. Что позволяет нам в классе Calculator
использовать ILogger
следующим образом:
public class Calculator { private ILogger _log; public Calculator(ILogger log) { _log = log; } //тут методы калькулятора }
Теперь класс более высокого уровня ничего не знает про реализацию класса более низкого уровня. Теперь, чтобы воспользоваться нашим калькулятором мы должны его создавать вот так:
Calculator calculator = new Calculator(new Logger());
Более того, мы можем создать, например, вот такой вариант логгера:
public class FileLogger: ILogger { //тут реализация методов логгера с записью текста в файл }
и изменить создание калькулятора всего в одном месте:
Calculator calculator = new Calculator(new FileLogger());
и приложение продолжить работать без ошибок, так как мы «отвязали» логгер от калькулятора и теперь классу Calculator совершенно не важно как реализованы методы вывода информации — в консоль, в файл или вообще отправляются по электронной почте. Главное, что Calculator «знает» какие методы вызывать.
Преимущества принципа инверсии зависимостей в C#
Ниже перечислены основные преимущества применения принципа инверсии зависимостей в C#:
- Уменьшение зависимостей между классами: благодаря использованию абстракций, а не конкретных реализаций, модули становятся слабосвязанными. Такое уменьшение зависимостей упрощает модификацию, расширение или рефакторинг системы без влияния на другие ее части.
- Упрощение обслуживания и обновления: поскольку классы высокого уровня не связаны жёстко с классами низкого уровня, обновления или изменения в деталях реализации системы не требуют внесения изменений в классы высокого уровня. Такое разделение упрощает обслуживание и внедрение новых функций.
- Улучшенная тестируемость: инверсия зависимостей упрощает использование фреймворков для имитации, позволяя разработчикам писать модульные тесты, не зависящие от внешних ресурсов или сложных зависимостей. Тестирование логики высокого уровня без необходимости вникать в детали зависимостей приводит к созданию более надёжных тестов.
- Способствует параллельной разработке: поскольку компоненты не связаны между собой и взаимодействуют через абстракции, различные части системы можно разрабатывать параллельно. Команды могут работать над отдельными компонентами одновременно, не дожидаясь, пока другие завершат свои задачи, что ускоряет процесс разработки.