[5+1] The Richter Substitution Principle (2)

Preface

The object-oriented SOLID design principle, plus a Dimit's rule, is the 5+1 design principle we often say. 640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1↑ Five, and one more is 5+1. Hahaha.
The position of these six design principles is a bit overwhelming. In terms of principle and theoretical guiding significance, they are inferior to encapsulation, inheritance, abstraction or high cohesion and low coupling, so when writing code or code review, they are difficult to be convincing "should do this" or "should not do this" Reason for force. In terms of flexibility and practical operation guidelines, they are inferior to design patterns or architectural patterns, so even if you can tell that a certain piece of code violates a certain principle, it is often difficult to clearly point out what is wrong and how to change it.
So, here to discuss the "why" and "how" of these six design principles. By the way, as a part of object-oriented design ideas, I also want to talk about their relationship with abstraction, high cohesion and low coupling, and encapsulation and inheritance polymorphism.



[5+1] Richter substitution principle (1)

Please click me to read the previous article.



Liskov substitution principle


Richter substitution and object-oriented

"Result orientation" and "process control" are common ideas in project management.
Take product demand as an example.
From a result-oriented perspective, we only need to achieve the business functions proposed in the requirements and we are done. As for what technology is used and what design is made, it doesn't really matter.
From the perspective of process control, we are not only responsible for the final results, but also for the process of project progress, plan details, and milestone products.
Obviously, the two are opposites and unity. One-sided emphasis on one aspect while ignoring the role of the other will bring unnecessary problems to the project. Only by "grasping with both hands" can "both hands be hard."
The Richter substitution principle is the embodiment of these two project management ideas in object-oriented design.
From a result-oriented perspective, as long as we handle the input and output parameters of the interface definition, we are done. As for whether the interface is a "god implementation class", a template class-implementation class, or a proxy class-implementation class... it doesn't really matter.
From the point of view of process control, we are not only responsible for the final interface, but also for the process of class hierarchy, code quality, readability and maintainability.
The two are unified in the Richter substitution principle.
The Richter substitution principle requires that the child class does not change the behavior of the parent class, essentially requiring the same parameters to get the same results. But it doesn't care whether the actual implementer of the function is a parent or a child. Isn't this just "requires the correct implementation of the function, and does not care whether the code is written by Lao Wang or Xiao Li"? Isn't it a result-oriented way of thinking?
At the same time, in order to ensure that both the subclass and the parent can get the same result, the Richter substitution principle puts forward many requirements for the operation of "subclass inherits from the parent". Subclasses cannot override the non-abstract methods of the parent class, but can add their own processing methods, etc. These are all concrete measures for process control.
Combining result orientation with process management and control, project management can be done well. Similarly, with the help of these two ways of thinking, object-oriented can also be successful. The Richter substitution principle is the only way to make object-oriented become popular.



Richter substitution and abstraction

We have repeatedly mentioned that abstraction in object-oriented must remain stable and cannot be changed day by day. In most contexts, we are talking about code stability during compilation, such as keeping method signatures unchanged, and input and output parameter types unchanged. The Richter substitution principle puts forward a higher stability standard: functional stability during operation.
If we break the compile-time stability, such as adding a method parameter, Java will give an Error alert when the code is compiled. However, if we break the runtime stability, no one will yell at our ears "There is a problem in this place"-even if we do a lot of unit tests and integration tests, it is easy to miss the problem.
For example, one of our systems uses SpringBatch+HIbernate for batch processing. In order to simplify the code and configuration, a colleague put the code that should be placed in the Processor into a custom Writer:

