2026a

# 代码生成的 Julia 编程要求


用于代码生成的 Julia 代码,其最核心的要求是类型稳定

代码生成功能对 Julia 代码作出了如下限制:

  • 代码生成入口函数是全路径类型稳定的
  • 函数运行过程中,不能调用 Julia 编译器
  • 函数运行过程中,不能调用来自 libjulia 的 C 函数

此章节将介绍以上三类限制,随后重点展示如何解决它们。通常来说,后两类问题解决起来相对直接,因为它们在理解上比较直观,且与用户自身的代码质量关系不大。

关于类型稳定的第一类问题需要特别关注,因为它是代码生成的核心要求。就现阶段而言,要想编写类型稳定的 Julia 代码并支持代码生成,要求我们对这一概念有基本的认识,并对识别和解决类型稳定问题具备一定的实践经验。

此外,类型稳定不仅有益于代码生成,它还带来以下好处:

  • 类型稳定有益于性能优化:类型稳定是高性能 Julia 代码的基石;
  • 类型稳定有益于改善用户体验:类型稳定可辅助解决 Julia 首次运行延迟高的问题。

# 编写类型稳定的代码

类型稳定是针对函数调用而定义的概念。类型稳定的定义是,对于一个调用点 (f(arg1, ..., argn)),该调用的函数 (f) 和所有参数 (从arg1argn) 的类型, 都能在运行之前被 Julia 编译器类型推断为 Julia 具体类型。对于函数调用存在关键字参数的情况,上述定义仍然适用,这是因为 Julia 编译器会将关键字参数变形为位置参数。

下面是类型稳定/不稳定的具体例子:

function example()
   bad = Any[1, "2"]

   # 类型不稳定:
   #     编译器推断 bad[1] 的类型是 Any (非具体类型)
   func(bad[1])

   good = Int[1, 2]
   # 类型稳定:
   #     编译器推断 good[1] 的类型是 Int64, isconcretetype(Int64) 为 true
   func(good[1]) # 类型稳定
end

# 加 @noinline 是为了后续直观地观察 Julia IR 差异
@noinline func(x::Number) = x + 1
@noinline func(x::String) = length(x)

例子里的 example 函数介绍了两个例子,func(bad[1])是类型不稳定的,func(good[1])是类型稳定的。

可以看到,类型稳定与函数调用点关系更大。即使是编写良好的函数func,根据函数func调用位置(调用点)的不同,既可能类型稳定的,也可能类型不稳定。这就是说,即使函数定义本身是良好的,一旦函数调用点的参数类型无法推断为具体类型,也会导致调用点类型不稳定。

在上面的例子里,我们手工观察代码的类型稳定问题。下面我们介绍类型稳定的检测手段。

# 基于 Julia Typed IR 认识类型不稳定

Julia Typed IR 是 Julia 源码到机器码中间的一环,它可以精确地指示 Julia 代码是否类型稳定。

通过 InteractiveUtils.code_typed 函数或 InteractiveUtils.@code_typed 宏,可以获取函数体特化后的 Julia Typed IR;然后观察 Julia Typed IR、与 IR 中的动态调用和静态调用,就可以确认 Julia 代码的类型稳定情况。

提示

code_typed() 要求提供参数类型信息

获取 Julia Typed IR 的代码通常是 code_typed(函数, (参数1的类型, ..., 参数N的类型)),此处参数类型是必要的。由于 Julia 函数是支持重载的,我们无法仅通过函数名获取 IR,且编译器会根据参数类型来优化、裁剪代码 (类似 C++ constexpr),因此,对于不同的参数类型,即使函数相同,也可以产生不同的 IR 输出。

function example()
   bad = Any[1, "2"]

   # 类型不稳定:
   #     编译器推断 bad[1] 的类型是 Any (非具体类型)
   func(bad[1])

   good = Int[1, 2]
   # 类型稳定:
   #     编译器推断 good[1] 的类型是 Int64, isconcretetype(Int64) 为 true
   func(good[1]) # 类型稳定
end

# 加 @noinline 是为了后续直观地观察 Julia IR 差异
@noinline func(x::Number) = x + 1
@noinline func(x::String) = length(x)

# @code_typed example()
possible_typed_ir = code_typed(example, ());
typed_ir = possible_typed_ir[1].first;

display(typed_ir)
CodeInfo(
1 ── %1  = Core.tuple(1, "2")::Tuple{Int64, String}
   ...省略...
13 ─ %31 = Base.arrayref(true, %2, 1)::Any
│    %32 = Main.func::typeof(func)
   ...省略...
│          invoke %32(%35::String)::Any
└───       goto #16
15 ─       Main.func(%31)::Any
└───       goto #16
   ...省略...
21 ─       goto #17
22 ┄       goto #23
23 ─ %59 = Base.arrayref(true, %41, 1)::Int64
│    %60 = invoke Main.func(%59::Int64)::Int64
└───       return %60
)

在上面的例子里,我们打印了 example() 产生的 IR,有总共 60 条指令。这个 IR 不表示 example() 这个调用本身的 IR,而是 example 函数体的 IR。

我们简单介绍上面 IR 的打印结构:

  1. IR 左侧为指令序号,从1开始。在这里仅用于定位指令,其具体含义不做深究。

  2. IR 序号的右侧,就是转换自 Julia 源代码的 IR 低级指令。

观察上面这段 IR,可在序号 23 附近找到一个静态调用(即 invoke 指令,详细信息请参见静态调用):

        %60 = invoke Main.func(%59::Int64)::Int64

也可在序号 15 右侧找到一个动态调用(形如 f(arg, ...) 的指令,详细信息请参见动态调用):

        Main.func(%31)::Any

上述动态调用实际上对应着 example 函数体中的 func(bad[1])

function example()
   bad = Any[1, "2"]

   # 类型不稳定:
   #     编译器推断 bad[1] 的类型是 Any (非具体类型)
   func(bad[1])

   good = Int[1, 2]
   # 类型稳定:
   #     编译器推断 good[1] 的类型是 Int64, isconcretetype(Int64) 为 true
   func(good[1]) # 类型稳定
end

一般来说,可以通过观察 Julia Typed IR 来定位动态调用/类型不稳定的来源。但这种方法需要 Julia IR 有一定了解,如果希望对一些简单情况做快速定位,可以对 code_typed() 函数传入关键字参数 debuginfo=:source

function example()
   bad = Any[1, "2"]

   # 类型不稳定:
   #     编译器推断 bad[1] 的类型是 Any (非具体类型)
   func(bad[1])

   good = Int[1, 2]
   # 类型稳定:
   #     编译器推断 good[1] 的类型是 Int64, isconcretetype(Int64) 为 true
   func(good[1]) # 类型稳定
end

# 加 @noinline 是为了后续直观地观察 Julia IR 差异
@noinline func(x::Number) = x + 1
@noinline func(x::String) = length(x)

# @code_typed example()
code_typed(example, (); debuginfo=:source)[1]
CodeInfo(
   ...省略...
     @ <path-to-script>\test.jl:6 within `example`
   ...省略...
15 ─       Main.func(%31)::Any
   ...省略...
) => Int64

上面 IR 打印出现了源码位置,我们寻找 Main.func(%31)::Any 这一指令往前最近的源码位置:

  @ <path-to-script>\test.jl:6 within `example`

这说明动态调用出现在 test.jl 文件的第 6 行,函数名是 example。此处的 test.jl 将随着源文件位置变化,在实际情况下,这是你的 Julia 源文件路径。

# 代码生成入口函数的全路径类型稳定

代码生成要求入口函数是全路径类型稳定的:入口函数内部的所有函数调用,以及这些调用更内部的函数调用,均递归地类型稳定。

当入口函数不满足全路径类型稳定时,代码仍能被正常生成,但执行生成代码至非类型稳定的路径时将抛出异常。

此处以应用程序的 main 入口函数为例:

# main.jl
direct(x::Int32) = x + 2
direct(x::Int) = x + 1
direct(x::String) = string(x, x)
direct(x::Float64) = x * 2

function recursive(bad)
   println(direct(bad[1]))
end

# bad:main 入口函数不满足全路径类型稳定,
#      虽然 recursive(bad) 类型稳定,
#      但内部调用 direct(bad[1]) 类型不稳定
function main()
   bad = Any[Int32(1), 2, "2", 1.5]
   recursive(bad)
end

对于上述程序中的 main 函数,虽然调用点 recursive(bad) 满足类型稳定的条件,但是 recursive 函数内 bad[1] 的具体类型无法推断,此时的函数调用 direct(bad[1]::Any) 是类型不稳定的,因此 main 函数不满足全路径类型稳定

