# Julia 常见问题及最佳实践


# 概述

Julia 是一种为科学计算而生的、高性能的动态编译型语言,其独特的语言机制(如多重分派、类型系统等),为开发者提供了强大的灵活性,但也带来了特定的设计与实践挑战。本文主要介绍 Julia 语言工程应用中一些常见使用问题及对应的最佳实践。

在阅览本文之前,您需要掌握 Julia 的一些入门知识。如果您是首次接触 Julia,可以先学习 Julia 语言概览Julia 中文文档

# 变量作用域问题

Julia 变量作用域划分为全局作用域和局部作用域,具体如下:

  • 全局作用域:每个模块会引进一个新全局作用域,与其他所有模块的全局作用域分开;
  • 局部作用域:大多数代码块都引入了新的局部作用域。
    • 硬作用域:当x = <value>出现在某局部作用域,如果 x 还不是局部变量,则会在赋值作用域中创建一个名为 x 的新局部变量;
    • 软作用域:当x = <value>出现在某局部作用域,如果 x 还不是局部变量,行为取决于全局变量 x 是否被定义:
      • 如果全局变量 x 是未定义,此次赋值会在该作用域创建一个名为 x 的新局部变量;
      • 如果全局变量 x 是已定义,此次赋值会被认为是有歧义的: 在非交互的上下文(如脚本)中,会打印歧义警告,同时创建一个新局部变量; 在交互的上下文(如 REPL)中,会向全局变量 x 赋值。
结构 作用域类型 允许使用在
module, baremodule 全局 全局
struct 局部(硬) 全局
for, while, try 局部(软) global, local
macro 局部(硬) 全局
函数、do 语句块、let 语句块、数组推导、生成器 局部(硬) global, local

关于变量作用域的详细帮助,请参考 Syslab 帮助文档的变量作用域章节。

易出错的代码示例

s = 0
for i = 1:10
    s = s + i # 报错:出现歧义,s 未定义
end
println(s)

上述代码,在 Julia REPL 中直接运行,可以得到正确结果。但是,如果将该代码保存为脚本去运行,将出现报错:

┌ Warning: Assignment to `s` in soft scope is ambiguous because a global variable by the same name exists: `s` will be treated as a new local. Disambiguate by using `local s` to suppress this warning or `global s` to assign to the existing global variable.
└ @ f:\Syslab\MwSyslab\04 详细设计\预研\预研-工程化问题(长期)\Julia语言机制问题类最佳实践\Unnamed.jl:3
ERROR: UndefVarError: `s` not defined
Stacktrace:
 [1] top-level scope
   @ f:\Syslab\MwSyslab\04 详细设计\预研\预研-工程化问题(长期)\Julia语言机制问题类最佳实践\Unnamed.jl:3

我们可以将上述代码修正如下,这样就消除歧义。

s = 0
for i = 1:10
    global s  # 声明全局变量
    s = s + i # ok
end
println(s) # 输出55

但是,如果 for 循环在函数作用域内,则可以访问外部局部变量。

function myfunc()
    s = 0
    for i = 1:10
        s = s + i # ok:赋值现有局部变量
    end
    println(s)
end
myfunc() # 输出55

此外,在 for 循环中修改数组分量,则也是允许的。

ss = [0]
for i = 1:10
    ss[1] = ss[1] + i # ok:允许修改数组分量
end
println(ss[1]) # 输出55

# 函数返回多值

Julia 函数允许返回 1 个或多个结果值。当函数返回多个结果值时,将以元组形式返回。

易出错的代码示例

function myfunc(x)
    y1 = x
    y2 = 2*x
    y3 = 3*x
    return y1,y2,y3
end

res = myfunc(1.0) # 本想得到第一个返回值,但是返回了全部值,即元组(1.0, 2.0, 3.0)

如果用户只想获取第一个返回值,则需要使用,来获取元组分量,如下所示:

res_1, = myfunc(1.0) # res_1 等于 1.0

res_1, res_2 = myfunc(1.0) # res_2 等于 2.0

res_1, res_2, res_3 = myfunc(1.0) # res_3 等于 3.0

res_1, res_23... = myfunc(1.0) # res_23 等于 (2.0, 3.0)

# 严格区分向量与行/列矩阵

在 Julia 语言中,严格区分 向量(n 个元素集合)、行矩阵(1xn 矩阵)、列矩阵(nx1 矩阵),其声明如下所示:

