API 设计:真实世界的情况
在此前的文章中,我们已经探索了一些基本原则,不过还没开始使用新发现的技能。让我们休息一下,看看一些今天真实场景下的一些代码示例,以及我们如何对其改进。
› Go的 math/big
库
大部分语言都有处理超大数值的库,Go 也不例外。
下面是一个操作示例:
package main
import (
"fmt"
"math"
"math/big"
"os"
)
func main() {
r := &big.Float{}
_, ok := r.SetString("2.5")
if !ok {
fmt.Fprintln(os.Stderr, "Could not parse float")
return
}
r2 := &big.Float{}
r2.Mul(r, r)
pi := big.NewFloat(math.Pi)
result := &big.Float{}
result.Mul(pi, r2)
fmt.Printf("%v\n", result)
}
上面的代码打印了半径为 2.5 的圆的面积。
这个 API 中有很多困扰我的东西。
› SetString
返回布尔值,而非错误
Go 中的惯例是函数返回两个值:结果和错误。调用者先检测是否有错误,如果没有错误再使用结果。而 Go 的 math/big
包返回的是一个结果及一个布尔值。
返回布尔值而非错误并不是完全没有先例。索引到 map 也使用这种模式,比如:
m := make(map[string]string)
m["hello"] = "world"
s, ok := m["world"]
// `ok` will be false to represent that the key was not found
其他 map 索引之外的地方,我认为会返回错误,并通过阅读错误理解我犯的错。
在 SetString
中,我更愿意看到一些错误信息。这个错误可以提示传入的字符为空,或者不是一个浮点数,或者任何其他不仅仅是否定的信息。
› 函数 receiver 的混用
math/big 中的操作使用函数 receiver 作为计算结果。在 a.Add(b,c)
中,a 被设为 b+c 的结果。该库在文档中证实了这一点:
始终通过函数 Receiver 传入结果值,内存的使用可以更好地得到控制。这样不用为每个结果分配新的内存,操作可以重用分配给结果值的空间,并在进程中用新的结果重写覆盖该值。
不过我会觉得这是一个有误导的权衡。它以牺牲普通用例为代价,换取少数利基用例的方便。想象一下如果 receiver 作为参数使用,返回值是结果。
package main
import (
"fmt"
"math"
"math/big"
"os"
)
func main() {
r := &big.Float{}
_, ok := r.SetString("2.5")
if !ok {
fmt.Fprintln(os.Stderr, "Could not parse float")
return
}
result := r.Mul(r).Mul(big.NewFloat(math.Pi))
fmt.Printf("%v\n", result)
}
越少的 mutation,越少的可见噪音,更少的函数 receiver 混用。
如果最终仍然需要控制分配的数,那么包上可能会有一组不同的函数。类似这样的内容:
package main
import (
"fmt"
"math"
"math/big"
"os"
)
func main() {
result := &big.Float{}
_, ok := result.SetString("2.5")
if !ok {
fmt.Fprintln(os.Stderr, "Could not parse float")
return
}
big.MulFloat(result, result, result)
big.MulFloat(result, result, big.NewFloat(math.Pi))
fmt.Printf("%v\n", result)
}
第一个参数是结果,第二、第三个参数是正在操作的东西。这使通用用例保持简单,同时仍然为更小众的用例提供工具。
› Java 旧的 File.exists()
方法
这是我最新的一个方法。它怎么了?
import java.io.File;
public final class Exists {
public static void main(String... args) {
if (args.length != 1) {
System.err.println("usage: java Exists filepath");
System.exit(1);
}
if (new File(args[0]).exists()) {
System.out.println("File \"" + args[0] + "\" exists!");
} else {
System.out.println("File \"" + args[0] + "\" does not exist.");
}
}
}
你可能会猜测这与 .exists()
方法有关,是的。我在标题中提到,这是一个较旧的 Java API,但为什么要更改它?
.exists()
返回一个布尔值,不抛出任何异常。最后一点是关键。在 Linux 上,Java 发出一个 stat 系统调用来判断文件是否存在。如果没有,stat 将失败,错误代码为 ENOENT.stat 也可能由于各种其他原因而失败。
将上述代码保存在一个名为 Exists.java 的文件中,并运行以下命令:
$ javac Exists.java
$ java Exists Exists.java
File "Exists.java" exists!
$ java Exists DoesntExist.java
File "DoesntExist.java" does not exist.
到目前为止,一切都很好。现在,让我们使用一个名为 strace 的工具来模拟 stat 失败:
$ strace -f -qq -P Exists.java -e fault=stat:error=ENOMEM -- java Exists Exists.java
[pid 7199] stat("Exists.java", 0x7f7227c2b780) = -1 ENOMEM (Cannot allocate memory) (INJECTED)
File "Exists.java" does not exist.
我们让计算机看起来内存不足,在这种情况下 .exists()
错误地报告文件不存在!
这实际上是一个 bug,在新的 Files.exists()
中更清楚的是,false 并不总是意味着文件不存在。还有 readAttributes()
,如果 stat失败,它将抛出 IOException
,如果您需要确定,这就是您应该使用的。
这里的API设计问题是试图用一种只能表示两种结果的数据类型来表示几十种可能的结果。每当你考虑返回一个布尔值时,停下来想想枚举是否会更好。不过,如果你这样做,尽量避免使用经典的三态布尔值。
顺便说一句:Java 的 Math.abs()
函数也有类似的设计问题,如果你能发现的话,就指出来。
› Apache 的 EntityUtils.consume()
这里的问题不在于 EntityUtils.consumer()
,而是它被认为是必要的。让我们看一个例子,然后谈谈哪里出了问题。
import org.apache.http.HttpResponse;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.HttpClients;
public final class App {
public final static void main(String[] args) throws Exception {
HttpClient client = HttpClients.createDefault();
while (true) {
HttpGet req = new HttpGet("http://example.com/");
HttpResponse res = client.execute(req);
System.out.println(res.getStatusLine());
}
}
}
如果你要运行这个程序,在程序无限期挂起之前,你不会得到超过几个成功的响应。为什么?当然,因为我们忘记了关闭 HTTP 响应。我们需要在 System.out.println()
调用之后添加以下行:
EntityUtils.consume(res.getEntity());
我不得不不止一次地在生产代码中跟踪这一点,这总是让我思考:为什么这种情况一直在发生?语言没有给我们提供更好的工具来管理可关闭的资源,这是错误的吗?这个库可否能有更好的接口?当它检测到我们犯了这个错误时,它能输出一个有用的消息或异常吗?
答案是这些因素的混合。Java 有一个名为 Closeable
的接口,这是库作者向用户发出需要关闭的信号的最佳方式。需要在 HttpResponse 中关闭的东西被隐藏在里面。这意味着 IDE 无法静态分析代码并提醒用户这个问题。
库作者注意到了这一点,这是现在他们建议的代码:
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
public final class App {
public final static void main(String[] args) throws Exception {
try (CloseableHttpClient client = HttpClients.createDefault()) {
while (true) {
HttpGet req = new HttpGet("http://example.com/");
try (CloseableHttpResponse res = client.execute(req)) {
System.out.println(res.getStatusLine());
}
}
}
}
}
这将显示 Closeable
接口,现在 IDE 可以在你忘记关闭响应时提醒你。这是更好的,但无助于可怜的家伙调试他们的悬挂(hanging)的进程。我希望看到的是在程序挂起之前输出一条日志消息,说明用户可能忘记关闭响应。
Go 使用 defer
关键字可以很好地处理资源回收。
package main
import (
"os"
)
func main() {
f, err := os.Create("myfile")
if err != nil {
panic(err)
}
defer f.Close()
// do other stuff with `f`
}
直到函数结束,才会执行 f.Close()
。此功能使创建和清理保持在彼此靠近的位置,而不是屏幕分开。这并不完美,但确实有帮助。
› 大部分语言中的断言相等
最后,大多数语言的单元测试库中的经典相等断言。此处我选用 Ruby,不过这几乎在所有语言中都存在:
require "minitest/autorun"
class MyTest < Minitest::Test
def test_equality
a = 1
b = 2
assert_equal a, b
end
end
assert_equal
在这里接受两个参数:预期参数(expected)和实际参数(actual)。但哪个是哪个?这通常先是预期的,然后是实际的,但也有一些例子不是这样的。
- Ruby MiniTest: expected, actual
- Kotlin: expected, actual
- Rust: left, right
- JavaScript chai: actual, expected
- Python: first, second
- Go: expected, actual
我不知道你是怎么做的,但我总是要花一秒钟的时间来记住应该朝哪个方向走。“不过,这有关系吗?”你可能会问。是的,经常是这样的。每个测试库都会生成以下形式的测试失败输出:“期望 X 等于 Y。”哪个参数是 X,哪个参数是 Y 并不总是显而易见的,尤其是当你比较两个函数调用的输出时。
我们能做得更好吗?对是的!
“Fluent” API 在这方面做得非常好,这也是许多单元测试框架的发展方向。
以下是 Ruby 中使用 rspec 库的一个示例:
require 'spec_helper'
describe "a test" do
it "should be equal" do
a = 1
b = 2
a.should eql(b)
end
end
在 Java 中使用谷歌的 Truth 库:
int a = 1;
int b = 2;
assertThat(a).isEqualTo(b);
assertThat(a).isLessThan(b);
Fluent API 读起来更流畅,也更清楚地表明实际 actual 总是第一位的。从 API 设计的角度来看,这种接口确实会让用户更难犯错误。这是一个很好的 API 设计原则,我们将在未来的文章中对此进行探讨。
› 结语
这里没有太多的结论。我希望你能从真实的例子中得到一些见解,我希望我建议的改进是有意义的。如果你不同意,或者认为有更好的情况,我很高兴在评论中探讨。:)