Skip to main content

Why Java Sucks and C# Rocks(3):Attribute与Annotation



原文 - Why Java Sucks and C# Rocks(3):Attribute与Annotation

上一篇文章里里我谈了Java和C#语言中对于基础类型的不同态度,我认为C#把基础类型视做对象的做法比Java更有“万物皆对象”的理念,使用起来也更为方便。此外,C#拥有一个Java 1.4所不存在的特性,即Attribute(自定义特性),而在之后的Java 5.0中也增加了类似的功能,这便是Annotation(标注)。那么,Attribute的作用是什么,Java中的Annotation和C#中的Attribute又有什么区别呢,Java 5.0中又从C# 1.0中吸收了哪些优点?我们现在就来关注这方面的问题。

自定义特性与设计

Attribute是C# 1.0中的重要功能,它的作用便是为某个成员,例如类、方法或参数附加上一些元数据,而在程序中则可以通过反射操作获取到这些数据。例如,在.NET框架中,每个类型在默认情况下是无法被序列化的,除非我们为类型添加Serializable标记。如下:

[Serializable]
public class Product { ... }

Product类在标记了Serializable之后,就可以被BinarySerializer或DataContractSerializer等工具类序列化或反序列化。C#有个约定:所有的Attribute都(直接或间接)继承System.Attribute类,并且类名以Attribute结尾,但是在使用时可以省略。因此,事实上Serializable标记其实是SerializableAttribute类,它是System.Attribute的子类。

C#中的Attribute对于软件设计有非常重要的作用,例如Kent Beck评价到:

NUnit 2.0 is an excellent example of idiomatic design. Most folks who port xUnit just transliterate the Smalltalk or Java version. That's what we did with NUnit at first, too. This new version is NUnit as it would have been done had it been done in C# to begin with.

简而言之,大部分xUnit框架都是简单地移植JUnit的代码,但是NUnit却利用了C#的Attribute提供了更优雅的设计,类似的观点在Martin Fowler所编的杂志中也有过更为具体的论述。因此,C#在这方面可谓大大领先于Java 1.4。幸运的是,在C#发布两年后Java语言也推出了5.0版本,增加了Annotation功能,这无疑缩小了与C#之间的差距。

只可惜,Java语言中的Annotation功能,我认为相对于C#语言的Attribute功能至少有两个缺点。

缺点1:失血模型

说起C#的Attribute与Java的Annotation,两者最大的区别便是:C#中的Attribute是类,而Java中的Annotation是接口。

由于C#的Attribute其实也是.NET中标准的“类”,因此与类有关的设计方式都可以运用其中,例如抽象类,抽象方法,重载方法,也可以实现接口等等。这类特性造就了一些非常常用的设计模式,例如可能对于大部分.NET程序员都非常熟悉的“验证标记”。

简单地说,这是一种通过标记来表示“验证逻辑”的做法,例如我们可以先定义一个基类:

public class ValidationResult { ... }

[AttributeUsage(AttributeTargets.Property, AllowMultiple = true)]
public abstract class ValidationAttribute : Attribute
{
public abstract ValidationResult Validate(object value);
}

ValidationAttribute类继承了System.Attribute,也就是说,它可以作为其他Attribute的基类。例如,我们可以定义这样一些通用的验证类:

public class RangeAttribute : ValidationAttribute
{
public int Min { get; set; }

public int Max { get; set; }

public override ValidationResult Validate(object value) { ... }
}

public class RegexAttribute : ValidationAttribute
{
public string Pattern { get; set; }

public override ValidationResult Validate(object value) { ... }
}

于是,我们便可以在一个类的属性上进行标记,表示对某个属性的验证要求:

public class Person
{
[Range(Min = 10, Max = 60)]
public int Age { get; set; }

[Range(Min = 30, Max = 50)]
public int Size { get; set; }

[Regex(Pattern = @"^\w+@[a-zA-Z_]+?\.[a-zA-Z]{2,3}$")]
public string Email { get; set; }
}

这样的标记便表示Age的合法范围是10到60,Size的合法范围是30到50,而Email需要满足特定的正则表达式。标记之后,我们便可以使用统一的代码进行验证,例如:

public static void Validate(object o)
{
var type = o.GetType();
foreach (var property in type.GetProperties())
{
var validateAttrs =
(ValidationAttribute[])property.GetCustomAttributes(
typeof(ValidationAttribute), true);

var propValue = property.GetValue(o, null);
foreach (var attr in validateAttrs)
{
var result = attr.Validate(propValue);
// do more things
}
}
}

如此,我们便可以轻易地获取每个属性上的验证条件,并调用Validate方法进行验证。我们有能力这么做的原因,是因为C#中的Attribute是类,这样我们可以使用抽象类ValidationAttribute进行统一控制。如果这段验证逻辑是由类库提供的,而开发人员想要增加额外的验证条件,也只需要自己定义新的类来继承ValidationAttribute,并提供自定义的Validate方法逻辑即可。这种方式大量出现在各类.NET的类库及框架中,给.NET程序员带来许多便利。

而在Java 5.0中,似乎Annotation和C#的Attribute在表现形式上差不多。例如,我们也可以定义一些“验证标记”:

public @interface RangeValidation {
int min();
int max();
}

public @interface RegexValidation {
String pattern();
}

使用时似乎也差不多:

public class Person {
@RangeValidation(min = 10, max = 60)
public int age;

@RegexValidation(pattern = "^\\w+@[a-zA-Z_]+?\\.[a-zA-Z]{2,3}$")
public String email;
}

与C#不同,Java的Annotation其实是接口,它默认实现类库中定义的java.lang.annotation.Annotation接口,并且只能定义一些无参数的方法(不过可以指定默认值)——因为这些方法的作用其实只是类似于一些“字段”,只是用于保存数据,以便在程序中返回。这样看来,似乎和C#中没有区别,只是一个使用了“属性”,一个利用了“方法”而已,不是吗?

自然不是,区别很大。试想,现在如果您要编写一段代码来进行统一的验证,那么该怎么做?似乎没法做,因为在C#中我们可以通过统一的基类来获取所有表示验证的Attribute,然后调用定义在基类中的Validate方法。在Java语言中我们无法做到这点,因此如果要识别RangeValidation,那么我们就必须独立准备一段逻辑,而要识别RegexValidation则又是另一段不同的方法。因为两者的“识别”方式完全不同,因此我们无法使用相同的代码进行验证工作。

更关键的是,如果验证逻辑是由类库提供的,而开发人员想要补充额外的验证方式,那么该怎么做?我们可以提供自定义的Annotation,这很容易,那么识别这个Annotation的逻辑该如何交给类库呢?这个自然也有办法解决,但无论如何都会带来较多的代码量,且做不到C#般优雅,自然。

因此,Java的Annotation落后于C#的Attribute的关键之处,在于Java的Annotation只能定义为失血的对象,而C#的Attribute可以在需要的时候包含一定的逻辑,这样便可以让C#程序员获得更好的灵活性,使用更丰富的开发模式。与此相比,“类”和“接口”的区别,其实倒真只是表象罢了。

缺点2:古怪的约定

相比于上一个缺点来说,第二个缺点似乎并不那么严重,不过我认为这的确也是Java语言的Annotation设计中无法令人满意的地方。

在前面的代码中我们已经可以发现,其实C#的Attribute及Java的Annotation在使用上非常相似,为此我们再来仔细对比一下:

[Range(Min = 10, Max = 60)] // C#
@RangeValidation(min = 10, max = 60) // Java

这样看来,C#和Java在使用时的形式基本完全一致,都是使用名称+属性名的方式进行标记。不过其实C#和Java都有额外的语法,例如在C#中,我们可以这样定义RangeAttribute类:

public class RangeAttribute : ValidationAttribute
{
public RangeAttribute() { }

public RangeAttribute(int min, int max)
{
this.Min = min;
this.Max = max;
}

public int Min { get; set; }

public int Max { get; set; }

...
}

与之前的RangeAttribute相比,新的定义增加了两个构造函数定义,一个是无参数的构造函数(其实原来的定义也有,只不过由编译器自动添加),还有一个构造函数则直接接收min和max参数,这样我们便可以直接通过构造函数来标记Attribute了:

