Location>code7788 >text

More than object-oriented SOLID principles

Popularity:320 ℃/2024-08-02 11:28:40

The SOLID principle was developed by Rober C. Martin, known as "Uncle Bob". He used the initials of five object-oriented design principles to form SOLID, and it has spread widely. The five principles are listed below:

  • Single Responsibility Principle (SRP): a class should have a single responsibility. Single is measured in terms of the dimension of change, i.e. a class should have only one cause of change.
  • Open-Closed Principle (Open-Closed Principle): The design module should be closed to modification and open to expansion.
  • Liskov Substitution Principle: Subclasses should fully implement all behavior required by the parent class. Replacing the parent class does not result in a change in the program's behavior.
  • Interface Segregation Principle: Dependencies between classes should be built on minimal interfaces. Users should not be made to depend on methods they cannot use.
  • Dependency Inversion Principle (Dependency Inversion Principle): high-level modules should not depend on low-level modules, both should depend on the interface.

The SOLID principle addresses many aspects of object orientation, such as cohesion, coupling, and good separation of concerns. AlthoughThese five principles are neither comprehensive nor orthogonal, yet still very positively instructiveThey are not limited to object-oriented design. They are not limited to object-oriented design, from functions, classes, components, and then to the system architecture, in all levels of software design they are excellent guidelines. The following author will be introduced one by one , and from their own main business Front-end / client development field to extract examples to explain in detail.

SRP: Single Responsibility Principle

Single responsibility is the first to be introduced, and at the same time is one of the simplest yet most important principles in the author's opinion. Let's describe it like this:

There should be one and only one reason for any software module to be modified.

No matter what level of design we are doing, we are constantly breaking down the module elements according to the goals and assigning responsibilities to these modules; the modules collaborate with each other and combine to become a bigger module to fulfill a bigger responsibility, which eventually constitutes a complete system from the bottom up. Below are some positive/negative examples from different perspectives to help us better understand this principle:

1. Low cohesion components

There is a type of SVG component in the visual editor's repository, shown below, that accepts a set of configurations to update the style of the SVG rendering, and also plays an animation of the movement of objects on the path:

One of the follow-up issues so brought up is thatprocessStylemethod depends ongetParsedPathmethod; and animation effects are a new feature added later, again relying directly on thegetParsedPath. When the student who initially maintained the SVG component receives a request for a modification, he or she makes as many changes as necessary to thegetParsedPathThe method was internally adjusted and the changes were committed after verifying that the new requirements were met. However, the same dependency on thegetParsedPathmethodologicalprocessAnimationbut was unaware of the change and threw an exception after the next release when it no longer worked properly with it.

One obvious place to start is with the fact that animation handling responsibilities should not belong to the SVG component. When we put code with different responsibilities in one place, it's easy to create conflicts. To solve this problem, the idea is to strip out the responsibilities that don't belong to the SVG component.

A new one is created hereAnimationControllerclass, specialized to handle animations and associated by SVG components. At the same time, both rely on the handling of the paths in question, and thepredictableIn the future, there is a high probability that similar functionality will be reused; therefore, a set of generalized SVG path processing related tool functions have been extracted.SVG Utils

The refactored code, despite a slight increase in the amount of code and the relative complexity of the call relationships, provides additional benefits:

  • Segregation of duties reduces risks associated with module changes
  • Clearer separation of concerns
  • Code Reusability Improvement

2. The more responsibilities a module contains, the more likely it is to create conflicts.

Typically, a module corresponds to a single source file, which may be a function, a class or a component. The more responsibilities a module contains, the more maintainers it has to deal with. Let's take the above example of an SVG component before refactoring:

One day both the SVG component and the animation received new requirements or bugs needed to be handled, and both tasks were taken on by two developers; this is common because animation requirements are often not specific to one component but to a group of similar components in the library, so it makes sense to assign them to different developers.

These two developers then pulled the new branch from the trunk to the local, and merged it back into the trunk after completing their own tasks. Since they both made changes to the source file for the SVG component, it was no surprise that a conflict arose during the code merge. One of the developers has to do the "dirty work" of conflict handling, reading the conflicting code and communicating with the other developer to make sure there is no ambiguity before submitting the merged code.