vec_1 = Float64[1, 2, 3] # 向量

vec_2 = Float64[1; 2; 3] # 向量

mtx_row = Float64[1 2 3] # 1x3 行矩阵

mtx_col = Float64[1;2;3;;] # 3x1 列矩阵

mtx = [1 2 3; 4 5 6] # 2x3 矩阵

mtx_2 = [1,2,3; 4,5,6] # error:非法

向量与行、列矩阵之间不能自动转换,需要手工显式转换。

# 矩阵 转 向量
vec(mtx_row)

# 向量 转 行矩阵
reshape(vec_1, 1, 3)

# 向量 转 列矩阵
reshape(vec_1, 3, 1)

# 数组构造与数组拼接容易混淆

数组也可以直接用方括号来构造。语法[A, B, C, ...]创建一个一维数组,该一维数组的元素用逗号分隔。

[1, 2, 3] # 元素类型为 Int 的向量

[1:2, 4:5] # 这里有一个逗号,因此并不会发生矩阵的拼接
#=
2-element Vector{UnitRange{Int64}}:
 1:2
 4:5
=#

如果方括号里的参数不是由逗号分隔,而是由单个分号(;) 或者换行符分隔,那么每一个参数就不再解析为一个单独的数组元素,而是纵向拼接起来。

[1:2; 4:5] # 纵向拼接
#=
4-element Vector{Int64}:
 1
 2
 4
 5
=#

[1:2
 4:5]
#=
4-element Vector{Int64}:
 1
 2
 4
 5
=#

类似的,如果这些参数是被制表符、空格符或者两个分号所分隔,那么它们的内容就横向拼接在一起。

[1:2  4:5  7:8] # 横向拼接
#=
2×3 Matrix{Int64}:
 1  4  7
 2  5  8
=#

[[1,2]  [4,5]  [7,8]]
#=
2×3 Matrix{Int64}:
 1  4  7
 2  5  8
=#

[1 2 3] # 数字可以被横向拼接
#=
1×3 Matrix{Int64}:
 1  2  3
=#

[1;; 2;; 3;; 4]
#=
1×4 Matrix{Int64}:
 1  2  3  4
=#

注意,数组拼接存在一定的时间开销,如下所示:

# 方式 1:数组拼接
function f1(n)
    x = Int[]

    for i in 1:n
        x = [x; i] # 注意:不要使用逗号
    end
    return x
end

# 方式 2:原地修改数组
function f2(n)
    x = Int[]

    for i in 1:n
        push!(x, i)
    end
    return x
end

# 方式 3:数组推导
f3(n) = [i for i in 1:n]

@time x1 = f1(10000) # 0.193791 seconds(性能最差)
@time x2 = f2(10000) # 0.000117 seconds(性能尚可)
@time x3 = f3(10000) # 0.000035 seconds(性能最好)

# 数组索引不支持浮点数

与 MATLAB 不同,索引 n 维数组A的一般语法是:X = A[I_1, I_2, ..., I_n],其中每个I_k可以是标量整数,整数数组或任何其他支持的索引类型。这包括Colon(:)选择整个维度中的所有索引,形式为a:ca:b:c的范围来选择连续或跨步的子区间,以及布尔数组以选择索引为true的元素。

x = [1 2 3; 4 5 6]

x[1.0] # error:数组下标不允许浮点数

x[Int(1.0)] # ok

x[1] == x[begin] # 首个数组元素

x[6] == x[end] # 最后一个数组元素

x[1,2] # 2

x[1, :] # 第一行数据,即[1, 2, 3]

x[1:2, 2:3]
#=
2×2 Matrix{Int64}:
 2  3
 5  6
=#

# Int 与 Float64 类型转换

Julia 类型系统融合了动态语言的灵活性与静态语言的性能优势,它允许变量动态绑定类型,同时通过严格的类型检查和显式转换要求,避免隐式错误、提升代码质量,并为高性能优化提供基础。

Julia 类型转换的显式要求也带来一些不便,如整型与浮点型需要显式转换:

function f_int(x::Int)
    return x
end

function f_float(x::Float64)
    return x
end

f_int(1)
f_int(1.0) # error:无法自动转换

f_float(1) # error:无法自动转换
f_float(1.0)

当然,Julia 也提供了显式转换方法,如下所示:

# 浮点转整型
x = Int(1.0)
x2 = convert(Int, 1.0)
x3 = convert(Int, 1.5) # error

# 整型转浮点
y = Float64(1)
y2 = convert(Float64, 1)

此外,对于onesroundzerosfloorceil等函数,支持通过传递类型参数来实现从浮点到整型的一次性转换完成。

round(2.6)      # 3.0
round(Int, 2.6) # 3

floor(2.6)      # 2.0
floor(Int, 2.6) # 2

ceil(2.6)       # 3.0
ceil(Int, 2.6)  # 3

ones(2,3)       # 2×3 Matrix{Float64} 全1矩阵
ones(Int, 2, 3) # 2×3 Matrix{Int64} 全1矩阵

zeros(2,3)      # 2×3 Matrix{Float64} 全0矩阵
zeros(Int, 2,3) # 2×3 Matrix{Int64} 全0矩阵

# 了解类型系统

类型系统和多重派发是 Julia 语言最主要的特征,了解类型系统对掌握 Julia 语言很有帮助。对于初学者而言,至少需要掌握常用基础类型、数组类型、类型继承关系、类型判断、类型标注等基础知识。

  1. 常用的基础类型,包括数值类型、字符串类型、字符类型等,类系图如下所示:

    • 对于 64 位系统,IntInt64的别名,但不存在Float类型;
    • ComplexF16、ComplexF32、ComplexF64分别是Complex{Float16}、Complex{Float32}、Complex{Float64}的别名。
  2. 常用的数组类型,包括VectorMatrixArray等,这些类型都是从AbstractArray派生的。

    • Vector{T}其实是Array{T,1}的别名;
    • Matrix{T}其实是Array{T,2}的别名。
  3. 通过subtypessupertype两个函数,可以查看一个类型的子类和父类。

    # 查看所有子类
    subtypes(Signed)
    #=
    6-element Vector{Any}:
    BigInt
    Int128
    Int16
    Int32
    Int64
    Int8
    =#
    
    # 查看父类
    supertype(Int) # Signed
    
  4. 通过isa<:可以进行类型判断。

    isa(1, Int) # true
    
    1 isa Number # true
    
    Float64 <: AbstractFloat # true
    
    Matrix{Float64} <: AbstractArray # true
    
    Matrix{Float64} <: AbstractArray{Float64} # true
    
    Matrix{Float64} <: AbstractArray{AbstractFloat} # 注意:返回 false
    
  5. 对于结构体或函数,建议标注类型,这可以给编译器提供额外的类型信息,有助于提升程序性能。

    # 结构体字段标注类型
    struct PointF
        x::Float64
        y::Float64
    end
    
    # 函数参数标注类型
    function myplus(x::Int, y::Int)
        x+y
    end
    
    # 函数返回值标注类型:返回值始终会被转换为Float64
    function mysinc(x)::Float64
        if x == 0
            return 1
        end
        return sin(pi*x)/(pi*x)
    end
    

关于类型系统的更多介绍,请参见 Syslab 帮助文档的类型系统相关章节。

# round 函数舍入问题

在实际工程中,经常有人会问,为什么 Julia 的 round 函数计算结果与 MATLAB 不一致?

例如:

# 默认情况下,两者存在差异
round(0.5) # Julia 返回 0.0,Matlab 返回 1
round(1.5) # Julia 返回 2.0,Matlab 返回 2

对于上述示例,Julia 和 MATLAB 计算结果都是正确的,不一致的根因是它们默认采用的圆整算法不一致。

Julia 中,round(X)按照默认的四舍五入模式将X舍入为整数值,并返回与 X 相同类型的值。默认情况下这将四舍五入到最近的整数,如果出现平局(with ties,即小数值为 0.5),则会被舍入为最近的偶数整数。

MATLAB 中,round(X)X的每个元素四舍五入为最近的整数。在对等情况下,即有元素的小数部分恰为0.5时,round函数会偏离零四舍五入到具有更大幅值的整数。

尽量 Julia 和 Matlab 的默认行为不同,但是都有方法来控制四舍五入算法,如下:

  • 对于 Julia,存在以下参数控制:
    • RoundNearest (default)
    • RoundNearestTiesAway:rounded away from zero in Julia(与 MATLAB 缺省参数等价)
    • RoundNearestTiesUp
    • RoundToZero
    • RoundFromZero (BigFloat only)
    • RoundUp
    • RoundDown
  • 对于 Matlab,存在TieBreaker关键字参数控制(Matlab R2022a 引入):
    • fromzero (default)
    • tozero
    • even
    • odd
    • plusinf
    • minusinf

