文章目录
前言
这篇文章的主要内容是我对软件构造课程-方法的设计规约部分的学习总结,以供未来使用。
内容主要包括
- 程序设计语言的方法
- 规约的作用
- 行为等价性
- 规约的结构
- 规约的安全性问题
- 规约的评价标准
- 绘制规约图
- 如何设计一个“好”的规约
一、程序设计语言的方法/函数
上一篇文章 数据类型与类型检验 聚焦于程序设计过程中的变量,也就是“名词”。而这里则介绍“动词”——也就是方法/函数/操作。
方法的构成
一个Java方法的基本构成如下:
修饰符 返回值类型 方法名(参数1类型 参数1名,参数2类型 参数2名...){
...
方法体
...
return 返回值;
}
这些名词的详细定义不多赘述。
这里值得注意的是参数的传递,这也是非常容易出错的地方。
参数传递
一般程序语言中的参数传递有两种:
- 按值传递
- 引用传递
之前所学的C语言中就同时具有着两种传参方法。其中引用传递可以使用"&"来表示。
但是,Java的参数传递只有值传递!而Java中的两种数据类型——基本数据类型和对象数据类型的值传递是有所不同的。
- 基本数据类型:接收值的副本。
- 对象数据类型:接收对象引用的副本。
其实经过上一篇文章的分析后,这里其实很容易理解。基本数据类型的变量在栈内存中直接存储值,而对象数据类型的变量在栈内存中存储的是对象在堆内存中的地址。Java的参数传递就是把两类变量(实参)所对应的存储内容传递给参数变量(形参),于是实参和形参实际上是具有不同作用域的相互独立的不同变量。
基本数据类型的参数传递:
public class Main {
public static void main(String[] args) {
int x = 0;
System.out.println("Before method call: x = " + x); // 打印方法调用前的值
modifyValue(x);
System.out.println("After method call: x = " + x); // 打印方法调用后的值
}
public static void modifyValue(int x) {
x = 5; // 修改参数的值
}
}
这里的两次输出均为0。因为方法中的x与方法外的x实际上是两个变量,它们之间的关系仅仅是在方法开头,形参的内容与实参相同。后续对形参的修改不会对实参产生任何影响。
对象数据类型的参数传递:
public class Main {
public static void main(String[] args) {
Person person = new Person("Alice");
System.out.println("Before method call: " + person.getName()); // 打印方法调用前的名字
modifyPerson(person);
System.out.println("After method call: " + person.getName()); // 打印方法调用后的名字
}
public static void modifyPerson(Person p) {
p.setName("Bob"); // 修改Person对象的名字
}
}
区别于基本数据类型,这里两次打印的结果分别是Alice和Bob。这是因为对象数据类型的参数传递接收的是实参对象地址的副本,也就是说在方法的开始,实参和形参对应的是同一地址下的对象,所以对形参的修改当然会影响到实参的值,因为二者指向的是同一对象。但是如果对形参重新赋值,那么就不会影响实参的值了,因为二者作为相互独立的两个变量指向了不同的对象:
public class Main {
public static void main(String[] args) {
Person person = new Person("Alice");
System.out.println("Before method call: " + person.getName()); // 打印方法调用前的名字
modifyPerson(person);
System.out.println("After method call: " + person.getName()); // 打印方法调用后的名字
}
public static void modifyPerson(Person p) {
p = new Person("Bob"); // 在方法内部重新分配一个新的Person对象
}
}
这里两次打印的结果均为Alice。
方法的设计思想
“方法”或者叫“函数”之所以存在,是为了增强代码的可复用性和封装性,以及提高代码的可读性和可维护性。
- 可复用性:通过将一段逻辑封装在方法中,可以在程序的其他地方多次调用该方法,而不必重复编写相同的代码。
- 封装性:方法提供了一种将一组相关操作组织在一起并将其视为单个单元的方式。通过将代码封装在方法中,可以隐藏实现细节,并提供一个清晰的接口供其他部分的代码调用。这样可以降低模块之间的耦合度,使代码更易于理解和维护。
- 可读性:通过将代码分解成多个方法,可以使代码更加清晰易读。方法的名称应该描述其功能,这有助于其他开发者理解代码的意图,并且使代码更易于阅读和理解。
- 可维护性:通过将代码组织成方法,可以使代码的维护更加容易。如果需要修改或调试代码,只需在方法内部进行修改或调试,而不必查找和修改整个代码库中的每个调用点。
然而,如果方法仅仅局限于代码的实现,并不足够契合这些设计思想。所以就出现了方法的规约(Specification)。有了方法的规约与实现,才能称得上是一个完整的方法。
二、规约:Programming for Communication
课程讲义上的标题就是:Programming for Communication。我的理解是所谓“Communication”可以理解为两个方面:程序与客户端之间,不同系统/组件之间的沟通(代码层面);不同开发者之间/过去与现在的沟通(开发层面)。
规约定义了软件组件之间的接口和交互方式,提供了一种共同的语言和标准,使得不同的组件之间可以进行有效的通信,也作为客户端与程序之间的一种“协议”规范双方的行为。
通过遵循规约,开发者可以更容易地理解和使用他人编写的代码,减少了沟通和理解的障碍。规约还有助于降低代码的耦合度,提高代码的可重用性和可维护性。在开发过程中,规约还可以作为一种文档形式,记录和传递关于代码的重要信息和约束条件。
总之,写方法的规约是十分必要且有价值的。
记录作用
规约本身可以看做一种特殊的文档形式,可以记录(Documenting)代码的约束条件和重要信息。
JavaAPI的文档就是一个很好的例子:
虽然代码本身就蕴含了一定的“设计决策”,但这对其他人而言是比较隐晦的,通过代码能够直接读出设计思想更是难上加难,甚至对于开发者本人来说,在较长时间过后可能也会忘掉自己的设计,读不懂自己写的代码。而规约具有的记录功能就很好的解决了这一问题。
契约作用
一段代码,不过就是一些指令的序列,其本身是不具备“正确”或者“错误”的定义的。所以代码的正确与否就需要一个达成一致的标准来定义,这就是规约的另一大作用——契约(Contract)。
- 规约确定了程序的预计功能。如果没有规约,编写代码就如同分子的布朗运动,既没有明确的目的,更谈不上正确与错误了。无规约,则不编程。
- 规约构建起程序与客户端之间的契约。它是程序与客户端之间达成的共识,在这种共识之下,双方自然就担负起一定的责任——必须同时遵守规约。
我们常说一段代码出现了“bug”,而bug就是错误,除了无法预知的错误以外,错误就是对规约的违背。
其实很多bug就来自于程序与客户端之间的误解。比如程序要求输入一个整数序列,而客户端却输入了小数;客户端想要返回一个增序序列,而程序却返回了一个无序序列…这些所谓的“错误”,就是规约不明确而导致的。假如一个方法具有精准的规约,那么该方法出错时,到底是开发者负责还是客户端负责,就一目了然了。
封装作用
规约还可以在客户端与程序之间建立防火墙*(firewall*),起到降低系统耦合度的作用。
- 规约对方法进行了封装。客户端不需要知道一个方法的内部实现,只需要根据规约的要求来调用方法就能成功达到目的。假如没有规约,客户端就不得不去查看方法的实现,去猜测开发者的意图,先不说能否做到,就算客户端有能力窥探到代码内部,这样调用方法会特别容易出现意想不到的错误。
- 规约对双方的行为进行了隔离。假设开发者获取到了方法内部的一个错误,需要进行修改。那么在不改变规约的情况下,他不需要通知客户端就可随意进行修改,甚至完全更换实现的方法或者逻辑。因为客户端只需要清楚该方法的功能,按照规约的调用即可。只讲“能做什么”,不管“如何实现”。
规约的封装作用如下图所示:
三、行为等价性
如果两个方法都能满足同一个功能的要求,在不考虑性能、安全性等其他方面因素的影响,仅从正确性的角度来看,这两个方法就是可以替换的。
其实这就是行为等价性(Behavioral Equivalence)的直观理解。如果两个及以上的方法能够满足同一个规约,那么这些方法对于这个规约就是等价的。
这里要注意的是,只要满足同一个规约即可,对于规约中没有提及的情况,这些方法可以具有任何天马行空的行为,但它们仍然是等价的。
举个例子,对于规约:
有两个可以对其进行实现的方法:
一个是从前向后遍历,没找到则返回数组长度;另一个是从后往前遍历,没找到则返回-1。两个方法的实现和逻辑具有很大不同,但对于这个规约所描述的情况(目标元素仅出现一次)而言,二者具有完全相同的行为:返回该元素的下标。
考虑其他的情况:比如该元素出现了多次,或者该元素根本没有出现,这两个方法的返回值是不同的。但行为等价性并不考虑规约以外的情况,所以二者的行为对此规约而言仍然是等价的。
四、规约的结构:前置与后置
一个方法的规约应该包含下面两部分:
- 前置条件(Precondition):以关键字requires声明,在使用方法时必须满足的条件,是对客户端的约束。
- 后置条件(Postcondition): 以关键字effects声明,在方法结束时必须满足的条件,是对开发者的约束。
所以,如果一个方法要满足规约,就必须在前置条件满足的情况下满足后置条件。
而如果前置条件不满足,那么方法就可以做任何事。
这两张图其实就分别反映了对开发者的约束和对客户端的约束。这就好比甲方委托乙方建一座大楼:如果甲方要求这座大楼要建50层,而乙方建了30层,那么责任在乙方;而如果大楼在满足甲方的要求“可容纳一万人同时工作”的条件下,甲方却在使用过程中强行容纳了十万人导致大楼损坏,那么责任就在甲方。
Java中的规约结构
在Java中,规约表现在方法的静态类型声明和方法前的注释。其中静态类型声明可以通过静态类型检查来判断是否满足规约,而注释中的规约则需要人工进行检查或者测试。
在注释中,规约的前置条件用 @param 引出,通常是对参数的要求。
而后置条件 @return 和 @throws 引出,通常是对返回值和抛出异常的要求。
五、安全性问题:对Mutating method的规约
Mutating method是指在对象上执行操作并改变其状态的方法。
这种方法比较容易出错,因为它将传入参数的可变对象进行了修改,这种修改很可能在程序中延续下去。这种情况在上一篇文章数据类型与类型检验中已经进行了详细说明。
所以我们应该尽量遵守这样的规则:
除非在后置条件中声明过,否则方法内部应该尽量不改变输入参数。
为了遵守这样的规则,我们很容易想到可以在规约中进行说明,比如“返回一个新的对象”或者“调用者不要更改返回的对象”。但是,规约并不像静态类型检查那样如果违背则无法执行,它只能对程序员的行为进行非强制性的约束。在这种涉及程序安全性的问题下,我们就不能仅仅依靠客户端或者开发者的职业道德来让其遵守规约。
在输入参数为可变对象的情况下,如果规约中说“返回一个新的对象”,那么开发者这边依然拥有该对象的别名,并不能确保开发者后续是否会偷偷的使坏 将该对象进行不当的更改。如果规约中说“调用者不要更改返回的对象”,那么调用者依然可以保留返回的对象的别名,也无法确保客户端是否会对其进行不当的修改。
究其原因,就是因为使用了可变的数据类型,导致类的实现体和客户端中可以保存多个指向同一对象的变量(别名)。这对程序的安全性造成了极大的破坏。
那么应该如何解决这个问题呢?经过上一章的学习,答案显而易见,那就是在规约中限制使用不可变的类型。因为所有不可变类型的“变化”都是在新的内存地址上进行的,所以可以保证客户端与开发者对变量可见性的完全隔离。
六、规约的评价标准
规约与代码一样,也是具有好坏之分的。其评价标准一般有三点:
- 规约的强度
- 规约的确定性
- 规约的陈述性
强度
规约的强度用于描述该规约是否容易被替换。强度越大的规约越不容易被替换,且可以替换任意强度更小的规约。这也意味着开发者的责任和工作量越大,而使用者的工作越轻松。
如果规约S2相比S1具有更宽松(或相等)的前置条件和更严格(或相等)的后置条件,那么称S2的强度大于等于S1。这样,S2就可以替换S1了。
举例:
还有一个较为特殊的例子:
显然S2的前置条件更弱,但是对于后置条件来说,S2的后置条件实际上没有变化:因为在S1的前置条件被满足的情况下,该方法具有相同的输出。此时根据定义,我们也可以说规约S2强于S1。
而下面这个例子:
S2的前置条件更弱,但后置条件也更弱了。此时S1和S2无法进行比较。
确定性
如果一个规约对于一个给定的、满足前置条件的输入,其给出的输出是唯一的、明确的,那么就称这个规约是确定的(Deterministic)。反之,如果其给出的输出是不唯一的,是可以具有多种可能的结果的(即使最终输出只有一个),那么就称这个规约是欠定的(Underdetermined)。
举例,确定的规约:
欠定的规约(重复元素):
但欠定的规约通常具有确定的实现。也就是说仅从规约角度看,无法确定输出的结果,但是从代码层面可以解读出输出的结果。这时对于一个给定的输入来说,程序在不同时间的输出是相同的。
而当一个规约对于同一个输入,多次执行的结果却不相同时,就称这个规约是不确定的(Nondeterministic)。比如依赖于随机算法和时间的程序。
其实这里跟离散数学中的“映射”非常相似,也与这学期所学的自动机有关联之处。确定的规约就如同单射和DFA,而欠定的规约则如同值域是集族的映射和NFA。
陈述性
从陈述性的角度来看,规约可以分为两类:
- 操作式规约(Operational):给出一系列方法执行的步骤,比如伪代码。
- 声明式规约(Declarative):不给出实现细节,只给出输入与输出以及二者关系。
就方法的封装性安全性而言,显然声明式规约更有价值。因为它们更加简洁明了,易于使用,且与方法的内部实现无关。
当然,操作式规约也有其价值,它可以帮助代码维护者快速理解实现思路,也可以帮助初学者进行学习。然而,其实这种方法是没有必要的,因为完全可以通过在方法内部进行注释来实现。
七、绘制规约图
在规约图中,一个点表示一个规约的具体实现方法,而规约则用一个区域(圆圈)来表示。如果一个点落在范围之内,则表示其满足该规约,反之则在之外。
给定一个规约,程序员可以选择任意的实现方式。也就是说,给定规约图中的一个区域,可以任选一个点作为实现。
当然,规约之间也存在着强度的大小关系。表现在规约图中,就是强度越大的规约,区域越小,被包含在强度比它小的规约区域内。这里又与集合的包含和被包含非常类似。
八、如何设计一个“好”的规约
一个方法写的是否好,不光在于代码本身,规约也是重要的一部分,甚至超过代码。
设计一个好的规约并没有绝对的规则,但是可以参考下面几条建议
- 内聚的:规约描述的功能应该单一、简单、易于理解。规约不应该包含过多的情况和处理,否则应该将其拆分为多个方法再进行组合。
- 信息丰富的:规约不应该具有歧义,应该明确的区分每一种情况。同一个返回值尽量不要对应多种含义,即尽量“一一对应”。
- 强度足够高的:太弱的规约,无法做到对许多特殊情况的处理。这种规约client不敢用。所以要求开发者在条件合适的情况下尽量使用强规约,对于各种特殊情况做出正确处理或者抛出异常。
- 强度足够弱的:太强的规约,会增加开发难度,而且许多特殊情况在实际应用过程中很少遇到,收益比较低。所以要针对实际情况来设计规约,不可一味追求强规约。
- 使用抽象类型:使用抽象类型可以给方法的实现和客户端的使用提供更大的自由度。比如在Java中,尽量使用接口类型,比如Map,而不是特定的实现类,比如HashMap。
另外,还有一个很重要的问题就是对前置条件和后置条件的权衡。太弱的前置条件,就需要在方法内部进行check以检验输入合法性,并在后置条件中说明,开发难度增大;而太强的前置条件则对client要求过高,客户端不喜欢调用,因为只要输入不符合要求就会失败。
而具体的做法视情况而定: