很多时候,类与类的关系并不是is-a(是一个)组成的严格树形结构,而是has-a(包含)或can-do(具有能力)这种跨越层次结构的,横向的关系。

解决这个问题的热门方案并不止一个。

下文的 Trait 均为 Rust 的 Trait。

范式

契约约束

让我们从 C# 熟悉的接口说起。

在经典的 OOP 中,接口只是个行为契约,在实现上仅作为一组方法、属性等成员的声明,来约束实现该接口的类必须提供这些成员的实现。

纯粹的接口清晰地划分了定义与实现的职责,也很好地解决了多重继承冲突出现在接口的可能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
interface IControl
{
void Paint(); // 定义控件是可以被绘制的
}

interface IWidget
{
void Paint(); // 定义小部件也是可以被绘制的
}

class Button : IControl, IWidget // 按钮既是控件也是小部件
{
void IControl.Paint() // 显式实现 IControl 的 Paint 方法
{
Console.WriteLine("Painting control..."); // 绘制控件的方式
}

void IWidget.Paint() // 显式实现 IWidget 的 Paint 方法
{
Console.WriteLine("Painting widget..."); // 绘制小部件的方式
}
}

尽管接口在实现子类型多态方面非常有效,但在代码复用上却无能为力,因为接口本身不包含任何实现细节。

Mixin

Mixin 并不一定是一个语言的特性,而是一种设计模式,允许将一个类的功能混入另一个类中,从而实现代码复用。

C# 8.0 引入的默认接口实现被视为一种 Mixin 的实现方式,允许在接口中提供方法的默认实现,从而使得接口不仅仅是一个纯粹的契约,还可以包含一些行为的实现细节,详见.NET 面向对象 - 接口

1
2
3
4
5
6
7
8
9
10
interface ILogger
{
// 默认实现日志记录方法
void Log(string message) => Console.WriteLine($"Log: {message}");
}

class FileLogger : ILogger
{
// 无需额外实现 Log 方法
}

从这个例子来看,Mixin 似乎并不是什么高级的特性,无非有点像是类的继承,也提供了可以修改的默认实现。

那 Mixin 解决了继承的什么问题?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Appliance: # 基类:是什么
def __init__(self, brand):
self.brand = brand

class WifiMixin: # Mixin:能做什么
def connect(self):
print(f"正在连接网络...")

class MusicMixin: # Mixin:能做什么
def play_music(self):
print("正在播放:🎵 Classic Jazz...")

# 组合

# 智能冰箱:既是电器,又能联网
class SmartFridge(Appliance, WifiMixin):
pass

# 智能音箱:既是电器,又能联网,还能放音乐
class SmartSpeaker(Appliance, WifiMixin, MusicMixin):
pass

# 使用
speaker = SmartSpeaker("Sonos")
speaker.connect() # 来自 WifiMixin
speaker.play_music() # 来自 MusicMixin

从代码上就会发现,这5个类都没有出现什么继承关系,更像是让类包含了什么功能 (can-do)

也就是说 Mixin:

  • 让一个类可以包含多个功能,而不是继承自多个父类。
  • 能实现代码复用。
  • 与继承后带着父类的一切特性不同,Mixin 可以让类只包含它需要的功能。

Trait

在接口与 Mixin 的特性之上,Trait 既像接口一样定义方法签名,又像 Mixin 一样提供行为实现。

以 Rust 为例,假如存在简单的圆和矩形结构体,需要计算它们的面积。

1
2
3
4
5
6
7
8
9
10
struct Circle { radius: f64 }
struct Rectangle { width: f64, height: f64 }

fn get_circle_area(c: &Circle) -> f64 {
3.14 * c.radius * c.radius
}

fn get_rect_area(r: &Rectangle) -> f64 {
r.width * r.height
}

借助 Trait,我们可以定义一个 Shape trait 来抽象出所有形状都应该具有的 area 方法,并为每个具体的形状提供实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
trait HasArea {
fn area(&self) -> f64;
}

struct Circle { radius: f64 }
impl HasArea for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
}

struct Rectangle { width: f64, height: f64 }
impl HasArea for Rectangle {
fn area(&self) -> f64 {
self.width * self.height
}
}