在 Julia 中:

# 默认情况下,两者存在差异
round(0.5) # Julia 返回 0.0,Matlab 返回 1
round(1.5) # Julia 返回 2.0,Matlab 返回 2

# 与 Matlab 等价的 round 用法
round(0.5, RoundNearestTiesAway)  # 1.0
round(1.5, RoundNearestTiesAway)  # 2.0

round(Int, 0.5, RoundNearestTiesAway)  # 1
round(Int, 1.5, RoundNearestTiesAway)  # 2

# 赋值与拷贝

科学计算语言,有的语言采用按值传递(Call by value),有的语言采用按共享调用(Call by sharing)。例如,MATLAB 采用按值传递(Call by value),这样做可以防止数组在被调函数中被意外地篡改,但这也会导致不必要的数组拷贝。目前,大多数现代语言都采用按共享调用(Call by sharing),包括 Java、Python、Ruby 等,Julia 也是如此。

Julia 的赋值操作实质上是变量绑定,即将一个变量存储的其变量值对象的地址赋给一个变量,如下图所示:

对于一些基础类型,包括NumberTupleString等,由于其对象的值不可更改(如字符串),所以在赋值与传参时,表现上与按值传递没有区别,感觉像拷贝。

对于一些可变复合类型(使用ismutable判断),包括数组、字典、可变结构体等,若内部数据被修改了,所有共享该对象的变量都会跟着变化,如下图所示。

为了方便大家使用,我们对 Julia 数组操作的一些典型使用场景,进行了梳理归纳,具体如下:

典型场景 说明 举例
数组赋值 共享底层数据 a = [1,2,3]
b = a
a[1] = 10
b # 输出:[10, 2, 3]
数组切片 拷贝 a = [1 2 3;4 5 6]
b = a[1,:]
a[1] = 10
b # 输出:[1, 2, 3]
数组视图 @view 共享底层数据 a = [1 2 3;4 5 6]
b = @view a[1,:]
a[1] = 10
b # 输出:[10, 2, 3]
reshape 共享底层数据 a = [1,2,3,4]
b = reshape(a, 2, 2)
a[1] = 10
b # 输出:[10 3; 2 4]
collect 拷贝 a = [1,2,3,4]
b = collect(a)
a[1] = 10
b # 输出:[1, 2, 3, 4]
vec 共享底层数据 a = [1 2; 3 4]
b = vec(a)
a[1] = 10
b # 输出:[10, 3, 2, 4]
共轭转置:', adjoint 共享底层数据 a = [3+2im 9+2im; 0 0]
b = a'
a[1] = 10
b # 输出:[10+0im 0; 9-2im 0]
转置:transpose 共享底层数据 a = [3+2im 9+2im; 0 0]
b = transpose(a)
a[1] = 10
b # 输出:[10+0im 0; 9+2im 0]
实数共轭:conj 共享底层数据 a=[1 2 ;3 4]
b=conj(a)
a[1] = 10
b # 输出:[10 2; 3 4]
复数共轭:conj 拷贝 a = [3+2im 9+2im; 0 0]
b = conj(a)
a[1] = 10
b # 输出:[3-2im 9-2im; 0 0]
copy (浅)拷贝 a = [1, [2, 3]]
b = copy(a)
b[1] = 100 # 修改顶层元素(不会影响a)
b[2][1] = 200 # 修改内部元素(会影响a)
println(a) # 输出:[1, [200, 3]]
println(b) # 输出:[100, [200, 3]]
deepcopy (深)拷贝 a = [1, [2, 3]]
b = deepcopy(a)
b[1] = 100 # 修改顶层元素(不会影响a)
b[2][1] = 200 # 修改内部元素(不会影响a)
println(a) # 输出:[1, [2, 3]]
println(b) # 输出:[100, [200, 3]]

# 字符串索引及常用操作