使用 --deubgtrace 编译选项进行编译,当程序执行到类型不稳定的函数调用时,将报错并给出错误来源。

scc main.jl -o main --debugtrace
.\main.exe # Linux 上命令为 ./main

运行可执行文件,程序执行到类型不稳定的路径时将报错:

Error: ErrorException("dynamic call(Main.direct, %1::Any)")
stacktrace:
    <path-to-script>/main.jl:8 at recursive()
    <path-to-script>/main.jl:16 at main()

为了让生成的代码能够正常运行,需要消除 recursive 函数内类型不稳定的调用,使其满足全路径类型稳定的条件。其中一种解决方案是,如果 bad[1] 的类型能够提前确定,可以手动标注其类型,使其类型稳定。

# main.jl
direct(x::Int32) = x + 2
direct(x::Int64) = x + 1
direct(x::String) = string(x, x)
direct(x::Float64) = x * 2

function recursive(bad)
   println(direct(bad[1]::Int32)) # 如果能够确认此处是 Int32 类型,可以手动标注其类型,使其类型稳定
end

# good:main 入口函数满足全路径类型稳定,
#       因为内部调用 recursive() 递归地类型稳定
function main()
   bad = Any[Int32(1), 2, "2", 1.5]
   recursive(bad)
end

上述示例手动标注 bad[1] 的类型,将 direct(bad[1]) 转变为类型稳定的函数调用 direct(bad[1]::Int32),此时 main 满足全路径类型稳定的条件,生成的程序能够被正常执行。

在实践中,还有其他各种方案来消除类型不稳定,我们将在下文详细介绍。

# 类型稳定相关的常见问题及解决手段

此小节介绍类型稳定的常见衍生概念与问题,以及面对相应类型不稳定的解决手段。

由于类型稳定在 Julia 中涉及面较广,因此,在有关 Julia 的技术沟通中,常使用“类型稳定”一词来模糊地指代与类型稳定相关的常见问题。此小节明确指出其中实际存在的三类问题:

  • 函数的类型稳定问题
  • 结构体的类型稳定问题
  • 返回值类型的类型稳定问题

上述三类问题虽称为类型稳定问题,但严格来说与类型稳定相差甚远。之所以有这样的命名,正是因为在实践中,它们很容易导致类型不稳定,因此接下来将对它们重点介绍解决方案。

# 函数的类型稳定问题

严格来说,只有调用点才有类型稳定的概念,但使用类型稳定一词来描述函数的定义也很常见。

函数的类型稳定,一般作以下理解:

任给一组符合参数类型标注的、合法的参数类型,特化后的函数体包含的所有调用点,均类型稳定。

以下是一个例子:

f(x::Any) = [x]

上面的函数 f 是类型稳定的,因为对于任意合法的x输入,假设其类型 typeof(x)T,则返回值类型一定是 Vector{T}

f(x::Any) = [x]

# 使用 code_typed(f, (argtypes..., ))[1].second
# 获取该特化的返回值类型
code_typed(f, (Int, ))[1].second
Vector{Int64}
code_typed(f, (String, ))[1].second
Vector{String}
code_typed(f, (Vector{Int}, ))[1].second
Vector{Vector{Int64}}

提示

typeof() 返回具体类型

对于任意 julia 对象,它一定是一个具体类型的实例,而不可能是一个抽象类型的实例。可以用 typeof() 函数拿到这个具体类型。

可以看到,参数类型标注并不影响类型稳定

函数的类型不稳定通常由函数各个重载的具体实现导致,以下是一个例子。

function f(x::Any)
   global unknown_var
   unknown_var + x
end

此处使用了全局变量 unknown_var,它的类型未知,因此无论 f 输入的参数是什么类型,函数体内的调用点 unknown_var + x 都是类型不稳定的。

此外,在实践中不需要、也不可能追求完备的函数类型稳定。在常规情况下,当我们说一个函数类型稳定,并不一定意味着它是严格的函数类型稳定,而是指在“某一类参数输入”的场景下具备函数类型稳定。下面是一个例子:

f(x::AbstractArray{T}) where {T <: Number} = x[1] + x[2]

示意函数的功能是将数组的前两个元素加起来。该例子表面没有什么不妥,但实际上,T 可以不是一个具体类型,也可能不支持加法重载,在这种情况下,函数体就会出现类型不稳定:

f(x::AbstractArray{T}) where {T <: Number} = x[1] + x[2]

abstract type AbsMyNum <: Number end
struct MyNum <: AbsMyNum end

@code_typed f(AbsMyNum[MyNum(), MyNum()])
CodeInfo(
1 ─ %1 = Base.arrayref(true, x, 1)::AbsMyNum
│   %2 = Base.arrayref(true, x, 2)::AbsMyNum
│   %3 = (%1 + %2)::Any
└──      return %3
) => Any

可以看到上面的 IR 中有一处动态调用:

   %3 = (%1 + %2)::Any

提示

Julia 运算符是函数

a + b 等价于 +(a, b)a ⊻ b 等价于⊻(a, b)

在 Julia 中,除短路逻辑运算外,绝大部分运算符都等效为函数调用。该对应是普遍适用的,例如当打印 Julia IR 时,运算符函数调用会打印出更简短的 a + ba ⊻ b 等形式。

这种处理不仅统一了运算符和函数,也不会带来性能问题,当 julia 代码编译为机器码,a + b 不会开辟函数栈,而只会生成一行汇编指令。

因此,当f的参数x是具体类型 Vector{AbsMyNum} 时,函数f的类型并不稳定。

虽然f存在类型不稳定的情况,但在多数情况下,数字数组的元素类型不会使用抽象类型表示,因此在实践中,上述写法也是合适的。

# 结构体的类型稳定

结构体类型稳定也是常见的类型稳定问题,相比于函数类型稳定,它更为简单:

当结构体类型为具体类型时,其所有字段的类型都是具体类型

以下是一个类型不稳定的结构体:

struct MyType
   vec::AbstractVector{Float64}
   param::Float64
end

上述 MyType 不是类型稳定的,示例如下:

isconcretetype(MyType)
true
fieldtype(MyType, :vec)
AbstractVector{Float64}
isconcretetype(fieldtype(MyType, :vec))
false

可以使用泛型编写功能等价的结构体,同时满足结构体类型稳定:

struct MyType{FV <: AbstractVector{Float64}}
   vec::FV
   param::Float64
end

MyType([1.0, 2.0], 1.0)
MyType{Vector{Float64}}([1.0, 2.0], 1.0)
isconcretetype(MyType)
false
isconcretetype(MyType{Vector{Float64}})
true
fieldtype(MyType{Vector{Float64}}, :vec)
Vector{Float64}
isconcretetype(fieldtype(MyType{Vector{Float64}}, :vec))
true

# 返回值类型的类型稳定

返回值类型的类型稳定,或返回值类型稳定,通常用来表达如下含义:

当调用点参数被编译器推断为具体类型时,返回值类型也推断为具体类型。

以下是一个返回值类型不稳定的例子:

function firstcall(val::Float64)
   if isinteger(val)
      # isinteger 表示浮点数是否为整形
      return 1
   elseif val > 0.5
      return 1.0
   elseif val > 0
      return "1"
   else
      return [1]
   end
end

secondcall(val::Float64) = println("浮点数")
secondcall(val::Int) = println("整数")
secondcall(val::String) = println("字符串")
secondcall(val::Vector{Int}) = println("整数数组")

function test()
   println("输入参数")
   val = parse(Float64, readline())
   # 此调用点的返回值不能被编译器推断为具体类型,
   # 即返回值类型不稳定
   ret = firstcall(val)

   # firstcall(val) 返回值类型不稳定,导致 secondcall(ret) 类型不稳定
   secondcall(ret)
end

实际上,上述调用点 firstcall(val) 是类型稳定的:其函数、参数的类型均已知,且被编译器推断为具体类型。但它的确是返回值类型不稳定的,因为调用点的返回值类型不能被编译器推断为具体类型。

因此,返回值类型的类型稳定与其他类型稳定衍生概念不同,返回值类型的不稳定并不导致当前调用点的类型不稳定,只是会影响后续调用点的类型稳定。

如果代码中出现返回值类型不稳定,根因一般是函数设计问题,常见示例和解决方案在 使用单例消除字符串关键字参数 中详细介绍。

除开函数涉及问题外,返回值类型不稳定的来源也可能是实际的需求。如果目标场景需要返回值类型不稳定,且返回值类型的情况有限,可以通过 if-else 手动拆分,以消除返回值类型不稳定对程序的影响:

