浅谈Julia语言:Julia的面向对象

2,144
原文链接: zhuanlan.zhihu.com

Julia语言将在今年8月6日发布1.0版本,我相信很多一直在观望的人也已经跃跃欲试了。这个系列的文章将结合我在开发Yao的过程中所实际感受到的一些问题和经验来谈谈Julia语言。因为并非PL背景,我不会从语言设计上去介绍太多细节,一切从实际使用感受出发。

Julia语言的OO是与其它语言完全不同的。Julia是基于多重派发和类型系统的OO。这一套系统设计地也很巧妙,所以也有人评价说,Julia的性能很大程度是语言设计带来的。

多态

Julia没有class,但是在Julia里你也可以认为一切都是object,而这些object都是某个类型的实例。一个Julia的复合类型(Composite Type)可以这样声明,几乎和C是一样的。而实际上在Julia里类型分为复合类型(Composite Type),基础类型(Primitive Type)等等,本文不会介绍基础语法,还请参考官方文档(英文)。

struct Kitty
    name::String
end

那么这样Julia就不能像Python/C++等语言一样去通过让某个method属于一个class来实现多态。因为类型除了自己的constructor以外是不允许含有其它方法的。Julia使用了多重派发来解决这个问题。什么是多重派发?可以参见我另外一篇文章:PyTorch源码浅析(五)

于是在Julia里,method便是类型(type)和类型之间的相互作用(interaction),而非类(class)对其它类之间的作用。对于传统的OOP的优缺点在知乎上已经有过很多讨论了,在数学,物理等科学计算领域,我们往往需要定义很多复杂的关系,在概念上这样的方式更加直接,OOP在很多科学计算的场景下并不是很合适。Julia这种基于多重派发和类型的OO不妨是一种更加合适的尝试。例如,一个浮点数和整数的加法

+(lhs::Int32, res::Float64) = # ...

这个加法并不属于Int类型也不属于Float,这在数学上很讲得通。总体来讲,用Julia为理论对象进行抽象会非常自然。

然后如果你使用jupyter notebook就还会发现,由于method不再属于一个class,方法和类型之间的耦合更小。你可以先定义一些方法,然后在cell运行一下,然后再定义一些方法,而不需要再class中将所有的方法都声明完。

类型系统

仅仅有多重派发只能提供一些多态,但是无法实现类似继承的功能。这一点由类型系统来完成,但是请切记,不要将传统OOP里继承的思想搬过来,这是我接触地很多Julia的初学者,尤其是从Python/C++转来的初学者会发生的错误。这样的代码很不Julian,因为语言本身并没有继承的概念而且你会发现最后会导致自己手动复制代码从而造成大量的重复代码。当然如果你想去写类似OOP的代码风格的Julia,当然是可以做到的,但我不建议这么做。

首先简要回顾一下类型系统。Julia的类型系统是由抽象类型和实际类型(Concrete Type)构成的类型树。子类型会获得父类型行为,而不会获得父类型的成员。所以Julia是鸭子类型(Duck Type)的。在文档中,Julia Team强调说:我们更加在意类型的行为,而不是它的成员,所以无法继承成员是设计成这样的。

很多人都问过我,那么如果我有一些公共的成员需要各个子类型都有怎么办?如何少些重复代码?下面我来讲几种方案,请针对自己的情况去选择

  1. 公共的成员是一些静态成员(不是type trait)

定义共享的行为,而不是共享的成员

abstract type A end
struct B <: A end
struct C <: A end

name(::A) = "no name" # 默认没有名字
name(::B) = "name B" # B 是另外的名字,而C就继承了A的行为

2. 成员是完全一样的,但是行为有所不同

使用Symbol作为标签来分发不同的行为,但是它们共享一个参数类型。

struct A{Tag}
    name::String
end

name(x::A{:boy}) = "the boy's name is $(x.name)"
name(x::A{:girl}) = "the girl's name is $(x.name)"

3. 成员不同,部分是公共的,并且不是静态的

这种情况下,我们依然是通过行为去定义类的结构。我们需要有一个公共的method作为interface,这样我们就不必去管类里具体的结构。虽然不可避免地你需要用一个新的类型封装一下公共的成员,或者你需要手写一遍。

struct A
    m1
    m2
    name
end

name(x::A) = x.name

struct B
    m1
    name
end

name(x::B) = x.name

所以使用类型的时候,我们不鼓励通过 . 来调用类型成员,我们鼓励去调用某个method,也就是使用类型的行为。不过实际上在具体实现的时候,通过合理地解耦,你会发现第三种情况实际上出现地相对较少,更多出现的是一二两种情况。如果你遇到了第三种情况不妨再思考思考。

以上经验,总结一下就是:在Julia里行为(behaviour)比其它的事情更加重要。而类型仅仅是用来派发行为的一种标签。