在 Julia 中,字符串是不可变类型,但是可以通过其它方式实现类似可变字符串效果。本节主要介绍字符串遍历、拼接、插值、格式化、解析与转换等一些常用基础知识。关于字符串的更多操作,请参见 Syslab 帮助文档的 Julia 语言概览中的字符与字符串章节。

  1. 字符串遍历:在 Julia 中遍历字符串时,由于字符串可能包含多字节字符(如 UTF-8 编码),索引操作需要特别注意。

    str = "Hello, 世界!"
    
    for i in 1:length(str)
        println(str[i]) # error:无效索引
    end
    
    for i in eachindex(str)
        println("索引 $i: 字符 $(str[i])") # ok
    end
    
  2. 字符串拼接:在 Julia 中,有多种方法实现字符串拼接。

    # 方法1(性能最好)
    str = "value: " * string(pi/2) # "value: 1.5707963267948966"
    
    # 方法2
    str = "value: $(pi/2)"
    
    # 方法3
    str = join(["value:", pi/2], " ")
    
    # 方法4
    str = string("value: ", pi/2)
    
  3. 字符串格式化

    可以使用Printf.@sprintf对字符串进行格式化,其函数声明如下:

    @sprintf("%Fmt", args...)
    

    其中,%Fmt类似 C 语言 printf (opens new window) 的 format 格式,完整格式是**%[flags][width][.precision][length]specifier**。

    using Printf
    
    str = (@sprintf "%s is %.2f" "value" 34.567)  # "value is 34.57"
    str = (@sprintf "%s is %6.2f" "value" 34.567) # "value is  34.57"
    
    str = (@sprintf "value is %.2e" 1.23456) # "value is 1.23e+00"
    str = (@sprintf "value is %.2f" 1.23456) # "value is 1.23"
    
    str = (@sprintf "value is %#x" 100) # "value is 0x64"
    
  4. 字符串与数值类型的转换

    通过string函数,可以将任意类型转为字符串。

    string(1.23) # "1.23"
    string(255, base=16) # "ff"
    string("a", 1, true) # "a1true"
    

    通过parse函数,可以将字符串转为数值类型。

    parse(Int, "0b1000") # 8
    parse(Int, "0o10") # 8
    parse(Int, "0xff") # 255
    parse(Int, "ff", base=16) # 255
    parse(Float64, "1.2e-3") # 0.0012
    parse(Complex{Float64}, "3.2 + 4.5im") # 3.2 + 4.5im
    
  5. 字符串路径

    对于 Windows 和 Linux 来说,路径分隔符分别为 '\' 和 '/'。从跨平台兼容角度,可以使用joinpathBase.Filesystem.path_separator来实现跨平台处理。

    # 方法1:
    myfile = joinpath(@__DIR__, "myfile.txt")
    
    # 方法2:
    myfile = string(@__DIR__) * Base.Filesystem.path_separator * "myfile.txt"
    

# 避免全局变量

全局变量的值和类型随时都会发生变化, 这使编译器难以优化使用全局变量的代码。

# 采用全局变量
global g_x = 1000

function f_global()
    s = 0.0
    for i in 1:g_x
        s += i
    end
    return s
end

变量应该是局部的,或者尽可能作为参数传递给函数。

# 采用参数传递
function f_local(x::Int)
    s = 0.0
    for i in 1:x
        s += i
    end
    return s
end

如果确实需要使用全局变量,可以将其改为常量+引用。这样既保持常量特性,又允许修改其值,如DEFAULT_X[] = 3

# 采用全局常量
const DEFAULT_X = Ref(1000)

function f_const()
    s = 0.0
    for i in 1:DEFAULT_X[] # []表示解引用
        s += i
    end
    return s
end

三者性能对比如下,全局变量性能最差。

using BenchmarkTools
@btime f_global()    #  46.200 μs(性能最差)
@btime f_local(1000) # 823.944 ns
@btime f_const()     # 829.487 ns

# 融合向量化操作

Julia 有特殊的点语法,它可以将任何标量函数转换为「向量化」函数调用,将任何运算符转换为「向量化」运算符,其具有的特殊性质是嵌套「点调用」是融合的:它们在语法层级被组合为单个循环,无需分配临时数组。如果你使用.=和类似的赋值运算符,则结果也可以 in-place 存储在预分配的数组。

在线性代数的上下文中,这意味着即使诸如vector + vectorvector * scalar之类的运算,使用vector .+ vectorvector .* scalar来替代也可能是有利的,因为生成的循环可与周围的计算融合。

例如,考虑两个函数:

f(x) = 3x.^2 + 4x + 7x.^3;

fdot(x) = @. 3x^2 + 4x + 7x^3; # equivalent to 3 .* x.^2 .+ 4 .* x .+ 7 .* x.^3

ffdot都做相同的计算。但是,fdot(在@.宏的帮助下定义)在作用于数组时明显更快:

x = rand(10^6);

@time y1 = f(x);    # 0.010712 seconds(慢)

