如何在C#中实现“trait”设计模式?

64

我知道C#中没有这个特性,但PHP最近增加了一个叫做Traits的特性,一开始我觉得有点儿傻,但后来开始思考就不这么认为了。

假设我有一个基类叫做ClientClient有一个名为Name的属性。

现在我正在开发一个可重复使用的应用程序,将被许多不同的客户使用。所有客户都认为客户端应该有一个名称,因此它被放在了基类中。

现在客户A来了,他说他还需要追踪客户端的体重。客户B不需要体重,但他想追踪身高。客户C想追踪体重和身高。

使用traits,我们可以将体重和身高特性都提取出来:

class ClientA extends Client use TClientWeight
class ClientB extends Client use TClientHeight
class ClientC extends Client use TClientWeight, TClientHeight

现在我可以满足所有客户的需求,而不必向类中添加任何额外的内容。如果我的客户稍后回来说:“哦,我真的很喜欢那个功能,我也可以有吗?” 我只需要更新类定义以包括额外的特性。

您如何在C#中实现这一点?

接口在这里不起作用,因为我想要属性和任何相关方法的具体定义,并且我不想为每个类版本重新实现它们。

(通过“客户”,我指的是雇用我作为开发人员的字面意义上的人,而通过“客户端”,我指的是编程类;我的每个客户都有他们想记录信息的客户端)


3
使用标记接口和扩展方法,你可以在C#中相当完美地模拟特性。 - Lucero
2
@Lucero 这些不是特征,也缺乏添加新成员的能力(除其他事项外)。尽管如此,扩展方法非常巧妙。 - user166390
3
@Lucero:这可以用于添加额外的方法,但是如果我还想在客户端对象上存储其他数据怎么办? - mpen
1
@Mark,那么你需要具备在任意对象上动态存储数据的能力,这不是运行时的特性。我会在我的回答中添加一些相关信息。 - Lucero
6
特性即将以默认接口方法的形式引入C#。请参阅此提案相应问题。(我无法提供答案,因为对此还不够了解。) - user247702
显示剩余5条评论
8个回答

65
你可以通过使用标记接口和扩展方法来获取语法。
先决条件:接口需要定义后续由扩展方法使用的契约。基本上,接口为能够“实现”一个特征定义了契约;理想情况下,添加接口的类应该已经具有接口的所有成员,以便不需要进行额外的实现。
public class Client {
  public double Weight { get; }

  public double Height { get; }
}

public interface TClientWeight {
  double Weight { get; }
}

public interface TClientHeight {
  double Height { get; }
}

public class ClientA: Client, TClientWeight { }

public class ClientB: Client, TClientHeight { }

public class ClientC: Client, TClientWeight, TClientHeight { }

public static class TClientWeightMethods {
  public static bool IsHeavierThan(this TClientWeight client, double weight) {
    return client.Weight > weight;
  }
  // add more methods as you see fit
}

public static class TClientHeightMethods {
  public static bool IsTallerThan(this TClientHeight client, double height) {
    return client.Height > height;
  }
  // add more methods as you see fit
}

使用方法如下:

var ca = new ClientA();
ca.IsHeavierThan(10); // OK
ca.IsTallerThan(10); // compiler error

编辑:有人提出了如何存储附加数据的问题。这也可以通过进行一些额外的编码来解决:

public interface IDynamicObject {
  bool TryGetAttribute(string key, out object value);
  void SetAttribute(string key, object value);
  // void RemoveAttribute(string key)
}

public class DynamicObject: IDynamicObject {
  private readonly Dictionary<string, object> data = new Dictionary<string, object>(StringComparer.Ordinal);

  bool IDynamicObject.TryGetAttribute(string key, out object value) {
    return data.TryGet(key, out value);
  }

  void IDynamicObject.SetAttribute(string key, object value) {
    data[key] = value;
  }
}

然后,如果“trait interface”继承自IDynamicObject,trait方法可以添加和检索数据:

public class Client: DynamicObject { /* implementation see above */ }

public interface TClientWeight, IDynamicObject {
  double Weight { get; }
}

public class ClientA: Client, TClientWeight { }

public static class TClientWeightMethods {
  public static bool HasWeightChanged(this TClientWeight client) {
    object oldWeight;
    bool result = client.TryGetAttribute("oldWeight", out oldWeight) && client.Weight.Equals(oldWeight);
    client.SetAttribute("oldWeight", client.Weight);
    return result;
  }
  // add more methods as you see fit
}

注意:通过实现IDynamicMetaObjectProvider,对象甚至可以通过DLR公开动态数据,当使用dynamic关键字时,访问其他属性将变得透明。

9
你的意思是把所有数据放在基类中,然后把所有方法实现放在扩展方法中,在接口上挂钩?这是一个有趣的解决方案,但也许可行。我唯一的疑虑是你让客户类承载了很多"死重量"(未使用的成员)。通过一些高级序列化技术,它不需要被保存在磁盘上,但仍然会消耗内存。 - mpen
2
“Sort of”。我确实想不到在C#语言中有更好的东西,所以+1。然而,我并不认为它与Trait地位相同。(Mark指出了一个严重的限制。) - user166390
1
@Mark,请查看我的动态数据存储更新(实现序列化留给读者作为练习;)由于接口无法定义字段的契约,因此您不能将字段用作“特质”的一部分,但是属性当然可以读写!我并不是说C#有特质,而是扩展方法可以作为接口的可重用代码块,这样就不需要重新实现方法;当然,代码必须在接口上拥有所有所需的成员。 - Lucero
@Lucero:你的使用示例中第一行是 var c1 = new Class1();。你是不是想说 var c1 = new ClassA(); 呢?感谢你的帮助回答! - DWright
有错误吗?为什么 ca.IsHeavierThan(10) 返回 OK,而 ClientA 没有使用 TClientWeightMethods 或其他类??? - T.Todua
显示剩余7条评论

17

使用默认接口方法,可以在C# 8中实现特征。Java 8也为此引入了默认接口方法。

使用C# 8,您可以几乎完全按照您在问题中提出的方式编写代码。这些特征是通过IClientWeight、IClientHeight接口实现的,它们为其方法提供了默认实现。在这种情况下,它们只返回0:

public interface IClientWeight
{
    int getWeight()=>0;
}

public interface IClientHeight
{
    int getHeight()=>0;
}

public class Client
{
    public String Name {get;set;}
}

ClientAClientB具有这些特征,但它们没有实现它们。 ClientC仅实现了IClientHeight并返回不同的数字,在本例中为16:

class ClientA : Client, IClientWeight{}
class ClientB : Client, IClientHeight{}
class ClientC : Client, IClientWeight, IClientHeight
{
    public int getHeight()=>16;
}

当通过接口在ClientB中调用getHeight()时,将调用默认实现。只能通过接口调用getHeight()ClientC实现了IClientHeight接口,因此将调用自己的方法。该方法可以通过类本身访问。
public class C {
    public void M() {        
        //Accessed through the interface
        IClientHeight clientB = new ClientB();        
        clientB.getHeight();

        //Accessed directly or through the class
        var clientC = new ClientC();        
        clientC.getHeight();
    }
}

这个SharpLab.io的示例展示了从这个示例生成的代码

许多在PHP traits概述中描述的特性可以通过默认接口方法轻松实现。Traits(接口)可以组合使用。还可以定义抽象方法来强制类实现某些要求。
假设我们希望我们的traits拥有sayHeight()sayWeight()方法,它们返回一个字符串,其中包含身高或体重。它们需要一些方式来强制展示类(从PHP指南中借来的术语)实现返回身高和体重的方法:
public interface IClientWeight
{
    abstract int getWeight();
    String sayWeight()=>getWeight().ToString();
}

public interface IClientHeight
{
    abstract int getHeight();
    String sayHeight()=>getHeight().ToString();
}

//Combines both traits
public interface IClientBoth:IClientHeight,IClientWeight{}

客户端现在必须实现getHeight()getWeight()方法,但不需要了解say方法的任何信息。

这提供了一种更清晰的装饰方式。

这个样例可以在SharpLab.io链接上查看。


8
需要将其转换为接口类型似乎会使代码更冗长。您知道它被设计成这样的原因吗? - Barsonax
4
根据文档显示,实现默认接口方法的主要原因是用于API开发、向后兼容以及与Swift和Android的交互,而不是作为特质/混合功能语言特性。如果您正在寻找混合/特质/多继承风格的语言特性,那么我完全同意将接口转换成类型是一种麻烦。可惜了。 - MemeDeveloper
2
@MemeDeveloper,Java中的这些特性被用于特质、混入和版本控制。新功能页面只是一个简短的描述,不包含原因。你可以在CSharplang Github存储库的设计会议中找到它们。AndroidSDK使用DIMs来实现特质,现在C#也是如此。另一方面,Android SDK互操作性可能是这个特性最重要的动机。 - Panagiotis Kanavos
2
在我看来(作为一名编程语言架构的门外汉),在C#中支持这一点似乎不需要任何重大问题。毫无疑问,编译器可以处理类似于部分类的位 - 即编译器可以在同一事物的多个定义时出错。看起来非常简单,可以使我的工作效率更高。无论如何,我想我可以用Fody或类似的东西找到解决方案。我只是喜欢保持简洁和DRY,并经常发现自己为了避免C#中的这种限制而付出很大的努力。 - MemeDeveloper
1
继承的“特质”实现必须通过显式接口引用访问的原因之一是为了避免潜在的“钻石问题” - 多个基础接口/特质可能会公开相同的方法签名。 - StuartLC
显示剩余3条评论

10

我想指向NRoles,这是一个在C#中使用角色(类似于特征)的实验。

NRoles使用后编译器重写IL并将方法注入类中。这使您可以编写如下代码:

public class RSwitchable : Role
{
    private bool on = false;
    public void TurnOn() { on = true; }
    public void TurnOff() { on = false; }
    public bool IsOn { get { return on; } }
    public bool IsOff { get { return !on; } }
}

public class RTunable : Role
{
    public int Channel { get; private set; }
    public void Seek(int step) { Channel += step; }
}

public class Radio : Does<RSwitchable>, Does<RTunable> { }

其中类Radio实现了RSwitchableRTunable接口。在幕后,Does<R>是一个没有成员的接口,因此基本上Radio被编译为空类。后续编译期间的IL重写将RSwitchableRTunable的方法注入Radio中,然后可以将其用作如果它真的派生自两个角色(从另一个程序集):

var radio = new Radio();
radio.TurnOn();
radio.Seek(42);

如果要在重写发生之前直接使用 radio(也就是在声明 Radio 类型的同一程序集中),您必须使用扩展方法 As<R>():

radio.As<RSwitchable>().TurnOn();
radio.As<RTunable>().Seek(42);

由于编译器不允许直接在Radio类上调用TurnOnSeek方法。


10

C#语言(至少到版本5)不支持Traits。

然而,Scala有Traits,并且Scala运行在JVM(和CLR)上。因此,这不是一个运行时的问题,而仅仅是语言的问题。

请注意,在Scala中,Traits可以被认为是“编译代理方法的神奇方式”(它们不会影响MRO,这与Ruby中的Mixins不同)。在C#中,获得这种行为的方法是使用接口和“大量手动代理方法”(例如组合)。

这个繁琐的过程可以通过假设的处理器完成(也许是通过模板自动生成部分类的代码?),但这不是C#。

祝编码愉快。


1
我不确定这个回答的意思。您是在建议我应该编写一些代码来预处理我的C#代码吗? - mpen
@Mark 不是的。我是在暗示C#语言本身无法支持它(虽然也许可以使用动态代理?这种魔法超出了我的能力范围)。Traits不会影响MRO,可以通过手动模拟来实现;也就是说,Trait可以被展开到混入的每个类中,就像组合一样。 - user166390
2
@Mark 啊,方法解析顺序。也就是说,特质(再次强调,这里指的是基于单继承运行时的Scala特质)实际上并不影响类层次结构。在[虚拟]分派表中没有“特质类”被添加。特质中的方法/属性会在完成时复制到相应的类中。这里有一些关于Scala中使用的特质的论文。Ordersky提出了特质可以在SI运行时中使用,这就是为什么它们在编译时被“烘焙”进去的原因。 - user166390
1
@Mark 这与 Ruby 这样的语言不同,Ruby 会将“mixin”类型(一种特质形式)注入 MRO(这是一种交替类层次结构的形式,但具有控制和限制)。 - user166390
3
我犹豫是否给你点赞,因为你还没有给我提供任何具体的东西,只是谈了很多关于其他语言的事情。我正在尝试弄清楚如何从Scala中借鉴一些想法...但这已经内置在语言中了。它怎么能转移过来呢? - mpen
显示剩余4条评论

8

有一个学术项目由瑞士伯尔尼大学软件组合小组的Stefan Reichart开发,为C#语言提供了真正的traits实现。

请查看关于CSharpT的论文(PDF),了解他所做的完整描述,基于mono编译器。

以下是一段示例代码:

trait TCircle
{
    public int Radius { get; set; }
    public int Surface { get { ... } }
}

trait TColor { ... }

class MyCircle
{
    uses { TCircle; TColor }
}

4

Lucero建议的基础上, 我想到了这个:

internal class Program
{
    private static void Main(string[] args)
    {
        var a = new ClientA("Adam", 68);
        var b = new ClientB("Bob", 1.75);
        var c = new ClientC("Cheryl", 54.4, 1.65);

        Console.WriteLine("{0} is {1:0.0} lbs.", a.Name, a.WeightPounds());
        Console.WriteLine("{0} is {1:0.0} inches tall.", b.Name, b.HeightInches());
        Console.WriteLine("{0} is {1:0.0} lbs and {2:0.0} inches.", c.Name, c.WeightPounds(), c.HeightInches());
        Console.ReadLine();
    }
}

public class Client
{
    public string Name { get; set; }

    public Client(string name)
    {
        Name = name;
    }
}

public interface IWeight
{
    double Weight { get; set; }
}

public interface IHeight
{
    double Height { get; set; }
}

public class ClientA : Client, IWeight
{
    public double Weight { get; set; }
    public ClientA(string name, double weight) : base(name)
    {
        Weight = weight;
    }
}

public class ClientB : Client, IHeight
{
    public double Height { get; set; }
    public ClientB(string name, double height) : base(name)
    {
        Height = height;
    }
}

public class ClientC : Client, IWeight, IHeight
{
    public double Weight { get; set; }
    public double Height { get; set; }
    public ClientC(string name, double weight, double height) : base(name)
    {
        Weight = weight;
        Height = height;
    }
}

public static class ClientExt
{
    public static double HeightInches(this IHeight client)
    {
        return client.Height * 39.3700787;
    }

    public static double WeightPounds(this IWeight client)
    {
        return client.Weight * 2.20462262;
    }
}

输出:

Adam is 149.9 lbs.
Bob is 68.9 inches tall.
Cheryl is 119.9 lbs and 65.0 inches.

虽然不如我所希望的那么好,但也并不太糟糕。


仍然不如 PHP 效率高。 - Digital Human

3

这实际上是对Lucero答案的建议性扩展,其中所有存储都在基类中。

那么,使用依赖属性如何呢?

这将使客户类在运行时变得轻量级,特别是当您有许多属性并不总是被每个子类设置时。这是因为值存储在静态成员中。

using System.Windows;

public class Client : DependencyObject
{
    public string Name { get; set; }

    public Client(string name)
    {
        Name = name;
    }

    //add to descendant to use
    //public double Weight
    //{
    //    get { return (double)GetValue(WeightProperty); }
    //    set { SetValue(WeightProperty, value); }
    //}

    public static readonly DependencyProperty WeightProperty =
        DependencyProperty.Register("Weight", typeof(double), typeof(Client), new PropertyMetadata());


    //add to descendant to use
    //public double Height
    //{
    //    get { return (double)GetValue(HeightProperty); }
    //    set { SetValue(HeightProperty, value); }
    //}

    public static readonly DependencyProperty HeightProperty =
        DependencyProperty.Register("Height", typeof(double), typeof(Client), new PropertyMetadata());
}

public interface IWeight
{
    double Weight { get; set; }
}

public interface IHeight
{
    double Height { get; set; }
}

public class ClientA : Client, IWeight
{
    public double Weight
    {
        get { return (double)GetValue(WeightProperty); }
        set { SetValue(WeightProperty, value); }
    }

    public ClientA(string name, double weight)
        : base(name)
    {
        Weight = weight;
    }
}

public class ClientB : Client, IHeight
{
    public double Height
    {
        get { return (double)GetValue(HeightProperty); }
        set { SetValue(HeightProperty, value); }
    }

    public ClientB(string name, double height)
        : base(name)
    {
        Height = height;
    }
}

public class ClientC : Client, IHeight, IWeight
{
    public double Height
    {
        get { return (double)GetValue(HeightProperty); }
        set { SetValue(HeightProperty, value); }
    }

    public double Weight
    {
        get { return (double)GetValue(WeightProperty); }
        set { SetValue(WeightProperty, value); }
    }

    public ClientC(string name, double weight, double height)
        : base(name)
    {
        Weight = weight;
        Height = height;
    }

}

public static class ClientExt
{
    public static double HeightInches(this IHeight client)
    {
        return client.Height * 39.3700787;
    }

    public static double WeightPounds(this IWeight client)
    {
        return client.Weight * 2.20462262;
    }
}

我们为什么要在这里使用WPF类呢? - Javid

0

这听起来像是PHP版本的面向切面编程。有一些工具可以帮助,例如PostSharp或在某些情况下使用MS Unity。如果您想自己动手,使用C#属性进行代码注入是一种方法,或者建议使用扩展方法处理有限的情况。

这真的取决于您想要多么复杂。如果您正在尝试构建复杂的东西,我会考虑使用这些工具中的一些来帮助。


AoP/PostSharp/Unity允许添加成为静态类型系统一部分的新成员吗?(我有限的AoP经验仅涉及注释切入点等。) - user166390
PostSharp 重写 IL 代码,应该是可以做到的。 - Lucero
是的,我相信可以通过成员/接口介绍的方面来实现(如注明的IL级别)。我的经验也有限,但我没有太多实际机会深入了解这种方法。 - RJ Lohan

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接