正确键入:干净代码的被低估的方面

大家好

不久之前,曼宁出版社出版了《用类型编程》,这本书几乎完成了我们的注意力,该书详细说明了正确键入的重要性及其在编写整洁持久的代码中的作用。



同时,我们在作者的博客中发现了一篇显然是在该书的早期工作中撰写的文章,并让其印象深刻。 我们建议讨论作者的想法有多有趣,以及整本书可能

火星气候轨道器

火星气候轨道飞行器在着陆期间坠毁并在火星大气层中坍塌,这是因为洛克希德软件组件提供了以磅力秒为单位的动量值。秒

您可以想象NASA开发的组件大致采用以下形式:

//    ,  >= 2 N s void trajectory_correction(double momentum) { if (momentum < 2 /* N s */) { disintegrate(); } /* ... */ } 

您还可以想象Lockheed组件像上面这样调用上面的代码:

 void main() { trajectory_correction(1.5 /* lbf s */); } 

磅力秒(lbfs)约为4.448222牛顿/秒(Ns)。 因此,从洛克希德公司的角度来看,将1.5 lbfs传递到trajectory_correction应该是完全正常的:1.5 lbfs约为6.672333 Ns,远高于2 Ns的阈值。

问题是数据解释。 结果,NASA组件将lbfs与Ns进行了比较而不进行转换,并且错误地将lbfs的输入解释为Ns的输入。 由于1.5小于2,所以轨道器崩溃了。 这是一种众所周知的反模式,称为原始痴迷。

对原始语的痴迷

当我们使用原始数据类型来表示问题域中的值并允许出现上述情况时,对原始的固定就会显现出来。 如果将邮政编码表示为数字,将电话号码表示为字符串,将Ns和lbfs表示为双精度数字,则确实会发生这种情况。

定义Ns的简单类型会更安全:

 struct Ns { double value; }; bool operator<(const Ns& a, const Ns& b) { return a.value < b.value; } 

同样,您可以定义一个简单的lbfs类型:

 struct lbfs { double value; }; bool operator<(const lbfs& a, const lbfs& b) { return a.value < b.value; } 

现在,您可以实现trajectory_correction的类型安全变体:

 //  ,   >= 2 N s void trajectory_correction(Ns momentum) { if (momentum < Ns{ 2 }) { disintegrate(); } /* ... */ } 

如果像上面的示例那样使用lbfs调用此代码,则由于类型不兼容,代码根本无法编译:

 void main() { trajectory_correction(lbfs{ 1.5 }); } 

请注意,现在通常在注释中指出的值类型信息( 2 /*Ns */, /* lbfs */ )现在如何被绘制到类型系统中并在代码中表示:( Ns{ 2 }, lbfs{ 1.5 } ) 。

当然,可以通过显式运算符将lbfs减少到Ns

 struct lbfs { double value; explicit operator Ns() { return value * 4.448222; } }; 

有了这种技术,您可以使用静态强制转换来调用trajectory_correction

 void main() { trajectory_correction(static_cast<Ns>(lbfs{ 1.5 })); } 

在此,通过乘以系数来实现代码的正确性。 强制转换也可以隐式执行(使用hidden关键字),在这种情况下,强制转换将自动应用。 作为经验法则,您可以在此处使用其中一种Python语言:
显式胜于隐式
这个故事的寓意是,尽管今天我们有非常聪明的类型检查机制,但是它们仍然需要提供足够的信息来捕获这种类型的错误。 如果我们在声明类型时考虑了我们学科领域的具体情况,这些信息就会进入程序。

状态空间

当程序以不良状态终止时,会发生故障。 类型有助于缩小其出现的范围。 让我们尝试将类型视为可能值的集合。 例如,bool是集合{true, false} ,其中此类型的变量可以采用这两个值之一。 类似地, uint32_t是集合{0 ...4294967295} 。 通过这种方式考虑类型,我们可以将程序的状态空间定义为某个时间点上所有活动变量的类型的乘积。

如果我们有一个bool类型的变量和一个uint32_t类型的变量,那么我们的状态空间将为{true, false} X {0 ...4294967295} 。 这仅意味着两个变量都可能处于任何可能的状态,并且由于我们有两个变量,因此程序最终可能处于这两种类型的任何组合状态。

