百科问答小站 logo
百科问答小站 font logo



Go、Rust、Nim等新兴语言,为什么都抛弃了constructor? 第1页

  

user avatar   yinfupai 网友的相关建议: 
      

面向对象编程方法的本质,其实就是一种如何将大量的代码与数据进行管理的归档方法。

每一个类,其实就像一个文档盒子,把一些相类似的东西,可以归类放在同一个盒子里(class)

比如,猫与老虎都属于猫科动物。

这样反向查阅时,可以快速找到相应的内容。

代码与普通文档不同的是,代码之间是有相互调用的。

于是我们把那些相互调用比较频繁的,通常又把它们一起放到更大的柜子里。(package或namespace)

然而,在归档的时候会发现很多细节问题。

比如A文档与B文档,只是多了两个属性,这些东西是否应该归在一起?

因为他们的区别很小,所以通常会把它们放在一起,并且把它们划成不同的类的同时,还要要保持它们之间的联系,然后标注成继承关系。

这样可以通过基类找出衍生类,也可以衍生类找出基类。

举例来说,比如一只骡子,算马还是算驴?

细分还有“马骡”与“驴骡”,要说马骡属于马,驴骡属于驴,这种管理起来似乎也不太好,显得马骡与驴骡变成了两种不同的动物,然而实际上它们的差异也没有那么大。

那怎么办呢,可以进行多重继承,它既可以继承于马,又可以继承于驴,这样就好了。

到此听起来都很完美,但是其实已经踩入了一个大坑。

因为面向对象整理归档在实践中,大家发现,尽量黑盒封装,在相互调用之间,尽量暴露最少的方法或属性,而且这个粒度拆的越细越好,这样可以更加灵活。

最好就是一个类,就它两三个方法,两三个属性,这样理解起来简洁,认知负担小。

然后多重继承的灾难就来了,复杂情况下一个普通的类,可能要继承几十个类。

但是你没有办法保证类之间的成员,不发生任何冲突。

比如马和驴,继承了马与驴的骡类,马与驴都能奔跑,这个奔跑是继承马的还是驴的?

因此这里的继承就需要选择了,指定选择实现谁的,这个显然会带来一些冲突。

当然一些语言里面已经解决了这个问题,比如元对象,动态类型之类的,但多线程环境下这些很容易遇到诡异的现象。

不过,更根本的问题不在这里。

更麻烦的是,骡子是不可生育的,两个类进行合成以后他失去的生育属性。

这就尴尬了,继承一般只能添加或修改成员,而不能删除。

因为如果能删除之类的属性的话就违反继承的概念了。

因为你大可以把一只兔子耳朵有羽毛全部去掉,然后能给它插上翅膀,加个尖嘴,变成老鹰。

这样无法保证大家不会搞得一团乱。

因此,最好的方式需要在马或驴的基类,实现一个是否可生育的属性,然后一路继承。

一个子类的实现要影响到爷爷类,这个肯定是有问题的。

问题出在哪里?一开始我们是想分门别类地把所有代码归档,通过抽象来进行简化管理,结果并没实现消除。

马驴骡的表达,如果改用组合关系表达,就会显得简单。

在常见的面向语言中,使用了一个抽象概念,接口。

接口设计的其实很简单,它不是为了继承而来,而是为了组合而设计。

任何一个类,它都由一个或多个接口组合而成,对于接口函数的具体的实现,是在实现类中,自己负责的。

原因在于所继承的基类方法与属性都是不可控的,所以要把继承的依赖,改成一种持有,这样就能消除多重继承带来的问题。

比如前面的马或驴,可以叫马和驴的特征抽象合成一个叫骡子接口,然后实现马骡和驴骡。

在实现中可以创建马和驴的对象,然后调他们相应的属性与函数,从而实现归档。

设计模式里面的不少方法,本质几乎都是为了变相解决多重继承的问题。

构造函数,对于组合关系来说,其实不是一个很好的设计。

因为构造函数的本质,其实本质就是一个可以直接返回自己对象的函数。

而把复杂的构建放在构造函数里,很容易在继承中导致各种灾难。

如何构建过程中,尴尬的发现不满足创建条件,怎么办?

一个下属,如果去办事,办到一半才发现这事办不了,那这个问题一定是出在领导身上,而不是下属身上的,因为这种问题应该一开始就避免。

构造函数无法合理传递返回出错信息,只是为了new的时候,自动调用运行的那么一下,实在没有必要。

因此,在设计模式中,通常会采用factory或builder的方式来创建对象。

到这里便会发现,已经开始尝试各种绕开类的继承了。

这样,就好比一块石头,你可以把它凿成你想要的样子拿来用。

然后我们知道builder负责造零件,factory是负责出产品的,假如我们把他们两个融合起来。

要是切成大量足够细的接口与实现,直接将它们进行组合,岂不要好得多。