function firstcall(val::Float64)
   if isinteger(val)
      # isinteger 表示浮点数是否为整形
      return 1
   elseif val > 0.5
      return 1.0
   elseif val > 0
      return "1"
   else
      return [1]
   end
end

secondcall(val::Float64) = println("浮点数")
secondcall(val::Int) = println("整数")
secondcall(val::String) = println("字符串")
secondcall(val::Vector{Int}) = println("整数数组")

function test()
   println("输入参数")
   val = parse(Float64, readline())
   # 此调用点的返回值不能被编译器推断为具体类型,
   # 即返回值类型不稳定
   ret = firstcall(val)

   # firstcall(val) 返回值类型不稳定,
   # 通过 if-else 手动拆分,消除返回值类型不稳定
   if ret isa Int
      secondcall(ret)
   elseif ret isa Float64
      secondcall(ret)
   elseif ret isa String
      secondcall(ret)
   elseif ret isa Vector{Int}
      secondcall(ret)
   else
      error("未知类型")
   end
end

注意,if-else 拆分不适用于所有返回值类型不稳定的场景,它仅适用于返回值类型有严格规范、且取值范围有限的场景,这些场景通常使用了特定的设计模式,如代数数据类型、模式匹配或 Visitor 模式等。

# 消除类型不稳定的一般性实践

# 1. 消除不定返回值类型分支

underlying(x::Complex) = x.re + x.im
underlying(x::Real) = x

# bad:
function f(complex::Complex)

   # 假设调用点处参数 complex 是 ComplxF64:
   # 则 julia 编译器推断 val 为联合类型 Union{ComplexF64, Float64}
   # 补充: julia 编译器事实上可优化小规模的联合类型;
   #       而本例是为展示最佳实践做的有意简化
   val = abs(complex) < eps() ? complex : real(complex)

   # val 推断为联合类型,而非具体类型,此调用点类型不稳定
   underlying(val)
end

# good
function f(complex::Complex)
   if abs(complex) < eps()
       return underlying(complex)
   else
       return underlying(real(complex))
   end

   # or
   # return abs(complex) < eps() ?
   #     underlying(complex)     :
   #     underlying(real(complex))
end

# 2. 消除抽象容器

常见的抽象容器误用有以下两种:

  • Vector{Any}: 通常来自不合时宜地使用 [] 语法,可改为 T[],其中 T是元素类型;
  • Vector{Num}:来自数组元素为任意数值类型的需求,该需求通常为伪需求,可以检查代码中使用 Vector{Number} 的位置,查看函数定义是否可以接受更具体的类型。

注意:抽象容器本身不造成类型不稳定,但后续操作将导致类型不稳定,详见下列代码中example函数。

以下代码介绍Vector{Any}的误用和最佳实践。

# bad
function cumsum(n)

   # 空向量,不指定类型,
   # 则默认是元素为 Any 类型
   xs = []
   s = 0
   for i = 1:n
      s += i
      push!(xs, s)
   end
   return xs
end

# good
function cumsum(n)
   # 可使用 T[] 语法
   # 指定空向量的元素类型
   xs = Int[]
   s = 0
   for i = 1:n
      s += i
      push!(xs, s)
   end
   return xs
end

function example()
   # 抽象容器本身类型稳定
   xs = cumsum(10)

   # 但使用抽象容器将导致后续操作类型不稳定
   xs[1] + xs[2]
end

以下代码介绍Vector{Number}的误用和最佳实践。

# bad
function myfunc(xs::Vector{Number})
   s = 0.0
   for each in xs
      s += each
   end
   return s
end
myfunc([1, 2.0, 3+2im]) # 因为没有匹配到相应参数类型的函数而报错
ERROR: MethodError: no method matching myfunc(::Vector{ComplexF64})

Closest candidates are:
  myfunc(::Vector{Number})
   @ Main ...
...
# good
# 使用泛型来编写数组的元素类型
function myfunc(xs::Vector{T}) where {T <: Number}
   s = zero(T)
   for each in xs
      s += each
   end
   return s
end
myfunc([1, 2.0, 3+2im])
6.0 + 2.0im

# 3. 使用单例消除字符串关键字参数

在实际的工程算法开发上,经常使用取值范围有限的关键字参数来指定算法或其他计算细节。例如在 Python/MATLAB/R 等语言中,通常使用一个字符串来表示这样的关键字参数。这种处理看起来是类型稳定的,但由于专业算法层面的因素,当关键字参数的取值不同,函数返回值类型可能也不同,因此导致不规范的 Julia 代码。