如果我们考虑初始化值的函数,那么一切都会变得更加有趣:

 bool get_momentum(Ns& momentum) { if (!some_condition()) return false; momentum = Ns{ 3 }; return true; } 

在上面的示例中,我们通过引用获取Ns,并在满足某些条件时进行初始化。 如果正确初始化了该值,该函数将返回true 。 如果该函数由于某种原因无法设置该值,则返回false

从状态空间的角度考虑这种情况,可以说状态空间是bool X Ns的乘积。 如果函数返回true,则表示已设置脉冲,并且是Ns的可能值之一。 问题是这样的:如果函数返回false ,则表示未设置脉冲。 动量以某种方式属于Ns的可能值的集合,但不是有效值。 通常,以下错误状态会意外地传播到以下错误:

 void example() { Ns momenum; get_momentum(momentum); trajectory_correction(momentum); } 

相反,我们只需要这样做:

 void example() { Ns momentum; if (get_momentum(momentum)) { trajectory_correction(momentum); } } 

但是,有一种更好的方法可以强制执行此操作:

 std::optional<Ns> get_momentum() { if (!some_condition()) return std::nullopt; return std::make_optional(Ns{ 3 }); } 

如果使用optional ,则此函数的状态空间将显着减小:代替bool X Ns我们得到Ns + 1 。 此函数将返回有效的Nsnullopt以指示没有值。 现在,我们根本无法拥有会在系统中传播的无效Ns 。 同样,现在也变得不可能忘记检查返回值,因为不能将可选内容隐式转换为Ns我们将需要特别解压缩它:

 void example() { auto maybeMomentum = get_momentum(); if (maybeMomentum) { trajectory_correction(*maybeMomentum); } } 

基本上,我们努力使我们的函数返回结果或错误,而不是返回结果和错误。 因此,我们排除了有错误的条件,并且我们可以避免出现不可接受的结果,这些结果可能会泄漏到进一步的计算中。

从这个角度来看,抛出异常是正常的,因为它与上述原理相对应:函数将返回结果或抛出异常。

区域情报研究所

RAII的意思是“资源获取是初始化”,但是在更大程度上,该原理与资源释放相关。 该名称最初出现在C ++中,但是,该模式可以用任何语言实现(例如,请参见.NET中的IDisposable )。 RAII提供自动资源清除。

什么是资源? 以下是一些示例:动态内存,数据库连接,操作系统描述符。 从原则上讲,资源是从外界获取的东西,在我们不再需要它之后会被退还。 我们使用适当的操作返回资源:释放它,删除它,关闭它,等等。

由于这些资源是外部资源,因此在我们的类型系统中未明确表示它们。 例如,如果我们选择动态内存的一个片段,我们将得到一个指针,通过该指针我们必须调用delete

 struct Foo {}; void example() { Foo* foo = new Foo(); /*  foo */ delete foo; } 

但是,如果我们忘记这样做,或者阻止我们调用delete什么?

 void example() { Foo* foo = new Foo(); throw std::exception(); delete foo; } 

在这种情况下,我们不再调用delete并导致资源泄漏。 原则上,这种手动清理资源是不希望的。 对于动态内存,我们有unique_ptr可以帮助我们对其进行管理:

 void example() { auto foo = std::make_unique<Foo>(); throw std::exception(); } 

我们的unique_ptr是一个堆栈对象,因此,如果它unique_ptr作用域(当函数引发异常时或当引发异常时堆栈展开时),则将调用其析构函数。 正是这个析构函数实现了delete调用。 因此,我们不再需要管理内存资源-我们将这项工作转移给包装器,包装器拥有它并负责其释放。

任何其他资源都存在(或可以创建)类似的包装器(例如,Windows的OS HANDLE可以包装为一种类型,在这种情况下,其析构函数将调用CloseHandle )。

在这种情况下的主要结论是永远不要手动清理资源。 请使用现有的包装器,或者如果没有适合您特定情况的包装器,我们将自行实现。

结论

我们从一个著名的示例开始了本文,该示例演示了键入的重要性,然后研究了使用类型来帮助编写更安全的代码的三个重要方面:

  • 声明和使用更强的类型(与对基本类型的痴迷相反)。
  • 减少状态空间,返回结果或错误,而不是结果或错误。
  • RAII和自动资源管理。

因此,类型可以极大地帮助使代码更安全并使代码适应重用。

Source: https://habr.com/ru/post/zh-CN460149/


All Articles