Interface Separation Principle (ISP)

The Interface Segregation Principle suggests that you should not force a client to implement an interface that contains methods it does not need. Instead, you should break larger interfaces into smaller ones that focus on specific use cases.

This principle is probably the simplest to understand, but important for implementation. We constantly encounter the division of interfaces, the government of any country has interfaces called ministries, companies have interfaces in the form of departments, the motherboard has interfaces for connecting memory, processors and other peripherals. It is logical to do the same in programming.

What is the essence of the principle of interface separation? To rephrase in simple words, you don’t need to do two things at once. Human consciousness is built this way, remember the children’s task when you are asked to simultaneously reach your finger to the tip of your nose and stroke your stomach clockwise.

Before creating your own example, let's look at a couple of code snippets from the Internet. Private parts of classes, as well as constructors and method parameters, have been removed from the code. Solely to save space.

The first example is an excerpt from a huge API interface class, the entire class contains about 30 methods and is not of interest, but the section shown illustrates the problems that the author of the code encountered when modifying it.

. . .
#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
. . .

Due to the change of the Unreal Engine version, the programmer had to change the interface class by applying preprocessor directives, but as you can see, this did not solve the problem, he had to resort to commenting. Following the preprocessor conditions and commented methods, there were sections of code that do nothing. The so-called artifacts. The release of future versions will further complicate the problem.

Now let's look at an example with a more thoughtful approach to the interface.

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:
   . . .
};

In addition to the fact that the classes contain only the necessary number of methods, they are also organized properly. Agree, the query class is not needed anywhere else except in the class for working with this query. There is absolutely no need to create a universal query class for all types of queries, it will always be more complex.

Here I would like to clarify that this is an API interface, therefore it will change entirely, if at all. After all, no one has cancelled backward compatibility.

It's another matter if this code is an internal solution of a distributed application, in which case it's better to make the request class separately and based on the request interface. After all, the developer can ignore backward compatibility and boldly expand the existing functionality.

In general, there are many discussions on forums and in comments around the principle of interface separation. Some advocate for unification of methods, others for separation, one method, one interface. However, experience has allowed us to develop the following mnemonic device.

Everyone knows that software versions are usually numbered as follows.

major.minor[.build[.revision]]

So, if you plan to make changes to the code at the revision and build stages, the rule one interface, one action must be followed. Let me clarify right away, you should not take this literally, any interface can contain several methods, but describe one action. For example. Rearrange the monitor, this is one action, but it contains three conditional “methods”, raise, move, lower.

At the stage of making minor changes, it is better to modify within individual modules. Modules usually have more generalized interfaces, since they organize not individual algorithms, but blocks of actions. As an example, we can consider how large applications consisting of dozens of files are created. Thanks to the described interfaces, older versions of module files can be replaced with new ones, if, of course, the program is correctly divided into modules.

The remaining interfaces related to the main part of the program (major) can be of any size. If the program is initially used to view a file, then when adding the functionality of editing the file, you will still have to significantly change its logic.

Let's look at this technique of separating interfaces using the example of creating a small module for writing and reading program parameters into an initialization file (.ini).

First of all, it is necessary to separate the module being created from the main business processes. We use the Facade pattern to define its functionality. The planned module is trivial, so to work with it, only two methods are enough: writing data to a file and reading from a file.

If the module were limited to only these two operations, then this interface would be sufficient (conditionally major). However, users can edit the initialization files themselves. This means that at the testing and operation stage, it will be necessary to make changes, adding or adjusting the data verification conditions. Therefore, writing and reading must be divided into two separate interfaces (conditionally minor).

Class diagram

Class diagram

This is a simple example and further separation of interfaces is not required, but if the file was supposed to have several sections and interrelated parameters. Then it would be necessary to separate the reading interface again (conditionally build). Let's say methods for checking the correctness of data in sections and a separate one for checking interrelated data. When there is a complex business process that depends on many parameters in its various parts.

Changes at a lower level of abstraction do not affect the higher one, and you can not worry about the compatibility of the changes made. But you should pay attention to how the separation of interfaces helps to safely expand the functionality of the module.

Let's assume that we were given a new task, to implement the ability to import settings from another source. It is not advisable to create a separate functionality. It will be necessary to build a new module and implement an entry point into the main software product. In addition, it will be necessary to perform data integrity checks again, but this functionality is already in our module.

Therefore, we will be able to expand the current module by adding a new interface.

Class diagram

Class diagram

This method solves the task and preserves the possibility of backward compatibility. Functionality that does not use import will work as before, “not noticing” the added capabilities.

Now I would like to dwell on working with data. In our example, a separate class (Info) was created to work with data. In fact, it is a wrapper for a dynamic array, so why do we need an extra class? There are two reasons for this: if the data becomes more complex during modification, you will have to rewrite all the code associated with this data, since the storage is tightly tied to the main code. For example, if a pair consists not of two strings, but of a string and an array of pairs. The second reason is more important, but less obvious: using the basic tools of the language, you are tied to a low-level way of thinking when programming. However, all the experience of software development speaks of the need to increase the level of abstraction; it is not for nothing that Python and JavaScript are at the top of the rating.

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;
}

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *