Carbon 函数、函数类型与函数调用规范解析:从fn声明到Call接口的统一调用模型
【免费下载链接】carbon-langCarbon Language's main repository: documents, design, implementation, and related tools. (NOTE: Carbon Language is experimental; see README)项目地址: https://gitcode.com/GitHub_Trending/ca/carbon-lang
导读
本篇基于 Carbon Language 官方设计提案 proposals/p002875-functions-function-types-and-function-calls.md,完整讲解 Carbon 中函数声明的类型语义、函数调用语法、直接调用(direct call)的参数推导流程,以及以Call接口为核心的间接调用/可调用对象(callable)统一模型。通过阅读本文,你将掌握:函数为什么是"无状态且类型唯一"的一等公民、绑定方法(bound method)的类型结构、泛型参数如何通过Call约束为可调用类型、以及用户类如何重载函数调用运算符。文中所有关键语义均结合当前仓库中的编译器实现(toolchain/check/call.cpp、toolchain/sem_ir/)与预置测试进行交叉印证,适合希望深入理解 Carbon 语言语义或参与工具链开发的读者。
背景:为什么要规范函数与函数调用
函数是 Carbon 定义执行语义的基本构件(primitive building block),但长期以来仓库缺少对"函数是什么、函数调用如何工作"的正式规范。该提案(对应 Pull Request #2875)要回答几个悬而未决的问题:
- Carbon 是否拥有一等公民(first-class)的函数类型?
- 类型如何重载函数调用这一操作?
- 可调用实体(callable entity)如何归类——例如参数化类名
Vector这样的实体,究竟是函数还是别的什么?
该提案假定读者已经理解 C/C++ 中函数与函数指针的工作方式,在此认知基础上给出 Carbon 自己的答案。值得注意的是,提案写作时的诸多设计决策后来已在当前仓库的编译器中落地,本文会逐一给出对应的源码位置加以印证。
提案总览:函数声明引入唯一无状态类型
提案的核心主张可以概括为两条:
- 函数声明引入一个新且唯一的无状态类型(stateless type),称为函数类型(function type)。函数名被绑定为该函数类型的一个值。
- 函数调用采用类 C 的语法:一个命名可调用实体的表达式后跟一个形似元组的参数列表。
提案将可调用实体划分为以下几类:
- 函数,以及更一般的函数类型的值;
- 绑定方法,例如
my_vector.Begin; - Lambda(待 Carbon 引入之后);
- 参数化实体,例如泛型类
Vector或泛型接口AddWith; - 被约束为可调用的依赖类型的值;
- 用户自定义的重载函数调用语法的类类型。
在前四类情况下,先使用**参数推导(argument deduction)确定被推导参数的值,再使用模式匹配(pattern matching)**把参数模式绑定到实参值。而在其余情况下,则由内置的Call接口决定调用语义:调用F(args)被改写(desugar)为F.(Call(ArgTypes).Op)(args)。注意这个改写结果本身又是一个绑定成员函数调用,而Call(ArgTypes)是对参数化实体的调用,因此该改写只执行一次。
函数与函数类型
声明即引入新类型
看一个典型的泛型函数声明:
fn FT:! type -> T { return x; }它引入一个名为F的值,其类型是唯一的函数类型。不同的函数拥有不同的函数类型,即使它们的签名完全相同。函数类型是空且平凡(empty, trivial)的类型,除了询问函数值的类型之外,没有其他方式可以命名一个函数类型。
函数值是一等公民
函数值是普通的(regular)值,可以存入变量、作为参数传递给其他函数:
// Compile-time function. fn TypeOfT:! type -> type { return T; } // ✅ `F` is a first-class value with a first-class type. let template FType:! type = TypeOf(F); var my_f: FType = F;这里借助编译期函数TypeOf将函数F的类型捕获到FType,随后my_f就能以该函数类型存储F。在另一个函数里可以直接调用它:
fn G() -> i32 { // ✅ `my_f` has function type `FType`. This is a direct call to `F`. return my_f(1); }由于函数类型是唯一的,把函数作为泛型参数传递时,泛型不会因签名相同而混淆两个不同函数:
fn Sorttemplate F:! type, T:! type*, f: F); fn Compare(a: i32, b: i32) -> Ordering { return a.(Ordered.Compare)(b); } fn SortInts(v: Vector(i32)*) { Sort(v, Compare); }孤儿规则中的归属
就孤儿规则(orphan rule)而言,函数类型被视为由引入该函数值的函数声明所声明(declared)。也就是说,函数类型在孤儿规则中的"归属方"是它对应的函数声明,而非其所在的类或包。
绑定方法(Bound Methods)
对每个与方法对应的函数类型,还存在一个对应的绑定方法类型(bound method type)。当对类类型的对象执行成员访问以取得某个方法时,得到的是一个绑定方法值(bound method value),其类型即绑定方法类型。
语义上:
- 绑定方法类型描述的是方法调用中的被调用方(callee);
- 绑定方法值则描述该调用中
self参数的具体取值。
示例:
class HasMember { // `HasMember.F` has a stateless function type, with signature // `self: Self -> i32`. fn Fself: Self -> i32; } fn F(h1: HasMember, h2: HasMember) -> i32 { // ✅ `h1.F` is a bound method value whose type is a bound method type, // with signature `(n: i32) -> i32`. var hf: auto = h1.F; // ✅ `h1.F` and `h2.F` are of the same bound method type. hf = h2.F; // ✅ Same as `h2.F(4)`. return hf(4); }注意两点:h1.F与h2.F属于同一种绑定方法类型(因此可以互相赋值);对该绑定方法值调用hf(4)等价于h2.F(4),即self已绑定为h2。
仓库实现印证:编译器在语义 IR 层面为绑定方法定义了专门的指令种类与单例类型,见 toolchain/sem_ir/inst_kind.def 中的BoundMethod与BoundMethodType,以及 toolchain/sem_ir/typed_insts.h 中的结构体BoundMethod(持有object_id与function_decl_id)。在检查阶段,toolchain/sem_ir/function.cpp 的TryGetCalleeAsBoundMethod会从被调用表达式中解包出绑定方法并取出其object_id(即self对象)与function_decl_id,随后调用逻辑据此把self参数重新接回调用——这与提案中"绑定方法值描述self参数"的语义完全一致。
调用语法(Call Syntax)
调用形如a(b, c, d)或a(b, c, d,),其中:
a是被调用方(callee),可以是一个名字、字面量、成员访问,或是用括号包裹的更复杂表达式;b、c、d是任意数量的实参表达式,用逗号分隔;如果实参列表非空,末尾可选地允许一个(但不要求)尾随逗号。
调用语法在句法上等价于"一个主表达式后跟一个元组字面量"。二者的区别在于:元组字面量必须带尾随逗号才能形成单元素元组(b,);而调用语法中a(b)与a(b,)都是合法的。
直接调用(Direct Calls)
判定条件与检查流程
当被调用方满足以下任一条件时,该调用表达式就是直接调用(direct call):
- 是一个参数化实体的名字,例如泛型类或泛型接口;
- 具有函数类型或绑定方法类型。
直接调用存在一个调用签名(call signature),用于把给定实参与被调用方声明的隐式参数(implicit parameters)和显式参数(explicit parameters)进行核对,流程如下:
- 参数推导:将声明的参数类型与实际实参类型比较,推导出让类型相等的隐式参数值;
- 然后按顺序处理显式参数列表中的每个绑定(binding),把所有已推导的参数值代入该参数:
- 若参数是
template :!绑定,实参表达式被转换为与绑定同类型、同模板常量表达式(template constant expression)阶段; - 若参数是符号化
:!绑定,实参表达式被转换为与绑定同类型、同符号常量表达式(symbolic constant expression)阶段; - 否则,参数与实参进行模式匹配。
- 若参数是
- 如果某参数是
:!绑定,则其对应的转换后实参表达式会被求值,其值会在处理后续参数之前加入已推导参数值列表。
仓库实现印证:该流程在 toolchain/check/call.cpp 中有着几乎逐条对应的实现:
ResolveCalleeInCall(call.cpp)首先校验实参数目(arity)是否与显式参数个数一致,不一致时报CallArgCountMismatch诊断;随后当实体是泛型时调用DeduceGenericCallArguments(声明见 toolchain/check/deduce.h)执行参数推导;- 转换实参、匹配参数的工作由
ConvertCallArgs完成(见PerformCallToFunction中对它的调用,call.cpp)。
调用表达式的结果类型
调用表达式的结果取决于被调用方的种类:
- 若被调用方是参数化实体(如泛型类或泛型接口),结果是对应的具体实例(某个类或接口),整个调用是一个类型为
type的值表达式; - 若被调用方是函数值,调用是一个初始化表达式(initializing expression),其类型为函数替换后的返回类型;求值时调用函数并产生其返回值;
- 若被调用方是绑定方法值,行为与函数值相同,唯一区别是所调用函数的
self参数被绑定为绑定方法值中的self值。
仓库实现印证:ResolveCalleeInCall内部使用EntityKind枚举区分四类被调用实体:Function、GenericClass、GenericInterface、GenericNamedConstraint(call.cpp)。四种情形各有专门的处理函数:
- 泛型类调用
Vector(i32)走PerformCallToGenericClass,产出ClassType(call.cpp); - 泛型接口/命名约束调用走
PerformCallToGenericInterfaceOrNamedConstaint,产出 facet 类型(call.cpp); - 函数调用走
PerformCallToFunction,推导出SpecificFunction或SpecificImplFunction作为具体化后的被调用方,并最终构造Call指令(call.cpp); - 非函数的被调用方(含泛型类型、C++ 模板名等)走
PerformCallToNonFunction,对不可调用值报CallToNonCallable诊断(call.cpp)。
PerformCall的统一入口会先尝试把被调用方视为函数,再回退到非函数路径(PerformCallHelper,call.cpp),与提案"先做直接调用判定、再做改写"的设计顺序一致。
泛型可调用参数与Call接口
泛型参数可以用Call接口约束为"可调用类型":
interface Call(Args: type) { let Result:! type; fn Opself: Self -> Result; }TODO:
Call应当是可变的(variadic)。目前先把它建模为接收一个元组类型,并把Call.Op建模为接收一个元组值。
例如,排序函数可以这样约束比较器参数:
fn Sort[T:! type, F:! Call((T, T)) where .Result = Ordering] (v: Vector(T)*, cmp: F) {一个非直接调用(non-direct call)表达式会被改写为对Call(Args).Op的调用,其中Args是调用实参元组的类型:
// In Sort... auto ord: auto = cmp((*v)[i], (*v)[j]); // ... is translated into ... auto ord: auto = cmp.(Call((T, T)).Op)((*v)[i], (*v)[j]);设计上有两个关键约束值得注意:
- 调用实参的类型被建模为接口参数(interface parameter),这使得
Call接口能够建模函数重载; - 返回类型是关联类型(associated type)而非参数——Carbon不允许按返回类型重载,也不希望类型信息从调用表达式出现的上下文向内传播到调用本身。
函数类型与Call的关系
函数类型对Call的自动实现
每个函数类型或绑定方法类型,对"直接调用该函数/绑定方法可接受的所有运行时实参类型集合"实现Call接口。Call.Op的行为就是使用给定实参列表去调用该函数或绑定方法。
以Select为例,编译器相当于为其生成了下面的实现:
fn SelectT:! type -> T { return if b then x else y; } // Generated: impl forall [T:! type, BType:! ImplicitAs(bool)] Select as Call((BType, T, T)) where .Result = T { fn Opself: Self) { let (b: bool, x: T, y: T) = args; return Select(b, x, y); } }对于不涉及推导参数的类型,允许隐式转换。其意图是让impl在函数支持直接调用的同样情形下支持间接调用,且语义一致。例如i64函数可以被当作接受i32的可调用对象传入:
fn TakeI32FnF:! Call(i32); fn I64Fn(n: i64); fn Run() { // ✅ `I64Fn` can be called with an `i32`, because // `i32 impls ImplicitAs(i64)`. TakeI32Fn(I64Fn); }局限与边界情形
通过Call接口发起的调用,实参一律作为值表达式(value expressions),调用本身一律是初始化表达式(initializing expression)。如果函数用var接收参数,或未来语言机制允许直接函数调用不是初始化表达式,就需要额外的转换;在这些转换不可行时(例如实参或返回值不可拷贝),Call接口被视为未被实现。提案预期在未来提案中重新审视这些约束,把函数调用接口扩展为与fn声明同等的一般性。
另外,Call接口只建模"给定参数类型的任意运行时值都能传入"的函数调用。只要函数签名的显式实参列表中含编译期参数,或者调用实参与函数参数之间存在可反驳的模式匹配(refutable pattern matching),该函数类型就不会实现Call:
fn RuntimeT:! type; fn CompileTime(T:! type, x: T); // 🤷 Undecided whether this is valid. fn RefutablePattern(1 as i32); fn Run() { // ✅ Calls `Runtime(0)`. Runtime.(Call(i32).Op)(0); // ❌ Can't call `CompileTime` this way, it can't implement `Call(type, i32)` // because the type would be passed at runtime. CompileTime.(Call(type, i32).Op)(i32, 0); // ❌ Can't call `RefutablePattern` this way, it can't implement `Call(i32)` // for arbitrary i32 arguments. RefutablePattern.(Call(i32).Op)(0); }重载调用运算符(Overloaded Call Operator)
用户可以通过为类型实现Call接口来重载函数调用运算符的含义:
class Func(Arg:! type) { impl as Call((Arg,)) where .Result = () { fn Opself: Self) { Print("hello, world"); } } } fn Run() { let f: Func(i32) = {}; // ✅ Prints "hello, world". f(42); }对被调用方的类型没有任何额外约束,只需满足实现接口的常规约束即可。下面的例子甚至展示了为"单字段结构体字面量"实现调用运算符——合法但明显不推荐:
class X { var n: i32; } // ✅ OK, but inadvisable. impl {.a: X} as Call(()) where .Result = i32 { fn Opself: Self) -> i32 { return self.a.n; } } fn Run() -> i32 { // Returns 1. return {.a = {.n = 1} as X}(); }编译期参数的绕行方案
Carbon 无法直接定义"接收编译期参数"的函数式可调用类类型——这与 C++ 中无法为类类型的值x定义operator()使x<T>()在编译期把T传给operator()的情形类似。但可以通过把编译期值放入实参的类型中来绕行:
class Wrap(T:! type) {} class Callable { impl forall [T:! Printable] as Call((Wrap(T), T)) where .Result = () { fn Opself: Self, T)) { let (_: auto, v: T) = args; Print(v); } } } fn CallItF:! Call(Wrap(i32), i32) { f({} as Wrap(i32), 0); } fn Run() { CallIt({} as Callable); }设计依据(Rationale)
提案所依据的项目原则与目标如下:
原则层面:
- 原则:唯一的静态开放扩展机制(one static open extension mechanism):重载调用通过实现接口来支持;
- 原则:倾向为同一件事只提供一种方式(one way):把可调用对象传给泛型函数只有一种显然的方式,并且无论实参是函数、可调用对象还是(未来的)lambda,它都能高效工作。
目标层面:
- 性能关键的软件(Performance-critical software):函数传递的效率不低于可调用对象的传递;
- 易读、易懂、易写的代码:C/C++ 的签名式函数类型出了名地难读,Carbon 通过不引入签名式函数类型来规避该问题;
- 与现有 C++ 代码互操作与迁移:该设计为函数指针打下了基础(见下文未来工作),C++ 函数指针与成员函数指针都可以建模为实现了
Call的值。
备选方案(Alternatives Considered)
方案一:签名式函数类型
可以让每个函数签名拥有独立类型(如 C/C++ 那样),这能为函数指针提供一个不依赖泛型、类型擦除或花哨表示优化的故事。
但其主要缺点在 C++ 中屡见不鲜:把函数传给模板泛型时,效率低于把函数对象传给同一模板。例如 C++ 中:
std::vector<int> v; bool cmp(int a, int b) { return simple_calculation(a, b); }调用ranges::sort(v, cmp)可能远不如ranges::sort(v, [](int a, int b) { return cmp(a, b); })高效——后者是直接调用,前者传入函数指针导致间接调用。
让函数产生唯一类型,意味着对函数、lambda 与函数式对象的调用具有更相似的语义和接近的效率属性。
方案二:让直接调用与间接调用行为统一
让直接调用与间接调用完全一致在理论上更理想,但实际并不可行,原因有三:
- 间接调用需要被改写为另一个调用表达式——语义的根基终究必须建立在某处的一次真实函数调用之上,而不是无穷地把一个调用改写为另一个;
- 项目已选定接口作为唯一的静态开放扩展机制,任何通用的函数调用重载机制都必须落在
interface/impl的边界之内; - 若试图在
impl查询中标注"每个实参是否可编译期传递",这样的查询必须能回退到"实参运行时传递",可能导致寻找匹配impl时出现指数级搜索。
未来工作(Future Work)
函数重载(Overloading)
重载在提案范围之外,但设计须为重载预留合理路径。重载函数拥有多个不同的签名(隐式与显式参数集合),在提案框架下意味着:一组重载函数引入单一的函数类型,该类型支持以不同方式被调用。
重载选择可能采用"逐一检查候选,命中即止"或"检查全部再择优"两种策略,当前意图是前者。对应到本提案,一个重要后果是:重载函数集合对应的函数类型拥有Call(...)接口的多个参数化实现,具体调用所选中的实现必须遵循重载机制选出的规则。例如若按"首个匹配"选择,下面的占位语法:
overloaded fn AbsT:! Unsigned -> T { return x; } overloaded fn AbsT:! Floating -> T { return x < 0 ? -x : x }会被合成为如下行为:
match_first { impl forall [T:! Unsigned] Abs as Call((T,)) where .Result = T { ... } impl forall [T:! Floating] Abs as Call((T,)) where .Result = T { ... } }对于同时满足Unsigned & Floating的类型,前者被选中,与重载选择行为一致。
按表达式类别与阶段重载
值得考虑是否允许按被调用方与实参的表达式类别(expression category)与表达式阶段(expression phase)重载调用;对编译期实参,或许还希望按常量值重载。
其他语言已有先例:Rust 提供Fn、FnMut、FnOnce区分可调用对象传入重载调用时的不同方式;C++ 允许operator()把*this以const、非const甚至&&接收。Carbon 可以仿照 Rust 提供多个Call接口处理不同self参数种类(建模方式类似下标所用的IndexWith与IndirectIndexWith接口)。更通用、更宏大的方案是把调用签名作为调用接口的一部分来描述:
impl MyCallable as call(T:! type, x: T) -> T;它可能是某种"以接口表达签名"的简写语法,也可能是一种新的一等语言原语。相关探索正在 issue #3154 中进行(本提案写作时)。
可变参数(Variadics)
待 Carbon 支持可变参数后,重载调用机制应改用可变参数,而非用元组类型近似可变参数列表。
Lambda
预期 lambda 在本提案模型下与函数行为基本一致,主要区别是 lambda 可以有状态(stateful),而函数无状态。
函数指针(Function Pointers)
出于与 C++ 互操作以及廉价存储无状态函数引用的目的,应当支持函数指针。函数指针可建模为:
alias FunctionPtr(Args:! type, Result:! type) = DynPtr(Stateless & Call(Args) where .Result = Result);其中:
Stateless是描述"空、无身份概念、实例可平凡创建与销毁"类型的接口;DynPtr是对给定 facet 类型执行类型擦除与运行时派发的类型;DynPtr带优化:DynPtr(Stateless & I)在I是恰好含一个关联函数的接口时,表示为指向该函数的指针。
仓库中的实现现状与延伸阅读
本提案的大部分核心设计已在当前仓库编译器落地,可作为继续深入阅读的入口:
- 调用检查主逻辑:toolchain/check/call.cpp ——
PerformCall/PerformCallHelper统一入口、ResolveCalleeInCall参数推导与 arity 校验、四类实体的调用处理(PerformCallToFunction、PerformCallToGenericClass、PerformCallToGenericInterfaceOrNamedConstaint、PerformCallToNonFunction); - 参数推导:toolchain/check/deduce.h(
DeduceGenericCallArguments的声明); - 绑定方法在语义 IR 中的表示:toolchain/sem_ir/typed_insts.h 的
BoundMethod/BoundMethodType,toolchain/sem_ir/inst_kind.def,以及 toolchain/sem_ir/function.cpp 的TryGetCalleeAsBoundMethod; - 调用相关的设计文档:docs/design/generics/details.md(孤儿规则、动态类型)、docs/project/principles/static_open_extension.md、docs/project/goals.md。
需要说明的是,Carbon 语言目前仍处于实验性阶段(参见 README.md),本文描述的语义以当前仓库的实现与上述提案为准;Call的可变参数化、按表达式类别/阶段重载等能力仍在探索中,后续提案可能进一步演进。
【免费下载链接】carbon-langCarbon Language's main repository: documents, design, implementation, and related tools. (NOTE: Carbon Language is experimental; see README)项目地址: https://gitcode.com/GitHub_Trending/ca/carbon-lang
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考