注意
本文是特性规范。 该规范充当该功能的设计文档。 它包括建议的规范更改,以及功能设计和开发过程中所需的信息。 这些文章将发布,直到建议的规范更改最终确定并合并到当前的 ECMA 规范中。
功能规范与已完成的实现之间可能存在一些差异。 这些差异被记录在相关的 语言设计会议(LDM)说明中。
可以在 规范一文中详细了解将功能规范采用 C# 语言标准的过程。
总结
本提案提供了一些语言结构,以公开目前在 C# 中无法高效访问甚至根本无法访问的 IL 指令码:ldftn 和 calli。 这些 IL 操作码在高性能代码中可能很重要,开发人员需要一种有效的方法来访问它们。
动机
以下问题描述了此功能的动机和背景(功能的潜在实现):
这是 编译器内部函数 的替代设计建议
详细设计
函数指针
该语言将允许使用 delegate* 语法声明函数指针。 下一部分详细介绍了完整的语法,但它旨在类似于 Func 和 Action 类型声明使用的语法。
unsafe class Example
{
void M(Action<int> a, delegate*<int, void> f)
{
a(42);
f(42);
}
}
这些类型使用 ECMA-335 中概述的函数指针类型来表示。 这意味着在调用 delegate* 时将使用 calli,而调用 delegate 时将在 callvirt 方法上使用 Invoke。
在语法上来说,两个构造的调用是相同的。
方法指针的 ECMA-335 定义包括调用约定作为类型签名的一部分(第 7.1 节)。
默认调用约定将是 managed。 非托管调用约定可以通过在 unmanaged 语法后添加 delegate* 关键字来指定,这将使用运行时平台的默认值。 然后,通过在 unmanaged 命名空间中指定以 CallConv 开头的任何类型,并省略 System.Runtime.CompilerServices 前缀,可以在 CallConv 关键字的括号中指定特定的非托管约定。 这些类型必须来自程序的核心库,有效的组合集依赖于平台。
//This method has a managed calling convention. This is the same as leaving the managed keyword off.
delegate* managed<int, int>;
// This method will be invoked using whatever the default unmanaged calling convention on the runtime
// platform is. This is platform and architecture dependent and is determined by the CLR at runtime.
delegate* unmanaged<int, int>;
// This method will be invoked using the cdecl calling convention
// Cdecl maps to System.Runtime.CompilerServices.CallConvCdecl
delegate* unmanaged[Cdecl] <int, int>;
// This method will be invoked using the stdcall calling convention, and suppresses GC transition
// Stdcall maps to System.Runtime.CompilerServices.CallConvStdcall
// SuppressGCTransition maps to System.Runtime.CompilerServices.CallConvSuppressGCTransition
delegate* unmanaged[Stdcall, SuppressGCTransition] <int, int>;
delegate* 类型之间的转换是基于它们的签名(包括调用约定)完成的。
unsafe class Example {
void Conversions() {
delegate*<int, int, int> p1 = ...;
delegate* managed<int, int, int> p2 = ...;
delegate* unmanaged<int, int, int> p3 = ...;
p1 = p2; // okay p1 and p2 have compatible signatures
Console.WriteLine(p2 == p1); // True
p2 = p3; // error: calling conventions are incompatible
}
}
delegate* 类型是指针类型,这意味着它具有标准指针类型的所有功能和限制:
- 仅在
unsafe上下文中有效。 - 只能从
delegate*上下文调用包含unsafe参数或返回类型的方法。 - 无法转换为
object。 - 不能用作泛型参数。
- 可以隐式将
delegate*转换为void*。 - 可以显式从
void*转换为delegate*。
限制:
- 自定义属性不能应用于
delegate*或其任何元素。 - 无法将
delegate*参数标记为params -
delegate*类型具有普通指针类型的所有限制。 - 不能直接对函数指针类型执行指针算术。
函数指针语法
完整的函数指针语法由以下语法表示:
pointer_type
: ...
| funcptr_type
;
funcptr_type
: 'delegate' '*' calling_convention_specifier? '<' funcptr_parameter_list funcptr_return_type '>'
;
calling_convention_specifier
: 'managed'
| 'unmanaged' ('[' unmanaged_calling_convention ']')?
;
unmanaged_calling_convention
: 'Cdecl'
| 'Stdcall'
| 'Thiscall'
| 'Fastcall'
| identifier (',' identifier)*
;
funptr_parameter_list
: (funcptr_parameter ',')*
;
funcptr_parameter
: funcptr_parameter_modifier? type
;
funcptr_return_type
: funcptr_return_modifier? return_type
;
funcptr_parameter_modifier
: 'ref'
| 'out'
| 'in'
;
funcptr_return_modifier
: 'ref'
| 'ref readonly'
;
如果未提供 calling_convention_specifier,则默认为 managed。
calling_convention_specifier 的精确元数据编码以及 identifier 在 unmanaged_calling_convention 中有效的内容在调用约定的元数据表示中介绍。
delegate int Func1(string s);
delegate Func1 Func2(Func1 f);
// Function pointer equivalent without calling convention
delegate*<string, int>;
delegate*<delegate*<string, int>, delegate*<string, int>>;
// Function pointer equivalent with calling convention
delegate* managed<string, int>;
delegate*<delegate* managed<string, int>, delegate*<string, int>>;
函数指针转换
在不安全的上下文中,扩展了一组可用的隐式转换(隐式转换),以包括以下隐式指针转换:
- 现有转换 - (§23.5)
- 从 funcptr_type
F0到另一个 funcptr_typeF1的转换需满足以下所有条件:-
F0和F1具有相同数量的参数,D0n中的每个参数F0具有与ref中的相应参数out相同的in、D1n或F1修饰符。 - 对于每个值参数(没有
ref、out或in修饰符的参数),从F0中的参数类型到F1中的相应参数类型之间,存在恒等转换、隐式引用转换或隐式指针转换。 - 对于每个
ref、out或in参数,F0中的参数类型与F1中的相应参数类型相同。 - 如果返回类型按值返回(无
ref或ref readonly的情况),则表示存在从F1的返回类型到F0的返回类型的标识、隐式引用或隐式指针转换。 - 如果返回类型是按引用(
ref或ref readonly),那么ref的返回类型和F1修饰符与ref的返回类型和F0修饰符相同。 -
F0的调用约定与F1的调用约定相同。
-
允许 address-of 到目标方法
现在允许方法组作为表达式地址的参数。 此类表达式的类型将是 delegate*,它具有与目标方法等效的签名,并采用托管调用约定。
unsafe class Util {
public static void Log() { }
void Use() {
delegate*<void> ptr1 = &Util.Log;
// Error: type "delegate*<void>" not compatible with "delegate*<int>";
delegate*<int> ptr2 = &Util.Log;
}
}
在不安全的上下文中,如果以下所有条件都为 true,则方法 M 与函数指针类型 F 兼容:
-
M和F的参数数相同,M中的每个参数ref、out或in修饰符与F中的相应参数相同。 - 对于每个值参数(没有
ref、out或in修饰符的参数),从M中的参数类型到F中的相应参数类型之间,存在恒等转换、隐式引用转换或隐式指针转换。 - 对于每个
ref、out或in参数,M中的参数类型与F中的相应参数类型相同。 - 如果返回类型按值返回(无
ref或ref readonly的情况),则表示存在从F的返回类型到M的返回类型的标识、隐式引用或隐式指针转换。 - 如果返回类型是按引用(
ref或ref readonly),那么ref的返回类型和F修饰符与ref的返回类型和M修饰符相同。 -
M的调用约定与F的调用约定相同。 这包括调用约定位,以及在非托管标识符中指定的任何调用约定标志。 -
M是静态方法。
在不安全的上下文中,如果 E 至少包含一个方法,并且该方法在其正常形式下适用于通过使用 F的参数类型和修饰符构造的参数列表,那么存在一种从目标为方法组 E 的取地址表达式到兼容的函数指针类型 F 的隐式转换,如下所述。
- 选择与表单
M的方法调用相对应的单个方法E(A),并进行以下修改:- 参数列表
A是一个表达式列表,每个表达式都被分类为一个变量,并且具有ref的相应out的类型和修饰符(in、 或F)。 - 候选方法只包括那些在正常形式中适用的方法,而不是适用于扩展形式的方法。
- 候选方法只是静态方法。
- 参数列表
- 如果重载解析算法生成错误,则会发生编译时错误。 否则,该算法将生成一个最佳方法,
M具有与F相同的参数数,并且转换被视为存在。 - 所选方法
M必须与函数指针类型F兼容(如上所述)。 否则,将发生编译时错误。 - 转换的结果是
F类型的函数指针。
这意味着开发人员可以依赖于重载解析规则来与 address-of 运算符结合使用:
unsafe class Util {
public static void Log() { }
public static void Log(string p1) { }
public static void Log(int i) { }
void Use() {
delegate*<void> a1 = &Log; // Log()
delegate*<int, void> a2 = &Log; // Log(int i)
// Error: ambiguous conversion from method group Log to "void*"
void* v = &Log;
}
}
address-of 运算符将使用 ldftn 指令实现。
此功能的限制:
- 仅适用于标记为
static的方法。 - 非
static本地函数不能在&中使用。 这些方法的实现详细信息没有被语言故意指定。 这包括它们是静态的还是实例化的,或者它们到底是用什么签名发出的。
函数指针类型的运算符
不安全代码中关于表达式的部分修改如下:
在不安全的上下文中,有几个构造可用于对所有不是 _funcptr_type_s 的 _pointer_type_s 进行操作:
*运算符可用于执行指针间接转换(§23.6.2)。->运算符可用于通过指针(§23.6.3)访问结构的成员。[]运算符可用于为指针编制索引(§23.6.4)。&运算符可用于获取变量的地址(§23.6.5)。++和--运算符可用于递增和递减指针(§23.6.6)。+和-运算符可用于执行指针算术(§23.6.7)。==、!=、<、>、<=和=>运算符可用于比较指针(§23.6.8)。stackalloc运算符可用于从调用堆栈(§23.8)分配内存。fixed语句可用于暂时修复变量,以便获取其地址(§23.7)。在不安全的上下文中,多个构造可用于在所有_funcptr_type_s上运行:
&运算符可用于获取静态方法的地址(允许 address-of 到目标方法)==、!=、<、>、<=和=>运算符可用于比较指针(§23.6.8)。
此外,我们将 Pointers in expressions 中的所有节修改为禁止函数指针类型,Pointer comparison 和 The sizeof operator除外。
更好的函数成员
§12.6.4.3 更好的函数成员将更改为包含以下行:
delegate*比void*更具体
这意味着,可以在 void* 和 delegate* 上重载,并且仍然合理地使用 address-of 运算符。
类型推断
在不安全的代码中,对类型推理算法进行了以下更改:
输入类型
添加了以下内容:
如果
E是方法组的地址,T是函数指针类型,则T的所有参数类型都是具有类型E的T的输入类型。
输出类型
添加了以下内容:
如果
E是方法组的地址,T是函数指针类型,则T的返回类型是具有类型E的T的输出类型。
输出类型推理
在项目符号 2 和 3 之间添加了以下项目符号:
- 如果
E是 address-of 方法组,T是一个具有参数类型T1...Tk和返回类型Tb的函数指针类型,并且类型为E的T1..Tk的重载解析生成一个返回类型为U的单一方法,则从 到U进行Tb。
更好的从表达式转换
以下子项目符号作为案例添加到项目符号 2 中:
V是函数指针类型delegate*<V2..Vk, V1>,U是函数指针类型delegate*<U2..Uk, U1>,V的调用约定与U相同,且Vi的引用性与Ui相同。
下限推定
以下案例已添加到项目符号 3:
V是函数指针类型delegate*<V2..Vk, V1>,并且存在一个函数指针类型delegate*<U2..Uk, U1>,使得U与delegate*<U2..Uk, U1>完全相同。V的调用约定与U完全一致,Vi的引用性与Ui完全相同。
从 Ui 到 Vi 的第一个推定项目符号被修改为:
- 如果
U不是函数指针类型,并且Ui未知为引用类型,或者U是函数指针类型,并且Ui未知为函数指针类型或引用类型,则进行 确切推理
然后,在从 Ui 到 Vi 的第三个推定项目符号之后添加:
- 否则,如果
V是delegate*<V2..Vk, V1>,那么推理取决于delegate*<V2..Vk, V1>的第 i 个参数:
- 如果 V1:
- 如果返回值是按值,则进行下限推定。
- 如果返回是按引用,则进行确切推定。
- 如果是 V2..Vk:
- 如果参数是按值,则会进行上限推定。
- 如果参数是按引用,则进行确切推定。
上限推定
以下案例已添加到项目符号 2:
U是一个函数指针类型delegate*<U2..Uk, U1>,V是一个与delegate*<V2..Vk, V1>相同的函数指针类型,U的调用约定与V相同,Ui的引用指向性与Vi相同。
从 Ui 到 Vi 的第一个推定项目符号被修改为:
- 如果
U不是函数指针类型,并且Ui未知为引用类型,或者U是函数指针类型,并且Ui未知为函数指针类型或引用类型,则进行 确切推理
然后,在从 Ui 到 Vi 的第三个推定项目符号之后添加:
- 否则,如果
U是delegate*<U2..Uk, U1>,那么推理取决于delegate*<U2..Uk, U1>的第 i 个参数:
- 如果是 U1:
- 如果返回是按值,则会进行上限推定。
- 如果返回是按引用,则进行确切推定。
- 如果是 U2..Uk:
- 如果参数是按值,则进行下限推定。
- 如果参数是按引用,则进行确切推定。
in、out和 ref readonly 参数和返回类型的元数据表示形式
函数指针签名没有参数标志位置,因此我们必须使用 modreqs 对参数和返回类型 in、out还是 ref readonly 进行编码。
in
我们重用 System.Runtime.InteropServices.InAttribute,将其作为 modreq 应用于参数或返回类型上的 ref 说明符,以表示以下含义:
- 如果应用于参数 ref 说明符,则此参数被视为
in。 - 如果应用于返回类型 ref 说明符,则返回类型将被视为
ref readonly。
out
我们使用 System.Runtime.InteropServices.OutAttribute(作为参数类型的 ref 说明符的 modreq),表示参数是 out 参数。
错误
- 将
OutAttribute作为 modreq 应用于返回类型是错误的。 - 将
InAttribute和OutAttribute作为 modreq 应用于参数类型会生成错误。 - 如果通过 modopt 指定了其中任何一个,则忽略它们。
调用约定的元数据表示形式
调用约定通过签名中的 CallKind 标志和签名开头的零个或多个 modopt 的组合编码在元数据中的方法签名中。 ECMA-335 当前在 CallKind 标志中声明以下元素:
CallKind
: default
| unmanaged cdecl
| unmanaged fastcall
| unmanaged thiscall
| unmanaged stdcall
| varargs
;
其中,C# 中的函数指针将支持除 varargs之外的所有功能。
此外,运行时(最终为 335)将被更新,以在新平台上包含新的 CallKind。 此名称目前没有正式名称,但本文档将使用 unmanaged ext 作为占位符来代表新的可扩展调用约定格式。 如果没有 modopt,unmanaged ext 是平台默认的调用约定,unmanaged 没有方括号。
将 calling_convention_specifier 映射到 CallKind
如果省略 calling_convention_specifier 或将其指定为 managed,则会映射到 defaultCallKind。 这是任何未被赋予 CallKind 属性的方法的默认 UnmanagedCallersOnly。
C# 识别 4 个特殊标识符,这些标识符映射到 ECMA 335 中特定的现有非托管 CallKind。 为了实现这种映射,这些标识符必须单独指定,没有其他标识符,并且这一要求被编码到 unmanaged_calling_convention 的规范中。 这些标识符是 Cdecl、Thiscall、Stdcall和 Fastcall,分别对应于 unmanaged cdecl、unmanaged thiscall、unmanaged stdcall和 unmanaged fastcall。 如果指定了多个 identifer,或者单个 identifier 不是特殊识别的标识符,我们将使用以下规则对标识符执行特殊名称查找:
- 我们在字符串
identifier前添加CallConv - 我们只查看
System.Runtime.CompilerServices命名空间中定义的类型。 - 我们只查看应用程序核心库中定义的类型,即定义
System.Object且没有依赖项的库。 - 我们只关注公共类型。
如果在 identifier 中指定的所有 unmanaged_calling_convention 上查找成功,则将 CallKind 编码为 unmanaged ext,并在函数指针签名开头的 modopt 集合中对每个解析的类型进行编码。 需要注意的是,这些规则意味着用户不能在这些 identifier 前加上 CallConv,因为这将导致查找 CallConvCallConvVectorCall。
解释元数据时,我们首先查看 CallKind。 如果它不是 unmanaged ext,则为了确定调用约定而忽略返回类型上的所有 modopt,并仅使用 CallKind。 如果 CallKind 是 unmanaged ext,我们查看函数指针类型开头的 modopts,取满足以下要求的所有类型的并集:
- 在核心库中定义,该库不引用其他库,并定义了
System.Object。 - 类型在
System.Runtime.CompilerServices命名空间中定义。 - 该类型以前缀
CallConv开头。 - 类型为公开。
这些表示在源代码中定义函数指针类型时,在 identifier 中对 unmanaged_calling_convention 执行查找时必须找到的类型。
如果目标运行时不支持该功能,那么尝试使用具有 CallKind 的 unmanaged ext 函数指针会生成错误。 这将通过查找 System.Runtime.CompilerServices.RuntimeFeature.UnmanagedCallKind 常量的存在来确定。 如果存在此常量,则认为运行时支持该功能。
System.Runtime.InteropServices.UnmanagedCallersOnlyAttribute
System.Runtime.InteropServices.UnmanagedCallersOnlyAttribute 是一个属性,用于指示 CLR 应使用特定调用约定来调用方法。 因此,我们引入了以下对使用该属性的支持:
- 直接调用 C# 中带此属性批注的方法是错误的。 用户必须获取指向该方法的函数指针,然后调用该指针。
- 将特性应用于普通静态方法或普通静态局部函数以外的任何内容都是错误的。 C# 编译器会将使用此属性从元数据导入的任何非静态或静态非常规方法标记为语言不支持。
- 带有该属性标记的方法,如果其参数或返回类型不是
unmanaged_type,那么这是一个错误。 - 用属性标记的方法具有类型参数会生成错误,即使这些类型参数被限制为
unmanaged。 - 用属性标记泛型类型中的方法会生成错误。
- 将带有特定属性标记的方法转换为委托类型是错误的。
- 为不符合在元数据中调用约定
UnmanagedCallersOnly.CallConvs的要求的modopt指定任何类型会生成错误。
确定使用有效 UnmanagedCallersOnly 属性标记的方法的调用约定时,编译器会对 CallConvs 属性中指定的类型执行以下检查,以找出应使用的有效 CallKind 和 modopt,以确定调用约定。
- 如果未指定任何类型,则
CallKind被视为unmanaged ext,且在函数指针类型的开头没有调用约定modopt。 - 如果指定了一个类型,并且该类型命名为
CallConvCdecl、CallConvThiscall、CallConvStdcall或CallConvFastcall,则CallKind将分别被视作unmanaged cdecl、unmanaged thiscall、unmanaged stdcall或unmanaged fastcall,并且在函数指针类型的开头没有任何调用约定modopt。 - 如果指定了多个类型,或者单个类型未被命名为上述特别标注的类型之一,则
CallKind将被视为unmanaged ext,且指定类型的并集在函数指针类型的开头被视为modopt。
然后,编译器将查看此有效的 CallKind 和 modopt 集合,并使用普通元数据规则来确定函数指针类型的最终调用约定。
待解问题
检测对 unmanaged ext 的运行时支持
https://github.com/dotnet/runtime/issues/38135 跟踪添加此标记。 根据审查反馈,我们将使用问题中指定的属性,或者使用 UnmanagedCallersOnlyAttribute 的存在作为判断运行时是否支持 unmanaged ext 的标志。
注意事项
允许实例方法
通过利用 EXPLICITTHIS CLI 调用惯例(在 C# 代码中命名为 instance),可以扩展该建议以支持实例方法。 这种形式的 CLI 函数指针将 this 参数作为函数指针语法的显式第一个参数。
unsafe class Instance {
void Use() {
delegate* instance<Instance, string> f = &ToString;
f(this);
}
}
这虽然是合理的,但为提议增添了一些复杂性。 特别是因为调用约定 instance 和 managed 不同的函数指针是不兼容的,即使这两种情况都用于调用具有相同 C# 签名的托管方法。 此外,在所有考虑到这一点有价值的情况下,都有一个简单的解决方法:使用 static 本地函数。
unsafe class Instance {
void Use() {
static string toString(Instance i) => i.ToString();
delegate*<Instance, string> f = &toString;
f(this);
}
}
在声明时无需要求不安全的操作
不要在每次使用 unsafe 时都要求 delegate* ,只在方法组转换为 delegate* 时才要求。 这就是核心安全问题发挥作用的地方(知道在值有效时无法卸载包含的程序集)。 在其他位置上要求 unsafe 可以被视为过于苛刻。
这就是最初设计的目的。 但由此产生的语言规则显得非常别扭。 无法隐藏这是一个指针值的事实,即使没有 unsafe 关键字,它也一直显露出来。 例如,不能允许转换为 object,也不能成为 class的成员,等等……C# 设计要求对所有指针使用 unsafe,因此这个设计遵循了这一点。
开发人员仍然能够在 值之上呈现一个delegate*包装器,就像他们今天对普通指针类型所做的那样。 考虑:
unsafe struct Action {
delegate*<void> _ptr;
Action(delegate*<void> ptr) => _ptr = ptr;
public void Invoke() => _ptr();
}
使用委托
无需使用新的语法元素 delegate*,只需使用现有的 delegate 类型,且在类型后加上 *。
Func<object, object, bool>* ptr = &object.ReferenceEquals;
处理调用约定可以通过用指定 delegate 值的属性注释 CallingConvention 类型来完成。 缺少属性将表示托管调用约定。
在 IL 中对此进行编码是有问题的。 基础值需要表示为指针,但还必须:
- 具有唯一的类型,以允许使用不同的函数指针类型进行重载。
- 在 OHI 应用中实现跨程序集边界的等效性。
最后一点特别有问题。 这意味着,每个使用 Func<int>* 的程序集都必须在元数据中编码一个等效的类型,即使 Func<int>* 是在程序集中定义的,但并不控制。
此外,在非 mscorlib 的程序集中以名称 System.Func<T> 定义的任何其他类型必须与 mscorlib 中定义的版本不同。
研究的一种方法是发出像 mod_req(Func<int>) void* 这样的指针。 这样行不通,因为 mod_req 无法绑定到 TypeSpec,因此无法以泛型实例化为目标。
命名函数指针
函数指针语法可能比较繁琐,尤其是在复杂情况下,例如嵌套函数指针。 为了避免每次都让开发人员输入签名,该语言可以像 delegate一样允许对函数指针进行命名声明。
func* void Action();
unsafe class NamedExample {
void M(Action a) {
a();
}
}
问题的一部分在于底层的 CLI 基元没有名称,因此这将完全是对 C# 的一种创新,并且需要进行一些元数据工作才能启用。 可行,但需要大量工作。 它本质上要求 C# 为这些名称提供一个类型 def 表对象。
此外,当检查命名函数指针的参数时,我们发现它们同样适用于许多其他方案。 例如,声明命名元组同样方便,以减少在所有情况下键入完整签名的需要。
(int x, int y) Point;
class NamedTupleExample {
void M(Point p) {
Console.WriteLine(p.x);
}
}
经过讨论,我们决定不允许对 delegate* 类型进行命名声明。 如果根据客户使用情况反馈发现这一点有重大需求,我们将调查适用于函数指针、元组、泛型等的命名解决方案。这与语言中完整的 typedef 支持等其他建议可能类似。
未来注意事项
静态委托
这是指建议,允许声明只能引用 delegate 成员的 static 类型。 优点是这样的 delegate 实例可以不受分配限制,在性能敏感的场景中效果更好。
如果实现函数指针功能,static delegate 提案可能会被终结。该功能的建议优势在于无需分配内存的特性。 然而,最近的调查发现,由于组件卸载,这是不可能实现的。 从 static delegate 到它所引用的方法必须有一个强句柄,以防止程序集从它下面卸载。
若要维护每个 static delegate 实例,则需要分配一个新的句柄,这与建议的目标背道而驰。 有一些设计可以将分配分摊到每个调用站点的单个分配中,但这有点复杂,似乎不值得权衡。
这意味着开发人员基本上必须在以下权衡之间做出决定:
- 面对程序集卸载时的安全性:这需要分配,因此
delegate已经是一个足够的选项。 - 在程序集卸载方面没有安全性:使用
delegate*。 这可以封装在struct中,以允许在代码的其余部分中在unsafe上下文之外使用。