鸭子类型和反射——关于编程范式的一点学习

前言:在北邮人论坛里看到了对多态,继承,多重继承等的讨论,遂对比较陌生的鸭子类型和反射做了些学习。实际上这两个概念属于编程范式的范畴,在语法层面之上。对之的了解,相信对于以后语言的学习,多少也有些触类旁通的帮助。

鸭子类型

静态语言和动态语言

编程语言按照数据类型大体可以分为两类,一类是静态类型语言,另一类是动态类型语言。

静态类型语言在编译时便已确定变量的类型,而动态类型语言的变量类型要到程序运行的时候,待变量被赋予某个值之后,才会具有某种类型。

静态类型语言的优点首先是在编译时就能发现类型不匹配的错误,编辑器可以帮助我们提前避免程序在运行期间有可能发生的一些错误。其次,如果在程序中明确地规定了数据类型,编译器还可以针对这些信息对程序进行一些优化工作,提高程序执行速度。

静态类型语言的缺点首先是迫使程序员依照强契约来编写程序,为每个变量规定数据类型,归根结底只是辅助我们编写可靠性高程序的一种手段,而不是编写程序的目的,毕竟大部分人编写程序的目的是为了完成需求交付生产。其次,类型的声明也会增加更多的代码,在程序编写过程中,这些细节会让程序员的精力从思考业务逻辑上分散开来。

动态类型语言的优点是编写的代码数量更少,看起来也更加简洁,程序员可以把精力更多地放在业务逻辑上面。虽然不区分类型在某些情况下会让程序变得难以理解,但整体而言,代码量越少,越专注于逻辑表达,对阅读程序是越有帮助的。

动态类型语言的缺点是无法保证变量的类型,从而在程序的运行期有可能发生跟类型相关的错误。这好像在商店买了一包牛肉辣条,但是要真正吃到嘴里才知道是不是牛肉味。

如在Ruby,Python,JavaScript等语言中,当我们对一个变量赋值时,显然不需要考虑它的类型,因此,JavaScript是一门典型的动态类型语言。

是什么

动态类型语言对变量类型的宽容给实际编码带来了很大的灵活性。由于无需进行类型检测,我们可以尝试调用任何对象的任意方法,而无需去考虑它原本是否被设计为拥有该方法。

这一切都建立在鸭子类型(duck typing)的概念上,鸭子类型的通俗说法是:“如果它走起路来像鸭子,叫起来也是鸭子,那么它就是鸭子。”

我们可以通过一个小故事来更深刻地了解鸭子类型。

从前有一个国王,他觉得世界上最美妙的声音就是鸭子的叫声,于是国王召集大臣,要组建一个1000只鸭子组成的合唱团。大臣们找遍了全国,终于找到999只鸭子,但是始终还差一只,最后大臣发现有一只非常特别的鸡,它的叫声跟鸭子一模一样,于是这只鸡就成为了合唱团的最后一员。

这个故事告诉我们,国王要听的只是鸭子的叫声,这个声音的主人到底是鸡还是鸭并不重要。鸭子类型指导我们只关注对象的行为,而不关注对象本身。

下面用代码来模拟。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var duck = {
duckSingingfunction(){
console.log( '嘎嘎嘎' );
}
};
var chicken = {
duckSingingfunction(){
console.log( '嘎嘎嘎' );
}
};
var choir = [];    // 合唱团
var joinChoir = function( animal ){
if ( animal && typeof animal.duckSinging === 'function' ){
choir.push( animal );
console.log( '恭喜加入合唱团' )
console.log( '合唱团已有成员数量:' + choir.length );
}
};
joinChoir( duck );     // 恭喜加入合唱团
joinChoir( chicken );    // 恭喜加入合唱团

我们看到,对于加入合唱团的动物,大臣们根本无需检查它们的类型,而是只需要保证它们拥有duckSinging方法。

在动态类型语言的面向对象设计中,鸭子类型的概念至关重要。利用鸭子类型的思想,我们不必借助超类型的帮助,就能轻松地在动态类型语言中实现一个原则:“面向接口编程,而不是面向实现编程”。

为什么

那么,为什么要有鸭子出现?

一、组合

C++等语言实现面向对象的主要方法是继承,而鸭子类型实现面向对象的方式是组合。而我们所熟知的,甚至被奉为面向对象编程原则的”多组合,少继承“的思想也说明了组合要比继承更加灵活方便。

