ObjectOriented.jl Cheat Sheet

ObjectOriented.jl为Julia提供面向对象编程的功能,支持多继承、点操作符取成员、Python风格的properties以及接口编程。

1. 类型定义

定义不可变的OO结构体。

@oodef struct ImmutableData
    x :: Int
    y :: Int

    function new(x::Int, y::Int) 
        @mk begin
            x = x
            y = y
        end
    end
end

d = ImmutableData(1, 2)
x = d.x

其中,new是构造器函数。构造器和方法都可以重载。

@mk语句块产生当前类型的实例,在随后的语句块中,形如a = b是设置字段,形如BaseClass(arg1, arg2)是基类初始化。

定义可变的OO结构体(class)。

@oodef mutable struct MutableData
    x :: Int
    y :: Int

    function new(x::Int, y::Int) 
        @mk begin
            x = x
            y = y
        end
    end
end

mt = MutableData(1, 2)
mt.x += 1

默认字段

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)

关于默认字段的注意点:

  1. 默认字段没有性能开销。
  2. @mk块显式指定字段初始化时,默认字段的求值表达式不会被执行。
  3. Base.@kwdef不同,默认字段的求值表达式无法访问其他字段。

2. 继承

@oodef mutable struct Animal
    name :: String
    function new(theName::String)
        @mk begin
            name = theName
        end
    end

    function move(self, distanceInMeters::Number = 0)
        println("$(self.name) moved $(distanceInMeters)")
    end
end

@oodef mutable struct Snake <: Animal
    function new(theName::String)
        @mk begin
            Animal(theName) # 初始化基类
        end
    end

    function snake_check(self)
        println("Calling a snake specific method!")
    end
end

sam = Snake("Sammy the Python")
sam.move()
# Sammy the Python moved 0
sam.snake_check()
# Calling a snake specific method!

此外,以下需要非常注意!

Snake <: Animal # false
Snake("xxx") isa Animal # false

记住,Julia原生类型系统并不理解两个class的子类型关系!详见基于接口的多态抽象

你应该使用下列方法测试继承关系:

issubclass(Snake, Animal) # true
isinstance(Snake("xxx"), Animal) # true
Snake("xxx") isa @like(Animal) # true

4. Python-style properties

@oodef mutable struct Square
    side :: Float64

    @property(area) do
        get = self -> self.side ^ 2
        set = (self, value::Number) -> self.side = convert(Float64, sqrt(value))
    end
end

square = Square()
square.side = 10
# call getter
square.area # 100.0

# call setter
square.area = 25
square.side # 5.0

5. 接口

接口类型,是大小为0(sizeof(t) == 0)的不可变OO类型。

接口类型的构造器是自动生成的,但也可以手动定义。

下面的HasLength是接口类型。

@oodef struct HasLength
    @property(len) do
        get  #= 抽象property: len =#
    end
end

@oodef struct Fillable
    function fill! end # 空函数表示抽象方法

    # 定义一个抽象的setter, 可以为全体元素赋值
    @property(allvalue) do
        set
    end
end

@oodef struct MyVector{T} <: {HasLength, Fillable}  # 多继承
    xs :: Vector{T}
    function new(xs::Vector{T})
        @mk begin
            xs = xs
        end
    end
end

check_abstract(MyVector)
# Dict{PropertyName, ObjectOriented.CompileTime.PropertyDefinition} with 3 entries:
#   fill! (getter)    => PropertyDefinition(:fill!, missing, :((Main).Fillable), MethodKind)
#   len (getter)      => PropertyDefinition(:len, missing, :((Main).HasLength), GetterPropertyKind)
#   allvalue (setter) => PropertyDefinition(:allvalue, missing, :((Main).Fillable), SetterPropertyKind)

check_abstract(MyVector)不为空,表示MyVector是抽象类型,否则需要实现相应属性或方法len, fill!allvalue

@oodef struct MyVector{T} <: {HasLength, Fillable}  # 多继承
    # 旧代码
    xs :: Vector{T}
    function new(xs::Vector{T})
        @mk begin
            xs = xs
        end
    end

    # 新增代码
    @property(len) do
        get = self -> length(self.xs)
    end

    @property(allvalue) do
        set = (self, value::T) -> fill!(self.xs, value)
    end

    function fill!(self, v::T)
        self.allvalue = v
    end
end

vec = MyVector([1, 2, 3])
vec.allvalue = 4
vec
# MyVector{Int64}([4, 4, 4], HasLength(), Fillable())
vec.len
# 3
vec.fill!(10)
vec
# MyVector{Int64}([10, 10, 10], HasLength(), Fillable())

此外,接口最重要的目的是基于接口的多态抽象。见下文基于接口的多态抽象

6. 多继承

