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 方法名(类实例, ...)开头。类实例变量推荐命名为selfthis

前面代码里MyClassMyStruct都实现了实例方法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)

关于默认字段的注意点:

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

Python风格的构造器

以往的OO语言,如Python/C++/Java/C#,没有原生支持的不可变类型,因此构造器的工作一般设计为:

  1. 创建一个新对象self(或this)
  2. 利用构造器参数对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.0

P.S: 由ObjectOriented.jl定义的OO类型,只能继承其他OO类型。

Python风格的properties

在Java中,getter函数(get_xxx)和setter(set_xxx)函数用来隐藏实现细节,暴露稳定的API。

对于其中冗余,很多语言如Python提供了一种语法糖,允许抽象self.xxxself.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慢。