下面是一个具体示例:

# bad
algo1(x) = string(x, x)
algo2(x) = 2x
algo3(x) = (x, 2x)

function f(x; algo="default")
   if algo == "default" || algo == "algo1"
      return algo1(x)
   elseif algo == "algo2"
      return algo2(x)
   elseif algo == "algo3"
      return algo3(x)
   else
      error("未知算法:", algo)
   end
end

# 使用 @code_typed 推断类型
(@code_typed f(1, algo="algo2")).second
Union{Int64, Tuple{Int64, Int64}, String}

这种情况下,可以使用单例来作为关键字参数,以避免返回值的类型稳定问题。

# good
abstract type AbsAlgo end
struct Algo1 <: AbsAlgo end
struct Algo2 <: AbsAlgo end
struct Algo3 <: AbsAlgo end

algo1(x) = string(x, x)
algo2(x) = 2x
algo3(x) = (x, 2x)

function f(x; algo::AbsAlgo = Algo1())
   if algo isa Algo1
      return algo1(x)
   elseif algo isa Algo2
      return algo2(x)
   elseif algo isa Algo3
      return algo3(x)
   else
      error("未知算法", algo)
   end
end

# 使用 @code_typed 推断类型
(@code_typed f(1, algo=Algo2())).second
Int64

上面代码保持了初始的程序结构,适用于对旧代码的改进。

但如果在设计阶段认识到各类类型稳定问题,可以采用最佳实践,将代码设计得更简洁、更容易维护:

# best practice
abstract type AbsAlgo end
struct Algo1 <: AbsAlgo end
struct Algo2 <: AbsAlgo end
struct Algo3 <: AbsAlgo end

f(x; algo = Algo1()) = _impl_f(x, algo)
_impl_f(x, ::Algo1) = string(x, x)
_impl_f(x, ::Algo2) = 2x
_impl_f(x, ::Algo3) = (x, 2x)

# 使用 @code_typed 推断类型
(@code_typed f(1, algo=Algo2())).second
Int64

# 避免编译器调用

提示

小节要点

  1. 代码生成产物不能再调用 Julia 编译器
  2. 代码生成之前,可以执行任意 Julia 代码、实现复杂的编译期计算

代码生成的目标是输出依赖少、体积小、可被其他项目集成的源码或二进制产物,这与在生成代码中调用 Julia 编译器有本质上的冲突。Julia 编译器依赖多、体积大,且对调用方/接入方有 ABI 要求,无法满足代码生成的目标。

因此,在代码生成中,我们禁止生成后的代码调用编译器。用户触发编译器一般通过 eval 函数,以下是具体示例:

# 模块顶层是编译期

# OK(支持代码生成):eval 在代码生成前调用,不涉及生成后代码的执行
typename = :MyType1
@eval struct $typename
   val::Int
end

# OK(支持代码生成): eval 在代码生成前调用,不涉及生成后代码的执行
eval(
   Meta.parseall(raw"""
   struct MyType2
      val::Float64
   end
   """)
)

function test()
   # OK(支持代码生成):生成后代码可调用编译期计算的结果
   x = MyType1(1)
   y = MyType(2.0)

   # Error: 生成后代码不能调用编译器
   eval(:($x + 1))
end

# 避免调用来自 libjulia 的 C 函数

libjulia 是 Julia 语言的核心动态库,它体积庞大、依赖大量其他动态库,是代码生成功能需要避免依赖的主要对象。但 libjulia 提供了大量辅助性质的 C 函数来给 Julia 标准库提供方便,因此用户层面的 Julia 代码可能在不经意间依赖这些 C 函数,这就有可能限制代码生成的可用性。

此限制通常可以单点解决,具体做法有:

  1. 非侵入式补丁 (SyslabCC.@patch)
  2. 与 C/C++ 联合编译
  3. 混合使用(1)与(2)

下面通过一个简短但完整的示例来介绍解决方案。

创建 test.jl 文件,内容如下:

function main()
   println("> 输入数字:")
   val = parse(Float64, readline())
   println("input 2 * x  = ", 2val)
end

提示

parse 函数

