65.9K
CodeProject 正在变化。 阅读更多。
Home

任何 .NET 语言中的动态接口

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.31/5 (11投票s)

2010 年 6 月 15 日

LGPL3

19分钟阅读

viewsIcon

65003

downloadIcon

393

受 Go 语言启发的库,允许您将任何对象适配到某个接口,只要它实现了该接口的所有方法。

GoInterfaces.png

引言

在 Go 编程语言中,您不必显式声明某个类型实现了某个接口。相反,只要一个类型实现了接口中的所有方法,它就可以被转换为任何接口。这常常会让人联想到 Python 或 Ruby 等动态语言中的“鸭子类型”,但它的速度更快;事实上,Go 接口调用与 C++ 和 C# 中的虚方法调用速度相同!

用 C# 的话说,如果您有一个类 T...

public class T {
    public void Foo(int x);
}

...以及一个名为“Interface”的接口...

public interface Interface {
    void Foo(int x);
}

...那么您就可以将 T 强制转换为 Interface,即使 T 没有显式实现它。

Interface t = new T();

这种强制转换可以是隐式的,因为编译器可以在编译时得知 T 实现了 Interface。但是,您可以将任何对象强制转换为 Interface,然后在运行时,Go 会确定它是否实现了 Interface

我写了一个模仿这种想法的库,我称之为GoInterfaces。上面的类 T 可以像这样“适配”到 Interface

object t = new T();
Interface iface = GoInterface<Interface>.From(t);
iface.Foo(100);

使用 GoInterfaces 就是这么简单,这也是我将本文标记为“初学者”和“高级”的原因。初学者可以使用它,但理解何时使用以及它是如何工作的则是更高级的主题。

背景

我最近写了一份关于.NET Framework 应该具备的功能的愿望清单,我在其中包含了 Go 接口,尽管我并不真正了解它们是如何工作的。

我一直对它们的工作方式感到困惑。我在网上搜索了很长时间,一无所获,然后我问了“Go Nuts” Google 群组 Go 方法分派是如何工作的,并被指向了文章“Go 数据结构:接口”。

总而言之,第一次将类型 T 转换为接口 Interface 时,会生成一个 vtable(虚函数表),就像 .NET 和 C++ 中用于虚调用的那种。然而,Go 不像 C++ 和 .NET 那样将 vtable 存储在对象本身中,而是将 vtable 指针与接口指针一起存储(也就是说,接口指针实际上是两个指针)。这种简单但独特的设计允许单个对象实现无限数量的接口,并且整体性能与 C# 和 Java 相当。

不幸的是,据我所知,在 .NET 中没有有效的方法来实现这种技术而不改变 CLR 本身。虚方法表只是一个函数指针列表;重要的是,虚方法表中的函数指针不与特定对象关联,这使得它们与 .NET 委托不同。由于 vtable 不与特定对象关联,因此可以与任意数量的对象(只要它们是同一类)重用同一个 vtable。然而,.NET 委托与特定对象关联,因此我们不能使用它们来形成可重用的 vtable。

即使 .NET 允许不与特定对象关联的委托,.NET 上的委托调用也比虚方法调用慢;其原因对我来说并不完全清楚,但部分原因可能是微软决定将委托设为引用类型,而它们本应是更简单的 8 字节值类型(只包含一个函数指针和一个 'this' 指针)。

此外,为了获得接口的引用,除了要转换为接口的对象之外,还需要在堆上创建一个对象。在 .NET 中支持 Go 式接口的唯一方法是,如果“接口类型”实际上是一个值类型(一个 2 字长的结构)。但如果我采取那种方法,定义“接口”会很麻烦,而且它们的工作方式也不会像普通的 .NET 接口那样。

然而,一周前,我了解到 Visual Basic 9 有一个与 Go 非常相似的功能,称为“动态接口”,它允许您执行与 Go 接口大致相同的事情(尽管仅限于 Visual Basic)。到目前为止,我还没有听说过 VB 的动态接口是如何工作的,但我开始思考:将 Go 式接口引入所有 .NET 语言有多难,以及是否有可能获得良好的性能?