C++中的多继承特性从一定层面反映了开发这想通过更多的父类获得特性;优先组合的思想也反应出我们希望能够使用更多对象的特性并隐藏细节;接口的出现反映出我们不希望通过多继承这样庞大的机制就能够实现更为复杂的对象关系;这些原本都是继承机制的面向对象思想或原则。

在日常的面向对象编程过程中,通常会为了做一个相对完美的抽象,关注了更多的类之间的关系,而不能把主要精力集中在业务逻辑代码中。

鸭子类型提供了大道至简的编程思想和模式

二、复用

静态类型中最关键的一点是面向契约编程,即双方定下调用契约,然后你实现,我调用。这避免了许多运行中问题。可是,正因为此,复用被弱化很多。

这里再次提到C++,多重继承的提出也有复用的目的。因为,人们不满足于只能仅仅复用简单的个体,很希望能够吸取多种对象的功能。这和现实是很相近的。一个业务实体往往能够兼备多种实体的功能。

尽管后来其他语言都是采用接口的机制取代多重继承,来实现业务实体的多个功能面的契约定义。可是,接口只是解决的契约的定义。另外,对于契约,其实有时候是很不公平的事。微软的认证是一个例子:

微软的认证是有阶梯约束的。过了初级才能考中级,而不管你是否已经拥有了初级的能力。对于没有参与考试的人,这是件不公平的事。如果有一项任务,必须拥有某种资格认证的人才能做,你是看资质证书呢?还是看能力表现?

这是个非常有意思的问题。如果是你,你会选择哪个呢?静态语言选择了前者,动态语言选择了后者。鸭子类型就是充分想利用这些没有获得契约的资源。在不改变这些对象的前提下,对之复用。

总结

鸭子类型是一种多态的表现形式,是一些动态语言具有的特征。它能够避免一些类的重写,无需大量复制相同的代码,但是也需要良好的文档支持,和严谨的代码,否则代码调试起来会多处许多麻烦,谁知道你的鸭子是不是我的鹅呢?

反射

反射同样是个抽象的、在语法之上的概念,是一些语言具有的特征,它牺牲了语言的封装性来实现代码的重用和灵活性,有利有弊。在Java,Ruby,C++,C#,Scheme诸多语言,甚至C中都有体现。

是什么

元编程Metaprogramming)的角度来看,一门语言同时也是自身的元语言的能力称之为反射Reflection))。按照 Toby Davies 论文 Homoiconicity, Lazyness and First-Class Macros 的说法,反射(Reflection)其实是通过允许在运行时存取程序数据,以改变程序行为的程序设计技术。他认为,反射其实是一种“语义同像(Semantic Homoiconicity)”。具有语义同像性的语言必须把程序中的一些内部状态,比如符号表、指令指针,暴露给程序员。这样,程序员就可以把这些东西当作 First-Class-Value 而很容易地操作它们。

提供反射这种特性,无外乎是出于为了提高生产力、提高编程时的灵活性等考量。

下面以Ruby为例。

1
2
3
4
5
6
7
8
irb> require 'what_methods'
=> true irb>[1, 2, 3, 4].what? 4
[1, 2, 3, 4].last == 4
[1, 2, 3, 4].pop == 4
[1, 2, 3, 4].lenght == 4
[1, 2, 3, 4].size == 4
[1, 2, 3, 4].count == 4
=>[:last, :pop, :length, :size, :count, :max]

又是一个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
module M; end
class A; include M; end
class B < A; end
class C < B; end
b = B.new
b.instance_of? A  #=> false
b.instance_of? B  #=> true
b.instance_of? C  #=> false
b.instance_of? M  #=> false
b.kind_of? A      #=> true
b.kind_of? B      #=> true
b.kind_of? C      #=> false
b.kind_of? M      #=> true

Scheme中甚至有指令指针级别的反射。例子略。

作用

解释究竟反射机制能够带来什么好处之前,先来看看具体的Reflection机制,以明白透过常见的Reflection支持,在程序中究竟能做到那些事情。这里以Java为例介绍,目的不在介绍Java完整的Reflection API,而是透过Java,帮助大家了解Reflection的一般性概念。

