标记接口的目的是什么?

108

标记接口的目的是什么?

10个回答

81

这是基于"Mitch Wheat"回答的一个有点离题的观点。

一般来说,每当我看到人们引用框架设计指南时,我总是喜欢提醒大家:

在大多数情况下,你应该忽略框架设计指南。

这并不是因为框架设计指南存在任何问题。我认为.NET框架是一个非常出色的类库。其中很多精彩之处都来自于框架设计指南。

然而,设计指南并不适用于大多数程序员编写的大多数代码。它们的目的是为了创建一个被数百万开发者使用的大型框架,而不是为了使库的编写更加高效。

它中提到的许多建议可能会引导你做出以下结果:

  1. 可能不是实现某个功能最直接的方法
  2. 可能导致额外的代码重复
  3. 可能有额外的运行时开销

.net框架非常庞大。它如此之大,以至于认为任何人对其各个方面都有详细的了解是绝对不合理的。事实上,假设大多数程序员经常遇到他们从未使用过的框架部分更加安全。

在这种情况下,API设计者的主要目标是:

  1. 与框架的其余部分保持一致
  2. 消除API表面区域中不必要的复杂性

框架设计指南推动开发人员创造实现这些目标的代码。

这意味着采取措施避免多层继承,即使这意味着重复编写代码,或将所有异常抛出代码推向“入口点”,而不是使用共享帮助程序(以便堆栈跟踪在调试器中更有意义),以及许多类似的事情。

这些指南建议使用属性而不是标记接口的主要原因是因为删除标记接口会使类库的继承结构更易于理解。具有30个类型和6层继承层次结构的类图比具有15个类型和2层层次结构的类图更加令人望而却步。

如果有数百万开发人员使用您的API,或者您的代码库非常庞大(比如超过100K LOC),那么遵循这些准则可以帮助很多。
如果500万开发人员花费15分钟学习API而不是60分钟学习它,则结果是节省了428人年。那是很多时间。
然而,大多数项目不涉及数百万开发人员或100K+ LOC。在一个典型的项目中,例如4个开发人员和约50K loc,这组假设是完全不同的。团队中的开发人员将更好地理解代码的工作方式。这意味着最重要的是优化生产高质量代码的速度,并减少错误数量和进行更改所需的工作量。
花费1周时间编写符合.NET框架的代码,与8小时编写易于更改且具有较少错误的代码相比会导致:
1. 项目延误 2. 薪水奖金降低 3. 增加错误数量 4. 在办公室花费更多时间,而不是在海滩上喝玛格丽特鸡尾酒。
通常情况下,如果没有其他4999999个开发人员来吸收成本,那就不值得这样做。
例如,测试标记接口只需要单个“is”表达式,会导致比查找属性更少的代码。
所以我的建议是:
1. 如果您正在开发供广泛使用的类库(或UI小部件),请虔诚地遵循框架指南。 2. 如果您的项目有超过100K LOC,请考虑采用其中的一些准则。 3. 否则完全忽略它们。

15
就我个人而言,我会把自己编写的任何代码视为日后需要使用的库。我并不在意这些代码是否被广泛使用 - 遵循指南可以增加一致性,并减少当我需要查看并理解代码多年后的惊喜... - Reed Copsey
17
我并不是说指南不好。我是说它们应该根据你的代码库规模和用户数量而有所不同。许多设计指南是基于维护二进制兼容性之类的东西,但对于只被少数项目使用的“内部”库来说,并不像对BCL那样重要。而其他与可用性相关的指南则几乎总是很重要的。结论就是不要过于教条地遵循指南,特别是在小项目上。 - Scott Wisniewski
7
+1 - 没有完全回答原帖的问题 - MI的目的 - 但仍然非常有帮助。 - bzarah
5
我认为你忽略了一些重要的点。框架指南不仅适用于大项目,也适用于中小型项目。当你试图将它们应用于Hello-World程序时,它们就变得过度复杂了。例如,将接口限制为5个方法始终是一个好的经验法则,无论应用程序的大小如何。还有一个你忽略的问题是,今天的小应用可能成为明天的大应用。 因此,最好在构建时考虑适用于大型应用程序的良好原则,这样当需要扩展时,您就不必重新编写大量代码。 - Phil
3
我不太明白遵循(大部分)设计准则会导致一个8小时的项目突然需要1周时间的原因。例如:将一个“虚拟保护”模板方法命名为“DoSomethingCore”,而不是“DoSomething”,并不需要太多额外的工作,而且您可以清楚地表明这是一个模板方法...在我看来,那些不考虑API编写应用程序的人(“但是...我不是框架开发人员,我不关心我的API!”)恰恰是那些编写了大量重复代码(而且通常没有文档,也难以阅读)的人,而不是相反。 - Laoujin
显示剩余4条评论

56

标记接口用于在运行时标记类实现特定接口的能力。

接口设计.NET类型设计准则-接口设计推荐在C#中使用属性,而不是标记接口。但正如@Jay Bazuzi指出的那样,检查标记接口比检查属性更容易:o is I

所以,代替这种方式:

public interface IFooAssignable {} 

public class Foo : IFooAssignable 
{
    ...
}

根据.NET指南,建议您这样做:

public class FooAssignableAttribute : Attribute 
{
    ...
}

[FooAssignable]
public class Foo 
{    
   ...
} 

35
我们可以完全使用标记接口来实现泛型,但不能使用属性。 - Jordão
23
虽然我喜欢属性及其从声明性角度的外观,但它们在运行时并不是一等公民,并需要相当数量的比较低级的管道来使用。 - Jesse C. Slicer
4
@Jordão - 这正是我的想法。举个例子,如果我想要抽象数据库访问代码(比如说 Linq to Sql),拥有一个公共接口会使它变得更加容易。事实上,我认为使用属性无法编写这种抽象,因为你不能将属性强制转换并且不能在泛型中使用它们。我想你可以使用一个空的基类来衍生出其他类,但那感觉上和使用空接口差不多。此外,如果你后来意识到需要共享功能,机制已经就位了。 - tandrewnichols
@JesseC.Slicer 我很久以前就同意你的看法了。一般来说,现在只需要一个或两个简单的扩展方法就足以使用属性了。 - bopapa_1979
1
@bopapa_1979,十二年前的我和现在的我可能需要就设计和方法进行一些交流。 - Jesse C. Slicer
显示剩余2条评论

27

因为其他回答都表明“应该避免使用”,所以有必要解释一下为什么。

首先,为什么会使用标记接口:它们存在的目的是允许使用实现此接口的对象的代码检查它们是否实现了该接口,并在其实现了该接口时以不同的方式处理该对象。

这种方法的问题在于它破坏了封装性。对象本身现在对如何在外部使用它具有间接控制权。此外,它还知道它将被用于的系统。通过应用标记接口,类定义暗示它期望在某个检查标记存在的地方使用。它隐含地知道它所用于的环境,并试图定义它应该如何被使用。这与封装思想相违背,因为它知道完全超出自己范围的系统部分的实现细节。

从实际角度来看,这会减少可移植性和可重用性。如果在不同的应用程序中重新使用该类,则还需要复制接口,而在新环境中可能没有任何意义,使其完全无用。

因此,“标记”是关于类的元数据。这些元数据不会被类本身使用,只有对(某些)外部客户端代码有意义,以便它可以以某种方式处理对象。因为这些元数据仅对客户端代码有意义,所以它们应该在客户端代码中,而不是类API中。

“标记接口”和普通接口之间的区别在于,具有方法的接口告诉外部世界它如何使用,而空接口则意味着它在告诉外部世界它应该如何被使用。


1
任何接口的主要目的是区分承诺遵守与该接口相关联的契约的类和那些不遵守的类。虽然接口还负责提供调用签名以满足契约所需的任何成员,但是契约而不是成员决定特定类是否应实现特定接口。如果IConstructableFromString<T>的契约规定只有具有静态成员的类T才能实现IConstructableFromString<T>... - supercat
...public static T ProduceFromString(String params);,该接口的伴随类可以提供一个方法public static T ProduceFromString<T>(String params) where T:IConstructableFromString<T>;如果客户端代码有一个像T[] MakeManyThings<T>() where T:IConstructableFromString<T>这样的方法,就可以定义新类型,使其能够与客户端代码一起使用,而无需修改客户端代码以处理它们。如果元数据在客户端代码中,则无法创建新类型以供现有客户端使用。 - supercat
但是T和使用它的类之间的契约是IConstructableFromString<T>,其中接口中有一个描述某些行为的方法,因此它不是标记接口。 - Tom B
类所需的静态方法不是接口的一部分。接口中的静态成员由接口本身实现;接口无法引用实现类中的静态成员。 - supercat
一个方法可以使用反射来确定一个泛型类型是否具有特定的静态方法,并在存在时执行该方法,但是在上面的示例中搜索和执行静态方法ProduceFromString的实际过程不会以任何方式涉及接口,除了使用接口作为标记来指示应该期望哪些类实现必要的函数。 - supercat