我选择的技术的性能不如 Go 语言,但作为交换,我付出了一点性能损失(我相信这无论如何都是不可避免的),GoInterface 类提供了 Go 语言本身无法获得的自动接口适配。具体来说,我的 GoInterface 类可以自动执行小型类型转换任务,例如将 int 扩展为 long、装箱值类型,并允许返回类型协变(例如,如果包装的方法返回 stringInterface 可以返回 object)。而且,由于 GoInterface 返回实际实现您所请求接口的堆对象(而不是像我刚才谈到的那种奇怪的 2 字长结构),因此它非常易于使用。

虽然 GoInterface 比 Go 语言中的接口慢,但我的设计确实允许它更灵活,并且我随意添加了各种功能,使其能够适应接口和目标类型之间的细微差别。

工作原理

GoInterface 类使用 .NET 的 Reflection.Emit 在“动态程序集”(基本上是一个仅存在于内存中的 DLL)中生成包装器类。每个包装器类实现您选择的单个接口,并将该接口上的调用转发给您选择的对象。

正如我之前提到的,使用上述类型...

public class T {
    public void Foo(int x);
}
public interface Interface {
    void Foo(int x);
}

...您可以使用 GoInterface 像这样将 T 强制转换为 Interface

Interface t = GoInterface<Interface>.From(new T());

第一次“强制转换” TInterface 时,GoInterface 会动态生成一个包装器类,如下所示

public class T_46F3E18_46102A0 : Interface
{
    T _obj;
    public T_46F3E18_46102A0(T obj) { _obj = obj; }

    public void Foo(int x) { _obj.Foo(x); }

    public override string ToString() { return _obj.ToString(); }
    public override int GetHashCode() { return _obj.GetHashCode(); }
    public override bool Equals(object o) { return _obj.Equals(o); }
}

类型名称中的十六进制数字只是包装的接口和类型的句柄,目的是在您用 GoInterface 包装许多不同类时保证没有名称冲突。

第一次强制转换,很抱歉说,非常慢,因为运行时生成类非常慢(以及用于选择要生成的代码的反射)。但是第一次强制转换后,所有后续的强制转换都非常快,特别是如果您调用 GoInterface<Interface,T>.From() 而不是直接调用 GoInterface<Interface>.From()。这是因为在 GoInterface<Interface,T> 完全初始化后,它的 From() 方法所做的只是调用一个包含以下代码的委托

delegate(T obj) { return new T_46F3E18_46102A0(obj); }

我不会详细解释 GoInterface 的工作原理,因为仅涵盖所有 GoInterface 功能就足以写一篇 CodeProject 文章了。源代码中有许多注释,希望足以自述。

如何使用 GoInterfaces

您可以使用 GoInterface<Interface>GoInterface<Interface, T>(请注意额外的类型参数 T)来创建包装器。

  • GoInterface<Interface> 适用于在编译时不知道对象类型时创建包装器。例如,如果您有一个类型未知的对象列表,并且想将它们强制转换为接口,请使用此选项。
  • GoInterface<Interface, T> 在您已知编译时对象类型时创建包装器。此版本假定 T 本身(而不是某个派生类!)包含您想要调用的方法。GoInterface<Interface, T> 的缺点是它无法调用 T 的派生类中的方法。例如,您不应该使用 GoInterface<Interface, object>,因为 object 类不包含 Foo 方法。

如果您不确定使用哪个,请使用 GoInterface<Interface>。如果您需要将大量对象适配到单个接口,则应尽可能使用 GoInterface<Interface, T>,因为它速度稍快。相比之下,GoInterface<Interface> 必须检查它接收的每个对象以找出其最派生的类型。但是,此过程已优化,仅对每个派生类型执行一次昂贵的分析,之后只需要哈希表查找。

GoInterface 会在早期进行大量工作,以换取后续快速的接口分派。第一次使用任何一对类型(接口类型和目标类型)时,速度非常慢,但一旦您获得了接口指针,调用其方法就相当快(几乎与通过普通接口调用一样快)。

与 Go 相比的开销

与 Go 编程语言中的接口相比,Go 接口为每个接口指针(vtable 指针,在 32 位代码中为 4 字节)具有 1 字的开销,GoInterface 包装器通常具有 3 字的开销(2 字用于包装器的对象头,1 字用于对包装对象的引用)。此外,GoInterface 生成的类生产成本更高(因为它们涉及运行时代码生成),并将显着增加程序的启动时间(代码中包含一个基准测试,展示了这一点)。此生成代码还具有固定的内存开销,毫无疑问,这比 Go 的实现要大得多。但是,一旦您使用 GoInterface 包装器运行起来,它们的性能就相当不错。

