Engee 文档

朱莉娅的特征

本章介绍函数、方法定义和方法表的工作方式。

方法表

Julia中的任何功能都是通用的。 通用函数本质上是一个单独的函数,然而,它由许多定义或方法组成。 通用函数的方法存储在方法表中。 方法表(类型’MethodTable')与名称`TypeName’相关联。 'TypeName’描述了一系列参数化类型。 例如,'Complex{Float32}`和'复杂{Float64}'有一个类型为`Complex’的通用名称对象。

在Julia中,所有对象都可以被调用,因为每个对象都有一个类型,而这个类型又与’TypeName’相关联。

函数调用

调用’f(x,y)时,执行以下操作:首先,以`typeof(f)的形式访问对应的方法表。name.mt `。 然后形成一个参数类型的元组。:'元组{typeof(f), typeof(x), typeof(y)}. 请注意,第一个元素是函数本身的类型。 原因是类型可能有参数,这可能需要调度。 方法表中搜索元组类型。

调度过程由`jl_apply_generic`函数执行,该函数接受两个参数:指向值`f`,`x`和`y`数组的指针以及值的数量(在本例中为3)。

系统有两种类型的Api用于处理函数和参数列表:其中一些分别接受函数和参数,而另一些则接受单个参数结构。 当使用第一种类型的API时,包含有关参数信息的部分不包含有关函数的信息,因为它是单独传递的。 当使用第二种类型的API时,函数是参数结构的第一个元素。

例如,下面的函数只需要一个指向"args"的指针来进行调用,因此args数组的第一个元素将是被调用的函数。

jl_value_t *jl_apply(jl_value_t **args, uint32_t nargs)

这个具有相同目的的入口点单独接受该函数,因此`args`数组不包含该函数。

jl_value_t *jl_call(jl_function_t *f, jl_value_t **args, int32_t nargs);

添加方法

在上面描述的调度过程中,添加新方法所需要的只是(1)元组类型和(2)方法体的代码。 此操作由`jl_method_def’函数实现。 要从第一个参数的类型中提取相应的方法表,调用’jl_method_table_for'。 这是一个比调度期间的相应过程复杂得多的过程,因为参数元组的类型可以是抽象的。 例如,以下定义:

(::Union{Foo{Int},Foo{Int8}})(x) = 0

它会起作用,因为与其对应的所有可能方法都将包含在同一个方法表中。

创建通用函数

由于可以调用任何对象,因此创建通用函数不需要特别的东西。 因此,'jl_new_generic_function’只是创建’Function’的单个子类型(零大小)并返回其实例。 函数可以有一个助记符显示名称,用于调试数据和输出有关对象的信息。 例如,'Base的名称。罪’就是’罪'。 按照惯例,正在创建的类型的名称与函数的名称匹配,前面加上字符`#。 因此,'typeof(sin)'返回’Base。#罪

短路

闭包只是一个可调用的对象,其字段名对应于捕获的变量。 例如,下面的代码:

function adder(x)
    return y->x+y
end

它归结为以下内容:

struct ##1{T}
    x::T
end

(_::##1)(y) = _.x + y

function adder(x)
    return ##1(x)
end

构造函数

调用构造函数只是一个类型调用。 'Type’的方法表包含所有构造函数定义。 TypeTypeunionAllUnion`和`DataType)的所有子类型目前通过特殊协议共享一个通用方法表。

内置函数

以下内置函数在"核心"模块中定义。

<: === _abstracttype _apply_iterate _apply_pure _call_in_world
_call_in_world_total _call_latest _compute_sparams _equiv_typedef _expr
_primitivetype _setsuper! _structtype _svec_ref _typebody! _typevar applicable
apply_type compilerbarrier current_scope donotdelete fieldtype finalizer
get_binding_type getfield getglobal ifelse invoke isa isdefined
memoryref_isassigned memoryrefget memoryrefmodify! memoryrefnew memoryrefoffset
memoryrefreplace! memoryrefset! memoryrefsetonce! memoryrefswap! modifyfield!
modifyglobal! nfields replacefield! replaceglobal! set_binding_type! setfield!
setfieldonce! setglobal! setglobalonce! sizeof svec swapfield! swapglobal! throw
tuple typeassert typeof

所有这些都是单个对象,其类型是`Builtin`类型的子类型,而`Builtin’又是’Function’的子类型。 它们的目的是在执行期间提供符合jlcall调用约定的入口点。

jl_value_t *(jl_value_t*, jl_value_t**, uint32_t)

内置函数的方法表为空。 相反,这样的函数在方法缓存('元组)中有一个通用条目{Vararg{Any}}`),其fptr jlcall指针指向对应的函数。 它是一种拐杖,然而,工作得很好。

命名参数

命名参数通过向kwcall函数添加方法来工作。 此函数通常充当"命名参数排序器",然后调用函数的内部主体(匿名定义)。 Kwsorter函数中的每个定义都包含与常规方法表中的某些定义相同的参数,但在开头添加了`NamedTuple`参数,其中包含传递的命名参数的名称和值。 Kwsorter函数的任务是根据名称将命名参数放置在适当的位置,以及计算和替换所有必要的默认值表达式。 结果是一个常规的位置参数列表,它被传递给编译器生成的另一个函数。

理解此过程的最简单方法是查看具有命名参数的方法的定义是如何降低的。 对于下面的代码:

function circle(center, radius; color = black, fill::Bool = true, options...)
    # draw
end

实际上,创建了_three方法的定义。 第一个是一个函数,它接受所有的参数(包括命名的参数),它们都是位置的,并且包括方法体代码。 她的名字是自动生成的。:

function #circle#1(color, fill::Bool, options, circle, center, radius)
    # draw
end

第二种方法是原始’circle’函数的标准定义,用于未传递命名参数时的情况。:

function circle(center, radius)
    #circle#1(black, true, pairs(NamedTuple()), circle, center, radius)
end

在这种情况下,只需向第一种方法执行调度,并传输默认值。 'Pairs’函数应用于具有键值迭代其余参数的命名元组。 请注意,如果该方法不接受其他命名参数,则省略此参数。

最后,创建了kwsorter的定义:

function (::Core.kwftype(typeof(circle)))(kws, circle, center, radius)
    if haskey(kws, :color)
        color = kws.color
    else
        color = black
    end
    # и т. д.

    # Остальные именованные аргументы помещаются в `options`
    options = structdiff(kws, NamedTuple{(:color, :fill)})

    # Если метод не принимает остальные именованные аргументы, происходит ошибка
    # при непустом `options`

    #circle#1(color, fill, pairs(options), circle, center, radius)
end

的’核心’功能。kwftype(t)`创建’t.name.mt.kwsorter`字段(如果它还不存在)并返回函数类型。

这种方法有一个特点:如果在调用位置中不使用命名参数,则不需要特殊过程;一切工作就好像它们根本不存在于语言中一样。 但是,如果在调用位置使用命名参数,则被调用的函数将被调度到kwsorter排序器。 例如,挑战:

circle((0, 0), 1.0, color = red; other...)

它归结为以下内容:

kwcall(merge((color = red,), other), circle, (0, 0), 1.0)

'kwcall'(也在`Core`中)代表kwcall的签名和调度。 解包命名参数(写成'other)的操作。..')调用命名元组的’merge’函数。 该函数进一步解压缩每个_element_'other`,并且假设它们中的每一个都包含两个值(符号和实际值)。 当然,如果所有解压缩的参数都命名为元组,则可以使用更有效的实现。 请注意,原始的’circle’函数被传递来处理闭包。

与编译效率相关的问题

为每个函数创建一个新类型可能会在编译器资源消耗方面产生严重后果,结合Julia的"所有参数的默认特化"方法。 实际上,这种方法的初始实现存在一些缺点,例如构建和测试时间要长得多,内存消耗增加,系统映像的大小几乎是基本映像的两倍。 有了一个原始的实现,这个问题变得更加严重,以至于系统几乎不可能使用。 为了使这种方法切实可行,需要进行一些重大的优化。

第一个问题是函数对于函数类型的参数的不同值的过度专业化。 许多函数只是将参数传递到其他地方,例如传递到另一个函数或存储位置。 这样的函数不需要为每个传递的闭包专门化。 幸运的是,很容易识别这样的情况:检查函数是否调用其参数之一(即参数是否出现在初始位置的任何位置)就足够了。 需要高性能的高阶函数,如`map',必须调用参数函数,因此根据需要进行专门化。 这种优化是通过在"分析变量"传递期间预先固定调用的参数来实现的。 当’cache_method`在`Function`的类型层次结构中检测到传递到声明为`Any`或`Function`的插槽的参数时,行为将与`@nospecialize’注释相同。 在实践中,这样的启发式机制被证明是非常有效的。

下一个问题与方法缓存中的哈希表的结构有关。 实证研究表明,绝大多数动态调度调用使用一个或两个参数。 而且,大多数这些情况可以仅基于第一个参数来解决。 (让我们稍微偏离话题:单一派遣的追随者根本不会感到惊讶。 然而,从上面实际上可以看出,多个调度在实践中很容易优化,因此应该使用它而不是选择单个调度。)因此,方法缓存使用第一个参数的类型作为主键。 但是,请注意,对于函数调用,这对应于元组类型的第二个元素(第一个元素是函数本身的类型)。 通常,类型在初始位置几乎总是相同的。 大多数函数都是没有参数的单一类型。 但是,在构造函数的情况下,情况不同:一个方法表包含每种类型的构造函数。 因此,`Type’方法表被专门化,以便使用元组类型的第一个元素而不是第二个。

接口部分为所有闭包生成类型声明。 最初,这是通过创建常规类型声明来实现的。 然而,结果,创建了太多的构造函数,每个构造函数都非常原始(所有参数都简单地传递给 '新')。 由于方法只是部分排序,因此添加它们具有O(n2)算法复杂性,并且需要额外的资源来存储它们。 优化是通过直接创建`struct_type`表达式(绕过创建默认构造函数)并直接使用`new`创建闭包实例来实现的。 也许不是最优雅的解决方案,但必须做点什么。

下一个问题是宏`@test`,它为每个测试用例创建了一个没有参数的闭包。 事实上,没有必要这样做,因为每个测试用例只需当场执行一次。 因此,`@test’宏部署在try-catch块中,该块捕获测试结果(true、false或exception)并调用测试套件处理程序。