继承、重构与平衡
文章目录
1. 前言
六王毕,四海一,蜀山兀,阿房出。覆压三百余里,隔离天日。骊山北构而西折,直走咸阳。二川溶溶,流入宫墙。五步一楼,十步一阁;廊腰缦回,檐牙高啄;各抱地势,钩心斗角。盘盘焉,囷囷焉,蜂房水涡,矗不知其几千万落。长桥卧波,未云何龙?复道行空,不霁何虹?高低冥迷,不知西东。
——杜牧《阿房宫赋》
当我画完UML类图,点上背景颜色,缩小,35个类和接口一齐呈现在我眼前,确有一种大厦已成的释然感。恍惚间想到这篇一直很喜欢的文章,虽然“覆压三百余里”十分夸张,“矗不知其几千万落”更是痴心妄想,但类图中的继承、实现、关联和聚合还是有点“各抱地势,钩心斗角”的味道。
这三周,有迷茫,有困惑,甚至被自己一些奇怪想法气笑过。不过好在没有被迫搞“楚人一炬,可怜焦土”的彻底重构。总的来说,是一个从“高低冥迷,不知西东”到“云销雨霁,彩彻区明”的过程;是一个不断进行小规模重构的过程;是一个逐渐掌握一套把控复杂度的方法的过程;是一个逐渐找到平衡的过程。
2. 最终结构
1) 整体概览
UML类图大致按照从解析到处理到化简排了序。一共35个类,除Main类之外分了6个包,lexer
负责词法分析,parser
负责语法分析,func
处理自定义函数,expr
和factor
负责数据存储与交换,本身的数据结构也兼有一定的化简功能。simple
(这个名字没取好)负责最后的化简工作。
个人认为分包的好处是清晰,负责不同事务的类和接口在物理上有了隔离。IDEA的文件是按字母排序,目录树本身提供更多信息后,选择文件也更为方便。分包还能实现pacakge访问权限(虽然我基本上都public),对于**“程序员之间不能互相信任”**这一点还是很有帮助的(笑)。
一点不足是分包后,其他包的类要使用这个包的类必须import
,好在IDEA的自动import
管理能解决这个问题,防着它自动变成import *
过不了checkstyle就可以了。
2) 主函数
主函数的设计大同小异——用Scanner
读入数据,创建对象处理数据,最后输出结果。
预处理有三步,用replaceAll都可完成:第一步是删除所有空白符(’ ‘、’\t’)。第二步是将指数中的+删除。第三步是将其余的+/-都改为+1*/-1*。从表达式到项到常数因子,符号都是可选的,不仅有连续符号问题,还有跨级修改符号问题(表达式的符号影响项的,项的符号影响常数因子的)、多个符号在哪一级读等等问题,实在是复杂。都改为+1*/-1*后,所有符号问题都放在常数因子解决,复杂性得到了控制。当然,毕竟要多算一个乘法,和直接字符串级处理正负号会损失一些性能。但是当我看到互测的代价计算规则Cost(+a) = Cost(-a) = Cost(a) + 1
时感觉是与这个处理方法不谋而合的。
经过预处理后,符合原始文法的输入转变为符合以下文法的式子
- 表达式 → 项 | 表达式 项
- 项 → 因子 | 项 ‘*’ 因子
- 因子 → 变量因子 | 常数因子 | 幂次表达式|求导因子
- 变量因子 → 幂函数 | 指数函数 | 自定义函数调用
- 常数因子 → 带符号的整数
- 幂次表达式 → ‘(’ 表达式 ‘)’ [指数]
- 幂函数 → 自变量 [指数]
- 自变量 → ‘x’
- 指数函数 → ‘exp(’ 因子 ‘)’ [指数]
- 指数 → ‘^’ 允许前导零的整数 (指数一定不是负数)
- 带符号的整数 → [加减] 允许前导零的整数
- 允许前导零的整数 → (‘0’|‘1’|‘2’|…|‘9’){‘0’|‘1’|‘2’|…|‘9’}
- 加减 → ‘+’ | ‘-’
- 自定义函数定义 → 自定义函数名 ‘(’ 形参自变量 [‘,’ 形参自变量 [‘,’ 形参自变量 ]] ‘)=’ 函数表达式
- 自定义函数名 → ‘f’ | ‘g’ | ‘h’
- 函数表达式 → 表达式(将自变量扩展为形参自变量,可以调用其他自定义函数,不含求导因子)
- 形参自变量 → ‘x’ | ‘y’ | ‘z’
- 自定义函数调用 → 自定义函数名 ‘(’ 因子 [‘,’ 因子 [‘,’ 因子]] ‘)’
- 求导因子 → ‘dx(’ 表达式 ‘)’ | ‘dx(’ 求导因子 ‘)’
我的程序是按照这个文法构建的。但是原始文法下合法的式子和这个文法下合法的式子的对应关系是单射而非满射。例如"x1",在这个文法下会被解释为通常的"x+1",但实际上在原始文法下无对应式子。这在输入严格保证合法的情况下没有问题,不过后续要新增在不符合原始文法情况的下报错会比较麻烦。也为后续对拍埋下了一定的隐患。
3) 词法分析器包
词法分析器的功能是将字符串转换为Token流。Token流由Token类的对象构成,其类型为枚举类型TokenType。用字符、字符串描述Token类型是可行的。但是使用字符,会在代码中留下大量的"magic word",单个字符也容易打错;使用字符串效率上不如底层是整数的枚举类型好。同分包一样,使用枚举类型能提升代码语义上的清晰度。
转换成Token流的过程是一个一个字符读取,判断对应的Token类型,部分字符读取后要连带着读取一批字符作为该Token的内容。值得注意的是,因为我采用的自定义函数的处理方式是实参字符串替换形参后再解析,函数定义的表达式和函数调用的实参均直接读取整个字符串而不进行任何解析。
鉴于函数定义Lexer和表达式Lexer都有Token流的方法和数据结构,故两种Lexer继承了AbstractLexer这一抽象类。抽象类中实现了Token流,并设置生成Token流的抽象方法。如何生成Token流由继承的子类重写具体实现。使用抽象类实现了方法复用和数据结构复用,统一了外部调用的方式,降低了复杂度。抽象类的使用在JDK库中也有所体现,如HashMap、TreeMap等Map工具类都是继承了AbstarctMap这一抽象类。
4) 语法分析器包
语法分析器的功能是根据文法将Token流转换为对象。在不同的Parser返回的对象类型不同的情况下,又希望通过Parser接口统一Parser间的相互调用,这就需要泛型。泛型通过将类型作为参数,大幅提升了同一代码处理不同类型的对象的复用能力。同时类型参数可以指定为某个类的子类或者父类,实现对类型参数中的可填入类型的控制。泛型在JDK库中的使用极为广泛,如常用的ArrayList、HashMap的<>
就是在传入类型参数。
不同的Parser间相互调用,形成由多个函数组成的递归调用关系。调用其他Parser时,调用者向被调用者对象的构造函数传递Lexer。解析主体表达式和完成实参替换形参的函数表达式可以使用同一套Parser,只要传递给表达式Parser构造函数对应的Lexer即可。
表达式Parser直接调用项Parser。项Parser调用因子Parser,但是因子Parser众多,需要根据Token类型决定调用哪种Parser。使用简单工厂模式,可以将判断调用哪种Parser与项Parser调用因子Parser解耦。项Parser关注如何利用简单工厂生产出来的因子Parser即可(表达式Parser和项Parser也成为了从第一次到第三次都没有修改过逻辑的类)。
5) 自定义函数包
自定义函数包的功能是管理存储自定义函数,并在响应自定义函数的调用。所有的自定义函数由自定义函数管理器统一管理,因为程序全局只会有一个自定义函数管理器,所以可以使用单例模式。响应自定义函数调用时,通过传入的函数名返回对应的自定义函数对象。
我采用的自定义函数的处理方式是先以字符串形式保存。响应自定义函数调用时用实参字符串替换形参后将结果用Lexer和Parser解析,解析完成得到表达式。**这样做就天生支持了函数的嵌套调用,甚至可以不用保证函数的定义顺序,**只要在调用时确保整个过程用到的所有函数已经完成定义即可,
采用字符串替换的最大问题是误把exp的x替换。为防止这种情况,在构造自定义函数对象时,传入的函数定义表达式中的形参将被替换成"%pi%"(i=1,2,3)。用不一样的符号%作为边界有助于在扩展中应对问题。另外一个问题是符合文法问题,形参自变量替换的自变量是在幂函数中的,如果直接替换可能不符合文法。如"f(x)=x^2, f(x2)“直接替换会变成x22。我的解决方案是无论实参是什么都在两边加上括号,**将函数表达式中的幂函数转变为幂次表达式**,例如上例将变为(x2)^2,就符合文法了。
6) 表达式包和因子包
表达式包包括表达式、项和基本项。因子包包括各种因子。它们都是Parser生成的结果对象的类。区别在于表达式包中三个类的对象会被“长期存储”。而因子包中类的对象在merge到项之后即被丢弃。
表达式包将存储分为表达式<->项<->基本项。表达式表述为多个(系数*基本项)相加(负号/减体现为负的系数),并通过HashMap存储实现保持同类项合并状态 。项表述为系数*基本项*(表达式),项包含文法中定义的所有信息,主要功能是参与构造、传递信息和实现依赖倒置,在参与构造和传递信息时,项以整体存在,完成相关过程后其被拆开为基本项和系数存入表达式。基本项表述为x^exponent*exp(Expr),其中自变量只有x,只需存储其指数;指数函数内部涉及相加等运算,故将指数函数内部统一作为表达式处理。基本项是有(表达式)的项展开之后除系数之外的部分,是合并同类项时不变的部分。通过重写hashCode()方法和equals()方法支持其作为HashMap的Key。(见3.实现细节-1)HashMap)
表达式、项、基本项都支持展开时必要的运算,运算的原则是对本对象进行修改,同时作为参数传入的外部对象中的引用类型不被本对象引用。后续新增处理过程需用到某个运算时,无需考虑自身数据因所调用函数而被意外修改,降低实现新增功能的复杂度。(见3.实现细节-2)引用共享与深克隆)
因子分为两大类,一类是常数因子、幂函数和指数函数,这些因子在项中的存在相互独立。一类是幂次表达式、函数调用和求导,统称为表达式型因子。这些因子经过计算/调用后都会成为(表达式),合并到项中的步骤都是先展开,然后与项中已有的表达式相乘。因子包所有类都实现了Factor接口,或者实现了继承Factor接口的ExprTypeFactor接口,符合FactorParser接口的泛型类型参数的要求。设置ExprTypeFactor接口符合依赖倒置原则,新增该种因子也无需修改项类的代码。
7) 化简包
化简包实现合并同类项之外的化简,包括提取指数函数内的表达式公因数 (见3.实现细节-3)不重复造轮子)、对表达式的各项按照其系数进行排序,让系数大先输出,尽量节约表达式开头的符号。toString过程也涉及到化简,包括指数函数内的表达式只有一个因子时不额外输出括号、系数为+1/-1可以直接缩减为项开头的符号、指数为1时不输出。
化简过程是从解析完成的表达式以ArrayList呈现的各项开始,从上往下直到表达式树的叶子。化简包的各类都实现了Simple接口,符合依赖倒置原则,方便递归过程。本次任务在项级没有toString化简外的化简,所以SimpleTerm的simplify是空方法(其实是给三角函数预留的)。
化简包的源于第一次作业设计时的化简工厂想法(将解析好的数据送进工厂,放到“检测器”类中可以判断是否能够进行某种化简。符合多种化简规则时,由*“中控器”类判断当前执行何种化简。最后放入最佳的“化简器”类中,根据“检测器”类的“检测报告”*执行化简)。化简包实现了化简逻辑和解析、展开、存储逻辑的彻底解耦,二者只存在调用先后顺序,无任何其他相互影响。只要解析结果的结构不变,化简包的代码就可以不变;新增更加强大的化简方法,亦不需要修改表达式包和因子包的代码,确保化简包的代码的正确性即可。
3. 实现细节
1) HashMap
表达式包的表达式类中采用HashMap存储各个项。用HashMap的Key唯一性实现任何时候都是合并同类项的状态,用HashMap操作的高效性降低合并同类项操作的时间复杂度。
前文提到,基本项是在合并同类项中不变的部分,且在合并同类项后应只保留一份,符合HashMap的Key的唯一性要求。但是,不对基本项做任何适应性修改直接使用并实现不可行。究其原因,HashMap中Key的运作与一个作为Key的类中hashCode()方法和equals()方法有关。来看JDK库的源码(其中的K和V是泛型的类型参数,K即Key,V即Value)
// HashMap.java
public boolean containsKey(Object key) {
return getNode(hash(key), key) != null;
}
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这是HashMap中判断是否存在某个Key是否存在的containsKey()方法,它调用getNode()方法,传入的第一个参数是对key的hashCode()返回值处理后的结果。传入的第二个参数是key对象。
// HashMap.java
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
这是containsKey()、get()方法都调用的getNode()方法。HashMap采用链地址法处理hash冲突,从某个散列地址的链表第一个结点开始逐个比较(源码中的 // always check firest node和后面的do-while)。比较方法是先比较hashCode(),hashCode()相同时必须满足地址相等(用==比较)或者equals()返回true才才判定相等。结合逻辑运算符的短路运算特性,HashMap判断Key相等的过程是先用hashCode()进行筛选,然后比较对象地址,不成再调用equals()比较。绝大多数判断在hashCode()就已经终结,不必调用复杂的equals()方法。hashCode()相等也不会直接被判定为同一Key,只要equals()方法能将其区分即可。
由上述源码,可以得到hashCode()和equals()方法重写时要满足三个要求:
- (性能)hashCode()方法关注快速,equals()方法关注全面
- (左限)定义为相同的对象一定要返回相同的hashCode(),否则hashCode()不同就会直接被判定为不同
- (右限)定义为相同的对象equals()结果一定要相同,不同的对象equals()结果一定要不同,否则这个判断没有正确性。
**不同的对象hashCode()即便返回相同值也无妨,还有equals()方法判断托底。**但仍然要尽量让不同的对象hashCode()返回不同值,以充分利用哈希的性能优势。(极端情况是所有对象hashCode()都返回同一值,每一次判断都要使用equals(),并且依次比较各个对象)
基于HashMap的原理,要让基本项作为HashMap的Key,必须重写其hashCode()方法和equals()方法。Object类的hashCode()和equals()方法基于对象的地址实现,而合并同类项时两个项的基本项是先后由Parser创建的,地址必不相同。
// BasicTerm.java
@Override
public int hashCode() {
return powFExponent.intValue();
}
@Override
public boolean equals(Object obj) {
if (!(obj instanceof BasicTerm)) {
return false;
}
BasicTerm basicTerm = (BasicTerm) obj;
return this.powFExponent.equals(basicTerm.powFExponent) &&
this.expFExpr.equals(basicTerm.expFExpr);
}
hashCode()方法的返回值是int类型,为了快速返回,直接返回BigInteger型的指数的低32位。注意,这里调用的是intValue()方法,不能调用intValueExact()方法。因为intValueExact()方法在数字超过int范围时会抛出异常,打断程序的运行。equals()方法调用BigInteger和Expr的equals()比较即可。
// BigInteger.java
public int intValueExact() {
if (mag.length <= 1 && bitLength() <= 31)
return intValue();
else
throw new ArithmeticException("BigInteger out of int range");
}
2) 引用共享和深克隆
在表达式类因子展开和两个表达式相乘过程中,可能会出现引用共享问题。例如表达式因子展开时,括号外面的指数函数表达式加到括号内每一个项的指数函数表达式中。如果没有克隆,那么展开后的每一个项的指数函数表达式中都有相同的基本项引用,之后对某一个项做修改时就会影响到其他项,这是我们不希望看到的。
一种解决方案是不可变对象。这种对象创建完成之后若要修改,不是修改原有对象,而是返回一个完成修改的新对象。JDK库中不少常用类的对象都是不可变的,如String、BigInteger。这种对象即便引用共享也无妨,因为不可能在任何地方修改该对象的状态。
// String.java
public String concat(String str) {
int otherLen = str.length();
if (otherLen == 0) {
return this;
}
int len = value.length;
char buf[] = Arrays.copyOf(value, len + otherLen);
str.getChars(buf, len);
return new String(buf, true);
}
// BasicTerm.java
private void mergePowFunc(PowFunc newFactor) {
// this.powFExponent是BigInteger类型
this.powFExponent = this.powFExponent.add(newFactor.getExponent());
}
这种方式的最大问题在于若创建对象的过程比较复杂,每一次创建对象都需要花费很多时间去复制,严重影响性能。JDK库中不可变对象复制时很多都是底层内存复制,效率很高。但这次表达式中有基本项,基本项中又有表达式,需要递归进行深克隆,并且没有JVM的优化加持,难以应对超大规模数据。
另一种解决方案是仍然修改原有对象,**各修改方法对传入参数中的引用类型都进行深克隆,保证传入参数中的引用类型不被本对象引用。**这样调用某个修改方法的调用者可以重复利用传入的参数,被调用修改方法的对象无需丢弃,能在一定程度上提高性能。如表达式与另一个表达式相加
// Expr.java
public void addExpr(Expr otherExpr) {
// clone实现了保护other
Expr otherExprClone = otherExpr.clone();
otherExprClone.basicTMap.forEach(this::mergeBasicTerm);
}
深克隆是一个递归过程,对于引用类型要克隆其中的每一个内容,如果该引用类型中还有引用类型则要递归进行深克隆。深克隆的一种实现方法是设计不同的构造函数,向该构造传入原始对象,该构造函数返回复制后的新对象,主要问题是语义上不够明确。另一种实现方式是重写clone()方法。重写clone()方法首先需要该类实现Cloneable接口。Cloneable接口是个空接口,主要目的是通知JVM。若不实现,调用clone()时会抛出CloneNotSupportedException异常。重写的clone()方法要调用super.clone(),如果没有继承关系,调用的就是Object的clone()方法,该clone()方法是浅克隆,JVM直接将这个对象的内存复制一块。对于不可变对象或者基本类型,利用Object的clone()没有问题。引用类型则要重新创建引用类型的克隆后修改刚克隆的对象中的引用。如基本项的克隆
// BasicTerm.java
public class BasicTerm implements Cloneable {
@Override
public BasicTerm clone() {
try {
BasicTerm clone = (BasicTerm) super.clone();
clone.expFExpr = this.expFExpr.clone();
return clone;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
}
值得注意的是JDK库中HashMap、ArrayList都提供了clone()方法,但都是浅克隆。这些clone()方法创建了新对象之后将原对来持有的对象批量添加进新对象而并不克隆。(不能因要实现克隆功能要求其持有对象的类必须实现Cloneable接口)
3) 不重复造轮子
JDK库帮我们实现很多优秀的数据类型、算法和数据结构,如常用的BigInteger、ArrayList、HashMap。这能让我们专注于面向对象架构的设计,节约具体实现的时间。三次作业下来,我的感受是
- 当需要轮子的时候,查一查JDK文档,果然已经有造好的轮子
- 当觉得这不太像轮子的时候,查一查JDK文档,发现已经被造成轮子了
例如合并同类项的时候需要判断HashMap中是否已有基本项、若有基本项对应系数相加、若无增加一个新的键值对,以上的操作HashMap已经提供了merge()方法了
// Expr.java
private void mergeBasicTerm(BasicTerm newBasicTerm, BigInteger newCoefficient) {
this.basicTMap.merge(newBasicTerm, newCoefficient, BigInteger::add);
if (this.basicTMap.get(newBasicTerm).equals(BigInteger.ZERO)) {
this.basicTMap.remove(newBasicTerm);
}
}
再例如指数函数表达式的化简,为了减少计算的次数,希望从最小的数开始计算最大公因数、每次与一个更大的数字计算、不希望重复与某个数计算最大公因数。以上的操作用TreeSet的保持顺序性质和pollFirst()方法就可以方便实现了。BigInteger也已经实现了Comparable接口、compareTo()方法(使用TreeSet的条件)和gcd()方法。
// SimpleExpFExpr.java
private final TreeSet<BigInteger> coefSet;
// ...
BigInteger gcdResult = this.coefSet.pollFirst();
BigInteger other;
while ((other = this.coefSet.pollFirst()) != null) {
gcdResult = gcdResult.gcd(other);
if (gcdResult.equals(BigInteger.ONE)) {
break;
}
}
4) Lambda表达式
Lambda表达式(或称闭包),最大特点是简洁地实现匿名函数,在集合的遍历操作中尤甚,除此之外还有变量捕获等功能。各个语言都有自己一套的闭包的简化规则。第一次接触到闭包是在Swift中
var descendingArray = array.sorted(by: {s1, s2 in return s1 > s2})
当然也可以简化成这样(十分震撼)
var descendingArray = array.sorted(by: >)
Java中的Lambda表达式的语法是(argument) -> (body)
,(body)部分可以是一条语句,也可以是一个代码块。整体的类型是一个“函数”,可以作为参数传入接受“函数类型”(如BiConsumer<T, U>
)参数的函数中(如ArrayList、HashMap的forEach())。如实现求导的加法法则
// Derivation.java
@Override
public void expand() {
resultExpr = new Expr();
expr.getTermList().forEach((item) -> item.diff().forEach(this.resultExpr::mergeTerm));
}
4. 迭代过程
1) 从想法到第一次作业
混乱不是深渊,混乱是阶梯
如果你第一周在主北和三号楼的连廊上走过,你或许会看到我从南走到北,再从北走到南。
当拿到第一份作业之后,我经历了两三天的迷茫。掌握了递归下降法的基本思想后,仅仅实现第一次作业并不是一件难事,在实验代码基础上稍加修改就能完成。但老师的提醒,同学的交流,都在催促着我去思考如何给我的代码留下最大程度的扩展空间。迷茫期的思考没有什么章法,一会在想应该用什么数据结构存储一个表达式,一会儿在想三角函数如何进行化简。我试图在第一次设计时就将一切变化设计成不变的,让代码一次成型,不再重构。
很快,我感受到问题空间已经大到我难以控制。递归下降广义上是递归的,各个类之间相互调用,这让我无从下手。
我要让自己尽快从混乱的过程中走出来。归结混乱期间的思考,围绕的是两个关键问题:
- 递归下降应以怎样的结构安排以提供针对文法的扩展性,面对新的因子(当时考虑的是三角函数因子)如何扩展
- 表达式的化简过程应以怎样的形式呈现以方便添加新的化简规则,给定任意的函数f(x)、g(x)及其化简规则P(f(x), g(x))(当时考虑是三角函数的化简规则)如何扩展,尤其是如果化简存在竞争性时如何实现安排。
时间没有等我,转眼已经到了周五。在DDL的驱赶下,我把我当时已经想好的所有想法都写了出来。实现了下图这个结构。大体上看是不是和最终的UML类图在颜色和形秩上都有着几分相似?我没有想到的是,或许在我还没有想明白的时候,我已经把我一直苦苦追寻的我认为的扩展空间留好了。
2) 从第一次作业到第二次作业
我发现我依然没有办法不设计就开始编码。虽然离P7才两个月,CO学的很多东西我已经忘得差不多了。不过我觉得CO给我留下最深刻记忆是设计。第二次作业我似乎回到了CO,我花了两天时间,完完整整地写了八页的设计手稿。这八页纸框定了接下来的问题空间,定位到了不少的问题,也为如何修改提供了指导。
第二次作业相比第一次作业,主要的改变如下:
-
整理了目录。精简了并无必要的抽象层次(如常数因子层/变量因子层),修正了限制性过强的抽象层次(如因子设计成了抽象类,而且指定了Content只能为一个属性。
-
为了满足自定义函数的新增任务,设立了自定义函数包
-
合理实现了化简工厂。拆分出了simple包实现了化简逻辑与解析运算逻辑的彻底解耦。但是没有实现*”检测器”类和“化简器”类*,而是使用simplify()方法实现了以上功能
-
拆分了Factor类的功能。第一次作业中基本项的属性是Factor类型的,项要输出时调用因子的toString()方法。但是我希望确保在每个基本项中只有一个幂函数(为了保持化简)或者表达式因子(为了展开方便),又给每种因子实现了merge()方法。其实这没有什么不好的,扩展性非常好。甚至可以给每种因子一个ID。在项中实现一个HashMap,Key是每个因子的ID,值是ID对应的因子。如果有某种因子就merge(),没有某种因子就往HashMap添加一个键值对。问题在于这个结构与我的方法实现配合得并不好,因为我的项中很多方法(如展开),都要求针对特定种类的因子,这种因子放到HashMap里面,还是以Factor形式作为项的属性,甚至项里面只存因子的必要信息(如只存幂函数的指数)都不改变项的方法是针对特定因子的事实。所以既然违反开闭性原则我无法避免,那么我能做的就是保证单一职责原则。Factor类从此只作为数据传输的存在,当然表达式型因子还有一些简单的处理,可以理解为存储为项能接受的形式。项只存因子的必要信息,merge()的功能让项完成就行了,输出功能就交给simple包中的各类的toString()方法。同时我给表达式类型定了个规矩,如果为null代表没有表达式,如果为空表达式,代表为0
-
拆开了Lexer的Token流功能和具体生成Token流的方法,实现了抽象类Lexer<-函数定义Lexer/表达式Lexer的结构
-
破除了一个不合理的继承。第一次作业中我的项是
a∙x^n∙(expression)
,基本项(第一次作业叫简单项,但是增加了simple包后就改叫了简单项)是a∙x^n
。这个两个类在第一次作业中是继承关系!但是基本项主要的功能是存储,项的主要功能是数据交换和参与计算。二者在行为上就没有一致性。项也没有用到基本项的任何方法,甚至还要防着父类的方法被调用出问题。单有共同属性不叫继承!
-
为了后续可能的主表达式的自变量不只x有,将基本项中存储幂函数的部分改成了使用HashMap<String, BigInteger>*,但是打脸很快就来了
3) 从第二次作业到第三次作业
果然,最后一次作业并没有把自变量扩展到x以外。此时,扩展性的代价就显现了,如果只有一个自变量x,那么在基本项中只需要存储幂函数的指数,即一个BigInteger,实现求导乘法法则也比较方便。使用HashMap则实现乘法法则难度有所增加,还要考虑HashMap为空的情况。于是猛回头,把用HashMap存幂函数改成了用BigInteger。
另外处理了指数函数表达式默认值为null的问题。第二次作业时定义了表达式处为null代表没有表达式,表达式为空表达式代表为0。项的表达式因子确实应该区分没有和为0两种情况。但是对于指数函数来说,表达式不存在和表达式为0是同一效果,因为exp(0)==1
。但引入null后需要在每个用到表达式的地方特判null,稍不留神就会埋下NullPointerException隐患。所以把指数函数表达式默认值改为了空表达式。
新增的需求自定义函数定义可以调用别的函数,因为我采用的是实参字符串替换形参再当成表达式解析,所以是天然支持的。
最重要的求导任务,倒是最轻松的,三步就做完了
- 新增Token类型,在Lexer中注册
- 新增DerivationParser,在FactorParserFactory中注册
- Term类新增diff()方法,实现求导乘法法则。鉴于第三次作业已是最终状态,我直接写死了只考虑幂函数和指数函数的乘法法则
5. 量化分析
接下来利用量化方式,采用经典指标,衡量各个方法的复杂度。衡量的指标如下
-
CogC: Cognitive Complexity 认知复杂度 - 衡量方法的控制流理解的困难程度。越高越难以被理解,难以维护。
-
ev(G): Essential Cyclomatic Complexity 基本复杂度 - 衡量方法编写时的非结构化程度。
-
iv(G): Module Design Complexity 模块设计复杂度 - 衡量调用与其他方法的调用关系,越高意味着软件的方法耦合度高。
-
v(G): Cyclomatic Complexity 圈复杂度 - 衡量方法的独立路径的条数。
分析采用IDEA插件MetricsReloaded进行。下面详细列出了所有的数据,表格较长可以直接跳过。
1. lexer包
Lexer包生成和维护Token流操作较为简单,各个衡量指标表现都很好。char2TokenType()方法是将符号转换为Token类型。因为使用了长switch
这个非结构化的结构,所以基本复杂度和圈复杂度较高。
Method | CogC | ev(G) | iv(G) | v(G) |
---|---|---|---|---|
lexer.AbstractLexer.add2tokenStream(Token) | 0 | 1 | 1 | 1 |
lexer.AbstractLexer.current() | 2 | 2 | 2 | 2 |
lexer.AbstractLexer.next() | 0 | 1 | 1 | 1 |
lexer.AbstractLexer.reachEnd() | 0 | 1 | 1 | 1 |
lexer.AbstractLexer.updateTokenAmount() | 0 | 1 | 1 | 1 |
lexer.ExprLexer.ExprLexer(String) | 0 | 1 | 1 | 1 |
lexer.ExprLexer.generateTokenStream() | 8 | 1 | 7 | 7 |
lexer.ExprLexer.lexFunc() | 1 | 1 | 2 | 2 |
lexer.ExprLexer.lexFuncArg() | 6 | 1 | 2 | 6 |
lexer.ExprLexer.lexNumber() | 2 | 1 | 3 | 3 |
lexer.FuncDefLexer.FuncDefLexer(String) | 0 | 1 | 1 | 1 |
lexer.FuncDefLexer.generateTokenStream() | 1 | 1 | 2 | 2 |
lexer.FuncDefLexer.lexPara() | 0 | 1 | 1 | 1 |
lexer.token.Token.getContent() | 0 | 1 | 1 | 1 |
lexer.token.Token.getType() | 0 | 1 | 1 | 1 |
lexer.token.Token.Token(TokenType) | 0 | 1 | 1 | 1 |
lexer.token.Token.Token(TokenType, String) | 0 | 1 | 1 | 1 |
lexer.token.TokenType.char2TokenType(char) | 1 | 7 | 1 | 7 |
2. parser包
Parser包分析Token流中的各个Token,并且要返回创建的Expr、Term、Factor等对象,逻辑比较复杂。但是使用了泛型、简单工厂并将不同种类的语法分析拆解到不同的类和方法后,每个方法的复杂度都较低。只有简单工厂依然使用了长switch
语句导致基本复杂度和圈复杂度较高。
Method | CogC | ev(G) | iv(G) | v(G) |
---|---|---|---|---|
parser.expr.ExprParser.ExprParser(ExprLexer) | 0 | 1 | 1 | 1 |
parser.expr.ExprParser.parse() | 2 | 1 | 3 | 3 |
parser.expr.TermParser.parse() | 2 | 1 | 3 | 3 |
parser.expr.TermParser.TermParser(ExprLexer) | 0 | 1 | 1 | 1 |
parser.factor.CallFuncParser.CallFuncParser(ExprLexer) | 0 | 1 | 1 | 1 |
parser.factor.CallFuncParser.parse() | 1 | 1 | 2 | 2 |
parser.factor.DerivationParser.DerivationParser(ExprLexer) | 0 | 1 | 1 | 1 |
parser.factor.DerivationParser.parse() | 0 | 1 | 1 | 1 |
parser.factor.ExpFuncParser.ExpFuncParser(ExprLexer) | 0 | 1 | 1 | 1 |
parser.factor.ExpFuncParser.parse() | 3 | 1 | 3 | 3 |
parser.factor.FactorParserFactory.FactorParserFactory(ExprLexer) | 0 | 1 | 1 | 1 |
parser.factor.FactorParserFactory.get(Token) | 1 | 7 | 1 | 7 |
parser.factor.NumberParser.NumberParser(ExprLexer) | 0 | 1 | 1 | 1 |
parser.factor.NumberParser.parse() | 4 | 1 | 3 | 3 |
parser.factor.PowExprParser.parse() | 3 | 1 | 3 | 3 |
parser.factor.PowExprParser.PowExprParser(ExprLexer) | 0 | 1 | 1 | 1 |
parser.factor.PowFuncParser.parse() | 3 | 1 | 3 | 3 |
parser.factor.PowFuncParser.PowFuncParser(ExprLexer) | 0 | 1 | 1 | 1 |
parser.func.FuncDefParser.FuncDefParser(FuncDefLexer) | 0 | 1 | 1 | 1 |
parser.func.FuncDefParser.parse() | 1 | 1 | 2 | 2 |
3. func包
func包的表现较为优秀。因为喔采用的自定义函数的处理方法是实参字符串替换实参的方式,逻辑相对简单,只需要考虑保护指数函数不被误替换即可。
Method | CogC | ev(G) | iv(G) | v(G) |
---|---|---|---|---|
func.FuncDef.FuncDef(String, ArrayList, String) | 0 | 1 | 1 | 1 |
func.FuncDef.getFunName() | 0 | 1 | 1 | 1 |
func.FuncDef.replaceDefString(ArrayList, String) | 4 | 1 | 3 | 3 |
func.FuncDef.sub(ArrayList) | 1 | 1 | 2 | 2 |
func.FuncDefManager.addDef(String, FuncDef) | 0 | 1 | 1 | 1 |
func.FuncDefManager.FuncDefManager() | 0 | 1 | 1 | 1 |
func.FuncDefManager.get() | 0 | 1 | 1 | 1 |
func.FuncDefManager.getDef(String) | 0 | 1 | 1 | 1 |
4. expr包
expr包的表现也很不错,通过使用JDK库提供的诸多HashMap方法、Lambda表达式和实现依赖倒置,减少了许多编码工作,也降低了复杂度。
Method | CogC | ev(G) | iv(G) | v(G) |
---|---|---|---|---|
expr.BasicTerm.BasicTerm() | 0 | 1 | 1 | 1 |
expr.BasicTerm.BasicTerm(BigInteger, Expr) | 0 | 1 | 1 | 1 |
expr.BasicTerm.clone() | 1 | 1 | 1 | 2 |
expr.BasicTerm.equals(Object) | 2 | 2 | 2 | 3 |
expr.BasicTerm.getExpFExpr() | 0 | 1 | 1 | 1 |
expr.BasicTerm.getPowFExponent() | 0 | 1 | 1 | 1 |
expr.BasicTerm.hashCode() | 0 | 1 | 1 | 1 |
expr.BasicTerm.mergeExpFunc(ExpFunc) | 0 | 1 | 1 | 1 |
expr.BasicTerm.mergeFactor(Factor) | 2 | 1 | 3 | 3 |
expr.BasicTerm.mergePowFunc(PowFunc) | 0 | 1 | 1 | 1 |
expr.BasicTerm.multiplyBasicTerm(BasicTerm) | 0 | 1 | 1 | 1 |
expr.Expr.addExpr(Expr) | 0 | 1 | 1 | 1 |
expr.Expr.clone() | 1 | 1 | 1 | 2 |
expr.Expr.equals(Object) | 1 | 2 | 1 | 2 |
expr.Expr.Expr() | 0 | 1 | 1 | 1 |
expr.Expr.getTermList() | 0 | 1 | 1 | 1 |
expr.Expr.mergeBasicTerm(BasicTerm, BigInteger) | 1 | 1 | 2 | 2 |
expr.Expr.mergeTerm(Term) | 1 | 1 | 2 | 2 |
expr.Expr.multiplyExpr(Expr) | 0 | 1 | 1 | 1 |
expr.Expr.multiplyNumber(BigInteger) | 4 | 1 | 3 | 3 |
expr.Term.diff() | 1 | 1 | 2 | 2 |
expr.Term.expand() | 4 | 1 | 4 | 4 |
expr.Term.getBasicTerm() | 0 | 1 | 1 | 1 |
expr.Term.getCoefficient() | 0 | 1 | 1 | 1 |
expr.Term.mergeExprFactor(ExprTypeFactor) | 2 | 1 | 2 | 2 |
expr.Term.mergeFactor(Factor) | 4 | 1 | 4 | 5 |
expr.Term.mergeNumber(Number) | 0 | 1 | 1 | 1 |
expr.Term.multiplyTermNoExpr(Term) | 0 | 1 | 1 | 1 |
expr.Term.Term() | 0 | 1 | 1 | 1 |
expr.Term.Term(BigInteger, BasicTerm, Expr) | 0 | 1 | 1 | 1 |
5. factor包
factor包是表现最为优秀的包。依照单一职责原则,factor包各类只有存储、传递信息的功能。只有表达式型因子因为在传递信息时涉及到展开的操作稍显复杂。
Method | CogC | ev(G) | iv(G) | v(G) |
---|---|---|---|---|
factor.CallFunc.CallFunc(String, ArrayList) | 0 | 1 | 1 | 1 |
factor.CallFunc.expand() | 0 | 1 | 1 | 1 |
factor.CallFunc.result() | 0 | 1 | 1 | 1 |
factor.Derivation.Derivation(Expr) | 0 | 1 | 1 | 1 |
factor.Derivation.expand() | 0 | 1 | 1 | 1 |
factor.Derivation.result() | 0 | 1 | 1 | 1 |
factor.ExpFunc.ExpFunc(Expr, BigInteger) | 0 | 1 | 1 | 1 |
factor.ExpFunc.getExpr() | 0 | 1 | 1 | 1 |
factor.Number.getNumber() | 0 | 1 | 1 | 1 |
factor.Number.Number(BigInteger) | 0 | 1 | 1 | 1 |
factor.PowExpr.expand() | 5 | 1 | 4 | 4 |
factor.PowExpr.PowExpr(Expr, int) | 0 | 1 | 1 | 1 |
factor.PowExpr.result() | 0 | 1 | 1 | 1 |
factor.PowFunc.getExponent() | 0 | 1 | 1 | 1 |
factor.PowFunc.PowFunc(BigInteger) | 0 | 1 | 1 | 1 |
6.simple包
simple包是复杂度的重灾区,所有方法中各项指标最高的方法就是指数函数提公因式化简方法。该方法的判断分支多并且存在嵌套,面向过程性强。各项度量指标都超过警惕值。同时,简化项toString()方法涉及到几种输出方式的选择,判断分支多并且判断条件复杂,认知复杂度也超过警惕值。
Method | CogC | ev(G) | iv(G) | v(G) |
---|---|---|---|---|
simple.SimpleExpFExpr.getExponent() | 0 | 1 | 1 | 1 |
simple.SimpleExpFExpr.isZero() | 0 | 1 | 1 | 1 |
simple.SimpleExpFExpr.SimpleExpFExpr(Expr) | 1 | 1 | 2 | 2 |
simple.SimpleExpFExpr.simplify() | 20 | 5 | 10 | 11 |
simple.SimpleExpFExpr.toString() | 6 | 3 | 5 | 5 |
simple.SimpleExpr.getSimpleTList() | 0 | 1 | 1 | 1 |
simple.SimpleExpr.SimpleExpr(Expr) | 1 | 1 | 2 | 2 |
simple.SimpleExpr.simplify() | 0 | 1 | 1 | 1 |
simple.SimpleExpr.sortByCoef() | 0 | 1 | 1 | 1 |
simple.SimpleExpr.toString() | 7 | 2 | 4 | 4 |
simple.SimpleTerm.coefDivide(BigInteger) | 0 | 1 | 1 | 1 |
simple.SimpleTerm.Comp.compare(SimpleTerm, SimpleTerm) | 0 | 1 | 1 | 1 |
simple.SimpleTerm.getCoefficient() | 0 | 1 | 1 | 1 |
simple.SimpleTerm.isPureExpFunc() | 1 | 1 | 3 | 3 |
simple.SimpleTerm.isPureNumber() | 1 | 1 | 2 | 2 |
simple.SimpleTerm.isPurePowFunc() | 1 | 1 | 3 | 3 |
simple.SimpleTerm.revertDivide() | 0 | 1 | 1 | 1 |
simple.SimpleTerm.SimpleTerm(Term) | 0 | 1 | 1 | 1 |
simple.SimpleTerm.simplify() | 0 | 1 | 1 | 1 |
simple.SimpleTerm.toString() | 16 | 2 | 8 | 8 |
6. Bug与Hack
1.评测出的Bug
在中测、强测中都没有出现正确性的问题。在互测中也没有被成功hack。
但是在最后一次作业的强测中因为化简方法的Bug导致公因数提取失败。不过我编写化简的逻辑是保守的,并没有导致正确性问题。
出问题的地方在于提取公因式时,存在提取了公因数后长度变长的情况,如exp((4*x+6*x^2)) == exp((2*x+3*x^2))^2
。所以需要计算化简前数字的长度和化简后的数字长度。并且提取的公因数只能为正数,指数函数表达式的各项的符号是不变的,故计算长度时可以选择同时计算符号的长度或者同时不计算符号的长度。但是我的程序在计算原始长度时取了绝对值没有算上符号长度,计算提取之后的长度时忘记了取绝对值,计算了符号长度。导致判断错误,放弃了公因数的提取,丢失了一些性能分。
// SimpleExpFExpr.java
for (Term complexTerm : complexExpr.getTermList()) {
this.coefSet.add(complexTerm.getCoefficient().abs());
this.originCoefLen = this.originCoefLen.add(
// 计算原始长度时使用了abs(,没有计算符号长度
BigInteger.valueOf(complexTerm.getCoefficient().abs().toString().length()));
}
for (SimpleTerm term : this.simpleTList) {
term.coefDivide(gcdResult);
newCoefLen = newCoefLen.add(
// 计算提取公因数后长度时没有使用abs(),计算了符号长度
// 错误:BigInteger.valueOf(term.getCoefficient().toString().length()));
BigInteger.valueOf(term.getCoefficient().abs().toString().length()));
}
2. Hack到的Bug
我采用的Hack策略是测评机根据文法批量生成随机数据,并将结果与sympy结果对比(第一次、第二次作业)、与自己的程序结果对拍(第三次作业)确定正确性。同时辅以手动构造数据。
第一次作业:造0。0是表达式中的特殊存在。
优化的时候没有考虑周全。如在表达式为"x+0"时优化为了"x",但是在表达式为"0"时也进行了优化。测试样例如下:
00*(-00)^00
Runtime Error: NullPointerException
第二次作业:自定义函数调用未处理好,给函数传入一个指数函数时将导致解析错误。测试样例如下:
1
h(x)=x
h(exp((x)))Wrong Answer: exp((1))
处理指数函数嵌套表达式因子时未处理好内部表达式因子展开时的乘方问题。测试样例如下:
0
exp((+exp(1)3+0*x3)^3)x1Wrong Answer: xexp((x^6+x^6exp(3)+x^3+x^3*exp(3)+exp(9)))
第三次作业:求导因子中的连续正负号解析未处理好
1
f(x)=exp(x)
dx(+-x*f(x))Wrong Answer: exp(x)*x-exp(x)
输出未处理好,如果表达式有0时没有添加符号
0
x+(000)Wrong Answer: x0
7. 后记与致谢
准备第二次研讨课分享的时候,我回想这三周OO以来种种。想到我第一周在连廊上思考为任意给定函数f(x), g(x)及其化简规则P(f(x), g(x))留扩展空间,实现时尝试为每个层次都进行一次抽象。那个时候感觉问题空间真是大到难以把控,又有一种奇怪的想法不允许自己把代码给“写死了”。随着这几次迭代,越发感觉“以有涯随无涯,殆已”。所谓扩展性是有代价的,不应在这条路上狂飙。所谓扩展空间,我觉得是历次迭代中不变的整体架构、不变的信息流动方式。是它们接住了变化、容下了扩展。
除了面向对象的基本思想、设计方法,优秀的特性、工具和设计模式。我想,我正在不断寻找的是时间、简洁性、扩展性和鲁棒性的平衡。
我觉得用继承、重构与平衡总结第一单元,恰到好处。
眼前有景道不得,崔颢题诗在上头。
——李白《黄鹤楼》
学长学姐的博客在本单元的作业中给了我很多的启发。博客中有些地方想深入讲讲,但确实有“崔颢题诗在上头”之感,怎么写也写不好。一些震撼与感动难以言表。非常感谢优秀的学长学姐们
-
关于面向对象的思考,感谢thysrael
-
探究HashMap的动机,感谢musel
-
测评机的搭建,感谢saltyfishyjk
最后,感谢所有在讨论区分享的同学们。
8 未来建议
相比去年的OO课程,今年的OO课程采用了指数函数而没有采用三角函数,确实能让同学们将重心放在层次化设计上而不是想着各种算法去做优化。一点希望是明年第二次作业的强测数据可以加强一点,让同学尽早认识到一些嵌套调用的性能问题。如(x+x)^8^8^8^8^8这样的数据。周围有一些同学作业三被hack了嵌套调用,bug修复的时候大规模调整架构还是略有点辛苦。同时可以在讨论区发几篇往年的优秀帖子供同学们学习避雷。
感谢助教和课程团队。