news 2026/9/19 16:19:47

.NET 源生成 COM 互操作(`[GeneratedComInterface]`)中的属性与索引器:vtable 布局、受支持语法面与 ABI 设计约束

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
.NET 源生成 COM 互操作(`[GeneratedComInterface]`)中的属性与索引器:vtable 布局、受支持语法面与 ABI 设计约束

.NET 源生成 COM 互操作([GeneratedComInterface])中的属性与索引器:vtable 布局、受支持语法面与 ABI 设计约束

【免费下载链接】runtime.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.项目地址: https://gitcode.com/GitHub_Trending/runtime6/runtime

[GeneratedComInterface]是 .NET 运行时仓库(dotnet/runtime)中新一代源生成 COM 互操作的核心机制,它让开发者可以用纯 C# 语法声明 COM 接口,而不必手写get_X/set_X方法对。本文以 docs/design/libraries/ComInterfaceGenerator/Properties.md 设计文档为骨架,结合本仓库的生成器实现源码(src/libraries/System.Runtime.InteropServices/gen/ComInterfaceGenerator/)与真实测试资产,完整讲解属性(property)和索引器(indexer)在[GeneratedComInterface]接口上的受支持语法面、产生的 ABI(vtable)形状、继承与遮蔽规则,以及默认实现成员(DIM)这一逃生舱机制。读完本文,你将能判断一个属性/索引器声明能否被源生成,能精确推算出它占据哪些 vtable 槽位,并能正确处理[MarshalUsing][IndexerName]new遮蔽等易错点。

设计目标与非目标

目标

  • 用自然 C# 语法替代手写访问器对:用户可以直接写int Count { get; set; },由生成器展开为底层的get_Count/set_CountABI 方法,无需手工维护两套方法签名。
  • 与内置 COM CCW 保持线缆兼容:生成的 vtable 布局必须与内置 CLR 为[ComVisible(true)]托管接口生成的布局一致,从而保证源生成 COM 与内置 COM 在常见形状下可以互通。
  • 复用既有 per-method ABI 管线:访问器本质上就是"恰好支撑一个属性声明的IMethodSymbol"。属性/索引器的访问器存根走与普通方法完全相同的逐方法 ABI 生成管线(同一套封送(marshalling)规则、同一套 HRESULT 翻译逻辑),实现层面只是在其上加了一层薄薄的包装。
  • 提供 DIM 逃生舱:通过默认实现成员(见 DefaultImplementedMembers.md),用户可以声明纯托管糖(sugar)属性或方法,它们占用 vtable 槽位,从而让一个属性可以包装一对互不相关或不相邻的 ABI 方法。

非目标(明确的边界)

  • 不在 vtable 层面区分propputpropputrefpropputref概念存在于 TLB 元数据和IDispatch::Invoke中,在纯IUnknownvtable 中没有对应表示。所有 setter 统一映射到一个 vtable 槽位。
  • 不做IDispatchdispid 集成[GeneratedComInterface]上的属性只通过IUnknownvtable 暴露,不参与IDispatch的 dispatch id 体系。

vtable 布局:属性如何映射为槽位

对于按源码顺序排在第k位的属性,生成器会在前序成员槽位之后紧跟着发出一个或两个 vtable 槽位:

属性声明形状占用槽位数布局说明
T Foo { get; set; }2 个连续槽位getter 槽在前,setter 槽在后
T Foo { get; }1 个槽位仅 getter
T Foo { set; }1 个槽位仅 setter

槽位签名遵循 COM 自动化(OLE Automation)的经典形状:

  • getter 槽:HRESULT get_Foo(out T value)
  • setter 槽:HRESULT set_Foo(T value)

访问器存根由与普通方法完全相同的逐方法 ABI 管线生成(底层是VirtualMethodIndexAttribute这个可独立使用的 vtable 构建块,详见 VTableStubs.md),因此方法与访问器共享同一套封送规则,以及同一套PreserveSig风格的 HRESULT 翻译机制。

"getter 在前、setter 在后、按源码声明顺序排列"这一约定与内置 CLR 为[ComVisible(true)]托管接口生成的布局完全一致;只读或只写属性只产生单个槽位,同样与内置布局对齐。