MRO(方法解析顺序)使用Python C3算法,所以多继承行为与Python基本一样。主要差异是不严格要求mixin多继承的顺序。

@oodef struct A
    function calla(self) "A" end
    function call(self) "A" end
end

@oodef struct B <: A
    function callb(self) "B" end
    function call(self) "B" end
end

@oodef mutable struct C <: A
    function callc(self) "C" end
    function call(self) "C" end
end

@oodef struct D <: {A, C, B}
    function new()
        @mk begin
            A() # 可省略,因为A是接口类型
            B() # 可省略,因为B是接口类型
            C() # 不可省略,因为C是可变类型
            # 基类初始化可写成一行: A(), B(), C()
        end
    end
end

d = D()
d.calla() # A
d.callb() # B
d.callc() # C
d.call() # C
[x[1] for x in ootype_mro(typeof(d))]
# 4-element Vector{DataType}:
#  D
#  C
#  B
#  A

7. 基于接口的多态抽象

下面例子给出一个容易犯错的情况:

@oodef struct A end
@oodef struct B <: A end
myapi(x :: A) = println("do something!")

myapi(A())
# do something!

myapi(B())
# ERROR: MethodError: no method matching myapi(::B)

记住:Julia原生类型系统并不理解两个class的子类型关系!

如果希望Julia函数myapi的参数只接受A或A的子类型,应该这样实现:

myapi(x :: @like(A)) = println("do something!")

myapi(B())
# do something!

myapi([])
# ERROR: MethodError: no method matching myapi(::Vector{Any})

8. 一个机器学习的OOP实例

在下面这份代码里,我们实现一个使用最小二乘法训练的机器学习模型,并让其支持Julia中ScikitLearn的接口 (ScikitLearnBase.jl)。通过下面代码,用户可以像使用一般ScikitLearn.jl的模型一样来调用这个模型,更可以在MLJ机器学习框架中使用这个模型,而不必关心该模型由面向对象还是多重分派实现。

using ObjectOriented

@oodef struct AbstractMLModel{X, Y}
    function fit! end
    function predict end
end

using LsqFit

@oodef mutable struct LsqModel{M<:Function} <: AbstractMLModel{Vector{Float64},Vector{Float64}}
    model::M  # 一个函数,代表模型的公式
    param::Vector{Float64}

    function new(m::M, init_param::Vector{Float64})
        @mk begin
            model = m
            param = init_param
        end
    end

    function fit!(self, X::Vector{Float64}, y::Vector{Float64})
        fit = curve_fit(self.model, X, y, self.param)
        self.param = fit.param
        self
    end

    function predict(self, x::Float64)
        self.predict([x])
    end

    function predict(self, X::Vector{Float64})
        return self.model(X, self.param)
    end
end

# 例子来自 https://github.com/JuliaNLSolvers/LsqFit.jl

@. model(x, p) = p[1] * exp(-x * p[2])
clf = LsqModel(model, [0.5, 0.5])
ptrue = [1.0, 2.0]
xdata = collect(range(0, stop = 10, length = 20));
ydata = collect(model(xdata, ptrue) + 0.01 * randn(length(xdata)));

clf.fit!(xdata, ydata) # 训练模型
clf.predict(xdata)  # 预测模型
clf.param # 查看模型参数

# ScikitLearnBase提供了fit!和predict两个接口函数。
# 我们将ObjectOriented.jl的接口(@like(...))和Julia接口对接。

using ScikitLearnBase
ScikitLearnBase.is_classifier(::@like(AbstractMLModel)) = true
ScikitLearnBase.fit!(clf::@like(AbstractMLModel{X, Y}), x::X, y::Y) where {X, Y} = clf.fit!(x, y)
ScikitLearnBase.predict(clf::@like(AbstractMLModel{X}), x::X) where X = clf.predict(x)

ScikitLearnBase.fit!(clf, xdata, ydata)
ScikitLearnBase.predict(clf, xdata)

9. 性能问题

尽管ObjectOriented.jl生成的代码本身不引入开销,但由于递归调用点操作符运算 (Base.getproperty(...)) 的类型推断问题 (例如这个例子),尽管大多数时候ObjectOriented.jl编译出的机器码非常高效,但返回类型却忽然变成Any或某种Union类型。

这可能带来性能问题。出现该问题的情况是有限的,问题场合如下:

  1. 使用Python风格的property
  2. 在method里访问另一个成员,该成员再次递归调用点操作符

解决方案也很简单,使用@typed_access标注可能出现性能问题的代码即可。

@typed_access my_instance.method()
@typed_access my_instance.property

注意:上述代码中请保证my_instance类型已知。如果@typed_access标注的代码存在动态类型或类型不稳定,可能导致更严重的性能问题。