设计师的危险

哈Ha! 我向您介绍Aleksey Kladov撰写的文章“构造函数的危险”的翻译。


我最喜欢的Rust博客文章之一是Graydon Hoare撰写的Things Rust Rusted Without 。 对我而言,缺少可以在腿部射击的语言中的任何特征通常比表达能力更为重要。 在这篇略具哲学意义的文章中,我想谈谈Rust中缺少的我最喜欢的功能-有关构造函数。


什么是构造函数?


构造函数通常在OO语言中使用。 构造函数的任务是在世界其他地方看到它之前完全初始化该对象。 乍一看,这似乎是个好主意:


  1. 您在构造函数中设置不变式
  2. 每种方法都注意不变量的守恒
  3. 这两个属性一起意味着您可以将对象视为不变式,而不是特定的内部状态。

构造函数在这里起着归纳基础的作用,是创建新对象的唯一方法。


不幸的是,这些论点存在一个漏洞:设计人员本人以未完成状态观察对象,这会带来很多问题。


这个值


当构造函数初始化对象时,它以某种空状态开始。 但是,如何为任意对象定义此空状态?


最简单的方法是将所有字段设置为其默认值:布尔值false,数字值0,所有链接为null。 但是这种方法要求所有类型都具有默认值,并将臭名昭著的null引入语言。 这就是Java采取的路径:在创建对象的开始,所有字段均为0或null。


使用这种方法之后,将很难摆脱null。 Kotlin是一个值得学习的好例子。 默认情况下,Kotlin使用非空类型,但是它被迫与预先存在的JVM语义一起使用。 语言的设计很好地掩盖了这一事实,并且在实践中很好地适用,但是站不住脚 。 换句话说,使用构造函数,可以绕过Kotlin中的空检查。


Kotlin的主要功能是鼓励创建所谓的“主要构造函数”,该结构在执行任何自定义代码之前同时声明一个字段并为其分配值:


class Person( val firstName: String, val lastName: String ) { ... } 

另一个选择:如果未在构造函数中声明该字段,则程序员应立即对其进行初始化:


 class Person(val firstName: String, val lastName: String) { val fullName: String = "$firstName $lastName" } 

