面向对象基础:3.面向对象的设计原则
吃透这9大面向对象设计原则写出让人拍案叫绝的代码如果你学完了类、对象、继承、多态这些基础语法但写出来的代码还是一团糟改一个小bug牵一发而动全身加一个新功能要改十几个地方自己写的代码过一个月就看不懂了别人写的代码看一眼就想重构那这篇文章就是为你准备的。面向对象设计原则是无数前辈在几十年的软件开发中总结出来的黄金法则。它们不是死板的教条而是指导你写出低耦合、高内聚、可维护、可扩展代码的核心思想。今天这篇文章一次性讲透9个最重要的设计原则。看完你会发现原来好代码和烂代码之间只差了这几个原则的距离。先记住一个终极目标所有设计原则最终都指向同一个目标让你的代码能够从容应对变化。软件开发中唯一不变的就是变化。需求会变技术会变业务会变。好的代码应该是在变化来临时只需要修改少量代码甚至不需要修改原有代码只需要新增代码就能完成。而烂代码就是在变化来临时需要你推倒重来。第一部分SOLID五大原则核心中的核心SOLID是五个设计原则的首字母缩写由罗伯特·C·马丁Bob大叔提出是面向对象设计的基石。1. 单一职责原则SRP一个类只做一件事Single Responsibility Principle核心定义一个类应该只有一个引起它变化的原因。换句话说一个类只负责一项职责。通俗比喻一个公司里每个人都有明确的分工。程序员写代码产品经理提需求设计师做UI。如果让一个人同时干这三件事那他肯定会忙不过来而且哪件事都干不好。反面例子这是很多初学者最容易犯的错误把所有功能都塞到一个类里。// 违反单一职责原则的员工类classEmployee{privateStringname;privatedoublesalary;// 职责1管理员工基本信息publicStringgetName(){returnname;}publicvoidsetName(Stringname){this.namename;}// 职责2计算工资publicdoublecalculateSalary(){returnsalary*0.8;// 扣除五险一金}// 职责3生成员工报表publicvoidgenerateReport(){System.out.println(员工name工资calculateSalary());}}这个类有三个职责员工信息管理、工资计算、报表生成。这意味着有三个原因会导致这个类被修改员工信息字段发生变化工资计算规则发生变化报表格式发生变化任何一个职责的修改都可能影响到其他两个职责的正常运行。正面例子把三个职责拆分到三个独立的类中// 职责1员工信息类classEmployee{privateStringname;privatedoublesalary;publicStringgetName(){returnname;}publicvoidsetName(Stringname){this.namename;}publicdoublegetSalary(){returnsalary;}publicvoidsetSalary(doublesalary){this.salarysalary;}}// 职责2工资计算器classSalaryCalculator{publicdoublecalculate(Employeeemployee){returnemployee.getSalary()*0.8;}}// 职责3报表生成器classReportGenerator{publicvoidgenerate(Employeeemployee,SalaryCalculatorcalculator){System.out.println(员工employee.getName()工资calculator.calculate(employee));}}现在每个类都只有一个职责修改其中一个类不会影响其他类。设计建议当你发现一个类的代码超过200行或者方法超过10个就要考虑是不是违反了单一职责一个方法也应该遵循单一职责一个方法只做一件事拆分职责的标准如果有多个原因会导致这个类被修改就应该拆分2. 开闭原则OCP对扩展开放对修改关闭Open/Closed Principle核心定义软件实体类、模块、方法应该对扩展开放对修改关闭。换句话说当需要增加新功能时应该通过新增代码来实现而不是修改原有代码。通俗比喻电脑的USB接口。你可以通过插入不同的USB设备U盘、鼠标、键盘来扩展电脑的功能但你不需要拆开电脑修改它的内部电路。反面例子这是最常见的违反开闭原则的代码用if-else或者switch-case来处理不同的情况。// 违反开闭原则的图形绘制类classShapeDrawer{publicvoiddraw(StringshapeType){if(shapeType.equals(circle)){drawCircle();}elseif(shapeType.equals(rectangle)){drawRectangle();}elseif(shapeType.equals(triangle)){drawTriangle();}}privatevoiddrawCircle(){System.out.println(画圆形);}privatevoiddrawRectangle(){System.out.println(画矩形);}privatevoiddrawTriangle(){System.out.println(画三角形);}}现在如果要新增一个正方形的绘制功能你必须修改draw方法增加一个新的else if分支。这就违反了对修改关闭的原则。正面例子用抽象和多态来实现开闭原则// 抽象图形类abstractclassShape{publicabstractvoiddraw();}// 圆形类classCircleextendsShape{Overridepublicvoiddraw(){System.out.println(画圆形);}}// 矩形类classRectangleextendsShape{Overridepublicvoiddraw(){System.out.println(画矩形);}}// 三角形类classTriangleextendsShape{Overridepublicvoiddraw(){System.out.println(画三角形);}}// 图形绘制类classShapeDrawer{publicvoiddraw(Shapeshape){shape.draw();}}现在如果要新增正方形的绘制功能只需要新增一个Square类继承自Shape不需要修改任何原有代码。// 新增正方形类原有代码无需修改classSquareextendsShape{Overridepublicvoiddraw(){System.out.println(画正方形);}}设计建议开闭原则是所有设计原则中最重要的一个也是最终的目标实现开闭原则的关键是抽象把不变的部分抽象出来把变化的部分留给实现不要过度设计如果某个功能非常稳定未来几乎不会变化就不需要为了开闭原则而强行抽象3. 里氏替换原则LSP子类必须能替换父类Liskov Substitution Principle核心定义所有引用父类的地方必须能够透明地使用其子类的对象而不会产生任何错误或异常。换句话说子类必须能够完全替代父类而不影响程序的正确性。通俗比喻如果父亲会开车那么儿子也必须会开车。这样在任何需要父亲开车的场合儿子都可以代替父亲去开。反面例子最经典的违反里氏替换原则的例子正方形继承长方形。// 长方形类classRectangle{protectedintwidth;protectedintheight;publicvoidsetWidth(intwidth){this.widthwidth;}publicvoidsetHeight(intheight){this.heightheight;}publicintgetArea(){returnwidth*height;}}// 正方形类继承长方形classSquareextendsRectangle{OverridepublicvoidsetWidth(intwidth){super.setWidth(width);super.setHeight(width);// 正方形的宽和高必须相等}OverridepublicvoidsetHeight(intheight){super.setWidth(height);super.setHeight(height);}}现在我们写一个测试方法这个方法接收一个长方形对象设置它的宽和高然后计算面积publicvoidtestRectangle(Rectanglerect){rect.setWidth(5);rect.setHeight(10);System.out.println(预期面积50实际面积rect.getArea());}当我们传入一个长方形对象时输出是正确的预期面积50实际面积50但当我们传入一个正方形对象时输出就错了预期面积50实际面积100这就是违反了里氏替换原则子类Square不能替代父类Rectangle。正面例子让正方形和长方形都继承自一个更抽象的图形类// 抽象图形类abstractclassShape{publicabstractintgetArea();}// 长方形类classRectangleextendsShape{privateintwidth;privateintheight;publicRectangle(intwidth,intheight){this.widthwidth;this.heightheight;}OverridepublicintgetArea(){returnwidth*height;}}// 正方形类classSquareextendsShape{privateintside;publicSquare(intside){this.sideside;}OverridepublicintgetArea(){returnside*side;}}现在Square和Rectangle是平级的关系都继承自Shape不存在谁替换谁的问题。设计建议子类可以扩展父类的功能但不能改变父类原有的功能子类不能重写父类的非抽象方法子类的前置条件方法参数不能比父类更严格子类的后置条件方法返回值不能比父类更宽松当两个类之间不是是一个is-a的关系时不要使用继承4. 接口隔离原则ISP客户端不应该依赖它不需要的接口Interface Segregation Principle核心定义一个类对另一个类的依赖应该建立在最小的接口上。客户端不应该依赖它不需要的方法。换句话说不要把大而全的接口塞给客户端应该拆分成多个小而专的接口。通俗比喻一个餐厅的菜单。如果把所有菜品都印在一张菜单上那素食主义者看到满页的肉菜会很不舒服。正确的做法是把菜单分成素食菜单、荤菜菜单、甜品菜单等让不同的客户只看自己需要的菜单。反面例子一个大而全的动物接口// 违反接口隔离原则的动物接口interfaceAnimal{voidfly();voidswim();voidrun();}// 狗类实现动物接口classDogimplementsAnimal{Overridepublicvoidfly(){// 狗不会飞只能空实现thrownewUnsupportedOperationException(狗不会飞);}Overridepublicvoidswim(){System.out.println(狗在游泳);}Overridepublicvoidrun(){System.out.println(狗在跑);}}狗类被迫实现了它不需要的fly()方法这不仅增加了代码的冗余还可能导致错误。正面例子把大接口拆分成多个小接口// 会飞的动物接口interfaceFlyable{voidfly();}// 会游泳的动物接口interfaceSwimmable{voidswim();}// 会跑的动物接口interfaceRunnable{voidrun();}// 狗类只实现它需要的接口classDogimplementsSwimmable,Runnable{Overridepublicvoidswim(){System.out.println(狗在游泳);}Overridepublicvoidrun(){System.out.println(狗在跑);}}// 鸟类实现会飞和会跑的接口classBirdimplementsFlyable,Runnable{Overridepublicvoidfly(){System.out.println(鸟在飞);}Overridepublicvoidrun(){System.out.println(鸟在跑);}}现在每个类只需要实现自己需要的接口没有多余的代码。设计建议接口的粒度要小一个接口只负责一个功能不要为了方便把多个不相关的方法塞到一个接口里如果一个接口的实现类中有很多方法都是空实现说明这个接口需要拆分5. 依赖倒置原则DIP依赖抽象不要依赖具体Dependency Inversion Principle核心定义高层模块不应该依赖低层模块两者都应该依赖抽象抽象不应该依赖细节细节应该依赖抽象通俗比喻电脑的主板和各种硬件。主板不直接依赖具体的CPU、内存、硬盘而是依赖它们的接口插槽。只要接口相同你可以更换不同品牌的CPU、内存、硬盘而不需要更换主板。反面例子订单服务直接依赖MySQL数据库// 低层模块MySQL数据库classMySQLDatabase{publicvoidsave(Stringorder){System.out.println(保存订单到MySQLorder);}}// 高层模块订单服务classOrderService{privateMySQLDatabasedbnewMySQLDatabase();publicvoidcreateOrder(Stringorder){System.out.println(创建订单);db.save(order);}}现在如果要把数据库从MySQL切换到Oracle你必须修改OrderService类的代码把MySQLDatabase换成OracleDatabase。这就违反了依赖倒置原则。正面例子定义一个数据库接口让高层模块依赖这个接口低层模块实现这个接口// 抽象数据库接口interfaceDatabase{voidsave(Stringorder);}// 低层模块1MySQL数据库实现classMySQLDatabaseimplementsDatabase{Overridepublicvoidsave(Stringorder){System.out.println(保存订单到MySQLorder);}}// 低层模块2Oracle数据库实现classOracleDatabaseimplementsDatabase{Overridepublicvoidsave(Stringorder){System.out.println(保存订单到Oracleorder);}}// 高层模块订单服务依赖抽象接口classOrderService{privateDatabasedb;// 通过构造方法注入依赖publicOrderService(Databasedb){this.dbdb;}publicvoidcreateOrder(Stringorder){System.out.println(创建订单);db.save(order);}}现在切换数据库只需要更换实现类不需要修改OrderService的代码// 使用MySQL数据库OrderServiceservice1newOrderService(newMySQLDatabase());service1.createOrder(订单1);// 切换到Oracle数据库原有代码无需修改OrderServiceservice2newOrderService(newOracleDatabase());service2.createOrder(订单2);设计建议依赖倒置原则是实现开闭原则的重要手段面向接口编程而不是面向实现编程高层模块和低层模块都应该依赖抽象抽象是稳定的细节是易变的使用依赖注入DI来管理对象之间的依赖关系第二部分四大补充原则除了SOLID五大原则还有四个非常重要的设计原则它们和SOLID相辅相成共同构成了面向对象设计的完整体系。6. DRY原则不要重复代码Don’t Repeat Yourself核心定义不要在多个地方写相同的代码。如果有重复的代码就应该把它抽取出来放到一个统一的地方。通俗比喻不要把同一个知识点抄在多个笔记本上。如果这个知识点需要修改你就得修改所有笔记本很容易遗漏。正确的做法是把它抄在一个专门的笔记本上其他地方需要的时候引用它。反面例子classUserService{publicvoidregister(Stringusername,Stringpassword){// 验证密码长度if(password.length()6||password.length()20){thrownewIllegalArgumentException(密码长度必须在6-20之间);}// 注册逻辑}publicvoidchangePassword(StringoldPassword,StringnewPassword){// 同样的验证逻辑重复写了一遍if(newPassword.length()6||newPassword.length()20){thrownewIllegalArgumentException(密码长度必须在6-20之间);}// 修改密码逻辑}}正面例子把重复的验证逻辑抽取成一个方法classUserService{publicvoidregister(Stringusername,Stringpassword){validatePassword(password);// 注册逻辑}publicvoidchangePassword(StringoldPassword,StringnewPassword){validatePassword(newPassword);// 修改密码逻辑}// 抽取重复的验证逻辑privatevoidvalidatePassword(Stringpassword){if(password.length()6||password.length()20){thrownewIllegalArgumentException(密码长度必须在6-20之间);}}}设计建议重复代码是万恶之源它会导致维护成本成倍增加当你发现自己在复制粘贴代码时就要停下来思考是不是可以抽取成一个方法或者一个类注意DRY原则不是让你为了不重复而强行抽取代码如果抽取后反而降低了代码的可读性那就得不偿失了7. KISS原则保持简单Keep It Simple, Stupid核心定义代码应该尽可能简单易懂。不要写复杂的、炫技的代码简单的代码才是最好的代码。通俗比喻解决同一个问题有两个方案一个简单的方案和一个复杂的方案。永远选择简单的那个。反面例子用复杂的正则表达式来验证一个字符串是否为空publicbooleanisEmpty(Stringstr){returnstrnull||str.matches(^\\s*$);}正面例子用简单的方法来实现publicbooleanisEmpty(Stringstr){returnstrnull||str.trim().length()0;}设计建议简单的代码更容易理解、更容易维护、更容易测试不要过度设计不要为了使用设计模式而使用设计模式能用一行代码解决的问题就不要用十行代码写代码的时候要想着三个月后的自己能不能看懂这段代码8. YAGNI原则你不会需要它You Aren’t Gonna Need It核心定义不要去实现那些你现在不需要的功能。不要为了未来可能会用到而提前写代码。通俗比喻不要为了十年后可能会用到的东西现在就把它买回家堆着。不仅占地方而且等你真的需要的时候它可能已经过时了。反面例子一个简单的用户管理系统提前实现了角色权限、单点登录、第三方登录等复杂功能而实际上这个系统只有几个内部用户使用根本不需要这些功能。设计建议软件开发是渐进式的不要试图一步到位只实现当前需要的功能未来需要的时候再添加过度设计会增加代码的复杂度和维护成本记住预测未来是很难的你以为未来会用到的功能90%的概率永远都不会用到9. 优先使用组合而非继承Favor Composition Over Inheritance核心定义在实现代码复用的时候优先使用组合has-a关系而不是继承is-a关系。通俗比喻你需要一辆车来代步。你可以选择继承一辆车成为车的儿子也可以选择买一辆车拥有一辆车。显然买一辆车是更合理的选择。反面例子用继承来实现代码复用// 汽车类classCar{publicvoiddrive(){System.out.println(汽车在行驶);}}// 人类继承汽车类获得开车的能力classPersonextendsCar{publicvoidgoToWork(){drive();// 继承自Car类System.out.println(到达公司);}}这显然是不合理的因为人不是汽车。正面例子用组合来实现代码复用// 汽车类classCar{publicvoiddrive(){System.out.println(汽车在行驶);}}// 人类组合汽车类获得开车的能力classPerson{privateCarcar;publicPerson(Carcar){this.carcar;}publicvoidgoToWork(){car.drive();// 使用组合的Car对象System.out.println(到达公司);}}设计建议继承是耦合度最高的关系父类的任何改动都会影响到所有子类组合的耦合度比继承低而且更加灵活只有当两个类之间确实存在是一个is-a的关系时才使用继承否则优先使用组合来实现代码复用一张表总结所有设计原则原则名称英文缩写核心思想目标单一职责原则SRP一个类只做一件事高内聚开闭原则OCP对扩展开放对修改关闭可扩展里氏替换原则LSP子类必须能替换父类正确性接口隔离原则ISP客户端不依赖不需要的接口低耦合依赖倒置原则DIP依赖抽象不要依赖具体低耦合DRY原则DRY不要重复代码可维护KISS原则KISS保持简单可读性YAGNI原则YAGNI不要过度设计简单性优先使用组合而非继承-用组合代替继承实现复用灵活性写在最后设计原则不是死板的教条而是指导思想。它们之间不是孤立的而是相互关联、相互补充的。开闭原则是最终的目标单一职责原则是基础里氏替换原则、接口隔离原则、依赖倒置原则是实现开闭原则的手段DRY、KISS、YAGNI、优先使用组合而非继承是辅助原则帮助你写出更好的代码学习设计原则最重要的是理解它们背后的思想而不是死记硬背。在实际编码中要根据具体情况灵活运用不要为了遵守原则而遵守原则。记住好的代码不是写出来的而是改出来的。不要期望第一次就能写出完美的代码在不断的重构中逐步应用这些设计原则你的代码质量会越来越高。需要我补充一份设计原则常见误区与反模式的速查表帮你避开那些新手最容易踩的坑吗