public class UpdateOverDueDaysWriter extends HibernateItemWriter<Record> {/** * Update overdue days*/ @Override public void write(List<Record> items) {for(Record r: items) {Plan plan = r.getPlan(); // Calculate the overdue days of the repayment record if(plan.getState() == State.RESERVED) {//Calculation logic is slightly int overDueDays = caclulate(); // With the help of Hibernate's Session automatic flush mechanism, it can be automatically updated after set In the database. r.setOverDueDays(overDueDays);}}}}


According to this colleague’s vision, although the UpdateOverDueDaysWriter class does not explicitly update the database, after r.setOverDueDays(overDueDays), Hibernate’s transaction manager should be able to automatically call Session.flush(), thereby changing the new overDueDays The value is updated and stored. After all, its parent class HIbernateItemWriter implements this function. Therefore, although it does not conform to the SpringBatch specification, the function of this class should be no problem.

I used two "shoulds"-"should be ok" and "should be no problem". In fact, these two "shoulds" have all failed: overDueDays in the database has not been updated.
Why is this?
Let's take a look at the key source code of the parent class HibernateItemWriter:

public class HibernateItemWriter<T> implements ItemWriter<T>, InitializingBean {
 private SessionFactory sessionFactory;
 private boolean clearSession = true;
 @Override  public void write(List<? extends T> items) {    
   doWrite(sessionFactory, items);
    
    // 注意这个地方:这里手动调用了Flush方法
   
   sessionFactory.getCurrentSession().flush();
   
   if(clearSession) {
     
     sessionFactory.getCurrentSession().clear();
   
   }
 
 }
 
}

Note the line I added a comment. HibernateItemWriter explicitly calls the Session.flush() method here, instead of handing it over to Hibernate's transaction management mechanism. Although not sure why, this is an important reminder: the Session.flush() method is not automatically called by the HIbernate transaction manager, but requires code display calls to ensure that the data in the HIbernate Session is updated to the database.

However, when our subclass UpdateOverDueDaysWriter rewrites the HIbernateItemWriter.wite() method, although the interface, method signature or return value type is not changed, the code that calls Session.flush() in the parent class method is completely abandoned by the subclass: The subclass rewrites and changes the function of the parent class method, causing the data to be unable to update and store.
In other words, when the subclass UpdateOverDueDaysWriter rewrites the HIbernateItemWriter.wite() method, it violates the Richter substitution principle, destroys the functional stability of the write() method, and ultimately leads to lack of functionality and online bugs.
What is even more frightening is that our code compilation, static checking, code review, unit testing, QA testing, UAT testing and online deployment did not find this problem, because another batch task started at the same time as this batch task. This field is also updated-the latter is actually updated and stored. It was not until two years later that the second batch processing task was successfully completed and the deleted code went offline. We only found out: Why has the overDueDays field not been updated after two days have passed?
Fortunately, we discovered and solved this problem within two days of the incident. If an online bug occurs during the National Day or Spring Festival, the consequences are simply unimaginable.
This is the terrible thing about disrupting functional stability: there is no way we can guarantee that problems are found before online failures occur. In fact, the Richter's Substitution Rule can't solve this problem, so it changed the way of thinking: changing from repair afterwards to prevention beforehand. We can even say that the stability of abstract functions is directly proportional to the degree of strictness with which it complies with the Richter substitution principle.



Richter substitution and high cohesion and low coupling

The Richter substitution principle is not strongly related to high cohesion and low coupling. Following the Richter substitution principle, it is also possible to write code with low cohesion and high coupling.
However, the Richter substitution principle requires us to examine the hierarchical relationship between classes more deeply and put the code and functions in a more appropriate position. After doing this, usually we can get a higher cohesion and low coupling class.
For example, suppose we have such a class:


public class SomeService implements SomeInterface{    @Override    public Result service(Param param){        valid(param);         ServiceDO sDo = doService(param);         return transToResult(SDo);    }     protected void valid(Param param) throws ValidException{        // 略    }    protected ServiceDO doService(Param param){        // 略    }     protected Result transToResult(ServiceDO sDo){        // 略    }}


When we need to add a new business, we can simply add a subcategory:


public class OtherService extends SomeService{ @Override protected void valid(Param param)throws ValidException{ // Another verification logic, omitted} @Override protected ServiceDO doService(Param param){ // Another service logic, omitted}}


From the perspective of high cohesion and low coupling, although this is better than the if-else approach, it is still "still unsuccessful": unnecessary subclass coupling between SomeService and OtherService. If SomeService modifies part of the code for its own business reasons, OtherService will also be affected.

These two categories obviously violate the Richter substitution principle. The subclass OtherService overrides the methods valid() and doService() implemented by the parent class. If we want to add a verification rule to two methods at the same time, obviously, it is useless to modify the parent class SomeService.
Following the guidance of the Richter substitution principle, we can adjust the class structure to this:


public abstract class AbstractService implements SomeInterface{
    @Override
    public Result service(Param paran){
        valid(param);
        ServiceDO sDo = doService(param);
        return transToResult(SDo);
    }
    protected abstract void valid(Param param) throws ValidExcption;
    protected abstract ServceDO doService(Param param);
    private Result transToResult(ServiceDO sDo){
        // 略
    }
}

public class SomeService extends AbstractService{
    @Override
    protected void valid(Param param) throws ValidException{
        // 略
    }
    @Override
    protected ServiceDO doService(Param param){
        // 略
    }
}

public class OtherService extends AbstractService{
    @Override
    protected void valid(Param param) throws ValidException{
        // 略
    }
    @Override
    protected ServiceDO doService(Param param){
        // 略
    }
}


Such a class hierarchy is more in line with the requirements of the Richter substitution principle; at the same time, the coupling between SomeService and OtherService is also lower: in addition to increasing the amount of code, everyone is happy.



Richter substitution and encapsulation inheritance polymorphism

The relationship between Richter's Substitution Principle and inheritance and polymorphism is needless to say: it can be described as the "best practice" of inheritance and polymorphism. But the relationship between it and packaging is not so obvious.
When it comes to "encapsulation", we usually think of visibility modifiers such as public/protected/private. In fact, they are just encapsulation tools, not the encapsulation itself-as Zen masters said, this is just "the road to Buddhahood", not "the matter of Buddhahood".
For "encapsulation", the so-called "things of becoming a Buddha" are one "sealing" and one "installation": those that belong to a class are "sealed" in this class; those that belong to a class are "installed" In this class. As long as these two points are achieved, "encapsulation" is achieved.
So, what does the Richter's Substitution Principle "seal" up? The answer is "non-abstract methods in the parent class". Even if the modifier of this non-abstract method is public, default or protected, even if this non-abstract method is not a final method, the Richter substitution principle prohibits subclasses from overriding it. Isn't this also a kind of "seal"?
The Richter's Substitution Principle is a principle of "enclosure" as well as a principle of "installation". It stipulates that the subclass cannot override the non-abstract method of the parent class. It also stipulates that if a non-abstract method in a class is overridden by the subclass, then this method should not be placed in the current class. We should define a new class to "install" the abstract definition of this method; at the same time, let the original two classes inherit this new class in order to "install" two different implementations of the abstract method.
However, for the Richter substitution principle, whether it is "sealing" or "installation", basically it can only be regulated by people, and it is difficult to use grammar, compilers and other tools to make constraints. This is probably another reason why the Richter substitution principle is rarely mentioned in practice.



Richter replacement and other design principles
Richter replacement and single responsibility

Understanding the relationship between the Richter substitution principle and encapsulation actually clarifies the relationship between the Richter substitution principle and the single responsibility principle: when we promote a method from a child class to a parent class, or two classes from When the parent-child class is converted to the sibling class, we not only follow the Richter substitution principle, but also follow the single responsibility principle.
Take the previous code as an example. When our subclass rewrites the non-abstract methods in the parent class, their class structure is shown in the following figure:

640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1

In the above structure, ClassA assumes two responsibilities: its own business functions, and the responsibility of defining process templates for ClassB. And ClassB also assumes two responsibilities: its own business functions, and other business functions in ClassA. Obviously, they both assume some functional responsibilities that are not their own. Therefore, these two categories not only do not comply with the Richter substitution principle, but also do not comply with the single responsibility principle.
If we follow the requirements of the Richter substitution principle, transform the above class structure into this:

640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1

After the transformation, ClassC only assumes the responsibility of defining the process template, not any specific business functions; ClassA only assumes its own business functions, and no longer assumes the responsibility of defining the process template; ClassB also only assumes its own business functions, and The business functions of ClassA are no longer included. At this time, we can say: These three categories not only follow the Richter substitution principle, but also follow the single responsibility principle.



Richter replacement and opening and closing

The principle of opening and closing requires us to "open to new additions and close to modifications." The Richter substitution principle refines this principle between parent and child classes: we can add inheritance levels or add subclass methods, which is "open to new additions"; but we cannot modify the implementation in the parent class This is the "close to modification".



When discussing abstraction before, we mentioned that abstraction is hierarchical. Through inheritance, we can divide an abstract "vertically" into multiple levels; with the help of polymorphism, we can divide the abstract "horizontally" into multiple implementation classes within the same level. Between this vertical and horizontal, if not handled properly, the intricate parent-child coupling will turn the code into a mess. With the help of Richter's substitution principle, we can gradually split the abstract complexity and eliminate it into the invisible.
Therefore, although the Richter's Substitution Principle is a bit confusing and difficult to use, it is indeed a magic weapon and it is worth our time to master it.
Of course, we also have other ways to deal with abstract complexity, such as splitting a complex abstraction into multiple simple abstractions. However, this is the content of the next chapter-Interface Segregation Principles. So let's break it down next time.

qrcode?scene=10000004&size=102&__biz=MzUzNzk0NjI1NQ==&mid=2247484831&idx=1&sn=9462f09f48b68e3ec97119a8e9d012aa&send_time=


Guess you like

Origin blog.51cto.com/winters1224/2540777