这一点在仓库的测试资产中有非常直观的体现。测试共享接口 IProperties.cs 声明了 6 个属性,而原生测试资产 NativeExports/ComInterfaceGenerator/Properties.cs 中手工构造的 vtable 显示:前 3 个槽(0、1、2)为IUnknownQueryInterface/AddRef/Release,槽 3、4 为IntProperty的 get/set,槽 5 为只读的ReadOnlyInt,槽 6 为只写的WriteOnlyInt,槽 7、8 为GuidProperty,槽 9、10 为StringProperty,槽 11、12 为Self——每个属性严格按源码顺序占据连续槽位,读写属性各占 2 个、只读/只写各占 1 个。

继承与 vtable 布局

继承属性遵循与继承方法完全相同的规则(详见 DerivedComInterfaces.md):

  • 一个[GeneratedComInterface]若从另一个[GeneratedComInterface]派生,则继承基类所有访问器槽位并保持其在原接口中的索引不变,派生接口自己的访问器槽位追加在它们之后。
  • 派生接口还会收到生成器发出的用户可见的遮蔽(shadow)声明,覆盖每个继承来的属性访问器。这样,T value = derived.BaseProperty;这类调用不需要QueryInterface回到基接口类型即可完成分发——这是为了规避内置[ComImport]模型中"接口继承不是真的继承、必须手写new重声明每个基类方法"这个长期痛点(见 DerivedComInterfaces.md 中的性能设计讨论)。

仓库中的 DerivedProperties.cs 手工构造了两个 vtable 来验证这一点:基接口 vtable 有 13 个槽(3 个IUnknown+ 10 个访问器槽),派生 vtable 有 18 个槽,其中 3~12 槽与基接口 vtable逐字节一致(代码注释明确写着 "Inherited slots — must match the IProperties vtable layout above byte-for-byte"),派生新增属性DerivedIntPropertyDerivedStringPropertyDerivedReadOnlyInt的访问器槽从 13 开始追加。

受支持的属性语法面(Supported property surface)

生成器刻意将受支持面收得很窄,超出该清单的任何声明都会被SYSLIB1091("member will not be source generated")拒绝,以便未来在不破坏现有用户的前提下逐步扩展受支持的语义。