但是面向对象的语言,来实现这些的时候,会非常的笨拙。

很显然,那干脆,为什么我们不一步到位呢?

万物皆是组合而成。

于是trait,是接口,impl就是实现,struct,就是属性。

无论任何情况下,只要将它们进行组装就好了。

Rust天然就是这样,因此不要构造函数也很天然。

这样并非没有缺点,虽然看来灵活了,但是代码组织管理上的负担并没有消除。

大量的trait,还缺乏一种更加有效的组织管理方法。

很显然面向对象的编程的设计,是从代码级别实现组织管理,在过去的不少大型软件中已经得到了很好的实践。

并且面向对象的编程语言,因为它的规约性极强,可以让IDE也很强大,对于大型代码的管理非常方便。

如何将这两个优势进行结合,应该是未来语言的发展方向。

当我们创造一个概念时,这个概念必然会以某种形式反过来束缚我们。


user avatar   makoto-ruu 网友的相关建议: 
      

为构造函数赋予特殊的语法地位,并不总是一种画蛇添足。在不同的场合下,专用的构造函数,和语法地位与普通函数没有任何差别的工厂函数,可能有着不同的优劣对比。

先来看工厂函数优于构造函数的方面:

  • 降低语法复杂度,回避使用者需要记忆太多特殊规则的心智成本。
  • 可以和普通函数一样自由传递,交由各种需要回调的地方,免去需要将特殊语法(new 之类)再包一层的麻烦。
  • 可以更加灵活的定制构建对象的方式。构造函数的硬性局限在于无法改变调用之前即已分配内存、形成尚待初始化的空架子的固定操作。若特殊的逻辑需要改变这种默认行为则无法实现,包括但不限于:
    • 特定条件下返回空指针/引用。
    • 特定条件下不去新分配对象,而是返回一个别处已有的对象。
    • 在不同条件下分别构建不同的子类型。
    • 平时使用对象的方式并非通过其本身,而是通过某种包装或代理(如智能指针),希望简化构建过程。
    • 在异常发生时,因故不想通过抛异常来解决,而是通过特殊返回值、回调、外部状态等不打破常规执行流的方式进行反映。

可见其中很多场合是现实中不可避免的,所以实际编程中,工厂函数一定会被用到,哪怕语法中已经提供了专门的构造函数也是一样。于是用户时刻面临两种选择,需要仔细思考每个具体地方应该使用哪种,心智的开销、需求变化或单纯误用之后被迫重构的麻烦,都会不断引起反思。事实上,既然其中一种所能涵盖的用场确实是另一种的超集,所以,最终产生是否工厂函数其实可以包办一切、构造函数是否只是多余累赘的念头也是顺理成章。很难说,那些干脆没有构造函数语法概念的新生代语言完全没有受到这种观念的影响。

那么,这种观念是否总是正确呢?不妨就回来接着看下反面,构造函数优于工厂函数的方面:

  • 封装性。只有通过构造函数才能建立对象内部状态的一致性,外部代码没有机会来产生非法的对象状态,从而规避了各种误用导致的 bug。这点靠工厂函数是做不到的,因为无法限定用户只能通过工厂函数来创建对象,既然对象的类型本身必须公开(否则外界没法使用),外界就可以通过字面量来创建此种对象,而其内部状态无从经历专业的初始化,显然是非法的。此时类型作者针对用户的、对于类型使用方式的约束,只能通过文档、约定等“人治”的方式来进行,而不能经由编译器、检查器来形成“法制”的硬性保证。这给了用户犯错的机会,是一定程度上对软件工程质量进行了妥协。在具有构造函数概念的语言中,常见的“私有构造函数”套路就是为此而设,但在没有构造函数概念的语言中却无从实施。实际上,从结论上讲,工厂函数包办一切并没有彻底解决上述的二选一负担,依然在很多时候,还残留下了工厂和字面量的二选一(Go 程序员们想必有所体会,但 Rust 通过字段必须全部赋值的规则可以规避本条)。
  • 常量成员的初始化。很多语言具有常量成员的语法功能,只在构造函数中能够赋值,此外的地方则只读。说到底依然是封装性,封装性是广义的概念,其边界并不一定等于类型的边界,实际上,任何功能独立、边界清晰的代码单元都可以进行各种程度的封装,构造函数和常量成员所共同构成的集合同样可以视作一个小小的封装单元,对外界的使用方式进行了一定约束。而其目的,与别处的封装性相同,依然是为了代码质量,从“法制”上杜绝各种误用,一方面降低 bug 概率,另一方面也提高理解代码的容易度。而工厂函数,因为地位与普通函数并无区别,所以无法建立起与特定对象类型的特别联系,从而对对象内容具有特别的操作权限。
  • 给持有对象类型、但不持有工厂函数的用户以创建同型新对象的能力。语法上,构造函数是类型的一部分,而工厂函数并不是(哪怕业务上的确是,也不能被语法系统所识别)。创建同类对象并不一定是指克隆现有对象(这种通过普通方法即可实现,并不需要构造函数),也可以是给出全新参数、创建完全不同的个体。一个例子是扩展容器容量的场合:新增的槽位需要新创建的对象来填充。引用语义时填充空值这种取巧办法且略去不谈,若是值语义、或是引用语义也可能要求空值安全的场合则如何?常见的方案可能要求对象具备无参构造函数,以生成默认值对象。不光 C++ 常用,连 C# 这种也专门设立了 new() 的泛型约束。但无参也仅仅是个低保,还有时候是需要构建非默认的对象内容的。STL 有 emplace 一族,虽然只能插入一个元素,但这也只是库作者的选择而已,若想实现一次插入多个同样的非默认对象也并无难点。这都是拜构造函数所赐(是不是 placement 倒不是重点),若想靠工厂,就只能用一个额外的参数把函数传进去了,除编码繁冗外,开销也侵入到了运行时,而非构造函数方式的零开销抽象。或者还有说,这种靠普通的静态成员函数也能实现,并非一定要构造函数。也是事实,单从语法层面确实此处构造函数并无任何特别。但从习俗和便利方面,使用构造函数依然是比强求用户实现某个静态函数要来得人性化和通用化的。试想,在传统上众多可以使用默认隐式无参构造的场合,难道用户也要给随手定义的众多琐碎类型统统实现一个指定的静态方法?另外,若缺乏业界统一标准,不同的库会不会要求用户实现不同的静态方法?