[Range(10, 60)]

当然,有人说这种做法不如显式指定Min和Max来的清晰——可能是这样吧,但是在很多时候通过构造函数传参也有优势,例如我们可以重新定义之前的RegexAttribute为:

public class RegexAttribute : ValidationAttribute
{
public RegexAttribute(string pattern)
{
this.Pattern = pattern;
}

public string Pattern { get; private set; }

...
}

在这里我们为RegexAttribute提供了一个接收pattern的构造函数,并将Pattern属性的set方法设为private。于是我们便可以这样使用RegexAttribute了:

[Regex(@"^\w+@[a-zA-Z_]+?\.[a-zA-Z]{2,3}$")]

这么做有两个好处:首先,外部无法设置Pattern属性的值,这点加强了对象的封装性。其次,这里相当于强制RegexAttribute在使用时必须提供一个pattern参数(虽然无法进行验证)。这样,代码在使用时既显得优雅,可读性也非常良好。但其实,我认为更关键的是,这种使用模式和创建一个对象,并为其属性进行赋值一样:我们可以选择任意的构造函数创建对象,再“有选择地”进行属性赋值。例如上文AttributeUsage的使用:

[AttributeUsage(AttributeTargets.Property, AllowMultiple = true)]

这里的含义是,创建一个AttributeUsageAttribute类,提供AttributeTargets.Property作为构造函数的参数,并同时将AllowMultiple属性设为true。可见,这种做法给了我们相当自由性——而且非常自然,没有奇怪约定。在设计一个Attribute的时候,我们一般可以提供几个常用的构造函数,在大部分情况下使用这些构造函数也已经足够了。除此之外,Attribute对象的属性包含了默认值,在必要的时候可以进行修改。

在Java语言中,事实上我们也可以修改RegexValidation类,让开发人员可以通过这种方式来使用:

@RegexValidation("^\\w+@[a-zA-Z_]+?\\.[a-zA-Z]{2,3}$")

只要我们可以把RegexValidation改成这样即可:

public @interface RegexValidation {
String value();
}

在Java语言中,假如Attribute的一个成员名为value,且没有其他成员(或是其他成员都提供了默认值),那么便可以如C#中构造函数般使用。那么……

如果我想使用“构造函数”的方式传递数据,但成员名还是想用pattern,可以吗?

如果我想使用“构造函数”的方式传递多个参数,可以吗?

不可以。我也不知道为什么会有这种特别的约定规则呢?可能和Annotation是接口有关吧。接口没有构造函数,因此没有一个合适的方式以省去成员名的方式传递参数,只能指定一个特别的名称了——但对于开发人员来说,凭什么我的Regex值一定要叫value而不能叫pattern呢?

Java 5.0学习C#

我不知道为什么有了C#的优秀榜样,Java 5.0却还是不愿意做的更好。其实C#中的Attribute也有缺点,例如无法使用泛型,所以Java完全有胜过C#的机会。其实在以后的文章中您也可以发现这样一个现象:C#的榜样并不完美,但Java的进化更为糟糕。对此,我们除了一声长叹又能怎么办呢?

除了Annotation之外,Java 5.0还从C#处学习了以下功能:

  • 可以使用enum关键字定义强类型的常量——C# 1.0中也有类似功能(好吧,我承认,其实Java的enum功能比C#中要丰富一些)。
  • 可变参数,即可以使用“一一列举”形式,提供某个方法最后一个数组参数的内容——其实就是C# 1.0中的params。
  • 增强了for的能力,可以方便地使用枚举器(Iterator)——这其实就是C# 1.0中的foreach。

那么,到底是谁是所谓的“copy cat”呢?就像我在第一篇文章中写的那样,“自从C# 1.0诞生之日起,就只出现Java借鉴C#特性的情况,至今已将近10年”,以后我们还可以看到更多例子。我并不想说所谓的“抄袭”,我只想说“学习”或是“借鉴”。我认为,只要是优点,出现雷同这都是完全正常且值得鼓励的。我现在提到这些,主要的目的是想告诉那些固执地认为“C#只是Java的山寨复制品”的同学们一个事实。

本文为 赵劼 发表在 个人博客 的系列文章之一。