在Java中反射机制的源头,就是一个叫“Class”的class(在C#中有一个相似的类别,则叫做Type)。这个类别有点特殊,原因在于此类别的每一个对象都用来表示系统中的每一个类别。

具体来说,每个Class对象都描述了每个类别的相关信息,也提供你透过它可以进行的一些操作。想要开始Reflection的动作,就必须先取得Class类别的对象。最常被运用到的两个途径,一个便是Object(所有对象皆继承的类别)所提供的getClass()函数,另一个则是Class类别所提供的forName()静态函数。

前者让你得以取得一个对象(尤其是类型未知的对象)所属的类别,而后者则让你得以指定一个类别的名称后,直接得到该类别对应的Class对象。

有了Class对象之后,便能“审视”自身的特性,这些特性包括了它隶属于那个Package、类别本身究竟是Public还是Private、继承自那一类别、实作了那些接口等。更重要的是,你可以得知它究竟有那些成员变量以及成员函数(包括建构式)

透过这个自我审视的过程,程序便能够了解它所要处理的对象(尤其是类型未知的对象),究竟具备了什么特质。对运用反射机制的程序而言,所了解到的这些特质,便会影响到该程序的运作行为。

取得了某类别的成员变量后(在Java中是以Field类别的对象表示),便可以取得该类别对象的成员变量值,也可以设定其值。同样的,取得了某类别的成员函数后(在Java中是以Method类别的对象表示),便可取得该成员函数的回传类型、传入的自变量列表类型,当然更重要的是,Method类别的对象,可被用以呼叫类别对象的相对应成员函数。

有了反射,程序代码在撰写及编译的时间点,毋需明白实际在运行时,究竟会涉及那些类别以及它们各自的行为。你所写下的程序代码,可以完全是对要处理的类别一无所知,也可以是对他们有一点基本的假设(例如要处理的类别都具有相同名称的函数,却没有实作相同的接口,或是继承同样的类别),一切都可以等到执行时期,透过自我审视的能力,了解要面对的对象究竟具备什么特性,再依据相对应的逻辑,动态利用程序代码控制。 当程序毋需将行为写死,便消除了相依性

有了如此动态的能力,程序代码在撰写时毋需将行为写死,包括要处理的类别、要存取的成员变量、要呼叫的函数等。这大大增加了程序弹性,同时也增加了程序的扩充性。

举例来说,一个连接数据库的Java系统而言,在编译时期是不需要知道究竟运作时会使用那一个JDBC驱动程序,系统只需要透过某种方式,例如在设定档中指定类别名称,那么程序便可以依据这类别名称,加载相对应的JDBC驱动程序,程序代码中完全可以不涉及具体的JDBC驱动程序究竟为何。

总结

本来应该对程序员透明的机制,现在却暴露给了程序员,使得我们有能力去操纵它,让它能够更灵活地完成我们的工作。

所以说,反射这种东西,确实破坏了封装的初衷,但他们两者之间并不是绝对的对立。想一想,我们封装、隐藏细节、建立抽象屏障,无外乎都是为了降低程序的复杂度——这是工程上的折衷。但是在大量的实践中我们发现,我们抽象出来的通用模式并不是银弹,很多问题在它构建的框架之下解决起来就非常麻烦。

所以有了反射这么一手,把很多难以预测的问题留到运行时,动态地去考虑去解决。这也就使得我们在走投无路时,还可以有一道后门开着让我们大摇大摆地进入。

同时。

计算机程序在执行完一系列语句指令后,它知道自己执行的是啥么?它知道它自己是在干什么么?反射,就是试图在语言层面提供一种这样的能力:让代码有自省能力,让代码知道自己在干什么,尽管目前的实现还很初级、很浅薄

结语

对于编程模式的研究,在我看来有点类似一把砍柴刀,磨刀不误砍柴工,这方面的研究学习在不同的语言和语法实现上,会有着催化剂般的作用。就像数学基础之于计算机算法。

参考资料

  1. 动态类型语言和鸭子类型
  2. 鸭子类型:一切都是为了复用
  3. 初识go语言以及鸭子类型
  4. 编程语言中的 鸭子模型(duck typing)
  5. 动态与弹性 细看编程语言的反射机制
  6. 为什么语言里要提供“反射”功能?
  7. 语言的反射为什么比较慢,反射存在的意义是什么?为什么C++没有反射?
  8. 元编程的魅力——反射机制