注意:GoInterface 可以为值类型(结构)创建包装器,而不仅仅是类。此类包装器的内存开销与已装箱的结构相同,比引用类型的包装器少一个字。

我还想比较 GoInterface 的性能与 VB 9.0 的动态接口功能,但我还没有做到(我通常不使用 Visual Basic)。

那么它们有什么用呢?

自然的问题是,您为什么要使用此功能?

我看到的主要原因是,有时您可以在不同类之间看到一种模式(不是您自己控制的类),而原始作者没有看到或出于某种原因没有明确说明。例如,COM 组件有时提供集合类型,但不实现 IList<T>,即使它们提供了 IList<T> 的最重要方法。甚至微软也没有总是在其自己的集合类上实现 IList<T>!奇怪的是,Windows Forms 类(如 TreeNodeCollectionListViewItemCollectionObjectCollection)不实现 IList<T>,甚至不实现 IEnumerable<T>(因此 LINQ 甚至无法在它们上工作,除非您使用Cast<TResult> 扩展方法)。

我个人正在做一个项目,我在其中编写了几个只读列表类。其中一些具有索引器,而另一些仅仅是可枚举的,但具有 Count 属性。我不想为每个类费力实现 IList<T> 的所有 13 个方法和属性;相反,我定义了简化的接口

public interface IEnumerableCount<T> : IEnumerable<T>
{
    int Count { get; }
}
public interface ISimpleList<T> : IEnumerableCount<T> 
{
    T this[int index] { get; }
}

唯一的问题是,如果我想使用普通的 .NET 集合类,它就不实现这些接口。虽然编写将 IList<T> 转换为 ISimpleList<T> 的包装器并不难,但能够自动完成它还是相当不错的。

如果您还不清楚 GoInterface 的作用,请仔细考虑一下。我常常不知道如何使用新的编程语言功能,直到遇到一个该功能可以解决的问题。如果您找到了 GoInterface 的一个好用途,请留下评论!

GoInterface 的功能

结构体、类和接口包装

目标类 T 可以是 structclassinterface

Interface 不必是 .NET interface;它可以是抽象类。在这种情况下,GoInterface 将为类中的每个 abstract 方法生成一个包装器。使用抽象类可以定义目标对象中不存在的额外(非抽象)方法。

简单的隐式转换

我上面介绍的基本 GoInterface.From() 方法仅在目标类、值类型或接口(T)与接口类型(Interface)兼容时才有效。所谓“兼容”,我主要是指 Interface 的所有方法必须存在于 T 中,具有相同数量的参数,并且参数类型可隐式转换。如果 Interface 有方法

object Method(string x, short y);

而目标类型 T 有类似的方法

bool Method(object x, int y);

那么 GoInterface 可以成功转发调用。经验法则是,如果包装器不需要任何显式转换,GoInterface.From 就可以将 Interface 适配到 T

object Method(string x, short y) { return _obj.Method(x, y); }

在这里,string x 可以被隐式转换为 objectshort y 可以被隐式转换为 int,而 bool 返回值可以被隐式装箱并作为 object 返回。GoInterface 实现了 C# 的隐式转换规则;因此,例如,它不能int 隐式转换为 floatshort 隐式转换为 uint,或 object 隐式转换为 string。此外,GoInterface不支持用户定义的转换运算符。

此外,GoInterface 允许对 out 参数进行协变和隐式(扩展)数值转换。例如

// Successful match:
void Method(out byte a, out string b);      // method in target type T
void Method(out uint a, out IComparable b); // method in the Interface

如果为 Interface 的每个方法都未找到合适的 T 方法,GoInterface.From 将抛出 InvalidCastException。但是,您可以使用 ForceFrom() 方法强制任何转换成功。

强制转换

目前,GoInterface 的结构是,即使目标类型与接口不兼容,它也会始终生成一个包装器类。但是,如果您使用 From() 方法,当 TInterface 不完全兼容时,它不会创建包装器的实例。另一方面,GoInterface.ForceFrom 方法总是会生成包装器实例,即使某些方法(或所有方法)在 T 中丢失或由于某种原因无法匹配。

