BUAA OO2023 第一单元总结
第一单元的作业主要是实现表达式的化简,第一次作业要求实现对较为简单的表达式的化简作为迭代基础,第二次作业加入了三角函数、嵌套括号以及自定义函数,第三次作业加入了求导因子。由于本人在大二上学期并未选修面向对象的先导课程,在寒假也未花足够的时间提前预习OO,因此第一单元对本人而言实现难度较大。在本篇总结中,本人将尽可能详细地记录这几个星期对面向对象的探索心得以及总结反思。
一、基于度量来分析程序结构
首先先对三次作业的程序结构进行度量与分析,将会采用以下的度量评价方式:
代码量分析:采用IDEA的Statistics插件对Project进行代码量分析。
UML类图:直观地反映出代码的架构,基于此分析每一个类的设计思路,并给出该架构的优点与缺点。
类复杂度:在分析类之间的复杂度时采用IDEA提供的插件,主要有以下三个评价指标:
OCavg
= Average operation complexity
(平均操作复杂度)
OCmax
= Maximum operation complexity
(最大操作复杂度)
WMC
= Weighted method complexity
(加权方法复杂度)
方法复杂度:同样采用IDEA提供的插件进行分析。
CogC
= Cognitive complexity
(认知复杂度)
ev(G)
= Essential cyclomatic complexity
(基本圈复杂度)
iv(G)
= Design complexity
(设计复杂度)
v(G)
= cyclonmatic complexity
(圈复杂度)
复杂度分析主要针对类之间、方法之间的内聚与耦合进行分析,可以清晰地看出哪些类/方法的复杂度会显著过高,也可以为定位bug提供一个借鉴的方向。
1.1 Homework1
1.1.1 代码量分析
Sorce File | Sorce Code Lines | 属性个数 | 方法个数 |
---|---|---|---|
Expr.java | 143 | 1 | 4 |
Factor.java | 3 | 0 | 0 |
Lexer.java | 33 | 3 | 3 |
MainClass.java | 47 | 0 | 1 |
Number.java | 11 | 1 | 1 |
Parser.java | 137 | 1 | 6 |
Term.java | 12 | 1 | 2 |
Unit.java | 44 | 4 | 6 |
Var.java | 10 | 1 | 1 |
Total | 440 | 12 | 24 |
总体来看,代码量主要集中在Expr以及Parser中。在Parser中需要对读入的表达式进行解析,代码量较大时可以理解的。但是在Expr类中,代码占比达到总Project的1/3,这对于Expr类来说应该是过于复杂了。这也与我在第一次作业中将过多的功能(表达式化简、输出)集中在Expr类中有关,当时并没有考虑过多,一心想着先实现题目要求的功能,所以也就导致Expr类代码量过大问题。
1.1.2 UML类图
在第一次作业中,本人采用递归下降的方式对输入的表达式进行解析,因此基本按照形式化语言进行类的设计。表达式Expr包含了项Term,项Term包含了因子Factor,而因子Factor中又包含三种因子:表达式因子Expr、常数因子Number以及幂函数因子Var。因此,设计Expr,Term,Factor,Var,Number这些类是基于说明书中的文法并配合递归下降对表达式进行解析。
在建立好树结构后,需要将树结构转换为化简后的多项式。本人采用的方式是设计一个新的类Unit来代表单项式:
C
o
e
f
∗
x
i
∗
y
j
∗
z
k
Coef*x^i*y^j*z^k
Coef∗xi∗yj∗zk
然后在Expr类中设计对树结构进行遍历并合并指数、系数的方式。
架构的优点:树结构转换为多项式的过程较为直观。
架构的缺点:代码可维护性较差,后续迭代难度大,必然面临重构。
1.1.3 复杂度分析
类复杂度分析:
class | OCavg | OCmax | WMC |
---|---|---|---|
expression.Expr | 8.60 | 18 | 43 |
expression.Number | 1.00 | 1 | 2 |
expression.Term | 1.00 | 1 | 3 |
expression.Unit | 1.38 | 4 | 11 |
expression.Var | 1.00 | 1 | 2 |
Lexer | 2.00 | 4 | 8 |
MainClass | 2.50 | 3 | 5 |
Parser | 4.29 | 10 | 30 |
Total | 104 | ||
Average | 3.15 | 5.25 | 13.00 |
根据表格可知,Expr类和Parser类具有较高的复杂度,尤其是Expr类。Expr的高复杂度可以说是基本在预料之中,这是由于本人在HW1中对架构设计的欠考虑,导致表达式化简与输出的方法集中在Expr中实现。除此之外,当时也还没太深入了解java深拷贝的机制,导致在需要深拷贝的时候并没有设计相对应的方法。种种由于对OO和java的不熟悉导致本次框架设计中Expr的复杂度明显过高。
方法复杂度图(部分):
method | CogC | ev(G) | iv(G) | v(G) |
---|---|---|---|---|
expression.Expr.merge(Expr) | 45 | 1 | 15 | 15 |
expression.Expr.print(HashMap) | 60 | 3 | 22 | 22 |
Parser.exprFactor() | 8 | 4 | 6 | 6 |
Parser.numberFactor() | 4 | 4 | 4 | 4 |
Parser.parseTerm() | 22 | 1 | 10 | 10 |
… | … | … | … | … |
Total | 170 | 45 | 110 | 113 |
Average | 5.15 | 1.36 | 3.33 | 3.42 |
上述表格中仅截取了部分复杂度较高的方法,其中最离谱的两个方式是Expr中的merge()和Expr中的print()。现在回过头来再看这样的设计,大概也是当时的无奈之举,短时间内确实没想出其他将树结构转换的方式。要解决Expr中复杂度过高的问题,其实最好是将其要实现的部分功能“分发”给其他类,用递归的方式进行解决。
1.2 Homework2
1.2.1 代码量分析
Sorce File | Sorce Code Lines | 属性个数 | 方法个数 |
---|---|---|---|
Expr.java | 135 | 1 | 10 |
Factor.java | 5 | 0 | 2 |
Lexer.java | 59 | 3 | 4 |
MainClass.java | 55 | 1 | 1 |
Number.java | 40 | 1 | 6 |
Parser.java | 163 | 1 | 8 |
Pow.java | 42 | 1 | 6 |
Term.java | 101 | 1 | 9 |
Trigono.java | 89 | 2 | 7 |
Total | 689 | 11 | 53 |
相比于第一次作业,第二次作业的代码量有了显著的提升。一方面是因为题目需求的增加,另一方面是因为框架的重构。可以看出整体的代码量主要分布在Expr、Parser以及Term三个类中,代码量的分布相比于第一次作业更加均有,也更加合理。
1.2.2 UML类图
在第二次作业中,本人对代码进行了重构。同样采用递归下降的前提下,其实类的种类大体上并没有太大的变化。本次作业本人仍然是按照文法将其分为Expr、Term、Factor以及各种不同的Factor。本人主要重构的部分是Expr与Term中属性的类型,将terms与factors修改为HashMap来存储。方法中新增的hashCode以及equals也是为了配合HashMap,这样设计的好处将在后续代码框架设计体会中详细介绍。
本人在本次作业中设计了Trigono类来完成对三角函数的抽象,这样设计的原因是根据文法三角函数可以抽象为 sin(Expr)以及cos(Expr)这两种形式。三角函数名决定了后续合并同类项、优化等代码的基础。而对于三角函数里的表达式,我们只需要正常当作表达式进行解析即可,因为递归下降会帮助我们化简。
架构的优点:1.代码架构适合迭代开发 2.能够很好地应对合并同类项的问题 3.各个类、方法的复杂度均较为合理。
架构的缺点:部分类的toString方法复杂度较高
1.2.3 复杂度分析
类复杂度分析:
class | OCavg | OCmax | WMC |
---|---|---|---|
expression.Expr | 2.91 | 12 | 32 |
expression.Number | 1.25 | 3 | 10 |
expression.Pow | 1.29 | 3 | 9 |
expression.Term | 2.70 | 9 | 27 |
expression.Trigono | 2.38 | 6 | 19 |
Lexer | 3.00 | 6 | 15 |
MainClass | 2.00 | 3 | 6 |
Parser | 3.67 | 7 | 33 |
Total | 151 | ||
Average | 2.48 | 6.13 | 18.88 |
经过重构后,代码的类复杂度明显变的更加合理。Expr类的各项复杂度指标均有了明显的下降,但是由于Expr方法中较多,并且承载了进行化简的功能,故其WMC值仍相对较高。除此之外,Parser类的复杂度也较高,这是因为在Parser其实更像是一个“面向过程”的行为,对于输入的表达式进行不断的解析出各种组成部分,所涉及的条件判断也会比较多。
对于新加入的类Trigono,其类复杂度总体上与其他因子类持平,因此可以说明三角函数类设计较为合理。
方法复杂度图(部分):
method | CogC | ev(G) | iv(G) | v(G) |
---|---|---|---|---|
expression.Expr.toString() | 32 | 2 | 11 | 12 |
expression.Trigono.toString() | 10 | 5 | 7 | 9 |
Parser.parseFactor() | 7 | 5 | 8 | 8 |
Parser.parseNumber() | 4 | 4 | 4 | 4 |
… | … | … | … | … |
Total | 163 | 85 | 153 | 172 |
Average | 2.67 | 1.39 | 2.51 | 2.82 |
在上述表格中可以看出,方法的复杂度主要集中在toString方法中以及Partser中的partseFactor以及parseNumber之中。Expr.toString()中涉及的判断情况较多,如x**2优化为x*x,因此复杂度较高。Ttigono.toString()方法的复杂度较高的原因是:根据文法要求,必须满足sin(Factor)以及cos(Factor)的要求,这意味着需要对三角函数中套一层括号还是两层括号进行较为复杂的条件判断。
1.3 Homework3
1.3.1 代码量分析
Sorce File | Sorce Code Lines | 属性个数 | 方法个数 |
---|---|---|---|
Derivation.java | 34 | 2 | 6 |
Expr.java | 146 | 1 | 11 |
Factor.java | 6 | 0 | 3 |
Lexer.java | 59 | 3 | 4 |
MainClass.java | 61 | 1 | 1 |
Number.java | 49 | 1 | 7 |
Parser.java | 176 | 1 | 9 |
Pow.java | 52 | 1 | 7 |
Term.java | 133 | 1 | 10 |
Trigono.java | 109 | 2 | 8 |
Total | 825 | 13 | 66 |
HW3代码相对于HW2有了一定的提升,主要集中在新的求导因子类Derivation以及对各类的求导方法以及求导法则的实现之上。整体上看属于较为正常的迭代开发。
1.3.2 UML类图
在HW2奠定好良好的迭代框架后,HW3的任务量便相对较为轻松了,主要只需要增加新的类与方法以满足新的需求即可。
本人针对新的求导因子,设计了新的求导因子类。本人参照HW2中设计的Trigono三角函数类的抽象,将求导因子也抽象为d(var)(Expr)这样的形式。由于本次作业要求支持求偏导,因此Derivation类包含了求导因子的变量名以及需要求导的表达式expr。
为了能够实现求导的功能,需要为每一个类实现对应的求导方法。如在Term类中,需要实现乘法法则。本质上还是递归处理的方式,只需要处理好不同类进行求导时的方法,整体的框架便可以支持求导功能。
架构的优点:1.代码架构适合迭代开发 2.能够很好地应对合并同类项的问题 3.各个类、方法的复杂度均较为合理。
架构的缺点:未能很好地处理求导方法执行的位置。
1.3.3 复杂度分析
类复杂度分析:
class | OCavg | OCmax | WMC |
---|---|---|---|
expression.Derivation | 1.00 | 1 | 7 |
expression.Expr | 2.83 | 12 | 34 |
expression.Number | 1.22 | 3 | 11 |
expression.Pow | 1.38 | 3 | 11 |
expression.Term | 2.91 | 9 | 32 |
expression.Trigono | 2.33 | 6 | 21 |
Lexer | 3.00 | 6 | 15 |
MainClass | 2.00 | 3 | 6 |
Parser | 3.50 | 7 | 35 |
Total | 172 | ||
Average | 2.32 | 5.56 | 19.11 |
HW3中类复杂度与HW2中复杂度基本持平,依旧是Expr类复杂度较高,且原因基本同HW2,在此不过多阐述。
除此之外,可以看见其实设计的Derivation类复杂度较低,而Parser的类复杂度相对HW2中却有一定程度的升高。这是由于本人在设计的时候在Parser读取到求导因子后随之执行求导计算,导致有点“架空”了求导因子类应当承担的功能。针对这个问题,可能的解决方案是将执行求导的时间点从Parser推迟到表达式化简simplify之中,这样便可以更合理地分配求导功能的分布。
方法复杂图(部分):
method | CogC | ev(G) | iv(G) | v(G) |
---|---|---|---|---|
expression.Expr.toString() | 32 | 2 | 11 | 12 |
expression.Trigono.toString() | 10 | 5 | 7 | 9 |
Parser.parseFactor() | 8 | 6 | 9 | 9 |
Parser.parseNumber() | 4 | 4 | 4 | 4 |
Expr.diff() | 1 | 1 | 2 | 2 |
Term.diff() | 8 | 1 | 5 | 5 |
Pow.diff() | 2 | 1 | 2 | 2 |
Number.diff() | 0 | 1 | 1 | 1 |
Trigono.diff() | 2 | 1 | 2 | 2 |
… | … | … | … | … |
Total | 177 | 99 | 174 | 193 |
Average | 2.39 | 1.34 | 2.35 | 2.61 |
在方法复杂度层面,复杂度较高的仍是HW2中复杂度高的方法。除此之外,新增的求导功能的方法总体而言复杂度均较低,代表求导功能实现简洁,很好地处理了层次间求导的关系。
二、架构设计体验
第一单元的主要任务是对表达式进行化简,并在迭代开发中不断加入新的功能。本人接下来将按照迭迭代涉及的思路,介绍这三次作业架构的修改与完善。
2.1 Homework1
由于在进行第一次作业时对JAVA等基础概念以及面向对象的框架设计方法仍十分不熟悉,因此HW1的框架大体上延续了training中提供的思路。
整体代码分为了两个部分:解析与转换。
1>解析表达式
解析部分采用递归下降的方法,根据说明书中的文法对输入的表达式进行解析。根据training1中的架构,解析的类包括了Lexer
和Parser
。Lexer
的功能是将表达式从前往后逐步解析,并将解析得到的字符串交给Parser
,由Parser
进行类型判别,识别文法中的基本类型。
递归得到的表达式树顶层是Expr类,而Expr类中用ArrayList存储了各个Term。Term中又以ArrayList的形式存储了各个Factor。Factor又可以根据文法分为不同因子。这一部分代码的功能就是将**“横向”**的表达式进行纵向拉伸,使其具有树状结构。
2>将树转换为表达式
本部分中处理的方式较欠考虑,直接对构造出的树中的每一项进行遍历。本人设计了一个Unit
类,可以理解为最终化简后的表达式的一个单项式。由于HW1中括号嵌套最多只有一层,所以可以直接在一个方法中遍历计算,利用Arraylist来装下不同的Unit并修改对应的系数、指数。但是这样**“面向过程”的处理方式不利于后续的迭代开发**,也就必然导致HW2中的重构。
2.2 Homework2
在引入了三角函数、自定义函数以及多层嵌套括号后,本人认为HW1中的架构已经较难适用于HW2之中,因此对程序进行了一定的重构。整体架构设计以及考虑如下:
1>解析表达式
本人首先对解析部分进行了一定的重构。Parser和Lexer这两个类的设计本人认为可以很好地应对后续的迭代开发,因此并没有进行大幅度的修改,只是增加了对三角函数、自定义函数的解析功能。本人主要重构的是Expr,Term,Factor之间的存储结构。
Expr, Term中用Hashmap的方式来进行存储terms,factors,具体而言分别构造Hashmap<Term, BigInteger(常数项系数)>以及Hashmap<Factor, Integer(指数)>。本次重构转化为Hashmap的原因主要有以下三点:
-
改写Hashcode、equals方法使得在进行同类项合并较为简洁(示例代码如下)。三角函数的合并同类项问题采用HashMap+改写的方式即可解决。
-
在Expr的角度下看,term相同的应当合并系数;在Term的角度下看,factor相同的应该合并系数。HashMap更符合这样的对应关系。
-
数学表达式中Term之间,Factor之间应该是一种无序存储的结构,而HashMap的无序性更符合数学上的特性。因此,设计这两种Hashmap来存储树结构。
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Expr that = (Expr) o;
return Objects.equals(terms, that.terms);
}
@Override
public int hashCode() {
return Objects.hash(terms);
}
2>表达式化简
在HW1的框架中,并没有实现一个较为面向对象的化简方式。在HW2的重构中,本人对Expr,Term等类实现了simplify方法。这样设计也更符合我们第一单元作业递归下降的核心。在化简Expr的时候,我只需要考虑Expr层次的事情,而无需考虑Term的化简是如何实现的,以此实现化简任务的分解,多层括号嵌套的问题也迎刃而解了。
在实现细节方面,表达式化简的核心问题是怎么将层层嵌套的括号拆开。以(3+x*(x+1))*sin(x)为例,我们要怎么处理x*(x+1)这样的式子呢? 一种可能的思路是把x看作为(x),即将非括号嵌套的因子也当作表达式,这样处理x*(x+1)就变成了(x)*(x+1),即表达式相乘 。这样处理的原因是我们可以在Expr这个层次实现表达式相乘与相加的方法来满足化简的需求。在进行表达式相乘的时候,再遍历两个表达式中的每一个项并相乘即可。
3>表达式输出
在表达式输出方面,本人在架构中为不同的类设计了toString方法,相当于把HW1中集成的输出功能拆散给了不同的类,不同的类只需要关注自身应该是怎么输出即可。在toString方法中可以对部分输出进行优化,比如三角函数应该是一层括号还是两层括号。然后在toString方法中应当谨慎使用.replaceall函数进行替换,很容易会出bug。
2.3 Homework3
在HW2中经过重构后,代码的架构已经较为成熟,这页在HW3中的优势体现的较为显著,使本人能够较为轻松地完成HW3中求导的相关内容。
在HW2的基础上,主要是迭代开发了以下代码与功能:
1>求导因子类与求导方法
HW3中增加了求导因子,需要代码支持求导的操作。因为求导因子是一种新的因子,所以需要一个新的类来刻画求导因子(下称求导因子类)。求导因子类可以抽象为dx(Expr)的形式,其实类似于HW2中三角函数类的抽象。还是结合递归下降的思想,针对Expr,Term以及各Factor设计对应求导方法,在不同类的层次关注不同的求导法则,并将最终求导的结果作为Expr返回,即可解决求导的相关问题。
2>自定义函数支持定义调用
该部分仅需要在读入自定义函数时先对函数的主体进行解析即可,在此不过多阐述。
2.4 重构体验
本人在HW2中对代码进行了重构,在本次单元总结第一部分的UML类图和度量数据中可以看出,重构后的代码相比于重构前的代码具有以下特点:
- 各个类的方法数量增多:这是因为重构需要将部分冗杂的代码进行功能分配,需要新的方法来满足原本实现的功能,因此方法数量有所增多。
- 类与方法的总体复杂度下降幅度较大:在实现重构后,原本复杂度极高的方法已经被消除,这也更有利于进行代码的维护与迭代。
在第二周的理论课中,老师PPT上写着几个大字 “重构势在必行”,开始促使本人进行代码的重构。在看完了HW2的描述后,更加坚定了自己重构的决心。尽管重构的过程十分困难,而且也意味着HW1中部分代码必须舍弃,但是这也是值得的。因为进行代码重构后,其实是为了自己后续可以更方便的迭代,也减少了会出bug的可能性。如果当意识到架构不足以满足后续作业的要求时,应该进行重构而不是修修补补。
三、优化策略
在本部分,本人将对自己实现的优化进行总结。
-
x**2可以优化为x*x,这个在Expr的toString方法中加入特判即可实现。但是注意对于三角函数类型,需要将sin(x*x)换回为sin(x**2),因为前者是不符合文法的。
-
sin(0)修改为0,cos(0)修改为1,这里本人是在三角函数类的simplify方法中实现的。判断三角函数类的Expr值是否为0,如果为0则根据三角函数名返回不同的值。这里需要注意在处理返回值的时候不要搞错了。
-
诱导公式:sin(-x)=-sin(x),cos(x)=cos(-x),这里本人同样也是在simplify方法中实现的。根据系数判断是否为负,然后修改参数并进行相对应的操作即可。
-
三角函数括号判断:在进行三角函数的括号是加一层还是两层的判断里,一定要注意考虑清楚所有情况!本人HW2的bug就出现在了这里少考虑了一种情况。
四、分析自己程序的Bug
Homework1
本次作业在强测、互测中并未出现bug。
Homework2
在三次HW中,本人的程序仅有第二次HW的代码在强测阶段以及互测阶段遭到hack,遭受到hack的互测数据如下:
//互测
0
sin((5*cos(y)))
经过分析,本次强测与互测被发现的bug属于同一个bug。本人程序的bug是在三角函数的toString方法中,在处理一对扩号还是两对扩号时条件判断欠考虑了一种情况,并未特别判断系数是否为1,导致程序会输出sin(5*cos(x))这样格式错误的输出。该方法的类复杂度如下:
method | CogC | ev(G) | iv(G) | v(G) |
---|---|---|---|---|
expression.Trigono.toString() | 10 | 5 | 7 | 9 |
可以看出,该方法的复杂度是比较高的,在进行括号判断的时候进行了较多的条件语句嵌套。这也应证了一条普遍规律:bug通常会出现在复杂度高的代码之中。
Homework3
本次作业在强测、互测中并未出现bug。
五、分析自己发现别人程序Bug所采用的策略
Homework1
hack策略:由于第一次作业时没来得及编写自动评测机,故主要采用手动构造边界样例与特殊数据的方法对他人代码进行测试,但未发现bug。
Homework2
hack策略:在第二周的时候抽空写了一个较简易(弱)的评测机,通过随机生成数据+手动构造特殊样例的方法成功hack了三次:
1>有的同学在利用正则对表达式进行预处理的时候,.repalceall("\t","0")
。这个bug在进行中测的时候竟然没被测出来,这也说明了一个问题:利用正则进行预处理的时候一定要小心!!很容易出一些意想不到的错误。
2> 对于数据sin(0)**0,部分同学的代码输出结果是0。这种问题的原因应该是在优化过程中遇到sin(0)直接返回了0,并没有考虑其后面的指数项。这个数据出bug又告诉了我们另一个事情:进行优化一定要先确定准确性,每进行一处优化需要对优化进行充分的测试。
除此之外,还可以观察他人代码中有无实现三角函数的优化,可以针对别人三角函数优化的代码进行针对性的hack(尤其是实现了平方和的代码)。
Homework3
本次作业采用了鹿煜恒同学分享的自动评测机对房友进行随机数据测试,但是并未能发现房友的bug。本人通过构造特殊的测试数据也未能成功hack他人。
六、心得体会
经过一开学痛苦的三周,博客周终于有空来收拾下心情,整理下自己学到的知识。第一单元作业对于本人来说的确是一个很大的挑战,可能是自大学以来最困难,最痛苦的挑战。虽然本人对很多课程的设置具有不同的观点,但课程也不会因为我一个人的想法而发生任何改变,所以还是得自己去变化来适应这样的设置。
在第一单元中,我主要有以下的心得体会:
- 在面对超出自己范围的难题时,一定要多去和同学进行沟通。大家对作业的思路进行沟通后,其实会从更多角度来看待问题,也更有利于设计出更合理的框架。
- 本人在构建测试数据方面的能力较为欠缺,还需要多向大佬学习,同时在第二单元自行搭建更完善的评测机。
- 要平衡优化与正确性,任何的优化都需要基于代码输出结果正确。如果过度追求性能而导致出现了部分比较致命的bug,反而会得不偿失。
房友进行随机数据测试,但是并未能发现房友的bug。本人通过构造特殊的测试数据也未能成功hack他人。
六、心得体会
经过一开学痛苦的三周,博客周终于有空来收拾下心情,整理下自己学到的知识。第一单元作业对于本人来说的确是一个很大的挑战,可能是自大学以来最困难,最痛苦的挑战。虽然本人对很多课程的设置具有不同的观点,但课程也不会因为我一个人的想法而发生任何改变,所以还是得自己去变化来适应这样的设置。
在第一单元中,我主要有以下的心得体会:
- 在面对超出自己范围的难题时,一定要多去和同学进行沟通。大家对作业的思路进行沟通后,其实会从更多角度来看待问题,也更有利于设计出更合理的框架。
- 本人在构建测试数据方面的能力较为欠缺,还需要多向大佬学习,同时在第二单元自行搭建更完善的评测机。
- 要平衡优化与正确性,任何的优化都需要基于代码输出结果正确。如果过度追求性能而导致出现了部分比较致命的bug,反而会得不偿失。
希望在第二单元和后面的学习中能够再接再厉,祝自己一路顺利。