当前位置: 首页 > 科技观察

基于什么原则才能写出优秀的代码?

时间:2023-03-16 14:09:19 科技观察

作为一个程序员,我最不喜欢做的事情,除了开会,可能就是看别人的代码了。有时,当我接手一个新项目时,我会打开代码看一看。如果我身体不好,我可能会气得晕倒。风格各异,没有注释,连最基本的排版和缩进都做不到。这些代码的意思可能是为了证明一句话:跑起来也不是不可能。这时候大部分程序员的想法是:实在不想改这段烂代码,还不如直接重写。但是有时候,当我们看着一些著名的开源项目时,我们会感叹代码写得这么好,这么优雅。为什么好呢?这很难说,但无论如何都很好。所以,本文试图分析好的代码有哪些特点,可以用什么原则来写出优秀的代码。初级阶段,先说基本原理,只要你是程序员,不管是高级还是初级,都会考虑进去。这只是部分列表,还有更多。我将选择四个简单的例子来说明。格式统一,命名规范清晰,注释清晰,避免重复代码。下面以Python代码为例:格式统一。格式包括很多方面,比如import语句,需要按如下顺序编写:Python标准库模块Python第三方模块各部分之间用空行分隔。importosimportsysimportmsgpackimportzmqimportfoo另一个例子,添加适当的空格,像下面的代码;i=i+1submitted+=1x=x*2-1hypot2=x*x+y*yc=(a+b)*(a-b)代码都压在一起了,影响阅读。i=i+1submitted+=1x=x*2-1hypot2=x*x+y*yc=(a+b)*(a-b)加上空格后,立马感觉清晰了很多。另外,像Python的缩进,其他语言中花括号的位置,不管是放在行尾还是换行开始,都需要保证风格统一。统一风格,代码看起来会更干净。命名约定好的命名不需要注释,只要看命名就可以知道变量或函数的作用。比如下面这段代码:a='zhangsan'b=0a可能还是会猜到,但是代码量大的时候,如果满屏都是a,b,c,d,就不会爆炸了地方。变量名稍作改动,语义更清晰:username='zhangsan'count=0,命名风格要统一。如果用camelCase,就全用camelCase,用underline,用underline,有的不用humpCase,有的用underline,看起来很分裂。清晰的注释看别人的代码,最大的愿望就是清晰的注释,但是自己写代码的时候,从来不写。但是评论越多越好。我总结了以下几点:评论不限于中文或英文,但最好不要中英文混用。比较重要的代码段可以用双等号隔开,以突出它们的重要性。例如:#========================================#很重要的功能,是一定要谨慎使用!!!#=========================================deffunc(arg1,arg2):"""在这里写一个函数的一句话总结(如:计算平均值)。这里是具体的描述。参数----------arg1:intarg1arg2的具体描述:intarg2返回值的具体描述------int返回值的具体描述见--------otherfunc:其他相关函数等..例子--------例子使用doctest格式,`>>>`后面的代码可以作为测试用例被文档测试工具自动运行>>>a=[1,2,3]>>>print[x+3forxina][4,5,6]"""避免重复代码随着项目规模的增长,开发人员的增加,代码量肯定会增加。难免会出现大量的重复代码。这些代码实现的功能都是一样的。虽然不影响项目的运行,但是重复代码的危害是很大的。最直接的影响就是一旦出现问题,需要改很多代码。一旦漏掉,就会导致BUG。例如,以下代码:importtimedeffunA():start=time.time()foriinrange(1000000):passend=time.time()print("funAcosttime=%fs"%(end-开始))deffunB():start=time.time()foriinrange(2000000):passend=time.time()print("funBcosttime=%fs"%(end-start))if__name__=='__main__':funA()funB()funA()和funB()都有输出函数运行时间的代码,适合把这些重复的代码抽象出来。例如写一个装饰器:defwarps():defwarp(func):def_warp(*args,**kwargs):start=time.time()func(*args,**kwargs)end=time.time()print("{}costtime={}".format(getattr(func,'__name__'),(end-start)))return_warpreturnwarp这样通过装饰器方法实现同样的功能。如果以后需要修改,直接改装饰器就行了,一劳永逸。到了高级阶段,代码写久了,肯定会对自己有更高的要求,而不仅仅是这些基本规范的格式和注解。但在这个过程中,也有一些问题需要注意,下面会详细讨论。首先要说的是“秀技”。当自己对代码越来越熟悉的时候,总想写一些高级的用法。但现实的结果是,代码往往是过度设计的。我不得不说说我的个人经历。有一段时间,我特别迷恋各种高级用法。有一次我写了一段很长的SQL,很复杂,甚至还包含了递归调用。更有“显身手”嫌疑的Python代码,往往一行代码包含N个以上的魔术方法。写完之后,他露出了满意的笑容,觉得自己的功力还真不错。结果就是各种骂,更重要的是,一个星期下来,我已经看不懂了。其实代码并不是用的方法越高级越好,而是要找到最合适的。代码越简单,逻辑越清晰,越不容易出错。而且在一个团队中,你的代码不是你一个人维护的,降低别人阅读和理解代码的成本也很重要。脆弱性第二点要关注的是代码的脆弱性,小改动是否会导致大故障。代码中是否充满了硬代码?如果是,那不是一个优雅的实现。很可能每次性能优化或配置更改都需要修改源代码。甚至需要重新打包部署到线上,非常麻烦。然而,这些硬代码被提取并设计为可配置的。当需要更改时,直接更改配置即可。接下来,是否有参数的验证?还是容错处理?一个API被第三方调用,如果第三方没有按要求传参,程序会不会崩溃?例如:page=data['page']size=data['size']不如下面这样:page=data.get('page',1)size=data.get('size',10)继续,项目依赖的库是否及时更新?积极及时的升级可以避免跨大版本??升级,因为跨大版本升级往往会出现很多问题。另外,当遇到一些安全漏洞时,升级是一个很好的解决方案。最后,单元测试是否完美?覆盖率高吗?说实话,程序员喜欢写代码,但往往不喜欢写单元测试。这是一个非常不好的习惯。只有完善的、高覆盖率的单元测试,才能提高项目整体的健壮性,才能将因修改代码导致bug的可能性降到最低。重构随着代码量越来越大,重构是每个开发者必须面对的功课。MartinFowler将其定义为:在不改变软件外部行为的情况下改变软件的内部结构,使其更易于理解和修改。重构的好处很明显,提高代码质量和性能,提高未来的开发生产力。但是重构的风险也非常高。如果代码逻辑不清晰,回归测试做不好,那么重构必然会带来很多问题。这就需要在开发过程中特别注意代码质量。除了上面提到的一些规范外,还需要关注面向对象编程的原则是否被滥用,接口的设计是否过度耦合等一系列问题。那么,在开发过程中,是否有一个指导原则可以用来避免这些问题呢?当然有,再往下看。进阶,最近刚看完一本书,鲍勃大叔的《架构整洁之道》。感觉很好,学到了很多东西。整本书基本上都是在讲述软件设计的一些理论知识。大致分为三个部分:编程范式(结构化编程、面向对象编程、函数式编程)、设计原则(主要是SOLID)、软件架构(里面讲了很多TakayaJianling的内容)。总的来说,本书的内容可以让你从微观(代码层面)和宏观(架构层面)层面全面了解整个软件设计。其中,SOLID指的是面向对象编程和面向对象设计的五个基本原则。在开发过程中正确应用这五个原则可以使软件维护和系统扩展变得更加容易。五个基本原则是:单一职责原则(SRP)开闭原则(OCP)里氏替换原则(LSP)接口隔离原则(ISP)依赖倒置原则(DIP)单一职责原则(SRP)一个类应该只有一个一、改变的理由。–RobertCMartin软件系统的最佳结构高度依赖于系统组织的内部结构,因此每个软件模块只有一个改变的理由。这个原则很容易被误解。很多程序员认为每个模块只能做一件事,其实不然。例如:如果有一个类T,它包含两个函数,分别是A()和B(),当需要修改A()时,可能会影响B()的功能。这不是一个好的设计,表明A()和B()耦合在一起。开闭原则(OCP)软件实体应该对扩展开放,但对修改关闭。–BertrandMeyer,Object-OrientedSoftwareConstruction如果一个软件系统要容易改变,那么它的设计必须允许新的代码来修改系统的行为,而不仅仅是通过修改原来的代码。通俗的解释是设计的类是opentoextensionandclosedtomodification,即可以扩展,不能修改。看下面的代码示例,可以简单明了地解释这个原理。voidDrawAllShape(ShapePointerlist[],intn){inti;for(i=0;iitsType){casesquare:DrawSquare((structSquare*)s);休息;案例圈:DrawSquare((structCircle*)s);休息;默认值:中断;}}}上面的代码不符合OCP原则。如果我们要添加一个三角形,那么我们必须在switch下添加一个新的case。这样修改了源代码,违反了OCP的封闭原则。劣势也很明显。每次添加新形状时,都需要修改源代码。如果代码逻辑复杂,出问题的概率就相当高。类形状{public:virtualvoidDraw()const=0;}classSquare:publicShape{public:virtualvoidDraw()const;}classCircle:publicShape{public:virtualvoidDraw()const;}voidDrawAllShapes(vector&list){vector::iteratorI;for(i=list.begin():i!=list.end();i++){(*i)->Draw();}}这样修改,代码就优雅多了。如果此时需要添加新的类型,只需要添加一个继承Shape的新类即可。完全不需要修改源码,可以放心扩展。Liskov替换原则(LSP)要求不多,承诺不少。——JimWeirich这个原则的意思是,如果你想构建一个组件可替换的软件系统,那么这些组件必须遵守相同的契约,这样这些组件才能相互替换。里氏代换原则可以从两个方面来理解:第一是继承。如果继承是为了实现代码重用,即共享方法,那么共享的父类方法应该保持不变,不能被子类重新定义。子类只能通过增加新的方法来扩展功能。父类和子类都可以实例化,子类继承的方法与父类相同。父类调用一个方法的地方,子类也可以调用同样继承得到的方法,逻辑和父类一样。这时候父类对象换成子类对象,逻辑当然是一致的,没有问题。二是多态,多态的前提是子类重写并重新定义父类的方法。为了符合LSP,应该将父类定义为抽象类,并定义抽象方法,让子类重新定义这些方法。当父类为抽象类时,父类无法被实例化,所以程序中不存在可实例化的父类对象,也不存在子类替代父类实例(根本就没有父类实例)的可能的逻辑不一致。例如,看下面的代码:classA{publicintfunc1(inta,intb){returna-b;}}publicclassClient{publicstaticvoidmain(String[]args){Aa=newA();System.out.println("100-50="+a.func1(100,50));System.out.println("100-80="+a.func1(100,80));}}输出;100-50=50100-80=20现在,我们新增一个功能:完成两个数的相加,然后和100相加,由B班负责。即B类需要完成两个功能:两个数相减,两个数相加,然后加100。现在代码变成这样:classBextendsA{publicintfunc1(inta,intb){returna+b;}publicintfunc2(inta,intb){returnfunc1(a,b)+100;}}publicclassClient{publicstaticvoidmain(String[]args){Bb=newB();System.out.println("100-50="+b.func1(100,50));System.out.println("100-80="+b.func1(100,80));System.out.println("100+20+100="+b.func2(100,20));}}输出;100-50=150100-80=180100+20+100=220可见原来正常的减法运算出错了。原因是B类在方法命名时重写了父类的方法,导致所有运行减法函数的代码都调用了B类重写的方法,导致原本正常运行的函数出错。这样做违反了LSP并降低了程序的健壮性。更通用的做法是:原来的父类和子类都继承一个更流行的基类,去掉原来的继承关系,代之以依赖、聚合、组合等关系。接口隔离原则(ISP)不应强迫客户依赖于他们不使用的方法。–RobertC.Martin软件设计师应该在他们的设计中避免不必要的依赖。ISP的原则是建立一个单一的接口,而不是创建一个庞大臃肿的接口,尽可能细化接口,接口中的方法尽可能少。也就是说,我们需要为每个类创建一个专用的接口,而不是试图创建一个非常大的接口,让所有依赖它的类都可以调用。在编程中,依赖多个专用接口比依赖一个综合接口更灵活。单一职责与接口隔离的区别:单一职责原则侧重于责任;而接口隔离原则则侧重于接口依赖的隔离。单一职责原则主要约束类,其次是接口和方法,针对的是程序中的实现和细节;而接口隔离原则主要是对接口进行约束。例如:先解释一下这张图的意思:“Canidae”类依赖于“InterfaceI”中的方法:“predator”、“walking”、“running”;“鸟类”类依赖于“界面I”方法“捕食”、“滑翔”、“飞行”中的方法。“宠物狗”类和“鸽子”类分别是“犬”类和“鸟”类的依赖实现。对于具体的类:“宠物狗”和“鸽子”,虽然它们都有未使用的方法,但是因为它们实现了“接口I”,所以它们也必须实现这些未使用的方法,这显然是糟糕的设计。如果修改此设计以符合接口隔离原则,则必须拆分“接口I”。在这里,我们将原来的“界面一”拆分成了三个界面。拆分后,每个类只需要实现自己需要的接口即可。依赖倒置原则(DIP)高层模块不应依赖于低层模块。两者都应该依赖于抽象。抽象不应依赖于细节。细节应该依赖于抽象。–RobertC.Martin高级战略代码不应依赖实现低级细节的代码。这听起来难以理解,所以让我翻译一下。这大概意味着在编写代码时,应该使用更稳定的抽象接口,减少对多变的具体实现的依赖。例如:看下面的代码:publicclassTest{publicvoidstudyJavaCourse(){System.out.println("张三正在学习Java课程");}publicvoidstudyDesignPatternCourse(){System.out.println("张三正在学习设计模式课程");}}上层直接调用:publicstaticvoidmain(String[]args){Testtest=newTest();test.studyJavaCourse();test.studyDesignPatternCourse();}这样写乍一看没什么问题,功能实现的也不错,但仔细分析,不简单。第一个问题:如果张三学习了一门新课程,那么需要在Test()类中增加一个新的方法。随着需求的增加,Test()类会变得非常庞大,难以维护。而且,理想的情况是新代码不会对原有代码产生影响,这样才能保证系统的稳定性,降低风险。第二个问题:Test()类中的方法实现的功能本质上是一样的,只是定义了三个不同名称的方法。那么是不是可以把这三个方法抽象出来,如果可以的话,代码的可读性和可维护性会增加。第三个问题:业务层代码直接调用底层类的实现细节,耦合严重。如果你想彻底改变它,它会影响到整个身体。基于DIP解决这个问题,需要对底层进行抽象,防止上层直接调用底层。抽象接口:publicinterfaceICourse{voidstudy();}然后分别为JavaCourse和DesignPatternCourse写一个类:publicclassJavaCourseimplementsICourse{@Overridepublicvoidstudy(){System.out.println("张三在学习Java课程");}}publicclassDesignPatternCourseimplementsICourse{@Overridepublicvoidstudy(){System.out.println("张三正在学习设计模式课程");}}最后修改的Test()类:publicclassTest{publicvoidstudy(ICoursecourse){course.study();}}现在,调用方法变成这样:publicstaticvoidmain(String[]args){Testtest=newTest();test.study(newJavaCourse());test.study(newDesignPatternCourse());}通过本次开发,完美解决了上述三个问题。其实写代码并不难,但是通过设计模式来设计架构才是最难也是最重要的。所以,下次有需要的时候,先别急着写代码,先想想也不迟。这篇文章写得很辛苦,主要是后半部分有点难懂。而且,确实有一些原理是没有使用经验的。光靠文字理解还是几乎没有意义,无法理解本质。其实文章中的很多要求我都达不到,总结一下就相当于给自己一个鼓励。以后我会更多的是敬畏代码,而不是急着去实现功能。编写健壮而优雅的代码应该是每个程序员的目标,我们鼓励每个人。