Object-Oriented Design Principles
In software development, it’s essential not only to complete the core business functionalities but also to consider the maintainability and reusability of the entire project. The projects we develop aren’t solely for our own use; they also require collaborative maintenance by other developers. Therefore, we should write code as cleanly and consistently as possible. If we ignore these aspects, the project can become an unmanageable mess, and as it grows, its structure will only deteriorate.
Eventually, we might even find that our program is somehow running stably despite being built on top of numerous bugs...
To minimize the risk of this happening, let's discuss the principles of object-oriented design.
What is the SOLID Principle?
Simple Responsibility Pinciple ✅
The Single Responsibility Principle (SRP) is the simplest of object-oriented design principles, used to control the size and granularity of a class.
An object should have only one responsibility, and this responsibility should be entirely encapsulated within a single class.
In this example, we have a People
class:
// A person class
public class People {
/**
* People can code
*/
public void coding(){
System.out.println("int main() {");
System.out.println(" printf(\"Hello World!\");");
System.out.println("}");
System.out.println("Hmm, why isn't it running? I followed exactly what the instructor typed...");
}
/**
* People can also work in a factory
*/
public void work(){
System.out.println("So happy to join the assembly line at Foxconn.");
System.out.println("Wait, why are all my coworkers running away?");
}
/**
* People can also deliver food
*/
public void ride(){
System.out.println("Finally passed the final interview with Meituan and joined the dream company.");
System.out.println("The interview seemed pretty easy. Not sure why my friend had to solve a LeetCode 'Trapping Rain Water' problem, while I just had to say I could ride a bike.");
System.out.println("(*Excitedly puts on the delivery uniform*)");
}
}
Here, the People
class is highly versatile, covering various skills, but in reality, people specialize in their own fields. Following the Single Responsibility Principle, each skill should be encapsulated within its relevant role—programming for a coder, assembly work for a factory worker, and food delivery for a rider. This ensures each class has only one reason to change and keeps the code manageable.
Refactored code based on SRP:
class Coder {
/**
* Coders know how to program
*/
public void coding(){
System.out.println("int main() {");
System.out.println(" printf(\"Hello World!\");");
System.out.println("}");
System.out.println("Hmm, why isn't it running? I followed exactly what the instructor typed...");
}
}
class Worker {
/**
* Workers know how to assemble
*/
public void work(){
System.out.println("So happy to join the assembly line at Foxconn.");
System.out.println("Wait, why are all my coworkers running away?");
}
}
class Rider {
/**
* Riders know how to deliver
*/
public void ride(){
System.out.println("Finally passed the final interview with Meituan and joined the dream company.");
System.out.println("The interview seemed pretty easy. Not sure why my friend had to solve a LeetCode 'Trapping Rain Water' problem, while I just had to say I could ride a bike.");
System.out.println("(*Excitedly puts on the delivery uniform*)");
}
}
This restructuring makes it clear and organized. The same principle can be applied to separate concerns across different layers, such as Mapper, Service, and Controller, for different functionalities. It aligns with the design of microservices, where each service is responsible for a single function, thereby achieving high cohesion and low coupling.
https://www.bilibili.com/video/BV1u3411P7Na?spm_id_from=333.788.videopod.episodes&vd_source=73e7d2c4251a7c9000b22d21b70f5635&p=2
Open-Closed Principle ✅
The Open-Closed Principle (OCP) is also an essential object-oriented design principle.
Software entities should be open for extension but closed for modification.
This means that a software entity, such as a class, module, or function, should be open for extension but closed to modification. Here, openness to extension is from the perspective of the provider, while closure to modification applies to the caller or client.
For example, let’s say we have different types of programmers: Java developers, C# developers, C++ developers, PHP developers, frontend developers, etc. They all share the responsibility of writing code, but the way they code depends on their specific language. We can abstract the coding behavior into a common interface or abstract class. This meets the first requirement of OCP: open to extension, as each type of programmer can implement the coding behavior in their own way. The second requirement, closed to modification, is also met, as each programmer’s coding style is defined independently without interference from others.
Here’s an example:
public abstract class Coder {
public abstract void coding();
class JavaCoder extends Coder {
@Override
public void coding() {
System.out.println("Java is too competitive T_T, better start learning Go!");
}
}
class PHPCoder extends Coder {
@Override
public void coding() {
System.out.println("PHP is the best language in the world.");
}
}
class CPlusPlusCoder extends Coder {
@Override
public void coding() {
System.out.println("Funny, no matter how great Java is, it still depends on me for the low-level stuff.");
}
}
}
By providing an abstract Coder
class that defines the coding behavior without implementing it, we allow each specific type of programmer to implement their unique way of coding. This creates a flexible structure that is easy to extend with new types of programmers, ensuring longevity and adaptability.
In fact, as we review everything we’ve learned so far, it seems like the Open-Closed Principle has been applied in many places along the way.
https://www.bilibili.com/video/BV1u3411P7Na?spm_id_from=333.788.videopod.episodes&vd_source=73e7d2c4251a7c9000b22d21b70f5635&p=3
Liskov Substitution Principle ✅
The Liskov Substitution Principle (LSP) is a specific guideline for handling subtypes. It was first introduced by Barbara Liskov in her 1987 speech titled "Data Abstraction and Hierarchy."
Objects of a subclass should be replaceable with objects of the superclass without altering the correctness of the program.
In simple terms, a subclass can extend the functionality of the superclass, but it should not alter the existing functionality of the superclass:
- A subclass can implement abstract methods from the superclass but should not override concrete (non-abstract) methods.
- A subclass can add its own unique methods.
- When a subclass overloads a superclass method, the method's preconditions (i.e., input parameters) should be less strict than the superclass method.
- When a subclass implements a superclass method (either through overriding or overloading), the postconditions (i.e., output/return value) should be as strict as or stricter than those of the superclass.
Let's look at the following example:
public abstract class Coder {
public void coding() {
System.out.println("I know how to code");
}
class JavaCoder extends Coder {
/**
* In addition to coding, this subclass can also play games
*/
public void game() {
System.out.println("The strongest player in Ionia has logged in");
}
}
}
Here, JavaCoder
inherits from Coder
without overriding any superclass methods. It also extends functionality by adding its own method game()
, which complies with the Liskov Substitution Principle.
Now let’s examine a different example:
public abstract class Coder {
public void coding() {
System.out.println("I know how to code");
}
class JavaCoder extends Coder {
public void game() {
System.out.println("The strongest player in Ionia has logged in");
}
/**
* Here, we've overridden the behavior of the superclass, so it no longer retains its original functionality.
*/
public void coding() {
System.out.println("After sixteen years of hard work, I'm still outdone by a bootcamp graduate...");
System.out.println("Life is so tiring with the house, car, and marriage expenses.");
System.out.println("Should life be this hard just to buy a house? Can't I live freely?");
System.out.println("Giving up... alright, alright.");
// End of 'emo' phase—let's get back to work. Life has beauty, despite its hardships, and there are still things to look forward to.
}
}
}
In this case, we’ve overridden the coding
method in JavaCoder
, which changes the original behavior of the superclass. This violates the Liskov Substitution Principle because the subclass no longer preserves the original behavior of the superclass.
If a programmer doesn’t know how to code, can they still be called a programmer?
In this situation, inheriting from Coder
is no longer suitable. Instead, we can promote the behavior to a more generic class, People
, and redefine the structure:
public abstract class People {
public abstract void coding(); // Define the behavior here but don't implement it
class Coder extends People {
@Override
public void coding() {
System.out.println("I know how to code");
}
}
class JavaCoder extends People {
public void game() {
System.out.println("The strongest player in Ionia has logged in");
}
public void coding() {
System.out.println("Giving up... alright, alright.");
}
}
}
The Liskov Substitution Principle is an important way to achieve the Open-Closed Principle as well.
https://www.bilibili.com/video/BV1u3411P7Na?spm_id_from=333.788.player.switch&vd_source=73e7d2c4251a7c9000b22d21b70f5635&p=4
Interface Segregation Principle ✅
The Interface Segregation Principle (ISP) focuses on refining interfaces.
"Clients should not be forced to depend on interfaces they do not use."
When defining interfaces, it’s essential to control their granularity. Here’s an example:
interface Device {
String getCpu();
String getType();
String getMemory();
}
// A computer is a type of electronic device, so we implement this interface
class Computer implements Device {
@Override
public String getCpu() {
return "i9-12900K";
}
@Override
public String getType() {
return "Computer";
}
@Override
public String getMemory() {
return "32G DDR5";
}
}
// A fan is also an electronic device
class Fan implements Device {
@Override
public String getCpu() {
return null; // Does a simple fan really need a CPU?
}
@Override
public String getType() {
return "Fan";
}
@Override
public String getMemory() {
return null; // A fan doesn't need memory either
}
}
Although we defined a Device
interface, its granularity is too coarse. While it works well for a computer, it’s not suitable for a fan, which doesn’t require CPU or memory methods. This is where we should create more specialized, fine-grained interfaces:
interface SmartDevice { // Only smart devices have getCpu and getMemory
String getCpu();
String getType();
String getMemory();
}
interface NormalDevice { // Basic devices only have getType
String getType();
}
// A computer is a smart device, so it implements this interface
class Computer implements SmartDevice {
@Override
public String getCpu() {
return "i9-12900K";
}
@Override
public String getType() {
return "Computer";
}
@Override
public String getMemory() {
return "32G DDR5";
}
}
// A fan is a normal device
class Fan implements NormalDevice {
@Override
public String getType() {
return "Fan";
}
}
By dividing the interfaces into finer, more specific interfaces, each type of device can implement only the interfaces relevant to it. However, we should avoid over-segmentation and keep interfaces practical and based on real requirements.
https://www.bilibili.com/video/BV1u3411P7Na?spm_id_from=333.788.player.switch&vd_source=73e7d2c4251a7c9000b22d21b70f5635&p=6
Dependence Inversion Principle ✅
The Dependence Inversion Principle (DIP) is a fundamental principle in object-oriented design. A great example of its application is the Spring framework.
"High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details, but details should depend on abstractions."
Do you remember why we’ve consistently used interfaces to define functionality, implementing them afterward? Let’s look back at what code might look like without the Spring framework:
public class Main {
public static void main(String[] args) {
UserController controller = new UserController();
// use the controller as needed
}
static class UserMapper {
// CRUD operations
}
static class UserService {
UserMapper mapper = new UserMapper();
// Business logic
}
static class UserController {
UserService service = new UserService();
// Business logic
}
}
Now imagine one day the company changes its requirements, and the UserService
needs to be replaced with a new implementation:
public class Main {
public static void main(String[] args) {
UserController controller = new UserController();
}
static class UserMapper {
// CRUD operations
}
static class UserServiceNew { // This change in UserServiceNew impacts other high-level modules
UserMapper mapper = new UserMapper();
// Business logic
}
static class UserController { // Changing the lower-level module forces modifications here too
UserService service = new UserService(); // Old service no longer usable
UserServiceNew serviceNew = new UserServiceNew(); // Switching to the new service
// Business logic
}
}
As we can see, each module is directly dependent on another, creating a tightly coupled structure. Any change in a low-level module (like replacing UserService
) directly impacts higher-level modules that depend on it. In a large project, these modifications would be overwhelming and difficult to maintain.
With the introduction of the Spring framework, our development approach changes:
public class Main {
public static void main(String[] args) {
UserController controller = new UserController();
}
interface UserMapper {
// Only defines CRUD methods
}
static class UserMapperImpl implements UserMapper {
// Implements CRUD methods
}
interface UserService {
// Defines business logic methods
}
static class UserServiceImpl implements UserService {
@Resource // Spring selects an appropriate implementation and injects it, avoiding hard coding
UserMapper mapper;
// Implements business logic
}
static class UserController {
@Resource
UserService service; // Uses the interface only, so no need to modify if the implementation changes
// Business logic
}
}
Here, by using interfaces, we loosen the original tight coupling between modules. The high-level UserController
only needs to know about the methods defined in the UserService
interface. The specific implementation details are handled by the UserServiceImpl
class and are injected by Spring, removing the need for hard-coded dependencies.
This approach weakens dependencies, allows greater flexibility, and enhances maintainability, aligning well with the Dependence Inversion Principle.。
https://www.bilibili.com/video/BV1u3411P7Na?spm_id_from=333.788.player.switch&vd_source=73e7d2c4251a7c9000b22d21b70f5635&p=5
合成复用原则
合成复用原则(Composite Reuse Principle)的核心就是委派。
优先使用对象组合,而不是通过继承来达到复用的目的。
在一个新的对象里面使用一些已有的对象,使之成为新对象的一部分,新的对象通过向这些对象的委派达到复用已有功能的目的。实际上我们在考虑将某个类通过继承关系在子类得到父类已经实现的方法之外(比如 A 类实现了连接数据库的功能,恰巧 B 类中也需要,我们就可以通过继承来获得 A 已经写好的连接数据库的功能,这样就能直接复用 A 中已经写好的逻辑)我们应该应该优先地去考虑使用合成的方式来实现复用。
比如下面这个例子:
class A {
public void connectDatabase(){
System.out.println("我是连接数据库操作!");
}
}
class B extends A{ //直接通过继承的方式,得到A的数据库连接逻辑
public void test(){
System.out.println("我是B的方法,我也需要连接数据库!");
connectDatabase(); //直接调用父类方法就行
}
}
虽然这样看起来没啥毛病,但是还是存在我们之前说的那个问题,耦合度太高了。
可以看到通过继承的方式实现复用,我们是将类 B 直接指定继承自类 A 的,那么如果有一天,由于业务的更改,我们的数据库连接操作,不再由 A 来负责,而是由新来的 C 去负责,那么这个时候,我们就不得不将需要复用 A 中方法的子类全部进行修改,很显然这样是费时费力的。
并且还有一个问题就是,通过继承子类会得到一些父类中的实现细节,比如某些字段或是方法,这样直接暴露给子类,并不安全。
所以,当我们需要实现复用时,可以优先考虑以下操作:
class A {
public void connectDatabase(){
System.out.println("我是连接数据库操作!");
}
}
class B { //不进行继承,而是在用的时候给我一个A,当然也可以抽象成一个接口,更加灵活
public void test(A a){
System.out.println("我是B的方法,我也需要连接数据库!");
a.connectDatabase(); //在通过传入的对象A去执行
}
}
或是:
class A {
public void connectDatabase(){
System.out.println("我是连接数据库操作!");
}
}
class B {
A a;
public B(A a){ //在构造时就指定好
this.a = a;
}
public void test(){
System.out.println("我是B的方法,我也需要连接数据库!");
a.connectDatabase(); //也是通过对象A去执行
}
}
通过对象之间的组合,我们就大大降低了类之间的耦合度,并且 A 的实现细节我们也不会直接得到了。
迪米特法则
迪米特法则(Law of Demeter)又称最少知识原则,是对程序内部数据交互的限制。
每一个软件单位对其他单位都只有最少的知识,而且局限于那些与本单位密切相关的软件单位。
简单来说就是,一个类/模块对其他的类/模块有越少的交互越好。当一个类发生改动,那么,与其相关的类(比如用到此类啥方法的类)需要尽可能少的受影响(比如修改了方法名、字段名等,可能其他用到这些方法或是字段的类也需要跟着修改)这样我们在维护项目的时候会更加轻松一些。
其实说白了,还是降低耦合度,我们还是来看一个例子:
public class Main {
public static void main(String[] args) throws IOException {
Socket socket = new Socket("localhost", 8080); //假设我们当前的程序需要进行网络通信
Test test = new Test();
test.test(socket); //现在需要执行test方法来做一些事情
}
static class Test {
/**
* 比如test方法需要得到我们当前Socket连接的本地地址
*/
public void test(Socket socket){
System.out.println("IP地址:"+socket.getLocalAddress());
}
}
}
可以看到,虽然上面这种写法没有问题,我们提供直接提供一个 Socket 对象,然后再由 test 方法来取出 IP 地址,但是这样显然违背了迪米特法则,实际上这里的test
方法只需要一个 IP 地址即可,我们完全可以直接传入一个字符串,而不是整个 Socket 对象,我们需要保证与其他类的交互尽可能的少。
就像我们在餐厅吃完了饭,应该是我们自己扫码付款,而不是直接把手机交给老板来帮你操作付款。
要是某一天,Socket 类中的这些方法发生修改了,那我们就得连带着去修改这些类,很麻烦。
所以,我们来改进改进:
public class Main {
public static void main(String[] args) throws IOException {
Socket socket = new Socket("localhost", 8080);
Test test = new Test();
test.test(socket.getLocalAddress().getHostAddress()); //在外面解析好就行了
}
static class Test {
public void test(String str){ //一个字符串就能搞定,就没必要丢整个对象进来
System.out.println("IP地址:"+str);
}
}
}
这样,类与类之间的耦合度再次降低。