如果需要计算一个形状的面积,我们就可以使用 Trait 来实现多态:

1
2
3
fn print_area<T: HasArea>(shape: &T) {
println!("面积: {}", shape.area());
}

在这个例子中,Trait 扮演的用途很像带有默认实现的接口,但它的强度不止于此。

后验

类似于 C# 的扩展方法,Trait 还可以在不修改原有类型定义的情况下,为现有类型添加新的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 假设 Circle 和 Rectangle 在第三方库中定义
trait HasPerimeter {
fn perimeter(&self) -> f64;
}
impl HasPerimeter for Circle {
fn perimeter(&self) -> f64 {
2.0 * std::f64::consts::PI * self.radius
}
}
impl HasPerimeter for Rectangle {
fn perimeter(&self) -> f64 {
2.0 * (self.width + self.height)
}
}

通过这种方式,我们可以在不修改 CircleRectangle 结构体的定义的情况下,为它们添加计算周长的方法。

至此,C# 的默认接口实现配合扩展方法已经能够实现 Trait 的大部分功能了,但是仍然缺点东西。

非侵入

扩展方法解决了后验问题,但它的功能没那么强大:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
trait Speak {
fn talk(&self);
}
impl Speak for i32 {
fn talk(&self) {
println!("我是数字: {}", self);
}
}

fn do_something<T: Speak>(item: T) {
item.talk();
}

do_something(42);

以上代码在 C# 该怎么写呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
interface ISpeak
{
void Talk();
}

public class IntSpeak : ISpeak
{
private int _value;

public IntSpeak(int value)
{
_value = value;
}

public void Talk()
{
Console.WriteLine($"我是数字: {_value}");
}
}

public void DoSomething<T>(T item) where T : ISpeak
{
item.Talk();
}

DoSomething(new IntSpeak(42));

扩展方法仅仅是静态方法的语法糖,无法真正地将方法附加到类型上,因此在使用时需要额外的包装类来实现接口,这就达不到 Trait 的非侵入式特性了。

泛实现

Trait 还支持泛实现 (Blanket Implementation),允许为满足特定条件的类型自动实现 Trait。

1
2
3
4
5
impl<T> IsEmpty for T where T: HasLength {
fn is_empty(&self) -> bool {
self.len() == 0
}
}

然而,接口并不支持这种泛实现的特性。

性能

C# 与接口与去虚化

C# 的接口调用在 IL 层通常是callvirt指令。

在默认情况下,接口方法调用会涉及到虚函数表,先找对象的类型信息,再找函数地址,最后跳过去。

不过有些时候,如果编译器在运行时发现某个接口调用实际上是单态的(即只被一个具体类型实现),它就可以进行去虚化优化,直接将接口调用转换为普通的函数调用,从而避免虚函数表的开销。

同样的,借助 JIT 的分层编译 (Tiered Compilation),编译器可以先快速捏一个较低性能的版本,随着运行时发现一段代码被频繁调用,再重新编译并进行更复杂的去虚化优化。

Rust 与单态化

对于以下:

1
2
3
fn print_area<T: HasArea>(shape: &T) {
println!("面积: {}", shape.area());
}

编译器会为每个调用该函数的具体类型生成独立的副本,从而让这类函数调用都是 Direct Call,没有虚函数表的开销。

但单态化本身是个听起来就比较费时费力的过程,编译器要干的活变多,编译出的二进制文件也会变大。

至少大部分工作是在编译阶段完成的,运行时这方面的性能是非常不错的。

菱形继承问题

依旧是经典批判对象。

在经典的 OOP 中,如果一个类同时继承自两个父类,而这两个父类又有一个共同的祖先,就会出现所谓的菱形继承问题

1
2
3
4
5
  A
/ \
B C
\ /
D

Mixin 方案

在 Scala 中,Mixin 将继承结构拍平,借助 Mixin 的线性化算法来解决菱形继承问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
trait BaseLogger {
def log(msg: String): Unit = println(s"[Base] $msg")
}

trait ConsoleLogger extends BaseLogger {
override def log(msg: String): Unit = {
print("[Console]")
super.log(msg) // 这 super 指谁?
}
}

trait FileLogger extends BaseLogger {
override def log(msg: String): Unit = {
print("[File]")
super.log(msg) // 这 super 指谁?
}
}

