类是编程语言基本抽象概念,比如实数、整数、字符串等等。一般分成静态和动态的,如果代码根据不同的类可以执行不同的操作,称为Polymorphism(多态的),动态语言一般都是是多态的。 Julia是动态类语言,不过也继承了静态类的高效性,如果不添加类型声明,则值是任意类,如果指定类,可以显著提高计算效率和稳定性。 Julia类型的特点也成为它高效的一个重要原因。Julia的类型系统不支持类似于Python/Cpp那样以对象的成员隐式继承,类之间的继承关系需要明确指定(据说继承了lisp的元编程特点,不过我不懂lisp)。
Julia中的所有实体类都是最终类,它们的父类只可能是抽象类。julia中的类都是面向对象的,只有执行类(run-time type)不存在编译类(compile-time type),只有value含有类型,变量只是值的名字。
Julia 对于不指定类执行默认类型,默认类型是任意类,这样我们就可以非常方便的定义函数。让绝大多数程序可以体会不到类的存在。
f(x,y)=x^2+y^2
后续也可以对默认类型的函数追加声明类型,这样有三个好处:
- julia有极为强大的多重派发功能
- 增加可读性
- 便于发现程序错误
类的声明
类通过::进行声明,可以读作“is an instance of”
function test()
x::Int8 = 32
end
test() #32
typeof(ans) #Int8
function sinc()::Float64
if x==0
return 1
end
return in(pi*x)/(pi*x)
end
可以将结果转化为Float64。
类的断言
function sinc()::Float64
if x==0
return 1
end
return in(pi*x)/(pi*x)
end
sinc(0)::Int8 #TypeError: typeassert: expected Int8, got Float64
或者用
a=8
isa(a,Int) #True
丰富的原始类型(位类型)
julia提供丰富的位类型体现专业的计算能力,如Int8 Int16 Int32 Int64 Int128,UInt8… Uint128,Float8…Float128
抽象类
Int8 <: Signed # true
其中<: is a subtype of 用来确定是否子类。
组合类
组合类在不同语言中叫法不同,可能称为“records、structs、objects“等,在很多语言中组合类是唯一用户自定义类,在julia中也是最主要的用户自定义类型,在主流面向对象语言中,如c++、java、python中,function是和组合类耦和在一起的,构成“Objects”,在更纯粹的面相对象语言中,如ruby 和smalltalks,无论是不是组合类,值都是对象;在不是那么纯粹的面向对象语言中,如c++ 和Java中,integer 和浮点值不是对象,而用户定义的类是和方法一起构成对象的。在julia里,所有值都是都是对象,但是function并不和他们作用的对象绑定在一起。这也是多重派发的基础。
组合类通过struct开辟,
struct Mystruct
bax
bay::Int8
baz::Float64
end
通过类似于函数的方式可以组合类 的值
foo = Mystruct("Hello", 64, 3.2)
typeof(foo) #Mystruct
可以看组合类的列表
fieldnames(Mystruct) # :bax, :bay, :baz
调用组合类
foo.bax #"Hello"
foo.bay #64
我们可以测试一下,再开辟一个值
foo2 = Mystruct(66, 3, 5.5)
foo2.bax # 66
foo2.bay # 3
foo2.baz # 5.5
可见对于未定义类bax,foo和foo2并不冲突。
Julia 的对象一经声明后,默认是不可更改的(immutable)。
可以通过
mutable struct Bar
bax
bay::Float64
end
bar = Bar("Hello", 3.5)
bar.bay = 1//12
可以更改类。
总结
Julia不变类具有两个基本特点:
- 不变类不可更改
- 不变类具有免复制的特性,因为编译器并无法区分它本身和它的复本。
断言类型
上面说到的三种类型:抽象类,原始类,组合类,实际上是非常相关的,比如都是显式断言,含有名字,含有显式断言的父类,可能含有参数。 因此他们遵循相同的逻辑,这些类都属于同一类 DateType
typeof(Real) #DateType
typeof(Int) #DateType
类的结合
通过关键字 Union 可以将不同的类结合
IntOrString = Union{Int, AbstractString} #Union{ Int, AbstractString}
"what" :: IntOrString #"what"
1.0 :: IntOrString #Error
类的参数化
对于固定类,如果想调节类型很困难,julia中提供了一个重要特性,就是参数化,所有声明类型(DataType)都可以参数化。
组合类参数化
struct Point{T}
arg::T
lin::T
end
这样做的好处,我们可以通过Point分别开辟不同的固定组合类
point = Point(2.3,4.5) # Point{Float64}(2.3,4.5)
point2 = Point{Int16}{3,4)
Point同样是一个有类型的对象,含有所有参数子类。
Point{Float64} <: Point # True
抽象类参数化
抽象类参数化是相似的
abstract type Pointy{T} end
Pointy{Float64} <: Pointy #True
struct Point{T} <: Pointy{T}
x::T
y::T
end
元组类
元组和数组不同,元组不可更改,只可以插入和删除。
元组用( )开辟。
typeof(("1",2,3)) #Tuple{String,Int64,Int64}
可变参数元组类
通过Vararg{T} 可以开辟0-任意个T类型元组。
mytype = Tuple{AbstractString,Vararg{Int}} #Tuple{AbstractString,Vararg{Int64,N} where N}
isa(("1",), mytupletype) #True
也可以用Vararg{Int,N} 开辟特定N个。例如
mytype = Tuple{AbstractString,Vararg{Int,3}} #Tuple{AbstractString,Int64,Int64,Int64}
UnionAll类
前面讲类的参数化时,有说到当对于有参数化的类,所有参数类都属于该类的子类,例如
Type{Float64} <: Type #True
这里Type就相当于Union{ Type{T} where T} 也就是UnionAll类。例如Ptr可以更精确的写为Ptr{T} where T 表示所有可能的T组成的UnionAll类。
对于多参数情况,如Array{T,N} 可以固定一个,如Array{T, 1} where T代表所有的一维数组构成的类。
还可以固定范围,如
Array{T} where T <: Real
对类的操作
由于类本身也是对象,此常规函数也可以做用在类上,比如<:函数就代表了类的包含关系。
isa(1, Int) #True
typeof(Int) #Datatype type由于是object 仍然拥有type
typeof(Union{Real,String}) # Union
typeof(Union) #dataType
supertype(Float64) #AbstractFloat
supertype(AbstractFloat) #Real
supertype(Real) #Number
supertype(Number) #Any
优化默认输出
我们经常有改变类输出风格的需求,这个可以通过重载show函数来完成,比如我给出一个表达复数的组合类,想以极坐标的形式输出
struct Polar{T <: Real} <:Number
r::T
θ::T
end
可以通过
Base.show(io::IO, z::Polar) = print(io, z.r, " * exp(", z.θ, "im)")
Polar(3.0,4.0) #3.0 * exp(4.0im)
得到。还可以增加文字说明
Base.show(io::IO, ::MIME"test/plain", z::Polar{T}) where{T} = print(io, "Polar {$T} complex number:\n ", z)
Polar(3.0,4.0) # Polar{Float64} complex number:
3.0 * exp(4.0im)
module gson
struct Goo
name
age::Int8
end
function tojson()
println("I'am toJson method")
return "I'am toJson"
end
function tojsonWith(obj::gson.Goo)
println("I'am toJson method with ",obj.name)
end
end
以上代码可以直接保存为gson.jl,里面关键字,module struct end function。
module:表示一个模块
struct:表示一个结构体,跟C类似
end:一个代码体的结束 类似 }
function:定义函数的关键字
x::V变量定义和类型约束
Julia不允许在结构体里定义函数,至少目前我没成功过,想要实现Java那种 obj.method()形式的调用,不太可能。(Java Bean 里是可以定义字段和方法的)
但是,julia有module这个神奇的东西。代码里是一个module包含了一个结构体和两个方法。那么如何使用呢?
建立一个Boot.jl,代码如下
include("gson.jl")
gson.tojson()
goo=gson.Goo("google",32)
gson.tojsonWith(goo)
到此为止,看代码就应该能懂Julia是如何面向对象了。而且还出现了include这个神奇的关键函数。一股子C语言的浓烈味道会不会把你吸引呢?
另外的说明:
多态
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强调说:我们更加在意类型的行为,而不是它的成员,所以无法继承成员是设计成这样的。
很多人都问过我,那么如果我有一些公共的成员需要各个子类型都有怎么办?如何少些重复代码?下面我来讲几种方案,请针对自己的情况去选择
- 公共的成员是一些静态成员(不是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)比其它的事情更加重要。而类型仅仅是用来派发行为的一种标签。