写出可读的代码,重要程度不亚于写出可执行的代码
本书的逻辑模块:原则、模式、实践和启示。串联整个模块的是阅读和思考代码
此外,尽信书则不如无书,书中的东西固然有可取之处,但是有些东西也是针对当时的开发环境所提出的,那个时候和现在的并不完全一样,所以并不是说完全就要把作者提出的所有东西都奉为圭臬。
有些东西与Unity和C#关联性不强,如第15章整章内容“JUnit内幕”,或者11.5小节内容“纯Java AOP框架”就未在本笔记提及。
这块其实和程序文档里要求的大致方向是相同的,总体来说就是为了能让他人看到代码之后能尽快准确理解某个东西的用途,避免揣摩,揣摩意味着浪费时间精力甚至因理解错误产生误用
a1
这种无意义的)#define
或者枚举值替代,为字面值赋予意义,如StatusEnum.Start
)xxList
未必是数据结构的List,或者混淆0
与o
,I
与l
accountInfo
和accountData
nameString
并不会比name
好m_
应当退出历史舞台,因为强类型语言和编辑器高亮的存在AddressParser
)savePage
,或加上get
、set
、is
)fetch
、retrieve
和get
,类似地还有controller
和manager
state
=>addrState
,更好的做法是建一个Address类,但是只要短名称足够清楚则无需画蛇添足这一章围绕函数来说明代码的结构,贯穿始终的思想是抽象。相当于是在讲“抽象时机”、“抽象位置”和“抽象程度”的问题。也就是如何规划函数,设计完成一个功能要哪些函数,以及每个函数的意义。
代码是自底向上编写的,但是函数设计上完全可以自顶向下,捋顺一个函数的逻辑,一个函数要经过哪些步骤,这其中的每一个步骤都可以考虑是否需要抽象出来(以及抽象出几层)。代码抽象的最明面上的意义就是让“过程”具有可读性。
就原则上来讲:
try-catch
块,把内容形成新函数,不要让错误处理和正常流程混在一块)一开始写出来的东西总不可能完全符合规则,不断打磨修改是正常的
目的:能够自顶向下阅读代码
方式:不混用抽象层级,每个函数应位于一个抽象层级
抽象层级判定方法:"TO do something, we need …"句子。即“为了完成{函数的目标}
,我们需要进行{事件1}
、{事件2}
和{事件3}(
甚至还可以添加条件:并且当这三件事情的结果是{某种结果}
的时候需要做{事件4}
)”,通过这种自顶向下的捋顺之后,一个函数就可能长这样:
void func(){
var result1 = thing1();
var result2 = thing2();
var result3 = thing3();
if(result1 == true && ...){
thing4();
}
}
思想就是要完成一个大目标,考虑要分解成多少个子操作,然后对每个子操作也如法炮制,分解成更小的子操作
据说用在抽象工厂是勉强可以接收的,其它情况或多或少无法避免违反单一职责原则和开放闭合原则
如果一个函数需要多个参数,其中某些则有可能封装成类的必要
越少越好,理想是0参数。参数与函数名处在不同的抽象层级,他要求你了解目前不特别重要的细节,此外多个参数也不便测试
书上意思是传入的flag为true则做一件事,为false则做另一件事(违反了单一职责原则)
错误:Render(bool isSuite)
正确:RenderForSuite()
+ RenderForSingleTest()
作者意思是尽量避免return东西,还得中断思考检测函数签名(虽然我觉得这并不费力)
string a = insert(a, index, content); // 不好
a.insert(index, content); // 好
这不就是纯函数?
无副作用意味着只做承诺的那一件事,既不会额外改变其它未约定的东西,也不应该有任何先决条件(避免时序性耦合)
“必须先调用一个才能调用另一个”这种必须按照特定顺序调用的耦合关系叫做时序耦合。
if(attributeExists("userName")){ // 这是询问
setAttribute("userName", "value"); // 这是指令(没有同时出现)
}
这也说明了为什么要使用异常替代返回错误码,因为一种典型的写法没把他俩分开
if(deletePage(page) == E_OK){
// do something
}
抽离try-catch
块,把内容形成新函数,不要让错误处理和正常流程混在一块。
错误码会在多处引用,形成“依赖磁铁”
对代码进行修改的时候不意味着配套的所有注释都一道更新,或者注释和他指向的代码分隔开了,也就是说存在“注释和描述的代码不一致”的可能性。所以对待注释要持怀疑态度:注释可能会撒谎
/
开头那个东西)GetCurMonth
注释“返回当前月份”这样的///
的这种为了引人注目的东西)谈及格式,团队内部的格式总是最应当优先遵守的,其次才是这种书中提到的。
void f1(){
// something
}
void f2(){
// something
}
概念相关的代码应当放在一起,相关性越强,彼此之间的距离就应该越短(我认为在C#可以使用
#region
辅助)
相关的函数放一块:如果某个函数调用了另一个,这俩最好放一起,且被调用的在下面
使用空格把相关性较弱的东西隔开,如赋值操作符左右两侧
所幸已经有代码美化插件可以自动完成这些事情,但是作者声称多数代码格式化工具会模糊运算符优先级。例如(-b - Math.sqrt(c)) / (2*a)
,其中乘法没分隔而除法分隔了,因为(2*a)是一个有意义的整体。
作者还声称一个类头部有一堆东西要声明时说明类该拆分了(好吧,其实对于Unity程序员这并不是一个特别值得关心的事情)
if(xxx)f(); //❌if这种最好还是不要写在一行
while(xxx)
; // 无函数体的while最好分号放次行,不过显然Unity有更优雅的解决方案
抽象的意义在于隐藏底层数据细节(使用者无需了解数据实现就能操作数据本体)
// 具象点
public class Point{
public double x;
public double y;
}
// 抽象点
public interface Point{
double getX();
double getY();
void setCartesian(double x, double y);
double getR();
double getTheta();
void setPolar(double r, double theta);
}
我觉得其实就是在讲上图这件事:面向过程(左图)面向对象(右图)
然后现在要添加新类,对于左图,必须扯一根新线连到使用者(因为使用者去控制操作数据结构的这个过程)对于右图,使用者只需要知道调用数据结构的某个函数就行(数据结构自备了某种功能的实现)
显然:过程式代码难以添加新数据结构(特别是与某些调用的地方有耦合),面向对象容易添加新数据结构。但是对于添加新函数来说,面向对象就比面向过程要繁琐了(需要修改每一个类)
拓展:VISOTOR模式与双向分派
数据传输对象(DTO,Data Transfer Object)是一种只有数据字段没有业务逻辑的数据类,用于在不同的系统组件或模块之间传递数据。允许开发者将数据以一种简单且可序列化的方式传递,而不必暴露整个数据模型或复杂的业务逻辑。
这也就是上文说的“如果一个函数需要多个参数,其中某些则有可能封装成类的必要”
对象暴露行为而隐藏数据。
LogError
更多),而且Try语句块范围不要太大,并且异常应当携带充分的错误信息特例模式(Special Case Pattern)是一种用于处理特殊情况或异常情况设计模式,将异常封装到特例对象中,异常不会在正常的流程中直接处理,而是将控制权转交给特例类来处理,而且对于异常的处理逻辑更为集中
public interface OrderStatus
{
void Handle();
}
public class NormalOrder : OrderStatus
{
public void Handle()
{
// 正常订单的处理逻辑
}
}
public class CancelledOrder : OrderStatus
{
public void Handle()
{
// 取消订单的处理逻辑
}
}
public class CompletedOrder : OrderStatus
{
public void Handle()
{
// 订单已完成的处理逻辑
}
}
public class OrderProcessor
{
public void ProcessOrder(OrderStatus status)
{
status.Handle();
}
}
// 在使用时
OrderProcessor processor = new OrderProcessor();
processor.ProcessOrder(new NormalOrder());
processor.ProcessOrder(new CancelledOrder());
processor.ProcessOrder(new CompletedOrder());
上述代码所示,本身正常流程是处理常规的订单,但是订单如果已完成或者被取消是特殊情况(不应被处理),此时就会在异常类内部得到处理。
null
。因为一旦忘记检查容易出问题,所以不如不返回。当然,更不应该传递null
值。(但是C#有可空类型奥,这个见仁见智了)就变量声明顺序而言,从上到下:
类的设计准则:短小。类名应当描述其权责,就权责而言,如果无法为某个类命以精确的名称,这个类大概就太长了。
避免不恰当的权责聚集情况存在
就类和模块的设计而言:
“一开始就做对系统”纯属神话,主要关注的是做好当下的需求,并预留出拓展和重构的空间
软件系统应将启始过程和启始过程之后的运行时逻辑分离开,在启始过程中构建应用对象,也会存在互相缠结的依赖关系。
把构造集中放在某一部分,等运行到业务代码时,应该大部分构造好(业务代码不需关心如何构造)
上面大部分之外的,指的是一些情景可以通过工厂模式或者依赖注入
网上说的各种各样,我是倾向于,DI和工厂模式都是IoC的一种实现方式。不过似乎依赖注入会更加灵活。
IoC 的核心是将对象的创建和管理从程序代码中转移到外部容器或框架,所以也要看怎么界定这个“外部”了。对于java程序员来说,用XML来配置更容易被认为是外部。不过Unity程序员似乎不兴这套,业务代码之外的,也就是框架范畴的代码也容易被认为是外部。
stackoverflow的讨论中,有人说工厂模式是一种Manually Injected Dependency
(手动的依赖注入),似乎也有点道理。
暂略,等以后看StrangeIoC得了。对,又挖一个坑
后面说了点AOP的内容,反正我感觉Unity不太用得上。也不能完全这么说,使用属性标记也算沾点AOP思想。
没补充到的自己去看SOLID
一个方法应该只调用以下几类对象的方法:
其意图是限制对象之间的耦合性,减少对象之间的依赖关系
这意味着,方法不应该调用任何由函数返回的对象的方法(好叭,说实话这样调用看着还是很方便的)
// ❌违反该法则的示例
final string outputStr = ctxt.getOptions().getScratchDir().getAbsolutePath();
以上述代码为例,outputStr所在的类需要了解好几个类的具体细节,并且他们之中任意一个有改动的时候,为outputStr赋值的这行代码都需要改动(增加了依赖性)。简言之就是这个调用链的任意一环发生了改变,这个链式调用都有可能要变;链上的类的内部结构还有可能被暴露开了;而且这样写需要对其它类有了解(理解也需要成本,万一理解错了更麻烦)。
一个反面的例子如下:
// ❌不遵循Demeter法则的例子
public class Customer {
private List<Order> orders;
// 获取客户的所有订单
public List<Order> getOrders() {
return orders;
}
}
public class Order {
private List<Product> products;
// 获取订单中的所有产品
public List<Product> getProducts() {
return products;
}
}
public class ProductService {
// ❌不遵循Demeter法则的方法
public boolean hasSpecificProductType(Customer customer, String productType) {
// 直接访问客户的订单列表,然后遍历订单列表,再遍历订单中的产品列表
for (Order order : customer.getOrders()) {
for (Product product : order.getProducts()) {
if (product.getProductType().equals(productType)) {
return true;
}
}
}
return false;
}
}
假设出于某种策略,获取到的Order
实例要经过某个处理Order func(Order input)
之后才能获取产品,那这个东西加进来就怪麻烦的。
通过重构ProductService和底层类提供的功能,进行了如下的改进:
public class ProductService {
// ⭕遵循Demeter法则的方法,通过客户类的方法来实现
public boolean hasSpecificProductType(Customer customer, String productType) {
return customer.hasProductType(productType);
}
}
public class Customer {
private List<Order> orders;
// 获取客户的所有订单
public List<Order> getOrders() {
return orders;
}
// 检查客户的所有订单中是否存在特定类型的产品
public boolean hasProductType(String productType) {
for (Order order : orders) {
if (order.hasProductType(productType)) {
return true;
}
}
return false;
}
}
public class Order {
private List<Product> products;
// 获取订单中的所有产品
public List<Product> getProducts() {
return products;
}
// 检查订单中是否存在特定类型的产品
public boolean hasProductType(String productType) {
for (Product product : products) {
if (product.getProductType().equals(productType)) {
return true;
}
}
return false;
}
}
基于此不难总结出改进思路:
减少用到的类的数据细节对外的暴露,相应地改为提供接口/功能(就像上面代码把检查是否有某种产品类型的功能写在了顾客类,而不是ProductService直接把顾客和订单拿来检查)
这也渗透这单一职责的思想,具体来说最开始ProductService既包括了与产品数据相关的查询和判断,也直接涉及了客户和订单的内部结构。
主要说的是对象不应该直接暴露数据的意思,如果是数据结构,直接访问数据是OK的,此时Demeter法则就不适用了
依赖倒置的本质是“承诺”而非“指定”。即通过接口或者抽象类“承诺”有某种东西,但是不具体指定这个东西来自谁如何实现。
依赖倒置原则(Dependency Inversion Principle)
依赖源自于需求和关联,例如:
具体的原则是将高层模块与底层模块分离,通过抽象来实现解耦,即:高层模块(如业务逻辑)不依赖于底层模块(如数据库操作的具体实现),二者都应该依赖于抽象
即
A. High level modules should not depend upon low level modules. Both should depend upon abstractions.
B. Abstractions should not depend upon details. Details should depend upon abstraction.
A. 高层模块不应该依赖于低层模块,二者都应该依赖于抽象。
B. 抽象不应该依赖于具体实现细节,而具体实现细节应该依赖于抽象。
策略模式就是把算法封装在在单独的类中来实现
适配器(Adapter Class)是一种设计模式,用于将一个类的接口转换成客户端期望的另一个接口,使得系统中原本不兼容的接口协同工作。
【分类】就处理对象而言可以分为
【意义】这种代码使得很多东西被包装好(进而形成清晰的边界)客户端代码不需要越过边界去直接和外部代码接触。有边界意味着互相影响变少,最显著的表现是某一处改动不会扩散牵连到其他地方。
适配器类的实现形式通常需要声明一个接口或抽象类,适配器类去用“旧的或原先的或第三方的”代码实现接口或抽象类声明的“统一的”功能。
就其本质而言是包装类,就其作用而言则则称之为适配器类
书里有一章讲边界,他想说的是写代码过程中,涉及第三方代码和自己代码的边界问题。但是这章讲的很模糊,甚至很多东西只是java的(eg. log4j),所以还是得从别处看看相关的原则介绍