由于 Syslab 代码生成会不断改进对 Julia 标准库的兼容,当前版本的 Syslab 代码生成可能已支持使用 parse 函数解析浮点数。此处的例子是一种示意,用于介绍问题和解决方案。

早期的 Syslab 代码生成不支持调用 Julia 标准库解析浮点数,由于函数不是纯 Julia 实现,代码生成可能会出现报错。

# v0.1.1 版本开始,已支持了 parse 函数解析浮点数,此处仅为示意
scc test.jl -o main.exe --no-blas --static-mingw
# Linux/Windows 可使用统一命令:
# scc test.jl -o main --no-blas --static-mingw
...
Compiler Error(3): ccall on julia's c function which is not implemented: 'jl_try_substrtod'
in the body of typeof(main)
    File parse.jl, line 253, in Base.tryparse
    File parse.jl, line 372, in Base.#tryparse_internal#509
    File parse.jl, line 371, in Base.tryparse_internal
    File parse.jl, line 384, in Base.#parse#510
    File parse.jl, line 384, in Base.parse
    File <path-to-script>\test.jl, line 3, in Main.main

上面的报错信息很明确,Julia 在实现 parse 函数时,使用了 C 函数 jl_try_substrtod,该函数来自代码生成需要规避的 libjulia 库。

为了解决该问题,我们将使用 C 代码来实现浮点数解析的底层功能,并通过非侵入式补丁,让 parse(Float64, str) 接入自定义的 C 函数。

该工作分成两部分,首先使用非侵入式补丁,提供自定义的parse函数。该操作比较简单,创建一个新文件(如patch.jl),并使用SyslabCC.@patch宏标注需要补丁的重载定义即可。

# 文件名 patch.jl

# 只对 parse(::Type{Float64}, s::String)
# 分支使用补丁,对于其他重载,仍使用原生 Julia 实现
SyslabCC.@patch function parse(::Type{Float64}, s::String)
    valueRef = Ref{Float64}()
    arg1 = Base.unsafe_convert(Ptr{Cdouble}, valueRef)
    arg2 = Base.unsafe_convert(Cstring, s)

   # GC.@preserve 避免 Julia 回收变量 s,保证 C 代码可以访问 Julia 字符串的内存
   GC.@preserve s begin
      # 按照 C 风格的错误处理,C 函数返回值通常用来表达
      # 函数是否成功执行,而第一个参数是结果的指针
      retcode = ccall(
         # 定位到自定义的 C 函数:(导出函数名,动态库名称)
         (:custom_tryparse_double, "libpatch"),
         # 返回值类型
         Bool,
         # 参数类型
         (Ptr{Cdouble}, Cstring),
         # 参数值
         arg1::Ptr{Cdouble}, arg2::Cstring
      )
   end

   if retcode
      error("cannot parse $s as Float64")
   end
   return valueRef[]
end

其次,根据 Julia 部分的 ccall 调用,提供相应的 C 源码实现,并导出动态库。

// 文件名 patch.c
#include <stdint.h>
#include <stdlib.h>

uint8_t custom_tryparse_double(double *fptr, char *input)
{
   char *end = NULL;
   // 将解析结果写入 fptr
   *fptr = strtod(input, &end);
   // 返回 1 表示解析失败
   if (*input == '\0')
   {
      return 1;
   }
   if (end == input || *end != '\0')
   {
      return 1;
   }
   // 返回 0 表示解析成功
   return 0;
}

上述 C 源码可使用 gcc 编译器通过以下命令编译出动态库:

gcc -fPIC -shared patch.c -o libpatch.dll
# Linux 命令:
# gcc -fPIC -shared patch.c -o libpatch.so

如果使用其他编译器,需做相应适配处理。

随后我们修改 test.jl,接入上述的补丁,实现自定义的 parse 函数。

# 文件名:test.jl

# @isdefined(SyslabCC) 通过判断SyslabCC
# 模块是否存在,从而判断是否处于代码生成模式
@static if @isdefined(SyslabCC)
    include("patch.jl")
end

function main()
   println("> 输入数字:")
   val = parse(Float64, readline())
   println("input 2 * x = ", 2val)
end

最后,我们使用 scc 命令编译 test.jl,输出可执行文件并运行:

scc test.jl -o main --no-blas --static-mingw
.\main.exe # 获取用户输入,解析并乘2:
 # Linux 下使用 ./main 运行
> 输入数字:
123.3
input 2 * x = 246.6