尝试在初始化之前尝试使用字段被静态拒绝:


 class Person(val firstName: String, val lastName: String) { val fullName: String init { println(fullName) // :     fullName = "$firstName $lastName" } } 

但是只要有一点创造力,任何人都可以绕开这些检查。 例如,方法调用适用于此:


 class A { val x: Any init { observeNull() x = 92 } fun observeNull() = println(x) //  null } fun main() { A() } 

同样适合使用lambda(在Kotlin中按如下方式创建:{args-> body})来抓取:


 class B { val x: Any = { y }() val y: Any = x } fun main() { println(B().x) //  null } 

像这样的例子在现实中似乎是不现实的,但是我在真实代码中发现了类似的错误(软件开发中Kolmogorov的概率规则0-1:在相当大的数据库中,几乎可以保证存在任何代码段,至少在没有这样的情况下)编译器静态禁止;在这种情况下,几乎可以肯定不存在)。


Kotlin可能因此失败而存在的原因与Java协变量数组相同:检查仍在运行时进行。 最后,为了使上述情况在编译阶段不正确,我不想使Kotlin类型系统复杂化:考虑到现有的限制(JVM语义),运行时验证的价格/收益比要比静态验证好得多。


但是,如果每种类型的语言没有合理的默认值怎么办? 例如,在C ++中,用户定义的类型不一定是引用,您不能只为每个字段分配null并说这将起作用! 相反,C ++使用特殊语法为字段设置初始值:初始化列表:


 #include <string> #include <utility> class person { person(std::string first_name, std::string last_name) : first_name(std::move(first_name)) , last_name(std::move(last_name)) {} std::string first_name; std::string last_name; }; 

由于这是一种特殊的语法,因此该语言的其余部分无法正常工作。 例如,由于C ++不是面向表达式的语言(本身是正常的),因此很难将任意操作放入初始化列表。 要使用初始化列表中出现的异常,您需要使用该语言的另一种晦涩功能


构造函数的调用方法


正如Kotlin的示例所暗示的,一旦我们尝试从构造函数中调用方法,一切都会崩溃。 基本上,方法希望通过此方法访问的对象已经完全构建并且正确(与不变式一致)。 但是在Kotlin或Java中,没有什么阻止您从构造函数调用方法的,因此我们可以不小心对半构造对象进行操作。 设计者承诺会建立不变量,但与此同时,这是最可能违反它们的地方。


当基类构造函数调用派生类中重写的方法时,会发生特别奇怪的事情:


 abstract class Base { init { initialize() } abstract fun initialize() } class Derived: Base() { val x: Any = 92 override fun initialize() = println(x) //  null! } 

试想一下:任意类的代码调用其构造函数之前已执行! 类似的C ++代码将导致更有趣的结果。 除了调用派生类的函数外,还将调用基类的函数。 这没有什么意义,因为派生类尚未初始化(请记住,我们不能只说所有字段都为空)。 但是,如果基类中的函数是纯虚函数,则其调用将导致UB。


设计师签名


违反不变性并不是设计师面临的唯一问题。 它们具有带有固定名称(空)和返回类型(类本身)的签名。 这使得人们难以理解设计过载。


回填问题:std :: vector <int> xs(92,2)对应什么?

一个 两个长度的向量92

b。 [92,92]

c。 [92,2]

通常,当无法创建对象时,就会出现返回值问题。 您不能只从构造函数返回Result <MyClass,io :: Error>或null!


通常将其用作论点,以支持使用C ++没有异常是很困难的事实,并且使用构造函数还会强制您使用异常。 但是,我认为这种说法不正确:工厂方法可以解决这两个问题,因为它们可以具有任意名称并返回任意类型。 我相信以下模式有时在OO语言中很有用:


  • 创建一个私有构造函数,该函数将所有字段的值用作参数并简单地分配它们。 因此,这样的构造函数将在Rust中用作结构文字。 它也可以检查任何不变量,但是它不应该对参数或字段做其他任何事情。


  • 为公共API提供了具有适当名称和返回类型的public工厂方法。



构造函数的一个类似问题是它们是特定的,因此不能一概而论。 在C ++中,“存在默认构造函数”或“存在副本构造函数”不能比“某些语法有效”更简单地表达。 将此与Rust进行比较,其中这些概念具有适当的签名:


 trait Default { fn default() -> Self; } trait Clone { fn clone(&self) -> Self; } 

没有设计师的生活


Rust只有一种创建结构的方法:为所有字段提供值。 工厂函数(例如,通常接受的新函数)起构造函数的作用,但最重要的是,它们至少在具有或多或少正确的结构实例之前才允许您调用任何方法。


这种方法的缺点是任何代码都可以创建结构,因此没有单个位置(例如构造函数)来维护不变式。 在实践中,这很容易通过隐私解决:如果结构的字段是私有的,则只能在同一模块中创建此结构。 在一个模块内,不难遵守“所有创建结构的方法都必须使用新方法”的协议。 您甚至可以想象一种语言扩展,它允许您使用#[constructor]属性标记某些函数,以便结构文字语法仅在标记的函数中可用。 但是,再说一次,其他语言机制对我来说似乎是多余的:遵循本地约定几乎不需要付出任何努力。


就我个人而言,我认为这种折衷在总体上与合同编程完全相同。 诸如“ not null”或“ positive value”之类的合同最好以类型编码。 对于复杂的不变量,只需在每个方法中编写assert!(Self.validate())并不那么困难。 在这两种模式之间,在语言级别或基于宏实现的#[pre]和#[post]条件几乎没有空间。

斯威夫特呢?


Swift是另一种有趣的语言,值得一看的设计机制。 像Kotlin一样,Swift是null安全语言。 与Kotlin不同,Swift的null检查更强大,因此该语言使用了一些有趣的技巧来减轻构造函数所造成的损害。


首先 ,Swift使用命名参数,它对“所有构造函数都具有相同的名称”有所帮助。 特别是,具有相同类型参数的两个构造函数不是问题:


 Celsius(fromFahrenheit: 212.0) Celsius(fromKelvin: 273.15) 

其次 ,要解决“构造函数调用尚未完全创建的对象类的虚拟方法”的问题,Swift使用了一种经过深思熟虑的两阶段初始化协议。 尽管初始化列表没有特殊的语法,但是编译器会静态检查构造函数的主体是否具有正确和安全的形式。 例如,仅在类的所有字段及其子代初始化之后,才可以使用调用方法。


第三 ,在语言级别上,对构造函数的支持可能会失败。 可以将构造方法指定为可为空,这使调用该类的结果成为一个选项。 构造函数可能还具有throws修饰符,该修饰符在Swift中的两阶段初始化语义上比在C ++中的初始化列表语法上更好。


Swift设法解决了我抱怨的构造函数中的所有漏洞。 然而,这是有代价的: 初始化一章是 Swift书中最大的一章。


何时真正需要构造函数


千方百计,我至少可以提出两个为什么构造函数不能用结构文字代替的原因,例如Rust。


首先 ,继承在某种程度上迫使语言具有构造函数。 您可以想象支持基本类的结构语法的扩展:


 struct Base { ... } struct Derived: Base { foo: i32 } impl Derived { fn new() -> Derived { Derived { Base::new().., foo: 92, } } } 

但这在具有简单继承的OO语言的典型对象布局中将不起作用! 通常,对象以标题开头,后跟类字段,从基础到最衍生。 因此,派生类的对象的前缀是基类的正确对象。 但是,为了使这种布局正常工作,设计人员需要一次为整个对象分配内存。 它不能只为基类分配内存,然后附加派生字段。 但是,如果我们要使用语法创建可以为基类指定值的结构,则需要分段分配内存。


其次 ,与结构文字语法相反,设计人员拥有一个ABI,可以很好地将对象子对象放置在内存中(放置友好的ABI)。 构造函数使用一个指向此对象的指针,该指针指向新对象应占用的内存区域。 最重要的是,构造函数可以轻松地将指针传递给子对象构造函数,从而允许“就地”创建复杂的值树。 相反,在Rust中,构造结构在语义上包括很多副本,在这里,我们希望优化器的优美之处。 Rust尚未就子对象在内存中的位置提出可接受的工作建议,这绝非偶然!


更新1:修正了错字。 将“写入文字”替换为“结构文字”。

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


All Articles