ObjectOriented.jl
ObjectOriented.jl为Julia提供一套相对完整的面向对象机制,设计上主要基于CPython的面向对象,对Julia做了适配。
其功能一览如下:
| 功能名 | 支持 | 注释 |
|---|---|---|
| 点操作符 | 是 | |
| 继承 | 是 | 基类、子类不能直接转换 |
| 构造器和实例方法重载 | 是 | 基于多重分派 |
| 多继承 | 是 | MRO基于变种C3算法 |
| Python风格 properties | 是 | |
| 默认字段 | 是 | |
| 泛型 | 是 | |
| 接口 | 是 | 使用空结构体类型做基类 |
| 权限封装(modifiers) | 否 | 同Python |
| 类静态方法 | 否 | 不实现,避免type piracy |
| 元类(metaclass) | 否 | 不实现,推荐宏处理 |
快速学习请参考ObjectOriented.jl Cheat Sheet.
必须强调的是,我们非常认可Julia社区关于“不要在Julia中做OOP”的观点。
我们创建这个指南 将OOP翻译到地道的Julia,以指导用户如何将OOP代码翻译为更简短、更高效的Julia代码。
我们更是花费精力将ObjectOriented.jl设计成这个样子:OOP的使用能被局限在坚定的OOP使用者的代码里,通过接口编程,这些OOP代码和正常的Julia对接,以避免不合适的代码在外部泛滥。
对于熟悉Julia编程风格的人来说,ObjectOriented.jl提供的接口编程和字段继承仍然可能帮到你。对于这样的专业Julia程序员来说,推荐只在OO类型中定义字段,不推荐定义形如self.method()的点操作符方法。
OO类型定义
ObjectOriented支持定义class和struct,class使用@oodef mutable struct开头,struct使用@oodef struct开头。
using ObjectOriented
@oodef struct MyStruct
a :: Int
function new(a::Int)
@mk begin
a = a
end
end
function f(self)
self.a
end
end
@oodef mutable struct MyClass
a :: Int
function new(a::Int)
@mk begin
a = a
end
end
function f(self)
self.a
end
end上述代码中,function new(...)用于定义构造器。 构造器的返回值应当使用@mk begin ... end构造一个当前类型的实例,其中,begin语句块中使用字段名=值初始化字段。
缺省构造器的行为:
- 当类型为
class,所有字段未初始化。 - 当类型为
struct,且存在字段,使用Julia生成的构造器(dataclass)
构造器可以被重载。对于空间占用为0的结构体(单例类型),构造器可以省略。
实例方法
实例方法须以function 方法名(类实例, ...)开头。类实例变量推荐命名为self或this。
前面代码里MyClass和MyStruct都实现了实例方法f, 它们的实例,比方说instance::MyClass,以instance.f()的语法调用该方法。
@oodef mutable struct MyClass
a :: Int
# ... 省略部分定义
function f(self)
self.a
end
end实例方法支持任意形式的Julia参数,如变长参数,关键字参数,变长关键字参数,默认参数,默认关键字参数。
此外,实例方法支持泛型,且能被重载。
P.S: 如果要标注self参数的类型,应该使用self :: @like(MyClass)而不是self :: MyClass。这是因为实例方法可能被子类调用,而Julia不能支持隐式转换。
P.P.S: 什么是@like?对于一个OO类型Parent, 任何继承自Parent的子类Child满足Child <: @like(Parent),其中<:是Julia原生的subtyping运算。
默认字段
ObjectOriented.jl支持默认字段。
在为类型定义一个字段时,如果为这个字段指定默认值,那么@mk宏允许缺省该字段的初始化。注意,如果不定义new函数并使用@mk宏,默认字段将无效。
function get_default_field2()
println("default field2!")
return 30
end
@oodef struct MyType
field1 :: DataType = MyType
field2 :: Int = get_default_field2()
function new()
return @mk
end
function new(field2::Integer)
return @mk field2 = field2
end
end
julia> MyType()
default field2!
MyType(MyType, 30)
julia> MyType(50)
MyType(MyType, 50)关于默认字段的注意点:
- 默认字段没有性能开销。
- 在
@mk块显式指定字段初始化时,默认字段的求值表达式不会被执行。 - 与
Base.@kwdef不同,默认字段的求值表达式无法访问其他字段。
Python风格的构造器
以往的OO语言,如Python/C++/Java/C#,没有原生支持的不可变类型,因此构造器的工作一般设计为:
- 创建一个新对象
self(或this) - 利用构造器参数对
self进行初始化
Julia也支持这样的构造方式,但只对mutable struct有效,且不推荐。写法如下:
@oodef mutable struct MySubclass <: {MySuperclass1, MySuperclass2}
field1
function new()
self = @mk
# 初始化字段
self.field1 = 1
## 记住需要返回
return self
end继承,多继承
下面是一个简单的继承例子。
首先我们用@oodef定义两个结构体类型:
@oodef struct A
a :: Int
end
@oodef struct B
b :: Int
end随后,我们用一个类型C继承上面两个类型。
@oodef struct C <: {A, B}
c :: String
function new(a::Int, b::Int, c::String = "somestring")
@mk begin
A(a), B(b)
c = c
end
end
end
c = C(1, 2)
@assert c.a === 1
@assert c.b === 2可以看到,我们使用在@mk块中使用Base1(arg1, arg2), Base2(arg1, arg2)来设置基类,这和Python中的基类.__init__(self, args...)一致。
一个子类可以继承多个基类,当多个基类出现重名属性时,使用C3线性化算法来选取成员。我们使用的C3算法是一个变种,能允许更灵活的mixin抽象。
下面给出一个mixin的例子。mixin是继承的一种常见应用:
我们定义一个基类,多边形IPolygon,它的子类可能有正方形、长方形、三角形乃至一般的多边形,但这些子类都共享一个标准的周长求解算法:将所有边的长度相加。
则多边形的基类,可以用如下代码定义:
using ObjectOriented
const Point = Tuple{Float64, Float64}
function distance(source::Point, destination::Point)
sqrt(
(destination[1] - source[1]) ^ 2 +
(destination[2] - source[2]) ^ 2)
end
@oodef struct IPolygon
# 抽象方法
function get_edges end
# mixin方法
function get_perimeter(self)
s = 0.0
vs = self.get_edges() :: AbstractVector{Point}
if length(vs) <= 1
0.0
end
last = vs[1] :: Point
for i = 2:length(vs)
s += distance(vs[i], last)
last = vs[i]
end
s += distance(vs[end], vs[1])
return s
end
end利用上述基类IPolygon,我们可以实现子类,并复用其中的get_perimeter方法。
例如,矩形Rectangle:
@oodef struct Rectangle <: IPolygon
width :: Float64
height :: Float64
center :: Point
function new(width::Float64, height::Float64, center::Point)
@mk begin
width = width
height = height
center = center
end
end
function get_edges(self)
x0 = self.center[1]
y0 = self.center[2]
h = self.height / 2
w = self.width / 2
Point[
(x0 - w, y0 - h),
(x0 - w, y0 + h),
(x0 + w, y0 + h),
(x0 + w, y0 - h)
]
end
# 对特殊的子类,可以重写 get_perimeter 获得更快的求周长方法
# function get_perimeter() ... end
end
rect = Rectangle(3.0, 2.0, (5.0, 2.0))
@assert rect.get_perimeter() == 10.0P.S: 由ObjectOriented.jl定义的OO类型,只能继承其他OO类型。
Python风格的properties
在Java中,getter函数(get_xxx)和setter(set_xxx)函数用来隐藏实现细节,暴露稳定的API。
对于其中冗余,很多语言如Python提供了一种语法糖,允许抽象self.xxx和self.xxx = value,这就是property。
ObjectOriented.jl支持property,用以下的方式:
@oodef struct DemoProp
@property(value) do
get = self -> 100
set = (self, value) -> println("setting $value")
end
end
println(DemoProp().value) # => 100
DemoProp().value = 200 # => setting 200下面是一个更加实际的例子:
@oodef mutable struct Square
side :: Float64
function new(side::Number)
@mk begin
side = convert(Float64, side)
end
end
@property(area) do
get = self -> self.side ^ 2
set = function (self, value)
self.side = sqrt(value)
end
end
end
square = Square(5) # => Square(5.0)
square.area # => 25.0
square.area = 16 # => 16
square.side # => 4.0可以看到,在设置面积的同时,正方形的边长得到相应改变。
高级特性:抽象方法和抽象property
@oodef struct AbstractSizedContainer{ElementType}
# 定义一个抽象方法
function contains end
# 定义一个抽象getter
@property(length) do
get
end
end
# 打印未实现的方法(包括property)
ObjectOriented.check_abstract(AbstractSizedContainer)
# =>
# Dict{PropertyName, ObjectOriented.CompileTime.PropertyDefinition} with 2 entries:
# contains (getter) => PropertyDefinition(:contains, missing, AbstractSizedContainer, MethodKind)
# length (getter) => PropertyDefinition(:length, missing, AbstractSizedContainer, GetterPropertyKind)
@oodef struct MyNumSet{E <: Number} <: AbstractSizedContainer{E}
inner :: Set{E}
function new(args::E...)
@mk begin
inner = Set{E}(args)
end
end
# if no annotations for 'self',
# annotations and type parameters can be added like:
# 'function contains(self :: @like(MySet{E}), e::E) where E'
function contains(self, e::E)
return e in self.inner
end
@property(length) do
get = self -> length(self.inner)
end
end
my_set = MySet(1, 2, 3)
my_set.length # => 3
my_set.contains(2) # => true高级特性:泛型
泛型无处不在,业务中常见于容器。
在抽象方法一节,我们介绍了AbstractSizedContainer,可以看到它有一个泛型参数ElementType。
@oodef struct AbstractSizedContainer{ElementType}
# (self, ::ElementType) -> Bool
function contains end
function get_length end
end虽然在定义时没有用到这个类型,但在子类定义时,该类型参数能用来约束容器的元素类型。
ObjectOriented.jl支持各种形式的Julia泛型,下面是一些例子。
# 数字容器
@oodef struct AbstactNumberContainer{ElementType <: Number}
...
end
# 用来表示任意类型的可空值
@oodef struct Optional{T}
value :: Union{Nothing, Some{T}}
end高级特性:显式泛型类型参数
下面的代码给出一个特别的例子,构造器new无法从参数类型推断出泛型类型参数A。
@oodef struct MyGenType{A}
a :: Int
function new(a::Int)
new{A}(a)
end
end在这种情况下,可以显式指定泛型类型参数,构造类型实例:
my_gen_type = MyGenType{String}(1)
my_gen_type = MyGenType{Number}(1)
my_gen_type = MyGenType{Vector{Int}}(1)高级特性:接口
ObjectOriented.jl支持接口编程:使用@oodef struct定义一个没有字段的结构体类型,为它添加一些抽象方法,这样就实现了接口。
除开业务上方便对接逻辑外,接口还能为代码提供合适的约束。
@like(ootype)
@like 将具体的OO类型转为某种特殊的Julia抽象类型。
julia> @like(HasLength)
Object{>:var"HasLength::trait"}在Julia的类型系统中,具体类型不能被继承。其直接影响是,Julia的多重分派无法接受子类实例,如果参数标注为父类。
@oodef struct SuperC end
@oodef struct SubC <: SuperC end
function f(::SuperC) end
f(SuperC()) # ok
f(SubC()) # err@like(ootype) 很好地解决了这一问题。类型标注为@like(HasLength)的函数参量可以接受HasLength的任意子类型。
@oodef struct SuperC end
@oodef struct SubC <: SuperC end
function f(::@like(SuperC)) end
f(SuperC()) # ok
f(SubC()) # ok!例子,和零开销抽象
基于下面定义的接口HasLength,我们定义一个普通的Julia函数a_regular_julia_function:
@oodef struct HasLength
function get_length end
end
function a_regular_julia_function(o :: @like(HasLength))
function some_random_logic(i::Integer)
(i * 3 + 5) ^ 2
end
some_random_logic(o.get_length())
end现在,我们为HasLength实现一个子类MyList,作为Vector类型的包装:
@oodef struct MyList{T} <: HasLength
inner :: Vector{T}
function new(elts::T...)
@mk begin
inner = collect(T, elts)
end
end
function get_length(self)
length(self.inner)
end
end
a_regular_julia_function(MyList(1, 2, 3)) # 196
a_regular_julia_function([1]) # error可以看到,只有实现了HasLength的OO类型可以应用a_regular_julia_function。
此外,我们指出,ObjectOriented.jl的接口编程本身不导致动态分派。如果代码是静态分派的,抽象是零开销的。
@code_typed a_regular_julia_function(MyList(1, 2, 3))
CodeInfo(
1 ─ %1 = (getfield)(o, :inner)::Vector{Int64}
│ %2 = Base.arraylen(%1)::Int64
│ %3 = Base.mul_int(%2, 3)::Int64
│ %4 = Base.add_int(%3, 5)::Int64
│ %5 = Base.mul_int(%4, %4)::Int64
└── return %5
) => Int64
julia> @code_llvm a_regular_julia_function(MyList(1, 2, 3))
; @ REPL[6]:1 within `a_regular_julia_function`
; Function Attrs: uwtable
define i64 @julia_a_regular_julia_function_1290({ {}* }* nocapture nonnull readonly align 8 dereferenceable(8) %0) #0 {
top:
%1 = bitcast { {}* }* %0 to { i8*, i64, i16, i16, i32 }**
%2 = load atomic { i8*, i64, i16, i16, i32 }*, { i8*, i64, i16, i16, i32 }** %1 unordered, align 8
%3 = getelementptr inbounds { i8*, i64, i16, i16, i32 }, { i8*, i64, i16, i16, i32 }* %2, i64 0, i32 1
%4 = load i64, i64* %3, align 8
%5 = mul i64 %4, 3
%6 = add i64 %5, 5
%7 = mul i64 %6, %6
ret i64 %7
}P.S: 为接口增加默认方法可以实现著名的Mixin抽象。见继承,多继承中的IPolygon类型。
用@typed_access解决性能问题
因为编译器优化原因,使用Python风格的property会导致类型推导不够精准,降低性能。 对于可能的性能损失,我们提供@typed_access宏,在兼容julia原生语义的条件下,自动优化所有的a.b操作。
@typed_access begin
instance1.method(instance2.property)
end
# 等价于
ObjectOriented.typed_access(instance1, Val(:method))(
ObjectOriented.typed_access(instance, Val(:property))
)@typed_access让动态分派更慢,让静态分派更快。对于a.b,如果a的类型被Julia成功推断,则@typed_access a.b不会比a.b慢。