尽管 ForceFrom 转换成功,但当您调用在 T 中找不到或参数不兼容(或返回值不兼容)的方法时,包装器会抛出 MissingMethodException。这种行为——允许转换成功,但在尝试调用缺失的方法时失败——受到 VB 9 的新“动态接口”的启发,后者的行为方式相同。GoInterface 允许您选择是在转换时失败(通过调用 From())还是在调用缺失的方法时失败(通过调用 ForceFrom())。

GoInterface 实际上可以匹配一些参数数量不同的方法,尽管普通的 From() 方法在这些情况下会抛出异常。如果您使用 ForceFrom,那么就可以调用这个目标方法...

void Foo(int x, out int y, out int z);

...通过这个接口方法

void Foo(int x, string y);

GoInterface 允许在接口的最后参数被省略(在这种情况下是 string y),如果它们在目标方法中不存在(但它们必须是输入参数)。它还允许目标方法上的 out 参数被省略,如果它们在接口中不存在(yz)。它允许 ref 和输入参数之间的不匹配。例如,ref int 参数可以与 int 参数匹配,反之亦然。最后,目标方法上的 out 参数可以与接口上的 ref 参数匹配;调用者提供的输入值将被丢弃,但 ref 参数将接收 out 参数的值。

请记住,基本的 From() 方法在发生此类不匹配时会抛出异常;如果您想省略参数,或者出现 ref 不匹配,您必须调用 ForceFromFrom() 的第二个重载。

第二个 From() 方法将 CastOptions 值作为第二个参数。以下是可用的 CastOptions

  • CastOptions.As - 如果转换失败,From 将返回 null 而不是抛出异常。
  • CastOptions.AllowUnmatchedMethods - 允许在某些方法未找到、模糊或参数无法协调时转换成功。
  • CastOptions.AllowRefMismatch - 允许在出现 ref 不匹配(例如,ref floatfloat 匹配)时转换成功。
  • CastOptions.AllowMissingParams - 允许在某些方法只能通过省略参数列表末尾的参数来匹配时转换成功。
  • CastOptions.NoUnwrap - 通常,GoInterface<Interface> 会检测您要转换的对象是否已被包装,如果是,则将其解开,以免生成包装器中的包装器。此选项会抑制该行为。当然,Go 语言中不存在这个问题,因为 Go 不需要使用包装器。

CastOptions 仅控制转换是否成功,而不控制包装器上的方法调用是否成功。例如,如果在某个方法上出现 ref 不匹配,只要转换成功,您总是可以调用该方法;包装器不会抛出异常。调用 From()ForceFrom() 都会生成相同的包装器类,包装器无法跟踪您是否希望调用成功,因为它不存储任何 CastOptions 列表。

注意:采用 CastOptionsFrom 方法比其他两种稍慢。

默认参数

GoInterface 支持带有可选参数的目标方法。例如,如果目标方法是

void Sleep(int milliseconds, bool nightmares = false);

但接口仅包含

void Sleep(int milliseconds);

GoInterface 将插入缺失的默认参数到包装器中。默认值必须是简单的原始常量或文字字符串。

装饰器辅助(使用 GoDecoratorField 属性)

在编写完 GoInterface 的基本功能后,我意识到它也可以作为实现装饰器模式的有用方式。装饰器是一个类,它包装某个目标类(通常共享相同的接口或基类),同时修改目标的功能。例如,您可以为 TextWriter 编写一个装饰器,该装饰器过滤掉脏话,可能用关于彩虹和蝴蝶的积极词语替换它们。

编写装饰器有时很不方便,因为您只想修改某些函数的功能而保留其他函数不变。没有 GoInterface,您必须总是为每个方法编写一个包装器,手动将调用从装饰器转发给目标。

GoInterface 可以通过自动生成转发函数来提供帮助。

以下示例演示了如何使用 GoInterface 来帮助您创建一个装饰器

// A view of an IList in which the order of the elements is reversed.
// The test suite offers this example in full; this partial version
// just explains the concepts.
public abstract class ReverseView<T> : IList<T> 
{
    // Use the GoDecoratorField attribute so that GoInterface will access
    // the list through this field instead of creating a new field.
    // Important: the field must be "protected" or "public" and have 
    // exactly the right data type; otherwise, GoInterface will ignore 
    // it and create its own field in the generated class.
    [GoDecoratorField]
    protected IList<T> _list;