@time y2 = fdot(x); # 0.001999 seconds(快)

@time y3 = f.(x);   # 0.001757 seconds(快)

y1 == y2 == y3 # true

# 类型稳定

Julia 代码编译期间只能够获得类型信息,而无法获得值信息。

类型稳定的理解:

  • 理解 1:函数的返回值类型在不同情况下是不变的
  • 理解 2:函数的返回值(及中间结果)类型可以通过函数输入的类型来唯一推断

类型稳定的检测:

  • 使用 @code_warntype 来验证类型不稳定行为

类型不稳定会导致:

  • Julia 编译器难以做充足的编译优化
  • 额外的运行时类型检查和开销
  • 类型不稳定往往影响的是函数调用者的性能

例如,对浮点型矩阵A求和,以下代码的返回值类型有多种情况,属于类型不稳定。

# 类型不稳定
function mysum(A)
    rst = 0 # 当 A 为空时,返回整型;当 A 不为空时,返回浮点型
    @simd for i in eachindex(A)
        rst += A[i]
    end
    return rst
end

通过以下修改,可以使其变成类型稳定:

# 类型稳定
function mysum_stable(A)
    rst = zero(eltype(A)) # 构造与 A 元素类型相同的 0
    @simd for i in eachindex(A)
        rst += A[i]
    end
    return rst
end

两者性能对比:

using BenchmarkTools
A = rand(1024,1024)

@btime mysum($A)        # 876.700 μs(慢)
@btime mysum_stable($A) # 135.300 μs(快)

关于类型稳定的更多细节,请参见 Syslab 帮助文档的 Julia 高性能编程中的类型稳定章节。

# 多线程使用问题

Julia 提供了丰富的并行计算能力,包括异步计算、多线程计算、多进程计算、GPU 及异构计算。其中,多线程计算使用较为普遍。在使用多线程加速前,设置 Julia 启动线程数,一般推荐设置为计算机线程数的一半

首先,打开 Syslab,在 Julia 命令行窗口中输入Sys.CPU_THREADS可以查看计算机线程数;其次,打开首选项页面,设置 Julia 启动线程数,一般设置为计算机线程数的一半,设置完成后需要重启命令行窗口生效。

通过在 for 循环添加Threads.@threads即可将其变成多线程执行代码。

注意:使用多线程并行加速,要求每个线程的迭代操作相互独立,避免数据竞争。

using TyPlot
using TyImageProcessing

# 加载图像
img = imread("corn.tif");
h, w = size(img)

# 预分配内存
gray_img = Matrix{Float64}(undef, h, w)

# 使用多线程
Threads.@threads for i in 1:h
    for j in 1:w
        # 获取原图像素
        pixel = img[i, j, :] ./ 255

        # 计算灰度值(每个线程操作不同位置,无竞争)
        gray_img[i, j] = 0.299 * pixel[1] + 0.587 * pixel[2] + 0.114 * pixel[3]
    end
end

# 显示原图与灰度图
subplot(1, 2, 1)
imshow("corn.tif")

subplot(1, 2, 2)
imshow(gray_img)

关于 Julia 并行计算能力,详细请参见 Syslab 帮助文档的并行计算工具箱

# 函数重名问题

考虑两个或更多包导出相同名称的情况,如:

module ModeA
export myadd
function myadd(a, b)
    return a + b
end
end

module ModeB
export myadd
function myadd(a::AbstractString, b::AbstractString)
    return a * b
end
end

using .ModeA
using .ModeB

x = myadd(3, 4)     # error:myadd 未定义

运行报错:

WARNING: both ModeB and ModeA export "myadd"; uses of it in module Main must be qualified
ERROR: UndefVarError: `myadd` not defined
Stacktrace:
 [1] top-level scope
   @ f:\Syslab\MwSyslab\04 详细设计\预研\预研-工程化问题(长期)\Julia语言机制问题类最佳实践\函数重名问题.jl:18

有两种解决方法:

  • 方法 1:使用限定名称,如下所示:

    x = ModeA.myadd(3, 4)     # 返回7
    
    y = ModeB.myadd("3", "4") # 返回"34"
    
  • 方法 2:如果不想修改调用点,可以使用导入指定模块的指定函数,如下所示:

    using .ModeA
    using .ModeB
    using .ModeA: myadd # 导入特定函数
    
    x = myadd(3, 4) # 返回7(此时调用的是 ModeA.myadd)
    