10

当语言不支持判别式联合类型时,标记接口有时可能是必要的恶。

假设您想定义一个方法,该方法期望参数的类型必须是 A、B 或 C 中的一种。在许多以函数为先的语言(如 F#)中,这样的类型可以干净地定义为:

type Arg = 
    | AArg of A 
    | BArg of B 
    | CArg of C

然而,在面向对象的语言中(如C#),这是不可能的。在这里实现类似功能的唯一方法是定义接口IArg,并使用它来“标记”A、B和C。
当然,你可以避免使用标记接口,只需接受类型"object"作为参数,但这样会失去表达能力和某种程度的类型安全性。
区分联合类型非常有用,在函数式语言中已经存在了至少30年。奇怪的是,迄今为止,所有主流面向对象语言都忽略了这个特性——尽管它实际上与函数式编程本身无关,而属于类型系统。

值得注意的是,因为 Foo<T> 对于每个类型 T 都有一个单独的静态字段集,所以很容易让泛型类包含静态字段,这些字段包含处理 T 的委托,并使用函数预先填充这些字段以处理类应该处理的每种类型。在类型 T 上使用泛型接口约束将在编译时检查提供的类型至少声称是有效的,尽管它无法确保它实际上是有效的。 - supercat

7

标记接口就是一个空接口。一个类实现这个接口作为元数据,用于某些目的。在C#中,您通常会使用属性来标记一个类,以达到在其他语言中使用标记接口的同样目的。


6
这两个扩展方法将解决大多数问题,Scott认为标记接口优于属性:
public static bool HasAttribute<T>(this ICustomAttributeProvider self)
    where T : Attribute
{
    return self.GetCustomAttributes(true).Any(o => o is T);
}

public static bool HasAttribute<T>(this object self)
    where T : Attribute
{
    return self != null && self.GetType().HasAttribute<T>()
}

现在你拥有:

if (o.HasAttribute<FooAssignableAttribute>())
{
    //...
}

对比:

if (o is IFooAssignable)
{
    //...
}

我无法理解为什么按照Scott所说的,使用第一种模式构建API需要比第二种模式花费5倍的时间。


1
仍然没有泛型。 - Ian Kemp

4
一个标记接口允许将一个类标记为应用于所有子类的方式。 一个“纯”标记接口不会定义或继承任何内容;更有用的类型的标记接口可能是继承另一个接口但不定义新成员的接口。例如,如果有一个名为“IReadableFoo”的接口,则可以定义一个名为“IImmutableFoo”的接口,它会像“Foo”一样运行,但会承诺使用它的人什么也不会改变其值。 接受IImmutableFoo的例程将能够像使用IReadableFoo一样使用它,但该例程只接受声明为实现IImmutableFoo的类。
我想不出很多用于“纯”标记接口的用途。我唯一能想到的就是如果EqualityComparer(of T)。Default返回实现IDoNotUseEqualityComparer的任何类型的Object.Equals,即使该类型还实现了IEqualityComparer。这将允许具有未密封的不可变类型而不违反里氏替换原则:如果该类型密封了与相等测试相关的所有方法,则派生类型可以添加其他字段并使其可变,但这些字段的变化不会使用任何基类型方法可见。拥有未密封的不可变类并避免使用EqualityComparer.Default或信任派生类不实现IEqualityComparer可能并不可怕,但是当作为基类对象查看时,实现IEqualityComparer的派生类可能会出现为可变类。

1

标记是空接口。标记要么存在,要么不存在。

类 Foo : IConfidential

在这里,我们将 Foo 标记为机密。不需要实际的其他属性或属性。


1
标记接口实际上只是在面向对象语言中的过程式编程。接口定义了实现者和消费者之间的契约,除了标记接口,因为标记接口除了自身什么也不定义。因此,标记接口从一开始就无法实现接口的基本目的。

0
标记接口是一种纯粹的空接口,没有主体/数据成员/实现。
当需要时,类通过实现标记接口,仅仅是为了"标记";意味着它告诉JVM该特定类用于克隆,所以允许进行克隆。该特定类是为了序列化其对象,因此请允许其对象被序列化。

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