    // The derived class will init _list for you if you have a default 
    // constructor. If your constructor instead takes an IList<T> 
    // argument, you are expected to initialize _list yourself.
    protected ReverseView() { Debug.Assert(_list != null); }

    // The downside of using GoInterface to help you make decorators is 
    // that GoInterface creates a derived class that overrides abstract
    // methods in your own class, which means your class must be abstract,
    // and users can't write "new ReverseView"--instead you must provide
    // a static method like this one to create the wrapper.
    public static ReverseView<T> From(IList<T> list)
    {
        return GoInterface<ReverseView<T>, IList<T>>.From(list);
    }

    // Here are two of several methods we need to rewrite in order to 
    // make a list appear reversed.
    public int IndexOf(T item)
    { 
        int i = _list.IndexOf(item); 
        return i == -1 ? -1 : Count - 1 - i;
    }
    public void Insert(int index, T item)
    {
        _list.Insert(Count - index, item);
    }

    // Here are the functions that we don't have to implement, which we
    // allow GoInterface to implement automatically. Unfortunately, when 
    // implementing an interface you can't simply leave out the functions 
    // you want to remain abstract. C#, at least, requires you to make a
    // list of the interface methods that you aren't implementing. This 
    // inconvenience is only when implementing an interface; if you are
    // just deriving from an abstract base class, you don't have to do 
    // this because the base class already did it.
    public abstract void Add(T item);
    public abstract void Clear();
    public abstract bool Contains(T item);
    public abstract void CopyTo(T[] array, int arrayIndex);
    public abstract int Count { get; }
    public abstract bool IsReadOnly { get; }
    public abstract bool Remove(T item);
    public abstract IEnumerator<T> GetEnumerator();

    // IEnumerable has two GetEnumerator functions so you must use an 
    // "explicit interface implementation" for the second one. In C#,
    // anyway, you must write the second one yourself, as it can't be
    // marked abstract.
    System.Collections.IEnumerator
    System.Collections.IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    }
}

重载解析和歧义

当目标中的多个方法匹配接口方法时,GoInterface 可以选择“最佳”方法。选择最佳匹配的规则主要模仿 C# 标准,因此它通常会如您所料地工作。请注意,在某些情况下,匹配是模糊的——没有最佳匹配。例如,如果接口是

public interface IAmbig {
    void Ambig(string a, string b);
}

而您想适配以下类...

public class Ambig {
    void Ambig(string a, object b);
    void Ambig(object a, string b);
}

它不起作用。两种方法匹配得同样好,因此没有最佳方法,也没有被选中。当您尝试调用 IAmbig 上的方法时,您将收到 MissingMethodException

GoAlias 用于方法重命名

有时双方会为他们的方法选择不同的名称。只需在接口上使用 GoAlias 属性即可。例如,如果一个集合类使用一些愚蠢的方法名称,如下所示

public class MyCollection
{
     void Insert(object obj);
     int Size { get; }
     object GetAt(int i);
}

在接口上添加 GoAlias 以将这些名称更改为更传统的方式

public interface ISimpleList
{
    [GoAlias("Insert")] void Add(object item);
    
    int Count 
    {
        [GoAlias("get_Size")] get;
    }
    object this[int index]
    {
        [GoAlias("GetAt")] get;
    }
}

void Example()
{
    ISimpleList list = GoInterface<ISimpleList>.From(new MyCollection());
    list.Add(10); // calls MyCollection.Insert(10)
}

如果目标类型具有原始方法名称的匹配方法,则会忽略 GoAlias 属性。在此示例中,如果目标类型具有 Add(object) 方法,则会忽略别名 Insert

GoAlias 支持多个别名(用逗号分隔名称)。

GoInterface 区分大小写,因此您还可以使用 GoAlias 来解决大小写差异。

如果您不控制接口,仍然可以使用 GoAlias。创建一个实现接口 I 的抽象类 A,其中每个接口方法都有一个抽象方法。然后,您可以使用 GoAlias 在抽象方法上。请确保使用 GoInterface<A> 而不是 GoInterface<I>