class SavingApp extends ConsoleLogger with FileLogger

val app = new SavingApp
app.log("Saved")

在这个例子中,SavingApp 同时混入了 ConsoleLoggerFileLogger,它们都继承自 BaseLogger

Scala 会尝试从右向左,深度优先,去重地处理:

  1. 基于当前类 SavingApp
  2. 然后到最右侧的 FileLogger,及其继承的 BaseLogger
  3. 再到 ConsoleLogger,及其继承的 BaseLogger
  4. 按照SavingApp -> FileLogger -> ConsoleLogger -> BaseLogger的顺序线性化

于是调用app.log("Saved")时,Scala 会:

  1. 进入FileLoggerlog方法,输出[File]
  2. FileLogger中调用super.log(msg)
  3. 根据线性化顺序,进入ConsoleLoggerlog方法,输出[Console]
  4. ConsoleLogger中调用super.log(msg)
  5. 最后进入BaseLoggerlog方法,输出[Base] Saved
  6. 最终输出结果为:[File] [Console] [Base] Saved

super这样跑来跑去的,确实解决二义性了,但认知负担也上来了。

接口与 Trait 方案

接口的约束在签名上,在定义时阻止了二义性即可。

一个容易被判定为菱形继承问题的 C# 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
interface IFly
{
void Fly();
}
interface IBird : IFly { }
interface IPlane : IFly { }
class Drone : IBird, IPlane
{
public void Fly()
{
Console.WriteLine("Drone is flying");
}
}

编译器对这段代码没意见,因为它本来就不存在二义性,所以可以说这个类的接口实现是菱形的关系,但它并没有引入问题。

但是如果我们在接口中提供了默认实现:

1
2
3
4
5
6
7
8
9
10
interface IFly
{
void Fly() => Console.WriteLine("Flying...");
}
interface IBird : IFly { }
interface IPlane : IFly { }
class Drone : IBird, IPlane
{
// 这里就会出现二义性了
}

或者

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
interface IFly
{
void Fly();
}
interface IBird : IFly
{
void Fly() => Console.WriteLine("Bird is flying...");
}
interface IPlane : IFly
{
void Fly() => Console.WriteLine("Plane is flying...");
}
class Drone : IBird, IPlane
{
// 这里也会出现二义性
}

这俩情况都可以在编译时被检测出来,所以 C# 的接口在默认实现的情况下也能很好地避免菱形继承问题。

Trait 和接口都是显式地要求开发者在定义时就解决二义性问题的,而不是采取类似 Scala Mixin 那样隐式地通过线性化算法来解决。

C# 的演变

如果将 Interface / Mixin -> Trait 看作是一个演变的过程,C# 除了接口、默认接口实现和扩展方法之外,依然还在前进。

静态抽象成员

与默认接口实现让 C# 在实例上接近 Trait 不同,静态抽象成员则让 C# 在类型上接近 Trait。

在接口中定义一个规则,要求实现该接口的类型必须提供一个静态方法。

比如写一个解析器接口:

1
2
3
4
public interface IParsable<TSelf> where TSelf : IParsable<TSelf>
{
static abstract TSelf Parse(string input);
}

实现这个接口的类型必须提供一个静态Parse 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public struct Point : IParsable<Point>
{
public int X { get; }
public int Y { get; }

public Point(int x, int y)
{
X = x;
Y = y;
}

public static Point Parse(string input)
{
var parts = input.Split(',');
return new Point(int.Parse(parts[0]), int.Parse(parts[1]));
}
}

然后强大之处在于在写某个方法时,处理任何支持解析的类型都不需要关心具体类型:

1
2
3
4
public T CreateFromInput<T>(string input) where T : IParsable<T>
{
return T.Parse(input); // 不需要实例化 T
}

同样,泛型数学也可以借助静态抽象成员来实现,比如货币计算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public interface IAddable<TSelf> where TSelf : IAddable<TSelf>
{
static abstract TSelf operator +(TSelf left, TSelf right);
}

public record Money(decimal Amount) : IAddable<Money>
{
public static Money operator +(Money left, Money right)
=> new Money(left.Amount + right.Amount);
}