# 结构体如何重定义

在 Julia 中,结构体(struct)是常量,而常量不可修改,导致结构体无法重定义,否则报错。例如:

struct MyPoint
    x::Float64
    y::Float64
end

运行后,如果想将其修改为三维点,增加一个字段z,则会报错:

struct MyPoint
    x::Float64
    y::Float64
    z::Float64
end

此时会报错:

ERROR: invalid redefinition of constant MyPoint

为了使得修改生效,不得不重启 Julia 命令行窗口,非常不方便。

为了解决这个问题,在结构体定义修改期间,可以使用结构体+变量的组合方式,来避免反复重启 Julia 命令行窗口。

# 结构体使用临时名称
mutable struct MyPoint2
    x::Float64
    y::Float64
end

# 定义一个变量(采用结构体真实名称)
MyPoint = MyPoint2

isconst(Main, :MyPoint)  # false,变量
isconst(Main, :MyPoint2) # true,常量

pt = MyPoint(3,4)

假设此时需要将二维点修改为三维点,可以这么做(此时无需重启 Julia 命令行窗口):

# 修改结构体临时名称
mutable struct MyPoint3
    x::Float64
    y::Float64
    z::Float64
end

# 变量指向修改后的结构体
MyPoint = MyPoint3

pt = MyPoint(3,4,5)

如此,待结构体定义完全定型后,可以注释掉中间变量,恢复结构体真实名称。

# 需要重启 REPL
# 恢复结构体真实名称
mutable struct MyPoint
    x::Float64
    y::Float64
    z::Float64
end

# MyPoint = MyPoint3

pt = MyPoint(3,4,5)

# 结构体初始化

Julia 结构体可以通过new来进行默认初始化,但是不直观。

mutable struct PointF
    x::Float64
    y::Float64
    PointF() = new(0, 0) # 无参构造函数
end

pt = PointF()

可以通过Base.@kwdef来构造参数化结构体,如下所示:

@kwdef mutable struct PointF
    x::Float64 = 0
    y::Float64 = 0
end

pt = PointF()     # 输出 PointF(0.0, 0.0)
pt2 = PointF(3,4) # 输出 PointF(3.0, 4.0)
pt3 = PointF(y=1) # 输出 PointF(0.0, 1.0)

# 读取 jld2 文件兼容旧版本结构体

在 Julia 中,会出现将结构体保存为 jld2 文件,但在后续开发中,结构体的成员发生了改变,会导致读取 jld2 文件后,读取的结构体无法匹配最新的结构体。

例如,定义一个 v1 版本的 Person 结构体,并创建实例 person1 将其保存至 person1.jld2 文件中。

# v1 版本 Person
@kwdef struct Person
    name::String = ""
    age::Int = 0
    email::String = ""
end
person1 = Person(name="张三", age=24, email="zhangsan@email.com");

using TyBase
save("person1.jld2"; person1);

此时,重启 Julia 命令行窗口,定义一个 v2 版本的 Person 结构体,然后读取上述的 person1.jld2 文件。

# @kwdef 宏用于给结构体构造创建默认值
@kwdef struct Health
    height::Float64 = 0.0
    weight::Float64 = 0.0
end

# v2 版本 Person
@kwdef struct Person
    name::String = ""
    age::String = "0"
    health::Health = Health()
end

using TyBase
load("person1.jld2");
show(person1) # Reconstruct@Person(Any["张三", 24, "zhangsan@email.com"]);

可以看到由于两个版本的 Person 结构体发生了改变,所以读取 jld2 文件后,创建了一个默认类型的结构体。

下述代码提供一个转换函数,将读取出的默认类型的结构体转换为 v2 版本的 Person 结构体。

"""
将 jld2 文件中导入的默认结构体转换为指定类型的结构体,要求指定类型的结构体有默认构造函数

convert_to_target_type(target_type, source_struct)

输入:

target_type:需要转换的结构体类型

source_struct: 结构体实例
"""
function convert_to_target_type(target_type, source_struct)
    target_fields = fieldnames(target_type)
    args = Dict{Symbol,Any}()
    for field in target_fields
        if field in propertynames(source_struct)
            value = getproperty(source_struct, field)
            try
                convert(fieldtype(target_type, field), value)
                args[field] = value
            catch err
                @warn "字段 $field 类型不匹配"
            end
        end
    end
    target = target_type(; args...)
    return target
