[5 Minutes] Rookie Study on Design Patterns: Six Design Principles
[TOC]
As a rookie, the author will attempt to explain the characteristics and application scenarios of these principles in simple code and easy-to-understand language.
The six principles are the Single Responsibility Principle, Interface Segregation Principle, Liskov Substitution Principle, Law of Demeter, Dependency Inversion Principle, and Open/Closed Principle.
Single Responsibility Principle
The Single Responsibility Principle (SRP) states that a class should have only one reason to change.
The core of the Single Responsibility Principle is decoupling and enhancing cohesion.
Issue:
// Suppose this class is the database context
public class DatabaseContext { }
public class Test
{
private readonly DatabaseContext _context;
public Test(DatabaseContext context)
{
_context = context;
}
// User login
public void UserLogin() { }
// User logout
public void UserLogout() { }
// Add a user
public void AddUser() { }
// Update user information
public void UpdateUser() { }
// Delete a user
public void DeleteUser() { }
}</code></pre>
The Test class is responsible for responsibilities P1 (user login and logout) and P2 (user account management). When a change in the requirement of P1 necessitates a modification in the class, it may cause the proper functionality of responsibility P2 to fail.
In the code above, the two responsibilities are coupled together, fulfilling multiple functions.
A class should have only one reason to change, meaning it should only be responsible for one functionality, with tightly coupled code inside the class.
The above example is very simple, and we can naturally split it into two classes.
// Suppose this class is the database context
public class DatabaseContext { }
public class Test1
{
private readonly DatabaseContext _context;
public Test1(DatabaseContext context)
{
_context = context;
}
// User login
public void UserLogin() { }
// User logout
public void UserLogout() { }
}
public class Test2
{
private readonly DatabaseContext _context;
public Test2(DatabaseContext context)
{
_context = context;
}
// Add a user
public void AddUser() { }
// Update user information
public void UpdateUser() { }
// Delete a user
public void DeleteUser() { }
}</code></pre>
Thus, the solution to the Single Responsibility Principle is to encapsulate different responsibilities into different classes or modules.
Interface Segregation Principle
The Interface Segregation Principle (ISP) requires interfaces to be fine-grained, ensuring that a class inherits interfaces at the smallest granularity so that every method inherited by a client is needed by it.
The author has referenced some foreign materials, most of which define the Interface Segregation Principle as:
“Clients should not be forced to depend upon interfaces that they do not use.”
This means clients should not be forced to depend on methods they do not use.
This principle is explained very thoroughly in this article:
https://stackify.com/interface-segregation-principle/
This requires us to split bloated interfaces into smaller and more specific ones, making the functions that the interfaces are responsible for more singular.
Purpose: To reduce the side effects and frequency of required changes by dividing software into multiple independent parts.
The author considers two aspects:
First, when describing various animals, we might classify different species. However, this is not enough; for example, in the category of birds, we generally assume birds can fly, but penguins cannot fly~.
Thus, we need to further categorize them based on specific characteristics, such as blood color, whether they have a backbone, etc.
Second, we can express this through the following code:
// Related to login
public interface IUserLogin
{
// Login
void Login();
// Logout
void Logout();
}
// Related to user accounts
public interface IUserInfo
{
// Add a user
void AddUser();
// Update user information
void UpdateUser();
// Delete a user
void DeleteUser();
}</code></pre>
The two interfaces above implement different functionalities without overlap, which is perfect.
Next, let’s look at two classes that implement the IUserLogin interface:
// Managing user login and logout, resource preparation and release
public class Test1 : IUserLogin
{
public void Login(){}
public void Logout(){}
}
public class Test2 : IUserLogin
{
public void Login()
{
// Get user's unread messages
}
public void Logout()
{
}
}</code></pre>
For Test1, different operations are performed based on the login and logout states.
However, for Test2, it only needs the login state, and other situations are irrelevant to it. Hence, Logout()
is completely useless to it, which is an example of interface pollution.
The above code violates the Interface Segregation Principle.
However, a drawback of the Interface Segregation Principle is that it can lead to excessive interface fragmentation. Having thousands of interfaces in a project can be a maintenance nightmare.
Thus, the Interface Segregation Principle should be applied flexibly; for Test2, having an additional inherited method is not harmful; it can simply be unused. Many such implementations exist in ASP.NET Core.
public void Function()
{
throw new NotImplementedException();
}
Example address: https://github.com/dotnet/aspnetcore/search?q=throw+new+NotImplementedException%28%29%3B&unscoped_q=throw+new+NotImplementedException%28%29%3B
In Chapter Four of "Zen of Design Patterns," the author summarizes four requirements for the Interface Segregation Principle:
Interfaces should be as small as possible: avoid bloated interfaces.
Interfaces should be highly cohesive: enhance the handling capacity of interfaces, classes, and modules.
Custom services: Small granular interfaces can be composed into larger interfaces for flexible new functionality customization.
Interface design is limited: it is difficult to have a fixed standard to measure whether the granularity of an interface is reasonable.
Additionally, there are discussions on the relationship and comparison between the Single Responsibility Principle and the Interface Segregation Principle.
The Single Responsibility Principle looks from the service provider’s perspective, offering a cohesive, single-responsibility function;
The Interface Segregation Principle looks from the user’s perspective, also achieving high cohesion and low coupling.
The granularity of the Interface Segregation Principle may be smaller, flexibly composing multiple interfaces into a class that conforms to the Single Responsibility Principle.
We can also see that the Single Responsibility Principle tends to center around classes, while the Interface Segregation Principle focuses on interfaces and abstractions.
Open/Closed Principle
The Open/Closed Principle states:
“Objects (classes, modules, functions, etc.) in software should be open for extension but closed for modification.”
-- Bertrand Meyer, author of "Object-Oriented Software Construction"
The Open/Closed Principle means that an entity is allowed to change its behavior without modifying its source code. Changes to a class are implemented by adding code rather than modifying the source code.
The Open/Closed Principle includes Meyer’s version of the principle and the Polymorphic Open/Closed Principle.
-
Meyer’s Open/Closed Principle
Once the code is complete, a class's implementation should only be modified due to errors; new or altered features should be implemented by creating different classes.
Characteristics: inheritance, where a subclass inherits from a superclass, inheriting all its methods and extending them.
-
Polymorphic Open/Closed Principle
This principle uses interfaces rather than parent classes to allow different implementations, making it easy to replace them without changing their code.
In most cases, the Open/Closed Principle refers to the Polymorphic Open/Closed Principle.
When researching the Polymorphic Open/Closed Principle, the author found that this interface refers not to Interface
, but to abstract methods and virtual methods.
Question: What are the three major characteristics of object-oriented programming? Answer: Encapsulation, inheritance, polymorphism.
Yes, the Polymorphic Open/Closed Principle refers to this polymorphism. However, the principle requires that methods should not be overloaded or hidden.
Here is an example:
// Implementation of login and logout
public class UserLogin
{
public void Login() { }
public void Logout() { }
public virtual void A() {/* Do something */}
public virtual void B() {/* Also do something */}
}
public class UserLogin1 : UserLogin
{
public void Login(string userName) { } // Should we overload the method of the parent class?
public override void A() { } // √
public override void B() { } // √
public new void Logout() { } // Perhaps okay?
}
The benefit of the Polymorphic Open/Closed Principle is that it introduces abstraction, decoupling the two classes and allowing for the subclass to replace the superclass without modifying the code (Liskov Substitution Principle).
Sometimes, questions arise about the difference between interfaces and abstract classes.
The author vaguely recalls an explanation: interfaces are meant to implement common standards; abstractions are for code reuse.
Of course, both interfaces and abstractions can implement the Liskov Substitution Principle.
Through the Open/Closed Principle, we can understand polymorphism and the application scenarios of interfaces and abstractions.
Another question is that the Open/Closed Principle requires that when modifying or adding functionality, it should be done through subclasses rather than modifying existing code. So, is it permissible or should one overload and hide methods of the parent class?
At the core of the Open/Closed Principle is constructing abstractions, thus achieving extensions through subclassing. This aspect seems not to have been addressed.
The author feels this should not be the case...
Let’s discuss this issue further after considering the following Liskov Substitution Principle.
Liskov Substitution Principle
The Liskov Substitution Principle (LSP) states: wherever a parent class appears, a child class should be substitutable for it.
This means that subclasses must exhibit the same behavior as parent classes. The Liskov Substitution Principle is only satisfied when subclasses can replace any instance of the parent class.
Constraints of the Liskov Substitution Principle:
- Subclasses must implement the abstract methods of the superclass but should not override the methods already implemented.
- Subclasses can add methods to extend functionality.
- When subclasses override or implement (virtual/abstract) methods from the superclass, the input parameter constraints of the method should be looser, and the return value should be more strict than that of the parent class method.
Thus, we see in the examples of the Open/Closed Principle that should subclasses be allowed to overload methods of the superclass? Should they use the new keyword to hide superclass methods? To ensure that subclasses inherit while retaining characteristics consistent with the parent class, such practices are not advisable, my friend.
By adhering to the Open/Closed Principle, naturally, the Liskov Substitution Principle can be fulfilled.
Dependency Inversion Principle
The Dependency Inversion Principle requires that applications should depend on abstract interfaces rather than concrete implementations.
We can gradually evolve and derive theories from the code.
// Implementation of login and logout
public class UserLogin
{
public void Login(){}
public void Logout(){}
}
public class Test1 : UserLogin { }
public class Test2
{
private readonly UserLogin userLogin = new UserLogin();
}
public class Test3
{
private readonly UserLogin _userLogin;
public Test3(UserLogin userLogin)
{
_userLogin = userLogin;
}
}</code></pre>
In the code above, Test1, Test2, and Test3 all depend on UserLogin. Without discussing any issues with the above code, the dependency inversion principle should be coded like this:
// Related to login
public interface IUserLogin
{
void Login(); // Login
void Logout(); // Logout
}
// Implementation of login
public class UserLogin1 : IUserLogin
{
public void Login(){}
public void Logout(){}
}
// Implementation of login
public class UserLogin2 : IUserLogin
{
public void Login(){}
public void Logout(){}
}
public class Test4
{
private readonly IUserLogin _userLogin;
public Test4(IUserLogin userLogin)
{
_userLogin = userLogin;
}
}</code></pre>
The Dependency Inversion Principle introduces an abstraction that separates high-level modules from low-level modules. High-level and low-level modules are loosely coupled, such that changes in low-level modules do not necessitate changes in high-level modules.
The Dependency Inversion Principle encompasses two ideas:
-
High-level modules should not depend on low-level modules; both should depend on abstractions.
-
Abstractions should not depend on details; details should depend on abstractions.
By relying on abstractions, low-level modules can freely replace any module that implements the abstraction.
The Liskov Substitution Principle requires that subclasses and parent classes exhibit consistent behavior so that subclasses can substitute parent classes.
The Dependency Inversion Principle allows completely different behaviors for each method.
Law of Demeter
The Law of Demeter states that two classes should maintain minimal contact with each other.
For example, object A should not directly call object B but should communicate through an intermediate object C.
Please refer to https://en.wikipedia.org/wiki/Law_of_Demeter
Advantages: Loose coupling, reduced dependencies.
Disadvantages: Requires writing many wrapper codes, increasing complexity and reducing communication efficiency between modules.
The author researched a lot of materials, most of which pertained to Java...
Eventually, a C# article was found: https://www.cnblogs.com/zh7791/p/7922960.html
Generally, the Law of Demeter is not frequently mentioned. If the code complies with the Dependency Inversion Principle and Liskov Substitution Principle, it can be considered that it also complies with the Law of Demeter.
文章评论