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

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

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

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

Прежде чем создавать свой пример, давайте рассмотрим пару участков кода из интернета.

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

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

. . . #if ENGINE_MAJOR_VERSION==5 && ENGINE_MINOR_VERSION < 1 #if WITH_EDITORONLY_DATA virtual bool GetFunctionHLSL() override; virtual void GetParameterDefinitionHLSL() override; virtual void GetCommonHLSL() override; #endif #else #if WITH_EDITORONLY_DATA virtual bool GetFunctionHLSL() override; virtual bool AppendCompileHash() const override; virtual void GetParameterDefinitionHLSL() override; virtual void GetCommonHLSL() override; #endif //virtual bool UseLegacyShaderBindings() const override { return false; } virtual void BuildShaderParameters() const override; virtual void SetShaderParameters() const override; virtual FNiagaraDataInterfaceParametersCS* CreateShaderStorage() const override; virtual const FTypeLayoutDesc* GetShaderStorageType() const override; #endif . . .

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

Теперь рассмотрим пример с более продуманным подходом к интерфейсу.

class ApiClient { public: class RequestFunction { public: . . . enum HTTPMethod { GET, POST }; bool hasPermission(); std::optional<std::string> applyFunction(); bool isWriteOperation() const; bool isAuthless() const; HTTPMethod method() const; private: . . . }; . . . static bool contains(); static std::optional<RequestFunction> get(); private: . . . };

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

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

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

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

Всем известно что версии программного обеспечения принято нумеровать следующим образом.

major.minor[.build[.revision]]

Так вот, если планируется вносить изменения в код на этапах доработки (revision) и сборки (build), правило один интерфейс, одно действие необходимо соблюдать. Сразу уточню, не стоит это понимать буквально, любой интерфейс может содержать несколько методов, но описывать одно действие. Для примера. Переставить монитор, это одно действие, но содержит три условных «метода», поднять, переместить, опустить.

На этапе внесения второстепенных изменений (minor) лучше модифицировать в рамках отдельных модулей. Модули как правило имеют более обобщенные интерфейсы, так как организуют не отдельные алгоритмы, а блоки действий. В качестве примера можно рассмотреть, то как создаются крупные приложения, состоящие из десятков файлов. Благодаря описанным интерфейсам, более старые версии модулей-файлов можно заменять на новые, если конечно программа корректно разделена на модули.

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

Давайте рассмотрим этот прием разделения интерфейсов, на примере создания небольшого модуля для записи и чтения параметров программы в файл инициализации (.ini).

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

Если бы модуль ограничивался только этими двумя операциями, то этого интерфейса было бы достаточно (условно major). Однако файлы инициализации, пользователи могут редактировать самостоятельно. А значит, на этапе тестирования и эксплуатации, необходимо будет вносить изменения, добавляя или корректируя условия проверки данных. Следовательно запись и чтение необходимо разделить на два отдельных интерфейса (условно minor).

Диаграмма классов<br />
Диаграмма классов

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

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

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

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

Диаграмма классов<br />
Диаграмма классов

Таким приемом мы решаем поставленную задачу и сохраняем возможность обратной совместимости. Функционал не использующий импорт будет работать как и прежде «не замечая» добавленных возможностей.

Теперь хотелось бы остановиться на работе с данными. В нашем примере для работы с данными создан отдельный класс (Info). Фактически он является оберткой для динамического массива, так зачем нам лишний класс. Для этого существует две причины, если при модификации данные станут более сложными, придется переписывать весь код связанный с этими данными, так как хранилище жестко завязано с основным кодом. Например, если пара будет состоять не из двух строк, а из строки и массива пар. Вторая причина более важная, но менее очевидная, используя базовые инструменты языка вы привязываетесь к низкоуровневому образу мышления при программировании. Однако весь опыт разработки программного обеспечения говорит о необходимости повышения уровня абстракции, ведь не просто так в верхних строчках рейтинга находятся языки Python и JavaScript.

namespace INIModule { class Info { private: std::vector<std::pair<std::string, std::string>> vec; public: void add(const std::string parametr, const std::string value) { vec.push_back(std::make_pair(parametr, value)); } const std::string to_str() const { std::stringstream sstream; for (auto& elem : vec) { sstream << elem.first << "=" << elem.second << std::endl; } return sstream.str(); } }; namespace { class IWrite { public: virtual void Write(Info& info) = 0; virtual ~IWrite() {}; // RAII }; class WriteToFile final : public IWrite { private: std::ofstream outfile; public: WriteToFile(std::string filename) { outfile.open(filename, std::ios_base::in); } virtual void Write(Info& info) override { outfile << info.to_str(); } virtual ~WriteToFile() { outfile.close(); } }; class IRead { public: virtual Info Read() = 0; virtual ~IRead() {} // RAII }; class ReadFromFile final : public IRead { private: std::ifstream infile; public: ReadFromFile(std::string filename) { infile.open(filename, std::ios_base::out); } virtual Info Read() override { Info info; std::string buffer; while (infile >> buffer) { auto index = buffer.find("="); info.add(buffer.substr(0, index), buffer.substr(index + 1)); } return info; } virtual ~ReadFromFile() { infile.close(); } }; } class IModule : public IWrite, public IRead {}; class INIFile final : public IModule { private: std::string filename; public: INIFile(std::string name) : filename(name) {} virtual void Write(Info& info) override { auto wtf = std::make_shared<INIModule::WriteToFile>(filename); wtf->Write(info); } virtual Info Read() override { auto rff = std::make_shared<INIModule::ReadFromFile>(filename); return rff->Read(); } }; } int main() { INIModule::Info outinf; outinf.add("first", "parametr"); outinf.add("second", std::to_string(10)); auto ini = std::make_shared<INIModule::INIFile>("test.ini"); ini->Write(outinf); auto ininf = ini->Read(); std::cout << ininf.to_str() << std::endl; return 0; }
Начать дискуссию