Julia:非破坏性地更新不可变类型变量

Julia: non-destructively update immutable type variable

假设有一个类型

immutable Foo
    x :: Int64
    y :: Float64
end

还有一个变量foo = Foo(1,2.0)。我想使用 foo 构造一个新变量 bar 作为原型,字段 y = 3.0 (或者,非破坏性地更新 foo 产生一个新的 Foo 对象).在 ML 语言(Haskell、OCaml、F#)和其他一些语言(例如 Clojure)中,有一个惯用语在伪代码中看起来像

bar = {foo with y = 3.0}

Julia 有这样的东西吗?

这很棘手。在 Clojure 中,这将与数据结构一起工作,一个动态类型的不可变映射,因此我们只需调用适当的方法来 add/change 一个键。但是在使用 types 时,我们必须进行一些反射,以便为该类型生成一个合适的新构造函数。此外,与 Haskell 或各种 ML 不同,Julia 不是静态类型的,因此人们不能简单地查看像 {foo with y = 1} 这样的表达式并计算出应该生成什么代码来实现它。

实际上,我们可以为此构建一个类似 Clojure 的解决方案;由于 Julia 提供了足够的反射和活力,我们可以将类型视为一种不可变的映射。我们可以使用 fieldnames 按顺序获取 "keys" 的列表(如 [:x, :y]),然后我们可以使用 getfield(foo, :x) 动态获取字段值:

immutable Foo
  x
  y
  z
end

x = Foo(1,2,3)

with_slow(x, p) =
  typeof(x)(((f == p.first ? p.second : getfield(x, f)) for f in fieldnames(x))...)

with_slow(x, ps...) = reduce(with_slow, x, ps)

with_slow(x, :y => 4, :z => 6) == Foo(1,4,6)

然而,这被称为 with_slow 是有原因的。由于反射,它 远不及 withy(foo::Foo, y) = Foo(foo.x, y, foo.z) 这样的手写函数。如果 Foo 被参数化(例如 Foo{T}y::T),那么 Julia 将能够推断出 withy(foo, 1.) returns a Foo{Float64},但不会根本无法推断出 with_slow。正如我们所知,这会破坏 crab 性能。

使它与 ML 和 co 一样快的唯一方法是生成与手写版本有效等效的代码。碰巧,我们也可以推出那个版本!

# Fields

type Field{K} end

Base.convert{K}(::Type{Symbol}, ::Field{K}) = K
Base.convert(::Type{Field}, s::Symbol) = Field{s}()

macro f_str(s)
  :(Field{$(Expr(:quote, symbol(s)))}())
end

typealias FieldPair{F<:Field, T} Pair{F, T}

# Immutable `with`

for nargs = 1:5
  args = [symbol("p$i") for i = 1:nargs]
  @eval with(x, $([:($p::FieldPair) for p = args]...), p::FieldPair) =
      with(with(x, $(args...)), p)
end

@generated function with{F, T}(x, p::Pair{Field{F}, T})
  :($(x.name.primary)($([name == F ? :(p.second) : :(x.$name)
                         for name in fieldnames(x)]...)))
end

第一部分是生成类符号对象的技巧,f"foo",其值在类型系统中是已知的。生成的函数就像一个宏,它接受类型而不是表达式;因为它可以访问 Foo 和字段名称,所以它基本上可以生成此代码的手动优化版本。您还可以检查 Julia 是否能够正确推断输出类型,如果您设置参数 Foo:

@code_typed with(x, f"y" => 4., f"z" => "hello") # => ...::Foo{Int,Float64,String}

for nargs 行本质上是一个手动展开的 reduce,可以实现这一点。)

最后,为了避免有人指责我给出了有点疯狂的建议,我想警告说这在 Julia 中并不是那么惯用。虽然我不能在不了解您的用例的情况下给出非常具体的建议,但通常最好是拥有一组可管理的(小的)字段和一组对这些字段进行基本操作的函数;您可以在这些函数的基础上创建最终的 public API。如果你真正想要的是一个不可变的字典,你最好只使用专门的数据结构。

FixedSizeArrays.jl 包中还实现了 setindex(末尾没有 !),它以一种有效的方式实现了这一点。