允许的写法

  1. 无方法体的自动属性访问器,三种访问器组合均可:

    [GeneratedComInterface, Guid("…")] public partial interface IFoo { int Count { get; set; } // 两个 vtable 槽 string Name { get; } // 一个 vtable 槽 bool Verbose { set; } // 一个 vtable 槽 }
  2. 属性级访问修饰符publicinternalprivateprotected):这些只存在于 C# 表层,ABI 形状不变:

    internal int Count { get; set; } // 仍然是两个 vtable 槽

    注意:访问器级访问修饰符(如int Count { get; private set; })在访问器为抽象时会被 C# 语言本身以CS0442拒绝,根本到不了生成器。要收窄访问器可见性,必须给访问器加方法体——这会把属性推入下面的默认实现路径(DIM)。

  3. 属性声明上的unsafe修饰符:生成的访问器存根本来就位于unsafepartial 接口内,因此这基本是"免费"的;它允许属性的值类型是指针。

  4. 属性声明上的new修饰符:用于显式遮蔽继承来的 COM 属性。基类访问器槽位保持在原索引处,派生接口为遮蔽访问器追加全新的槽位——这与new关键字遮蔽方法的行为一致:

    [GeneratedComInterface, Guid("…")] public partial interface IBase { int Value { get; set; } } [GeneratedComInterface, Guid("…")] public partial interface IFoo : IBase { new int Value { get; set; } // 追加两个全新槽位;IBase.Value 的槽位保留 }
  5. 默认实现属性(访问器带方法体):见 DefaultImplementedMembers.md。

禁止的写法(以SYSLIB1091报告)

  • 属性上的externrequired修饰符。
  • init访问器initset的区别只是 C# 调用侧的语法区别,在 COM vtable 中没有对应表示。在[GeneratedComInterface]属性上声明init访问器无论是否带方法体都会被拒绝。若访问器应参与 vtable,请使用set;若只是想要init风格的托管辅助,请通过独立的托管专用抽象(例如 DIM)实现。
  • 混合访问器形状:一个访问器带方法体而另一个不带(例如int Mixed { get; set { … } })。这是默认实现成员章节中SYSLIB1091PropertyAccessorsMustBeAllOrNothing诊断对应的场景;在生成器分析之前,C# 编译器也会用CS0525(接口上的自动属性限制)先行拦截。
  • 属性级封送特性除[MarshalUsing]之外的任何形式[MarshalAs]不允许直接放在属性上。

此外,virtualabstractsealed修饰符目前不支持用于[GeneratedComInterface]属性——属性的修饰符约束比方法更严格(abstract是接口默认状态,无需书写;接口属性上的virtual要求方法体,会落入 DIM 路径但当前不被接受;sealed直接拒绝)。

生成器源码中的印证:ComMethodInfo.cs(src/libraries/System.Runtime.InteropServices/gen/ComInterfaceGenerator/ComMethodInfo.cs)在解析成员时检查访问器是否全部一致,否则报告GeneratorDiagnostics.PropertyAccessorsMustBeAllOrNothing;诊断描述符定义在 GeneratorDiagnostics.cs,其规则 ID 即SYSLIB1091

属性上的封送(marshalling)特性

[MarshalUsing]可以直接施加在属性上

[GeneratedComInterface, Guid("…")] public partial interface IFoo { [MarshalUsing(typeof(MyMarshaller))] MyType Item { get; set; } }
  • 属性级封送信息在生成器构建逐访问器存根时,会同时传播到两端:getter 作为返回值信息(return-value info)、setter 作为参数信息(parameter info)。
  • 属性级写法是推荐风格,因为它用一处声明表达了"封送这个属性"的完整意图。
  • 旧的逐访问器写法(getter 上[return: MarshalUsing(...)]、setter 上[param: MarshalUsing(...)]仍然支持;当两种形式同时存在时,逐访问器形式优先

[MarshalAs]不允许放在属性声明上:BCL 中MarshalAsAttribute[AttributeUsage]并不包含AttributeTargets.Property,因此 C# 编译器会在生成器运行之前就以CS0592拒绝该写法。如确实需要,请把[MarshalAs]施加到单个访问器的返回值/参数上。

派生接口中的遮蔽成员会剥离[MarshalUsing][MarshalAs]:生成器在派生接口上自动发出的遮蔽成员(shadow)不会复制这两个封送特性——遮蔽成员通过托管调用把调用转发给基访问器,在其上重复挂载封送信息是冗余的。

索引器(Indexers)

C# 索引器(T this[I0 i0, …, In in] { get; set; })受支持,且走与普通属性完全相同的逐访问器管线

  • 每个访问器各自成为自己的 vtable 槽位;
  • 索引器的 getter 槽位于其 setter 槽之前;
  • 索引器槽位与其他成员一起按源码声明顺序追加。

槽位签名

使用默认的[IndexerName("Item")]时:

  • getter 槽:HRESULT get_Item(I0 i0, …, In in, out T value)
  • setter 槽:HRESULT set_Item(I0 i0, …, In in, T value)

索引参数在 setter 上位于末尾的 value 参数之前——这与 C# 语言及内置 CLR 通过 COM 呈现索引器时的参数顺序一致。

只读与只写

this[I i] { get; }产生一个 getter 槽;this[I i] { set; }产生一个 setter 槽——与属性行为相同。

重载(Overloading)

一个[GeneratedComInterface]可以声明多个以索引参数类型区分的索引器重载;每个重载的访问器按源码顺序获得自己连续的一对槽位。C# 语言要求同一类型上的所有索引器共享一个有效的[IndexerName](编译器以CS0668强制),因此各重载槽位上的 IL 方法名完全相同,重载消歧完全依赖参数列表:

[GeneratedComInterface, Guid("…")] public partial interface IFoo { int this[int i] { get; set; } // 槽 3、4:get_Item(int, out int) / set_Item(int, int) int this[int i, int j] { get; set; } // 槽 5、6:get_Item(int, int, out int) / set_Item(int, int, int) int this[long l] { get; } // 槽 7: get_Item(long, out int) int this[short s] { set; } // 槽 8: set_Item(short, int) }

(槽位编号从 3 开始,是因为 0~2 被IUnknownQueryInterface/AddRef/Release占据。)

仓库测试对这一点覆盖得非常细致。共享接口 IIndexers.cs 声明了 5 个索引器(int单参、int,int双参、long只读、short只写、string键值),原生测试资产 NativeExports/ComInterfaceGenerator/Indexers.cs 手工构造的 vtable 注释逐槽列出了预期布局:槽 3~6 是两个读写重载的 get/set 对,槽 7 是get_Item(long),槽 8 是set_Item(short),槽 9、10 是 string 索引器的 get/set——与文档中的规则完全吻合。

[IndexerName]

索引器上的[IndexerName(...)]特性会重命名 IL 访问器方法(例如get_Element/set_Element替代get_Item/set_Item)。生成器在ABI 槽位生成派生接口上自动发出的转发遮蔽成员两处都会尊重该特性。

这里有一个必须遵守的约束:派生[GeneratedComInterface]若用new修饰符重新声明继承来的索引器,必须重复基接口的[IndexerName](若基接口使用默认名,则派生接口应省略该特性);两个值不能分歧。原因在于:生成器会把继承来的索引器以显式接口实现的形式(int IBase.this[…] => throw new UnreachableException();)emit 到派生接口上,这会被 C# 编译器视为派生类型上的一个索引器成员,从而触发 CS0668 的"同一类型所有索引器共享一个[IndexerName]"规则。如果用户声明的new遮蔽与基类的[IndexerName]不一致,编译器会以CS0668CS0111及相关诊断拒绝该派生接口。因此,若要把一个接口拆分成多个以[IndexerName]区分的形状,必须声明在彼此无关的独立接口上

仓库测试中的 IRenamedIndexer 接口正是为覆盖此路径而设:它使用[IndexerName("Element")],让生成的 IL 访问器名为get_Element/set_Element,从而验证[IndexerName]在派生接口遮蔽成员发射器中的传播。

支持的修饰符与封送

受支持的属性语法面 中允许/禁止的修饰符清单原样适用于索引器,只需把int Foo { … }换成int this[I i] { … }。索引器上的[MarshalUsing]会以与属性完全相同的方式传播到两个访问器存根:value 参数作为参数信息、getter 作为返回值信息。

默认实现索引器

与属性一致:索引器的访问器若全部带方法体,则被视为默认实现成员,不分配 vtable 槽位。混合访问器体(int this[int i] { get; set { … } })由 C# 语言本身以CS0501拒绝(对应属性的等价形状触发的是CS0525),总之用户可见的诊断都会在生成器分析之前浮现。

默认实现成员(DIM):不占槽位的托管糖

[GeneratedComInterface]上的属性访问器、索引器访问器和方法都可以携带用户提供的方法体;生成器将它们视为纯托管糖不分配 vtable 槽位。完整契约(规则、诊断、get_X/set_X名称预留陷阱)见 DefaultImplementedMembers.md,核心要点如下:

[GeneratedComInterface, Guid("…")] public partial interface IFoo { // 两个 vtable 槽——ABI 方法 double ReadValue(); void WriteValue(double value); // 零个 vtable 槽——包装上面两个 ABI 方法的托管糖 double Value { get => ReadValue(); set => WriteValue(value); } // 也是零个 vtable 槽——纯托管辅助方法 double DoubleIt() => ReadValue() * 2; }

DIM 是"方法→一个 vtable 槽 / 属性→一或两个相邻 vtable 槽"这一规范规则无法表达的场景的逃生舱,典型用途包括:

  • 包装名称不符合规范get_X/set_X命名的 ABI 方法;
  • 包装位于不相邻 vtable 槽上的 ABI 方法;
  • 添加线缆 ABI 不需要知道的辅助方法。

DIM 的关键规则

  • 所有访问器必须一致:属性要么全部抽象访问器(int X { get; set; }),要么全部带方法体(int X { get => …; set => …; })。混用会触发SYSLIB1091PropertyAccessorsMustBeAllOrNothing。(C# 编译器对裸get;搭配带体的set { … }还会先以CS0525拒绝。)
  • DIM 上的[MarshalUsing]/[MarshalAs]是警告:DIM 从不参与封送,任何挂在 DIM 上的封送特性都会产生SYSLIB1091警告MarshalAttributeOnDefaultImplementedComInterfaceMember——生成器不为 DIM 生成代码,挂封送特性具有误导性。
  • 继承的 DIM 会从基类 CCW 分发中跳过:生成器为继承接口生成 CCW 分发时,会排除非IsAbstract的访问器方法(即继承的 DIM),运行时通过托管对象上的普通虚分发解析它们。

get_X/set_X名称预留陷阱

一个看起来很自然的 DIM 模式是:用一对名为get_X/set_X的 ABI 方法去支撑属性X——但这无法编译

[GeneratedComInterface, Guid("…")] public partial interface IFoo { double get_Value(); // ABI 方法 void set_Value(double value); // ABI 方法 double Value { get => get_Value(); set => set_Value(value); } // DIM 包装 ABI 方法 —— 编译失败! }

只要 C# 接口声明了属性Value,语言就会为属性的访问器预留 IL 名称get_Valueset_Value;在同一接口上再显式声明double get_Value()方法会以CS0082("type already reserves a member called 'get_Value'")失败。正确的做法是让 ABI 方法避开属性访问器名称,由 DIM 通过调用而非同名来包装:

[GeneratedComInterface, Guid("…")] public partial interface IFoo { double ReadValue(); // ABI 方法 void WriteValue(double value); // ABI 方法 double Value { get => ReadValue(); set => WriteValue(value); } // DIM }

该约束同样适用于派生接口遮蔽:派生接口不能用 DIM 属性X去遮蔽一对继承自基类的名为get_X/set_X的 ABI 方法——ABI 方法与同名属性不能在同一 C# 接口链上共存。

实现层面的印证:访问器如何汇入逐方法管线

从生成器实现可以确认,属性/索引器访问器并不是一条独立代码路径,而是既有 per-method ABI 管线的成员形态扩展:

  • ComInterfaceGenerator.cs 在处理成员存根时区分MemberKind.IsPropertyOrIndexerAccessor(),并处理继承存根的遮蔽发射;
  • IncrementalMethodStubGenerationContext.cs 定义了IsPropertyOrIndexerAccessor()IsPropertyAccessorName(string)等辅助判定,将访问器识别为属性形状的成员;
  • VirtualMethodPointerStubGenerator.cs 在生成存根时对IsPropertyOrIndexerAccessor()的成员同样参与函数指针调用的发射;
  • 底层的VirtualMethodIndexAttribute(定义见 VTableStubs.md)提供了IndexImplicitThisParameterDirectionStringMarshallingSetLastError等原始能力,COM 生成器在其上叠加了属性/索引器的访问器语义。

也就是说,一个属性的 getter/setter 与一个普通方法在"调用 vtable 中某个偏移处的函数指针"这个层面毫无区别,区别只在于:访问器的槽位是成对出现、按 get-before-set 排序的,且共享属性级封送信息。

参考资料与进一步阅读

本仓库docs/design/libraries/ComInterfaceGenerator/目录下的配套设计文档:

  • Compatibility.md — 逐版本发布的语义兼容性说明(rolling notes)。
  • DefaultImplementedMembers.md —[GeneratedComInterface]上属性、索引器、方法的 DIM 完整契约。
  • DerivedComInterfaces.md — 属性声明所继承的继承与遮蔽规则,以及自动遮蔽成员的性能动机。
  • VTableStubs.md — 访问器存根最终消费的底层VirtualMethodIndexAttribute构建块。

感兴趣的读者还可以直接阅读生成器实现(src/libraries/System.Runtime.InteropServices/gen/ComInterfaceGenerator/)与测试资产(src/libraries/System.Runtime.InteropServices/tests/Common/ComInterfaces/ 中的IProperties.csIIndexers.cs,以及 src/libraries/System.Runtime.InteropServices/tests/TestAssets/NativeExports/ComInterfaceGenerator/ 中的Properties.csIndexers.csDerivedProperties.cs),其中手工构造的 vtable 是理解槽位分配规则的绝佳实物对照。

【免费下载链接】runtime.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.项目地址: https://gitcode.com/GitHub_Trending/runtime6/runtime

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/19 16:18:49

均方误差MSE详解:回归模型评估与损失函数的工程实践

这两年做机器学习项目,尤其是回归类任务时,几乎每个模型评估报告里都会出现“均方误差(Mean Squared Error, MSE)”这个词。无论是房价预测、销量预估,还是传感器数据拟合,MSE都是最常用的误差衡量指标之一…

作者头像 李华
网站建设 2026/9/19 16:18:32

行测数量关系备考:从赋值法到考场取舍的实战策略

简介:备考公务员考试行测数量关系部分时,许多考生常因题型陌生而选择放弃。这份资料面向公考考生,系统梳理了数量关系的核心考点与典型题型,如几何问题、行程问题、日期问题、年龄问题及最不利原则等,并选取代表例题给…

作者头像 李华
网站建设 2026/9/19 16:14:45

ADAMS SPLINE驱动:用AKISPL函数让模型按数据表运动

简介:一份面向ADAMS用户的技术文档,讲解如何在机械系统动力学仿真中通过SPLINE驱动把外部规划的电机角度位置或速度数据导入软件,并应用于MOTION驱动。内容从准备txt格式的外部数据(第一列为时间、第二列为位移)讲起&a…

作者头像 李华