This can be largely avoided by splitting the code based on function. Although for most people, especially those who are familiar with the project, it may seem trivial to deal with code merge conflicts, for a large project with multiple people, frequent code merge conflicts mean additional work and a higher probability of errors. However, for a large project with many people working on it, frequent code merge conflicts mean more extra work and higher probability of errors.

3. Highly cohesive components for better maintainability

Let's take an example of the architecture of a project under Unity that supports hot updates and simplify it as follows:

Briefly describe the function of each component from the bottom up:

  • Unity Engine Core: the underlying framework that has been defined since the beginning of the project, after which thealmost allNo changes are possible. Built as an executable and DLL.
  • Library files with game scripts written by yourself: This part is mainly made up of C# scripts that implement general support and performance sensitive logic modules in the game, such as engine API bridging, resource loading/unloading, network requests, game pathfinding algorithms, etc. The intermediate process will vary under different build methods (Mono/IL2CPP), but the final product is a DLL file.
  • Lua Scripts: Most of the business logic that will be modified frequently in the game is placed here, such as UI, character combat logic, monster AI, etc.. This is where Unity's ability to support hot updates lies.
  • Asset Bundle: Assets outside of the game code (models, textures, prefabs and audio/video resources) can be typed into ab packages. Support dynamically downloading/unloading resources while the game is running.

With the above brief description of the components, it is easy to see that there are very clear boundaries between the components; the responsibilities they have and the reasons why they need to be modified are very clear. If the SRP principle is described at the level of system components, it can be stated as followsPut files that have been modified for the same purpose into the same component; then when changes are needed we only need to change to as few components as possible and be able to publish, validate, and deploy them independently

When the game needs to fix online bugs and tweak values instantly, just go through thehot update (computing)Just replace the lua file. By loading the ab package, it is possible to immediately replace in-game art resources in the event of temporary holiday operations/review policy adjustments. When a major update is released after many changes to the game's underlying mechanics, it is necessary to replace the art resources with thea cold updateway to replace newly built DLLs and other files after game client downtime.

OCP: Opening and Closing Principle

Well-designed computer software should be easy to expand while resisting modification.

First, let's translate the two concepts in this sentence again: "expand" refers to adding code entities, and "modify" refers to making changes to existing code entities. This principle requires that we should not always need to make changes to existing code when realizing new requirements, but only add new code; otherwise, with the subsequent increase in new requirements, the complexity of the code within the same module and the risk of error will increase, which is a bad design.

Like the principle of single blame, the principle of openness and closure is most of the time not used as a design tool, but ratherMeans of testing; used to determine if a design is good enough. Let's look at an example of rich text rendering. In a rich-text application that supports rendering multiple elements, the lack of a reasonably abstract design could result in "spaghetti" code like this:

class RichEditor extends  {
    // ...

    renderElement(type, params) {
        if(type === 'img') {
            /* ... */
        } else if(type === 'url') {
            /* ... */
        } else if(type === 'dateTime') {
            /* ... */
        }
    }

    render() {
        // ...
    }
}

Clearly, this is a module design that is not cohesive and does not conform to the single blame principle. Let's look at it again through the lens of the Open-Closed Principle:

Now there is a new requirement for rich text to also be able to render a piece of table content. The most natural choice for most people is to use therenderElement()Followed by a new judgment condition:

else if(type === 'table') {/* ... */}

This is a classic example of violating the principle of openness and closure: extensions are not supported, requiring changes to established code. Concerns about the rendering type are centered on therenderElement()Inside, we'll keep adding code to this function as our needs grow, causing therenderElement()Increasingly complex and bloated.

Let's refactor this design.

First the logic controlling the rendering of an element is removed from theRichEditorto strip it out and implement a managerElementRegisterTake over this part of the logic and provide a registration interface to use as an expansion portal. This makes theRichEditordependenciesElementRegisterElementRegisterDependent on a specific element rendering implementation (RichEditor ---> ElementRegister ---> xxxElement). From the dependency chain point of view there is no fundamental difference between this design and and the original code, since theRichEditoris always still dependent on the underlying concrete implementation of theNot closed enough to modifications. We then turn the dependency directioninvert (upside-down, inside-out, back-to-front, white to black etc)For a moment, provide an interfaceIElementto be changed to byRichEditordependent on the interface and no longer on the concrete implementation. The underlying rendering element is responsible for implementing theIElementInterface:

Adjusted to this dependency structure, the specific implementation of the underlying element becomes invisible to the upper layers, theRichEditor/ElementRegisterOriented only to the interfaceIElementIt is closed for modification. When a new element needs to be added, it is sufficient to add another entity that conforms to the interface; it is also open for expansion.

Combination is better than inheritance

Take a look at another example, selected from Ali's Galacean Engine column on system architecture:

Entities in a scene are objects created at runtime that can be rendered in the scene and provide various capabilities by adding components.In component-based architectures, composition takes precedence over inheritance. For example, if you want an entity to be able to emit both light and sound, adding a light component and a sound component will do the trick. This approach is ideal for businesses with a high level of complexity such as interactivity - it is easy to scale by adding only one component for a specific function.

If inheritance is used, the original inheritance relationship may need to be adjusted each time a new entity with a specific function is added. This relationship is very fragile, especially if the classes in the inheritance chain are complex and the hierarchy is deep. In scenarios where entities are frequently added to the interactive game business iterations, this means that inheritance relationships may be broken frequently. In this wayNo closure on modifications, which can result in significant maintenance costs. Instead, with a combined flat structure, theMore friendly to expansionIn this architecture, all entities usually only need to inherit from a unified base class.

LSP: Richter's substitution principle

The principle of Richter's substitution is governed byBarbara LiskovPresented in the following formulation:

S is a derived type of T if for every object o1 of type S, there exists an object o2 of type T such that in all programs P written for T, the behavior of program P is functionally inconvenient after replacing o2 with o1.

Translating this academic expression into layman's terms means that subclasses should implement all the behavior required by the parent class in its entirety, so that even if other subclasses are subsequently replaced in a program that uses the parent class, the program will not be affected/perceive the change.

According to the above expression, the most obvious benefit of a design that conforms to Richter's replacement principle is that, for the classes/interfaces that the higher-level modules depend on, if all inherited subclasses/interfaces implement the required behaviors as expected, then these dependencies are all replaceable. Greatly reduce the need to replace the underlying dependencies in the later migration costs. The following is a concrete example that I encountered in the project practice:

Designed to implement a map modeling engine, in addition to displaying the map's base map is also able to display back/user manually draw a variety of graphics, draw paths, play animation and so on. The needs of the business area is already identified, we are ready to first use the map SDK (AMap) of Golder as the underlying map rendering engine, after completing the function of deploying a set of public network to facilitate the demonstration to the customer. Subsequent is deployed in the Party's intranet environment, so there is still a need for development to the site, the map SDK can not be sure that the high German, but to replace the Party's specified.

So consider masking the specific map engine so that it is not visible to the upper level business side. Provide an abstract classAbstractMapWidgetClass, in which the underlying map engine is defined to have those behavioral capabilities. The upper level business relies on this abstract class:

Then use the SDK for Goldmap to implement aAMapclass so that he inherits fromAbstractMapWidgetClassand correctly implements all the behaviors required by the parent class. Subsequent deployments to different SDKs in different Party A environments are replaced in the same way; just create a new subclass inheriting from the abstract parent class. For the upper tier application, since it only relies on the abstraction and not the concrete underlying module, it will not be affected when we replace any map rendering engine.

At the software architecture level, the LSP principle should also be noted. Those parts of the system architecture that may change in the future, such as the underlying modules on which the upper-layer applications depend, the platform infrastructure, etc., should have a high degree of replaceability, so that later migration does not affect the upper layer (here, "upper and lower layers" are relative terms).

The bridge of articulation between the user and the replacement part is theInterface. Here it is possible to extend theinterface-oriented programmingThis important way of thinking (the interface here does not refer to the interface in the object-oriented language, but the interface in a broader sense, or perhaps a closer call should be "contract"); I will expand on this part of the discussion at the end.

ISP: Principles of Interface Segregation

ISP is a principle that guides how interfaces should be designed. It suggests that interfaces should be as small and cohesive as possible, and that things that are not used by the dependents should not be in the interface. Scenarios in which this principle is violated are often not in new designs implemented from scratch, but rather in the evolution of functionality:

At first our visualization designer was only the report designerFreeReport(a kind of free layout similar to PPT) with a contextFreeReportContextdependencies. This context implements the corresponding interfaceIContextThe problem with this was that we added a new designer. These all looked good at first, but the problem became apparent when we subsequently added the new designer. The dashboard designer was subsequently addedDashboardIt also has a contextual dependencyDashboardContext. The context similarly implements the selfIContextInterface.

The problem now is thatIContextMost of the behaviors defined inFreeReportContextcap (a poem)DashboardContextAll should be satisfied. However, there were very few original methods such as the page information in the above figurepageCountIn the newly addedDashboardContextThere is no corresponding behavior, and theDashboardContextNew entrants requiredminimapBehavior inFreeReportContextAgain, this is not needed. If we continue to expand the new designer types at a later stage, then the above situation will become more and more common, towards the end of theIContextAny new behavior added affects all previous interface implementation classes.

For unsupported behaviors, we can make them optional based on the TS syntax?Or just implement it as empty. This is not semantically sound and not necessary. A reasonable solution would be to "separate" behavior that is not common to all dependencies. This can be done in two ways:

in this wayFreeReportContextcap (a poem)DashboardContextThe interfaces they depend on are cleaner: they no longer contain behavior they don't need. Which of the above two options is better depends on how the system evolves in the future.

DIP: Dependency Inversion Principle

Higher level modules should not depend on lower level modules, both should depend on abstractions.

When we modify an abstract interface, the corresponding concrete implementation must also be modified. However, when we modify the concrete implementation, the corresponding abstract interface does not necessarily need to be modified. In addition, abstract interfaces are usually well-designed, and the probability that they will be adjusted in the future evolution process is much smaller. Therefore, we can say that the abstract interface layer is stable. Making both high-level and low-level modules dependent on the abstraction can lead to a more stable design.

The author is not going to give a new example of the principle of dependency inversion here. Looking back at the examples of the above design principles, especially the rich text and map examples, we can see that the optimized design solutions are in the form of the following diagram. We can find that the optimized design solutions are in the form of the following diagrams, by making both high-level and low-level modules dependent on the interface, the direction of the dependency arrows that would otherwise point to the low-level modules is "inverted" upwards:

One other concept was mentioned earlier: interface-oriented programming. Let's review again the three principles of Richter substitution, interface isolation, and dependency inversion, which are all concrete manifestations of interface-oriented programming. So the author feels the need to mention this programming paradigm again at the end.

The interface-oriented programming approach turns "A depends on a specific B" into "A depends on the standard defined by the interface" or "A depends on the capabilities defined by the interface". This is a very important difference in mindset.

"A depends on a specific B" is similar to the non-standard parts we encounter in our daily lives. Suppose a small part in a car breaks, and the part is a non-standard part, then the car needs to be taken to a specialized car store to be repaired. If this store doesn't have the part, it will still take time to order it. But if it's a light bulb that's broken at home, then all that's needed is to go to the neighborhood hardware store, where you can buy a new one and have it fixed in no time. This convenience is possible because all light bulbs must follow national standards, thus allowing for flexible interchangeability. What's more: the standardized interfaces have successfully decoupled all home lighting systems from the various lighting equipment manufacturers, with coupling existing only with the national standards. The national standard is very stable, so naturally the maintenance cost of the entire lighting system is significantly reduced.

Standardization is the basis of modern industry. For the standardization mentioned in the previous paragraph, the latest standard in force is GB/T 1406.1-2008 "Forms and Dimensions of Lamp Heads". For example, the most commonly used lamp head in daily life is the E27 screw-in lamp head, and a little finer is the E14 lamp head. It is because of these standards that various lamp manufacturers and bulb manufacturers can be independent and their products compatible with each other. This simplicity and interchangeability is also the goal pursued by software system design. Although the complexity of software systems is much greater than that of lighting systems, making it very difficult to achieve fully standardized definitions, it is a general principle to rely on interfaces rather than on specific implementations.

An interface is a "design contract" that separates the concerns of what to do (the interface) and how to do it (the concrete implementation of the interface). The implementer of the interface needs to ensure that it correctly performs all the duties defined in the abstract interface, while the dependents of the interface can obtain the corresponding services if they ensure that they correctly call the interface. This results in a more stable design.