public T Add<T>(T a, T b) where T : IAddable<T>
{
return a + b; // 直接使用 + 运算符
}

有状态的 Mixin

在 .NET 动态语言中(如 IronPython),为了满足它们在不改变对象原始内存布局的前提下向对象附加属性的需求,如果使用Dictionary<string, object>在外部维护 Mixin 的状态,由于 Key 会持有对象的强引用,GC 将无法回收这些对象。

于是,C# 提供了一个泛型集合:ConditionalWeakTable<TKey, TValue>

观察ConditionalWeakTable中的CreateEntryNoResize

1
2
3
4
5
6
7
8
9
10
internal void CreateEntryNoResize(TKey key, TValue value)
{
// 省略
int hashCode = RuntimeHelpers.GetHashCode(key) & int.MaxValue;
int newEntry = _firstFreeEntry++;

_entries[newEntry].HashCode = hashCode;
_entries[newEntry].depHnd = new DependentHandle(key, value);
// 省略
}

会发现这个集合维护的 Key 是个 DependentHandle

1
2
3
4
5
6
7
public DependentHandle(object? target, object? dependent)
{
IntPtr handle = InternalAlloc(target, dependent);
if (handle == 0)
handle = InternalAllocWithGCTransition(target, dependent);
_handle = handle;
}

它是一个特殊的句柄,允许 GC 在 Key 不再被其他对象引用时回收它,同时也会自动清理与之关联的 Value。


体现为有状态的 Mixin,可以借助这个类来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class Person
{
public string Name { get; set; }
}

public static class TaggedMixin
{
private static readonly ConditionalWeakTable<object, TagState> _table = new();

private class TagState
{
public string Tag { get; set; } = "None";
}

public static string GetTag(this object obj)
{
return _table.GetOrCreateValue(obj).Tag;
}

public static void SetTag(this object obj, string value)
{
_table.GetOrCreateValue(obj).Tag = value;
}
}

var p1 = new Person { Name = "Alice" };
var p2 = new Person { Name = "Bob" };

// 像使用实例属性一样设置状态
p1.SetTag("Admin");
p2.SetTag("User");

Console.WriteLine($"{p1.Name} 的标签是: {p1.GetTag()}");
Console.WriteLine($"{p2.Name} 的标签是: {p2.GetTag()}");

源生成器

随着 .NET 的 Roslyn 增量源生成器 (Incremental Source Generators) 的引入,开发者可以在编译时生成代码,从而在不修改原有类型定义的情况下为它们添加新的功能。

源生成器在编译前期分析 AST 等信息,借助特性 (Attribute) 来定位到指定的 Mixin 模板类,并生成一个与目标类同名的新partial类来实现 Mixin 的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[GenerateMixin(typeof(LoggingMixin))]
public partial class MyClass { }

// 源生成器生成后

public partial class MyClass : ILoggingMixin
{
private readonly LoggingMixin _mixin = new LoggingMixin();

public void Log(string message)
{
_mixin.Log(message);
}
}

在现代 .NET 中,由于这种实现(如CommunityToolkit.Mvvm的属性生成)可以达成零成本抽象,同时满足 AOT 编译要求,它变得更加主流。

Extension Everything

2016年,dotnet/roslyn - Extension Everything 提案的核心目标是让开发者不再局限于扩展方法,还能扩展属性、静态成员、事件、操作符等等。

提案早期的形式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public extension class ListListExtensions<T>
: List<List<T>>
// , Interface
{
private static int _flattenCount = 0;

public static int GetFlattenCount()
{
return _flattenCount;
}

public static int FlattenCount
{
get
{
return _flattenCount;
}
}

public List<T> Flatten()
{
_flattenCount++;
...
}

public List<List<T>> this[int[] indices] => ...;

public static implicit operator List<T>(List<List<T>> self)
{
return self.Flatten();
}

public static implicit List<List<T>> operator +(List<List<T>> left, List<List<T>> right) => left.Concat(right);
}

后来,相关讨论移到了dotnet/csharplang - Exploration: Roles, extension interfaces and static interface members,出现了一个新的概念:角色 (Role),它是一个特殊的类型,可以被其他类型实现,从而为它们提供额外的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class DataObject
{
public DataObject this[string index] { get; } // throw if not found
public int ID { get; }
public void Reload();
public string AsString(); // throw if the DataObject does not represent a string
public IEnumerable<DataObject> AsEnumerable(); // throw if not...
...
}