以上最后一条或可视作小众场合,不做过多计较。事实上,最大的着眼点依然在于封装性。构造函数对对象创建的一切入口,从“法制”而非“人治”的层面,有着严密、完备、无所遗漏的接管,并严格杜绝一切产生非一致的非法对象状态的可能性。比起“创建对象”的作用本身,这种“完备”才是其真正意义所在。在笨重臃肿的软件架构日益受到诟病、乃至连OO的“政权合法性”都日益受到挑战的今天,或许强调这种名词并不容易引起共鸣。但实际上,封装性真的只是OO的一部分吗?不妨设想,如果完全扔掉封装性,则OO会变成怎样?——答案是,其实不会怎样,封装性其实是OO中最可有可无的一部分。有诸多动态语言,事实上就是几乎没有一点封装性,但表达能力上并没有受到任何影响,照样OO得欢。在它们里,可能构造函数才是真正的画蛇添足,模仿残余,完全去掉也没有关系。比起这点小小侧面,不如说它们欠缺的是整个的“法制”系统,所以才会项目一大就不免在“人制”方面如临大敌,各种规范限制工具辅助严阵以待不敢怠慢,一时爽火葬场的段子广为流传。这也是对本质的一种揭示,说到底,封装性是一种“减法”,并非为软件建模提供什么功能和方便,而正是以提供“不便”的方式,来对人员过度自由的行为进行约束,从而一方面提高软件质量,另一方面提升代码的可读性和可理解性,从而使语言的工程能力得到飞跃。极端一点说的话,弱类型语言才是自由度更大的,一块内存一时可以当整数、一时可以当字符串、再一时还能当指针,按说功能只会是强类型语言的超集。但大规模用起来会是什么感觉想必也显而易见。然而这也并不是在对某一类语言进行彻底否定,因为世上存在各种各样的领域,没有一把锤子能够普遍适应,天下也不尽是大型工程,在需要轻快敏捷的地方非要套用一大堆严谨规则也是自找麻烦。所以每种语言也有自己的选择,针对预定的问题域被打造成了特定的样子。如何认清自己所面临的问题域,选择最合适的工具,从而避免削足适履的麻烦,发挥最大的应有效用,才是语言的使用者需要始终思考的主题。




  

相关话题

  做一个不同编程语言之间的converter有没有意义? 
  golang,告警业务,每个告警源都有不同的分发频率,告警源可能新增or删除,该如何设计? 
  vlang,nim等新兴语言未来是否有可能掀翻go,rust,Python等老牌热门语言的霸主地位? 
  为什么Rust 标准库的 TreeMap 采用 B 树实现,而不是常用的红黑树? 
  为什么有很多人说 Go 语言不需要依赖注入? 
  后端领域,写java的程序员普遍比写Python和go的程序员水平低吗? 
  为什么 Go 语言在某些方面的性能还不如 Java? 
  如何激怒一位 Rust 爱好者? 
  如何评价 Golang 1.5 更新? 
  为什么 Go 语言能在中国这么火? 

前一个讨论
什么是无为?
下一个讨论
怎么用哲学解释大道至简?





© 2024-12-22 - tinynew.org. All Rights Reserved.
© 2024-12-22 - tinynew.org. 保留所有权利