end
v2_person1 = convert_to_target_type(Person, person1)
show(v2_person1) # Person("张三", "0", Health(0.0, 0.0))

对于变量名和类型匹配的成员 name 属性,成功进行了转换,对于修改了类型的 age 属性和新增的 Health 属性,使用了默认值,对于删除的 email 属性,进行了忽略。成功将 jld2 文件中读取的默认结构体转换成 v2 版本的 Person 类型的结构体。

# 以 ! 结尾的函数

在 Julia 中,以!结尾的函数名是一种约定俗成的命名规范,用于表示该函数会直接修改其输入参数(即“就地修改”)。这种命名习惯有助于代码的可读性和安全性,让用户能够快速区分哪些函数会改变原始数据。

例如:

# 不修改原数组的函数
original = [3, 1, 2]
sorted = sort(original)  # 返回新的排序数组
println(sorted)    # 输出 [1, 2, 3]
println(original)  # 输出 [3, 1, 2]

# 修改原数组的函数
sort!(original)    # 直接排序原数组
println(original)  # 输出 [1, 2, 3]

自定义!函数。例如:

# 不修改原数组的函数
function double(x::Vector)
    return 2 .* x  # 返回新数组
end

# 修改原数组的函数
function double!(x::Vector)
    x .*= 2     # 直接修改原数组
    return x    # 通常返回修改后的原数组
end

a = [1, 2, 3]
b = double(a)
println(a)  # 输出 [1, 2, 3]
println(b)  # 输出 [2, 4, 6]

double!(a)
println(a)  # 输出 [2, 4, 6](原数组被修改)

# 模拟 MATLAB 结构体

一般情况下,推荐使用 Julia 原生数据类型,可以获取更好编译器优化和性能。但是,在某些场景下,用户想拥有一个类似 MATLAB 效果的结构体,具备通过使用圆点表示法动态添加字段等功能,可以参考以下实现。

# 模拟 MATLAB 结构体
struct mstruct
    _data::Dict{Symbol,Any}

    mstruct() = new(Dict{Symbol,Any}())

    function mstruct(; kwargs...)
        data = Dict{Symbol,Any}()
        for (k, v) in kwargs
            data[k] = v
        end
        new(data)
    end
end

function Base.getproperty(obj::mstruct, name::Symbol)
    data = getfield(obj, :_data)
    return data[name]
end

function Base.setproperty!(obj::mstruct, name::Symbol, value)
    data = getfield(obj, :_data)
    data[name] = value
end

function Base.show(io::IO, obj::mstruct)
    data = getfield(obj, :_data)
    println(io, "mstruct with fields: ")

    ks = collect(keys(data))
    sort!(ks)

    for k in ks
        v = data[k]
        print(io, "  ", k , ": ")
        if v isa AbstractArray
            println(io, "[size: ", size(v), ", type: ", typeof(v), "]")
        else
            println(io, v)
        end
    end
end

Base.propertynames(obj::mstruct) = keys(getfield(obj, :_data))

function Base.delete!(obj::mstruct, field::Symbol)
    data = getfield(obj, :_data)
    delete!(data, field)
    return obj
end

我们不妨测试一下:

using TyPlot

# 动态添加字段
data = mstruct()
data.x = LinRange(0, 2 * pi, 100);
data.y = sin.(data.x);
data.z = Float64[]
data.title = "y = sin(x)"

# 动态删除字段
delete!(data, :z)

# 画图
plot(data.x, data.y)
title(data.title)

# 打印
println(data)

运行后,成功绘制正弦图,并输出结构体内容,如下所示:

mstruct with fields:
  title: y = sin(x)
  x: [size: (100,), type: LinRange{Float64, Int64}]
  y: [size: (100,), type: Vector{Float64}]

# 谨慎使用手动 GC

在 Julia 中,GC.gc()是触发垃圾回收(Garbage Collection, GC)的核心函数。合理使用它可以优化内存管理,但不当使用可能导致性能下降。一般情况下,用户无需手工调用GC.gc()来释放内存。

如果确实需要手工释放内存,可以在释放大对象后手动触发,如下所示:

# 创建大数组
data = rand(10^9)  # 8GB

# 处理数据...
result = sum(data)

# 释放引用并触发 GC
data = nothing
GC.gc()

相反,过度调用GC.gc()会显著降低性能:

# 严重影响性能
for i in 1:1000
    println("第 $i 次执行...")

    # ...
    GC.gc()  # 不要这样做!
end