System.Object 方法转发

GoInterface 包装器会自动转发对 object.ToString()object.GetHashCode()object.Equals() 的调用,即使这些方法可能不是被包装接口的一部分。

.NET 2.0

虽然使用了一些 C# 3.0 代码编写,但 GoInterface 仍然与 .NET Framework 2.0 兼容。

GoInterface 的局限性

  • 非常重要:接口和目标类都必须是 publicGoInterface 无法处理 internal 类型(请注意,internal 是 C# 中的默认访问级别)。这个问题出现是因为 GoInterface 会生成一个“动态程序集”,一个独立于您的代码所在程序集的程序集。CLR 的安全系统会阻止动态程序集使用其他程序集的 internal 类。如果有人知道绕过此限制的方法,请告诉我!
  • 在调用转发期间,GoInterface 不能使用自身将参数转换为其他接口。例如,假设您创建了一个 ILength 接口,其中包含一个 Length 属性。GoInterface 无法自动将接口中的 string 参数转换为目标中的 ILength 参数。
  • 目前,GoInterface 没有特定的泛型支持。最重要的是,GoInterface 无法处理包含泛型方法,并且会忽略目标类上的泛型方法。类本身或接口本身可以包含泛型类型参数,但 GoInterface 不会生成泛型代码。相反,GoInterface 会为泛型类型的每个特化生成单独的代码。因此,如果您创建了涉及 List<string>List<int> 的包装器,GoInterface 将生成两个单独的包装器类。
  • GoInterface 中没有代码支持包装事件。
  • GoInterface 不能在 .NET Compact Framework 或 Silverlight 中使用,因为这些版本的 .NET 不支持 Reflection.Emit
  • GoInterface 不能调用用户定义的隐式转换运算符。它只能将较小的原始类型转换为严格较大的类型(如 byteint),并将派生类型转换为基类型(如 stringobject)。

一个使速度加倍的简单更改

阅读了 Jon Skeet 的一篇关于 beforefieldinit 的文章后,我想知道静态构造函数是否会影响性能。事实证明,是的,它对 GoInterface 有很大影响。通过删除这个静态构造函数...

static GoInterface()
{
    _from = GenerateWrapperClassWhenUserCallsFrom;
    _forceFrom = GenerateWrapperClassWhenUserCallsForceFrom;
}

转而进行成员初始化...

private static GoWrapperCreator _forceFrom = GenerateWrapperClassWhenUserCallsForceFrom;
private static GoWrapperCreator _from = GenerateWrapperClassWhenUserCallsFrom;

GoInterface<Interface> 包装器创建基准测试耗时减少了 44%,而创建 GoInterface<Interface,T> 耗时减少了 56%。因此,我已经更新了屏幕截图和 zip 文件。

最后说明

我可以发泄一下吗?

我通过编写这个库学习了很多关于 Reflection 和 Reflection.Emit 的知识,但我学到的主要东西是它很糟糕。API 闻起来非常、非常糟糕。文档做得不好,设计显然一团糟,它不必要地效率低下,而且任何大量使用 Reflection 的代码(包括我的)都非常丑陋。我希望我对标准设计模式和原则更熟悉,这样我就可以解释它们被破坏得有多严重。此外,我对 CIL 还有一些挑剔。我希望微软(或其他人)有一天能写出改进的 Reflection 接口。

让我感到惊讶的一件事是,存在用于“ref”(和“out”)参数的特殊 Type 对象。起初,我假设 ParameterInfo.IsInParameterInfo.IsOut 会指示这一点,但事实证明 IsIn 始终为 false,而 IsOut 仅用于“out”参数而不是“ref”参数(尽管 ref 同时包含输入和输出)。“ref”和“out”参数获得相同的 Type 对象(您可以根据 ParameterInfo.IsOutParameterInfo.Attributes & ParameterAttributes.Out 来区分)。给定一个“ref”或“outType,您可以通过调用 Type.GetElementType() 来获取相应的非 ref 类型。

享受这个库吧!让我们知道您发现了哪些用途!

历史

  • 2010 年 8 月 13 日:通过删除静态构造函数提高了速度(v1.01)。
  • 2010 年 6 月 16 日:初始发布(v1.0)。
© . All rights reserved.