保护交换机

2020-09-03 03:33:27

这个基于OOP的解决方案在添加新操作符时确实具有很强的可扩展性,上面的示例缺少减法运算。我们可以通过定义一个新类来添加一个:

案例类Sub(Left:Expression,Right:Expression)扩展表达式{def eval:Double=Left。伊瓦尔-对。Eval}。

这真是太棒了--我们根本不用碰任何旧代码!哎呀,这里绝对是石头。

想象一下,在接下来的几年里,您继续使用更多的运算类来扩展我们的计算引擎,添加了乘法、除法、模数、变量、对数、三角函数等。

然后突然出现了一个新的要求--用户不仅要计算表达式的值,还要进行符号操作--例如简化表达式。例如,给定表达式a+a,他们希望得到表达式2*a作为结果。

此要求不能由表达式接口上的eval方法捕获。我们需要一种新的方法:

下一步,他们可能希望能够将表达式显示为字符串:

您现在需要更改多少个代码单元才能实现这些功能?表达式的所有实现。在触及所有类之前,代码甚至不会编译。看起来,在这种功能的上下文中,我们的多态解决方案是非常不可扩展的。

让我们退后一步,让我们看看如何以不同的方式实现这一点。Scala和许多其他现代语言都有一个称为模式匹配的功能,可以认为这是一个非常灵活、功能强大的开关。

我们不在Case类上定义eval或simplify这样的操作,而是把它们拉出来:

特征表达式{case类const(Value:Double)扩展表达式case类Add(Left:Expression,Right:Expression)扩展表达式def eval(e:Expression):Double={e Match{case const(X)=>;x case add(a,b)=>;a+b}}。

现在,添加像Sub这样的新操作需要对代码进行两次更改-在Match(Switch)语句中添加一个新类和添加一个新案例。

有些人可能会说这更糟糕,这不仅是因为有更多的地方需要更新,还因为有可能忘记更新开关,这可能会由于未处理的情况而导致运行时错误。幸运的是,Scala设计人员通过提供Seal关键字来考虑这一点,该关键字指示编译器所有的Case类只能定义在同一个模块中。这将解锁模式穷举分析,编译器将警告丢失的案例:

密封特征表达式案例类const(value:Double)扩展表达式案例类Add(Left:Expression,Right:Expression)扩展表达式def eval(e:Expression):Double={e Match{case const(X)=>;x case add(a,b)=>;a+b}}。

添加像Simplify或ToString这样的新函数怎么样?它只需要在一个地方长乐-通过添加所需的方法。无需更改现有代码!

定义简化(e:表达式):表达式={e匹配{case add(const(0),x)=>;x case add(x,const(0))=>;x case Other=>;Other}}def toString(e:表达式):字符串={e Match{case const(X)=>;x。ToString大小写add(a,b)=>;";(";+toString(A)+";+";+toString(B)+";)}}

我在简介中提到的博客文章指出,使用多态性而不是分支可以产生更具可读性的代码。我觉得这个说法太笼统了,实际上非常值得商榷。

首先,即使在该博客作者给出的他们自己的示例中,使用分支的解决方案也比使用OOP的解决方案更短、更简单。虽然简短的代码并不总是比较长版本的代码更具可读性,但在这种情况下,我发现分支非常明确且易于遵循。理解这样的程序中的控制流要容易得多,因为所有目标都显式地给出在一个地方。在OOP解决方案中,实际的实现隐藏在接口后面,如果没有具有“跳转到实现”特性的优秀IDE的额外帮助,要找到它们要困难得多(幸运的是,这通常适用于静态类型的语言,但我见过IDE有时很难处理Python这样的动态语言)。

其次,在一般情况下,分支有一个优点,即函数逻辑可能依赖于多个对象类型,甚至依赖于实际数据。例如,在本文的示例中,转换a*(b+c)=>;a*b+a*c将同时取决于加法和乘法。在经典的OOP解决方案中,您会将其放在Add类中还是MUL类中?这两种说法似乎都不对。此外,将其放入其中一个会创建对另一个的依赖。一个代码分散、跨越多个类、严重依赖于彼此的表达式简化程序将很难理解。

这是一个关于高性能编程的博客,所以如果没有关于性能的部分,这篇文章将是不完整的。从理论上讲,一个足够好的编译器应该生成相同的代码,而不管是选择分支还是动态多态性,但实际情况是这样的吗?编译器有其局限性,通常不会生成可能的最佳结果代码。

这一次让我们考虑一个更现实的例子。不久前,我在数据库系统中序列化/反序列化代码时,偶然发现了一组描述数据类型的类。它们都实现了一个公共接口,定义了用于序列化和反序列化给定数据类型的值以及计算序列化数据长度的方法。下面的铁锈代码片段是对该代码的极大简化,但它说明了概念:

Pub特征数据类型{fn len(&;self)->;usize;}pub struct BoolType;pub struct IntType;pub struct LongType;实施BoolType的数据类型{fN len(&;self)->;usize{1}}IntType的实施数据类型{fn(&;self)->;usize{4}}实施长度类型的数据类型{fn。Usize{8}}pub fn data_len(data_type:&;dyn dataType)->;usize{data_type.len()}。

在给定对DataType对象的引用的情况下,在不知道确切的静态类型的情况下计算与其关联的数据大小是很容易的:

让T1=IntType;让T2=LongType;让v:vec<;&;dyn dataType>;=vec![&;t1,&;t2];println!(";{}";,data_len(v[0]));//打印4 println!(";{}";,data_len(v[1]));//打印8

设mut data=vec::<;dataType>;::new();设mut rng=rand::thread_rng();for i in 1..。1000000{匹配rng.GEN_RANGE(0,3){0=>;data.ush(dataType::booltype),1=>;data.ush(dataType::inttype),2=>;data.ush(dataType::LongType),_=>;{}让mut=0;用于0中的i。1000{for data.iter(){len+=data_len(Dt);}}println!(";总len:{}";,len);

这段代码中没有分支!编译器注意到一个简单的查找表就可以完成这项工作。所以不仅矢量现在是完全平坦的,没有指针追逐,而且也没有跳跃。这对性能的影响非常显著:

1 762,37毫秒任务时钟#1,000 CPU使用了8个上下文开关#0,005 K/秒0 CPU迁移#0,000 K/秒387页错误#0,220 K/秒6 361 343 641周期#3,610 GHz 10 053 423 994指令#1,58 INSN/周期3 009 768 006分支#1707,796M/秒1 127 367分支-未命中#0,04%的所有分支33 221 LLC-加载-未命中1,127367。

那几乎快了4倍!分支未命中和LLC未命中的数量至少低两个数量级。

当然,您可能会发现,在更复杂的情况下,分支会产生与虚拟表分派完全相同的性能,因为开关/匹配通常也是由跳转表实现的。但是,通常情况下,分支为编译器提供了更大的优化灵活性,因为所有的跳转目标都是事先知道的。在虚拟分派的情况下,静态编译器在编译时可能不知道所有的跳转目标,因此通常这类代码更难优化。

当我们想要通过添加类型来扩展程序时,多态性的伸缩性很好,但是当我们想要在这些类型上添加函数时,它的伸缩性就不好。

当我们想要通过添加函数来扩展程序时,分支的伸缩性很好,但是当我们想要添加类型时,它的伸缩性就不好。

当分派目标依赖于多个类型时,分支是一种更干净的解决方案。