编写“整洁”的代码,
这是一条反复被人提及的编程建议,尤其是初学者,听得太多耳朵都长茧了。“整洁”的代码背后是一长串规则,告诉你应该怎么书写,代码才能保持“整洁”。
实际上,这些规则中很大的一部分并不会影响代码的运行时间。我们无法客观评估这些类型的规则,而且也没必要进行这样的评估。然而,一些所谓的“整洁”代码规则(其中有一部分甚至被反复强调)是可以客观衡量的,因为它们确实会影响代码的运行时行为。
整理和归纳“整洁”的代码规则,并提取实际影响代码结构的规则,我们将得到:
使用多态代替“if/else”和“switch”;
“DRY”(Don’t Repeat Yourself):不要重复自己。
这些规则非常具体地说明了为了保持代码“整洁”,我们应该如何书写特定的代码片段。然而,我的疑问在于,
如果创建一段遵循这些规则的代码,它的性能如何?
为了构建我认为严格遵守“整洁之道”的代码,我使用了“整洁”代码相关文章中包含的现有示例。也就是说,这些代码不是我编写的,我只是利用他们提供的示例代码来评估“整洁”代码倡导的规则。
提起“整洁”代码的示例,你经常会看到下面这样的代码:
/* ======================================================================== LISTING 22 ======================================================================== */ class shape_base {public : shape_base() {} virtual f32 Area() = 0 ; };class square : public shape_base {public : square(f32 SideInit) : Side(SideInit) {} virtual f32 Area() {return Side*Side;}private : f32 Side; };class rectangle : public shape_base {public : rectangle(f32 WidthInit, f32 HeightInit) : Width(WidthInit), Height(HeightInit) {} virtual f32 Area() {return Width*Height;}private : f32 Width, Height; };class triangle : public shape_base {public : triangle(f32 BaseInit, f32 HeightInit) : Base(BaseInit), Height(HeightInit) {} virtual f32 Area() {return 0.5f *Base*Height;}private : f32 Base, Height; };class circle : public shape_base {public : circle(f32 RadiusInit) : Radius(RadiusInit) {} virtual f32 Area() {return Pi32*Radius*Radius;}private : f32 Radius; };
这段代码是一个形状的基类,从中派生出了一些特定的形状:圆形、三角形、矩形、正方形。此外,还有一个计算面积的虚函数。
就像规则要求的一样,我们倾向于多态性,函数只做一件事,而且很小。最终,我们得到了一个“整洁”的类层次结构,每个派生类都知道如何计算自己的面积,并存储了计算面积所需的数据。
如果我们想象使用这个层次结构来做某事,比如计算一系列形状的总面积,那么我们希望看到下面这样的代码:
/* ======================================================================== LISTING 23 ======================================================================== */ f32 TotalAreaVTBL(u32 ShapeCount, shape_base **Shapes) { f32 Accum = 0.0f ; for (u32 ShapeIndex = 0 ; ShapeIndex { Accum += Shapes[ShapeIndex]->Area(); } return Accum; }
你可能会发现,此处我没有使用任何迭代,因为
“整洁代码之道”中没有建议你必须使用迭代器。
因此,我想尽可能避免有损“整洁”代码的写法,我不希望添加任何有可能混淆编译器并导致性能下降的抽象迭代器。
此外,你可能还会注意到,这个循环是在一个指针数组上进行的。这是使用类层次结构的直接结果:我们不知道每种形状占用的内存有多大。所以除非我们添加另一个虚函数调用来获取每个形状的数据大小,并使用某种步长可变的跳跃过程来遍历它们,否则我们需要指针来找出每个形状的实际开始位置。
因为这个计算数一个累加和,所以循环本身引起的依赖可能会导致循环速度减慢。由于计算累加可以以任意顺序进行,为了安全起见,我还写了一个手动展开的版本:
/* ======================================================================== LISTING 24 ======================================================================== */ f32 TotalAreaVTBL4(u32 ShapeCount, shape_base **Shapes) { f32 Accum0 = 0.0f ; f32 Accum1 = 0.0f ; f32 Accum2 = 0.0f ; f32 Accum3 = 0.0f ; u32 Count = ShapeCount/4 ; while (Count--) { Accum0 += Shapes[0 ]->Area(); Accum1 += Shapes[1 ]->Area(); Accum2 += Shapes[2 ]->Area(); Accum3 += Shapes[3 ]->Area(); Shapes += 4 ; } f32 Result = (Accum0 + Accum1 + Accum2 + Accum3); return Result; }
在一个简单的测试工具中运行以上这两个例程,可以粗略地计算出执行该操作每个形状所需的循环总数:
测试工具以两种不同的方式统计代码的时间。
第一种方法是只运行一次代码
,以显示在没有预热的状态下代码的运行时间(在此状态下,数据应该在 L3 中,但 L2 和 L1 已被刷新,而且分支预测器尚未针对循环进行预测)。
第二种方法是反复运行代码
,看看当缓存和分支预测器以最适合循环的方式运行时情况会怎样。请注意,这些都不是严谨的测量,因为正如你所见,我们已经看到了巨大的差异,根本不需要任何严谨的分析工具。
从结果中我们可以看出,这两个例程之间没有太大区别。这段“整洁”的代码计算这个形状的面积大约需要循环35次,如果幸运的话,有可能减少到34次。
所以,
我们严格遵守“代码整洁之道”,最后需要循环35次。
基于 Spring Boot + MyBatis Plus + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能
项目地址:https://github.com/YunaiV/ruoyi-vue-pro
视频教程:https://doc.iocoder.cn/video/
那么,如果我们违反第一条规则,会怎么样?如果我们不使用多态性,使用一个 switch 语句呢?
下面,我又编写了一段一模一样的代码,只不过这一次我没有使用类层次结构,而是使用枚举,将所有内容扁平化为一个结构的形状类型:
/* ======================================================================== LISTING 25 ======================================================================== */ enum shape_type : u32 { Shape_Square, Shape_Rectangle, Shape_Triangle, Shape_Circle, Shape_Count, }; struct shape_union { shape_type Type; f32 Width; f32 Height; };f32 GetAreaSwitch(shape_union Shape) { f32 Result = 0.0f ; switch (Shape.Type) { case Shape_Square: {Result = Shape.Width*Shape.Width;} break ; case Shape_Rectangle: {Result = Shape.Width*Shape.Height;} break ; case Shape_Triangle: {Result = 0.5f *Shape.Width*Shape.Height;} break ; case Shape_Circle: {Result = Pi32*Shape.Width*Shape.Width;} break ; case Shape_Count: {} break ; } return Result; }
这是代码整洁之道出现以前,很常见的“老派”写法。
请注意,由于我们没有为每个形状提供特定的数据类型,所以如果某个类型缺乏其中一个值(比如“高度”),计算就不使用了。
现在,这个结构的用户获取面积不再需要调用虚函数,而是需要使用带有 switch 语句的函数,这违反了“代码整洁之道”。即便如此,你会注意到代码更加简洁了,但功能基本相同。switch 语句的每一个 case 的都对应于类层次结构中的一个虚函数。
对于求和循环本身,你可以看到这段代码与上述“整洁”版几乎相同:
/* ======================================================================== LISTING 26 ======================================================================== */ f32 TotalAreaSwitch(u32 ShapeCount, shape_union *Shapes) { f32 Accum = 0.0f ; for (u32 ShapeIndex = 0 ; ShapeIndex { Accum += GetAreaSwitch(Shapes[ShapeIndex]); } return Accum; }f32 TotalAreaSwitch4(u32 ShapeCount, shape_union *Shapes) { f32 Accum0 = 0.0f ; f32 Accum1 = 0.0f ; f32 Accum2 = 0.0f ; f32 Accum3 = 0.0f ; ShapeCount /= 4 ; while (ShapeCount--) { Accum0 += GetAreaSwitch(Shapes[0 ]); Accum1 += GetAreaSwitch(Shapes[1 ]); Accum2 += GetAreaSwitch(Shapes[2 ]); Accum3 += GetAreaSwitch(Shapes[3 ]); Shapes += 4 ; } f32 Result = (Accum0 + Accum1 + Accum2 + Accum3); return Result; }
唯一的不同之处在于,我们调用常规函数来获取面积。
但是,我们已经看到了相较于类层次结构,使用扁平结构的直接好处:形状可以存储在数组中,不需要指针。不需要间接访问,因为所有形状占用的内存大小都一样。
另外,我们还获得了额外的好处,现在编译器可以确切地看到我们在这个循环中做了什么,因为它只需查看 GetAreaSwitch 函数。它不必假设只有等到运行时我们才能看得见某些虚拟面积函数具体在做什么。
那么,编译器能利用这些好处为我们做什么呢?下面,我们来完整地运行一遍四个形状的面积计算,得到的结果如下:
观察结果,我们可以看出,
改用“老派””的写法后,代码的性能立即提高了 1.5 倍。
我们什么都没干,只是删除了使用 C++ 多态性的代码,就收获了1.5倍的性能提升。
违反代码整洁之道的第一条规则(也是核心原则之一),计算每个面积的循环数量就从35次减少到了24次,这意味着,遵循代码整洁之道会导致代码的速度降低1.5倍。拿手机打个比方,就相当于把 iPhone 14 Pro Max 换成了 iPhone 11 Pro Max。过去三四年间硬件的发展瞬间化无,仅仅是因为有人说要使用多态性,不要使用 switch 语句。
然而,这只是一个开头。
基于 Spring Cloud Alibaba + Gateway + Nacos + RocketMQ + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能
项目地址:https://github.com/YunaiV/yudao-cloud
视频教程:https://doc.iocoder.cn/video/
如果我们违反更多规则,结果会怎么样?如果我们打破第二条规则,“没有内部知识”,结果会如何?如果我们的函数可以利用自身实际操作的知识来提高效率呢?
回顾一下计算面积的 switch 语句,你会发现所有面积的计算方式都很相似:
case Shape_Square: {Result = Shape.Width*Shape.Width;} break ; case Shape_Rectangle: {Result = Shape.Width*Shape.Height;} break ; case Shape_Triangle: {Result = 0.5f *Shape.Width*Shape.Height;} break ; case Shape_Circle: {Result = Pi32*Shape.Width*Shape.Width;} break ;
所有形状的面积计算都是做乘法,长乘以宽、宽乘以高,或者乘以 π 的系数等等。只不过,三角形的面积需要乘以1/2,而圆的面积需要乘以 π。
这是我认为此处使用 switch 语句非常合适的原因之一,尽管这与代码整洁之道背道而驰。透过 switch 语句,我们可以很清楚地看到这种模式。当你按照操作而不是类型组织代码时,观察和提取通用模式就很简单。相比之下,观察类版本,你可能永远也发现不了这种模式,因为类版本不仅有很多样板代码,而且你需要将每个类放在一个单独的文件中,无法并排比较。
所以,从架构的角度来看,我一般都不赞成类层次结构,但这不是重点。我想说的是,我们可以通过上述发现的模式大大简化 switch 语句。
请记住:这不是我选择的示例,这可是整洁代码倡导者用于说明的示例。所以,我并没有刻意选择一个恰巧能够抽出一个模式的例子,因此这种现象应该比较普遍,因为大多数相似类型都有类似的算法结构,就像这个例子一样。
为了利用这种模式,首先我们可以引入一个简单的表,说明每种类型的面积计算需要使用哪个系数。其次,对于圆和正方形之类只需要一个参数(圆的参数为半径,正方形的参数为边长)的形状,我们可以认为它们的长和宽恰巧相同,这样我们就可以创建一个非常简单的计算面积的函数:
/* ======================================================================== LISTING 27 ======================================================================== */ f32 const CTable[Shape_Count] = {1.0f , 1.0f , 0.5f , Pi32};f32 GetAreaUnion(shape_union Shape) { f32 Result = CTable[Shape.Type]*Shape.Width*Shape.Height; return
Result; }
这个版本的两个求和循环完全相同,无需修改,我们只需要将 GetAreaSwitch 换成 GetAreaUnion,其他代码保持不变。
下面,我们来看看使用这个新版本的效果:
我们可以看到,从基于类型的思维模式切换到基于函数的思维模式,我们获得了巨大的速度提升。从 switch 语句(相较于整洁代码版本性能已经提升了 1.5 倍)换成表驱动的版本,速度全面提升了 10 倍。
我们只是添加了一个表查找和一行代码,仅此而已!现在不仅代码的运行速度大幅提升,而且语义的复杂性也显著降低。标记更少、操作更少、代码更少。
将数据模型与所需的操作融合到一起后,计算每个面积的循环数量减少到了 3.0~3.5 次。与遵循代码整洁之道前两条规则的代码相比,这个版本的速度提高了 10 倍。
10 倍的性能提升非常巨大,我甚至无法拿 iPhone 做类比,即便是 iPhone 6(现代基准测试中最古老的手机)也只比最新的iPhone 14 Pro Max 慢 3 倍左右。
如果是线程桌面性能,10 倍的速度提升就相当于如今的 CPU 退回到2010年。代码整洁之道的前两条规则抹杀了 12 年的硬件发展。
然而,这个测试只是一个非常简单的操作。我们还没有探讨“函数应该只做一件事”以及“尽可能保持小”。如果我们调整一下问题,全面遵循这些规则,结果会怎么样?
下面这段代码的层次结构完全相同,但这次我添加了一个虚函数,用于获取每个形状的角的个数:
/* ======================================================================== LISTING 32 ======================================================================== */ class shape_base {public : shape_base() {} virtual f32 Area() = 0 ; virtual u32 CornerCount() = 0 ; };class square : public shape_base {public : square(f32 SideInit) : Side(SideInit) {} virtual f32 Area() {return Side*Side;} virtual u32 CornerCount() {return 4 ;}private : f32 Side; };class rectangle : public shape_base {public : rectangle(f32 WidthInit, f32 HeightInit) : Width(WidthInit), Height(HeightInit) {} virtual f32 Area() {return Width*Height;} virtual u32 CornerCount() {return 4 ;}private : f32 Width, Height; };class triangle : public shape_base {public : triangle(f32 BaseInit, f32 HeightInit) : Base(BaseInit), Height(HeightInit) {} virtual f32 Area() {return 0.5f *Base*Height;} virtual u32 CornerCount() {return 3 ;}private : f32 Base, Height; };class circle : public shape_base {public : circle(f32 RadiusInit) : Radius(RadiusInit) {} virtual f32 Area() {return Pi32*Radius*Radius;} virtual u32 CornerCount() {return 0 ;}private : f32 Radius; };
长方形有4个角,三角形有3个,圆为0。接下来,我们来修改问题的定义,原来的问题是计算一系列形状的面积之和,我们改为计算角加权的面积总和:总面积之和乘以角的数量。当然,这只是一个例子,实际工作中不会遇到。
下面,我们来更新“整洁”的求和循环,我们需要添加必要的数学运算,还需要多调用一次虚函数:
f32 CornerAreaVTBL(u32 ShapeCount, shape_base **Shapes) { f32 Accum = 0.0f ; for (u32 ShapeIndex = 0 ; ShapeIndex { Accum += (1.0f / (1.0f + (f32)Shapes[ShapeIndex]->CornerCount())) * Shapes[ShapeIndex]->Area(); } return Accum; }f32 CornerAreaVTBL4(u32 ShapeCount, shape_base **Shapes) { f32 Accum0 = 0.0f ; f32 Accum1 = 0.0f ; f32 Accum2 = 0.0f ; f32 Accum3 = 0.0f ; u32 Count = ShapeCount/4 ; while (Count--) { Accum0 += (1.0f / (1.0f + (f32)Shapes[0 ]->CornerCount())) * Shapes[0 ]->Area(); Accum1 += (1.0f / (1.0f + (f32)Shapes[1 ]->CornerCount())) * Shapes[1 ]->Area(); Accum2 += (1.0f / (1.0f + (f32)Shapes[2 ]->CornerCount())) * Shapes[2 ]->Area(); Accum3 += (1.0f / (1.0f + (f32)Shapes[3 ]->CornerCount())) * Shapes[3 ]->Area(); Shapes += 4 ; } f32 Result = (Accum0 + Accum1 + Accum2 + Accum3); return Result; }
其实,我应该单独写一个函数,添加另一层间接。为了保证对“整洁”代码采取疑罪从无的原则,我明确保留了这些代码。
switch 语句的版本也需要相同的修改。首先,我们再添加一个 switch 语句来处理角的数量,case 语句与层次结构版本完全相同:
/* ======================================================================== LISTING 34 ======================================================================== */ u32 GetCornerCountSwitch(shape_type Type) { u32 Result = 0 ; switch (Type) { case Shape_Square: {Result = 4 ;} break ; case Shape_Rectangle: {Result = 4 ;} break ; case Shape_Triangle: {Result = 3 ;} break ; case Shape_Circle: {Result = 0 ;} break ; case Shape_Count: {} break ; } return Result; }
接下来,我们按照相同的方式计算面积:
/* ======================================================================== LISTING 35 ======================================================================== */ f32 CornerAreaSwitch(u32 ShapeCount, shape_union *Shapes) { f32 Accum = 0.0f ; for (u32 ShapeIndex = 0 ; ShapeIndex { Accum += (1.0f / (1.0f