public role Order of DataObject
{
public Customer Customer => this["Customer"];
public string Description => this["Description"].AsString();
...
}
public role Customer of DataObject
{
public string Name => this["Name"].AsString();
public string Address => this["Address"].AsString();
public IEnumerable<Order> Orders => this["Orders"].AsEnumerable();
...
}
public static class CommerceFramework
{
public IEnumerable<Customer> LoadCustomers();
...
}

该机制讨论截止 2026/04/02 时查看,停留在 2023 年。


近期的 C# 13 预览版本中,出现了隐式扩展成员的特性,允许开发者在不修改原有类型定义的情况下,为它们添加新的成员:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 目前
public static class StringExtensions
{
public static int WordCount(this string str)
{
return str.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
}
}

string text = "Hello, world!";
int count = text.WordCount(); // 直接调用扩展方法

// C# 13 预览版本
public implicit extension StringExtension for string
{
public int WordCount => this.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
}

string text = "Hello, world!";
int count = text.WordCount; // 直接访问扩展属性

但是这个语法被推迟了,因为相关的 C# 语言设计会议:

  1. 在 June 12th, 2024 中:

    • Unsafe.As 方案
      • 原计划使用 Unsafe.As 进行底层类型转换的代码生成策略被认为是不安全的。
      • 在极端性能场景下,目前的策略可能会引发内存别名问题,导致 JIT 产生混淆。
      • 如果要等到运行时提供更安全的 API 来支持这种模式,可能需要等到 .NET 11 或更晚。
    • 作为 Fallback 的语法糖方案
      • 思路:退回到将扩展类型视为静态方法的语法糖(类似现有的扩展方法)。
      • 优势:有望赶上 .NET 9 推出预览版,且有机会与现有的扩展方法保持二进制兼容
      • 挑战:编译器内部的模型转换极其复杂,需要大量重写成员体(特别是处理嵌套闭包时),以模拟实例方法的语义。
  2. 在 June 26th, 2024 中:

    • 跨语言与兼容性的冲突
      • 选项一:强制其他语言/旧版编译器必须了解新特性(通过添加底层标记屏蔽调用),这意味着放弃向后兼容,旧代码无法平滑迁移。
      • 选项二:允许旧编译器继续以静态方法的形式调用新扩展。优势是可以完美兼容旧代码,但代价是 C# 未来必须永久支持这种调用格式,否则就是破坏性变更。
    • 拆分处理策略
      • 非实例成员(属性、事件等新扩展):坚决不允许以静态形式调用,其他语言必须显式支持。
      • 实例方法(类似现有的 this 扩展):强烈倾向于保留向后兼容性,方便旧的静态类平滑升级为扩展类型。
    • 兼容性带来的解析挑战
      • 编译器必须同时支持 instance.M()E.M(instance) 两种语法。
      • 新的“扩展类型”解析逻辑需要翻修,以对齐旧的“扩展方法”机制。
      • 签名歧义(Ambiguity):如果扩展类型内同时存在无参实例方法 void M() 和带参静态方法 static void M(Instance i),当用户使用 E.M(instance) 调用时,编译器将难以抉择。

最后,C# 14 添加了扩展块的特性,允许开发者在此处声明扩展属性:

1
2
3
4
5
6
7
8
9
10
11
public static class Enumerable
{
extension<TSource>(IEnumerable<TSource> source)
{
// 扩展属性:
public bool IsEmpty => !source.Any();

// 扩展方法:
public IEnumerable<TSource> Where(Func<TSource, bool> predicate) { ... }
}
}

总结

接口从类的角度定义了一个契约,Mixin则是从功能的角度提供了代码复用的机制,而Trait则试图将两者结合起来,既提供了契约又提供了实现。

从 C# 的发展历程来看,接口、默认接口实现、扩展方法、静态抽象成员、有状态的 Mixin 以及源生成器等特性,都在不断地向 Trait 的方向演进,试图在保持语言简洁性的同时,提供更强大的代码复用和抽象能力。