9.4.5 属性

C#属性是字段的扩展,它配合C#中的字段使用,用以构造一个安全的应用程序。属性提供了灵活的机制来读取、编写或计算私有字段的值,可以像使用公共数据成员一样使用属性,但实际上它们是称做“访问器”的特殊方法,其设计目的主要是为了实现面向对象(Object Oriented,OO)中的封装思想。根据该思想,字段最好设为private,一个设计完善的类最好不要直接把字段声明为公有或受保护的,以阻止客户端直接进行访问,其中一个主要原因是,客户端直接对公有字段进行读写,使得我们无法对字段的访问进行灵活的控制,比如控制字段只读或者只写将很难实现。

下面,我们将进一步学习属性及访问器。

1.属性声明和访问器

属性的声明主要包含以下几个部分:访问修饰符、属性类型、属性名称、访问器。

先来看一个属性声明的例子,如代码清单9-9所示。

代码清单9-9 属性声明


class Car

{

private string name;//私有字段

9.4.5 属性 - 图1

}

}


属性访问器包括get访问器和set访问器,分别用于字段的读写操作,但要注意的是,属性本身并不一定和字段相联系。仅包含get访问器的属性为只读属性,仅包含set访问器的属性为只写属性,同时包含两种访问器的属性可读也可写,称做读写属性。代码清单9-9中声明的属性就是一个读写属性。

get访问器的责任是返回字段的值,字段就是该属性所封装的字段,那么很自然地,返回值的类型应该和字段的类型一致。代码清单9-9中的get访问器返回的就是name字段的值,且类型和name字段类型相同,都是string类型。

set访问器的责任是为字段赋值,怎么赋值呢?它是通过一个隐式的参数value来实现值的传入,在代码清单9-9中的set访问器中,name的值就是通过value这个隐式参数赋予的。

注意 在属性中,除了get和set访问器,不允许有其他方法出现。

2.属性和关联字段

代码清单9-10 中第4行的speed字段就是属性的关联字段。

在一个属性中,get访问器和set访问器的职责之一就是对关联字段的封装。其语法为:

❑声明一个私有的字段级变量(这里是类字段,和局部变量不同,请注意区分);

❑使用下列语法声明一个属性,将私有字段封装起来:


public[数据类型][属性名]

{

get

{

//返回字段值

}

set

{

//使用隐式参数value为字段赋值

}

}


我们先来看一个示例,如代码清单9-10所示。

代码清单9-10 属性声明和访问器


1 class Car

2{

3//当前行驶速度

4 private double speed;

5

6//属性

7 public double Speed

8{

9//访问器

10 get

11{

12//返回字段speed的值

13 return speed;

14}

15 set

16{

17//为字段speed赋值

18 speed=value;

19}

20}

21}


我们对上述代码进行简要的讲解,如表9-4所示。

9.4.5 属性 - 图2

注意 从内存分配的角度来看私有字段的声明和初始化,CLR为其分配了内存,而并未给属性分配内存,因为属性本身并不存储数据,它操作的是关联字段。

3.自动实现的属性

自C#3.0以来,我们可以使用另外一种更加简洁的语法[2]来定义属性,其语法如下:


public[数据类型][属性名]

{

get;

set;

}


可见,上述代码可使属性声明变得更加简洁。不过这种语法形式也是有限制的,就是仅当属性访问器中不需要其他的逻辑时,才可以使用这种语法形式。如果属性的访问器中需要执行某些计算,就还是需要使用关联字段的方式。本质上,自动实现的属性这种语法也有自己的关联字段,只不过这个关联字段也是隐式的,是编译器自动生成的。由此可见,编译器帮助我们做了很多的工作,减少了我们的工作量。

下面,我们将从本质上对自动实现的属性进行剖析,了解一下编译器究竟为我们做了哪些工作,并且是如何做的,这对于深刻理解C#的工作原理是大有裨益的。通过查看生成后的CIL和原C#代码来一探究竟,可能是一个不错的主意。你可以在“开始”菜单中"Visual Studio 2010"目录下的"Microsoft Windows SDK Tools"下找到“IL反汇编程序”,它就是将生成的exe反编译成CIL语言的工具,如图9-4所示。

9.4.5 属性 - 图3

图 9-4 反编译工具

工具准备好了,接下来看一个示例程序,如代码清单9-11所示。

代码清单9-11 C#自动属性的工作原理


1 namespace ProgrammingCSharp4

2{

3 class ClassExample

4{

5 static void Main()

6{

7 Car car=new Car();

8 car.Speed=10f;

9 double result=car.Speed;

10}

11}

12

13 class Car

14{

15//属性

16 public double Speed

17{

18//访问器

19 get;

20 set;

21}

22}

23}


注意,这段代码声明了一个类Car,它有一个属性Speed,这里采用C#3.0的自动实现属性语法,可以看到,并没有单独声明一个私有字段变量(关联字段)。接下来,我们查看编译后生成的CIL代码,以了解C#编译器是如何工作的,如图9-5所示。

9.4.5 属性 - 图4

图 9-5 编译成CIL后类Car的结构

图9-5为编译后的Car类的成员组成图,从中可以看到:

❑编译器生成了一个私有的、类型为float64的私有字段级变量:<Speed>k__BackingField,只不过这个字段我们无法从源代码进行访问;

❑编译器生成了两个访问器方法,分别为get_Speed()和set_Speed(float64),我们特别注意到后者有一个float64类型的参数,它就是隐式的参数。

综合来看,可以得出如下结论:

❑本质上,编译器仍然使用的是和C#2.0相同的语法声明属性,即仍然使用关联字段;

❑属性的本质是方法,是一种特殊的方法。

知道了这些以后,我们来具体看一下CIL代码。阅读CIL代码有一定难度,但和汇编相比还是非常简单,因此后面会先介绍几个常见的CIL指令。话说回来,不能完全读懂这些代码也没有关系,我们的重点在于揭示工作原理,而不是学习CIL。只需要借助CIL让大家明白大致的工作原理就算达到了目的。为了帮助大家理解CIL代码,表9-5列举了几个必需的CIL指令。

在继续下文之前,我们还要强调两个概念:入栈和出栈。因为C#在本质上是基于栈的。在CIL中用来负责这个栈实现的部分叫做虚拟执行栈(Virtual Execution Stack,VES)。在下面的CIL代码中,你将看到CIL提供了一系列指令来完成将值压入到栈中,这个过程叫加载(load)。另外,CIL也定义了一系列指令来将栈顶的值移到内存中(例如局部变量),这个过程叫存储(store)。要注意的是,CIL不允许直接访问一个数据,包括局部变量、方法参数或者类的字段数据。为了实现访问,必须显式地将数据加载到栈中,并在使用时弹出。请务必注意这一点。

9.4.5 属性 - 图5

预备知识讲完了,我们可以尝试阅读CIL代码了,先来看Speed属性的CIL代码(对应图9-5中的②部分),如代码清单9-12所示。

代码清单9-12 Speed属性的CIL代码


1.property instance float64 Speed()

2{

3.get instance float64 ProgrammingCSharp4.Car:get_Speed()

4.set instance void ProgrammingCSharp4.Car:set_Speed(float64)

5}//end of property Car:Speed


从上述CIL代码可以看到:

❑第3行:get访问器,调用get_Speed()方法;

❑第4行:set访问器,调用set_Speed(float64)方法,类型为float64的参数即是前面讲到的隐式值参数(value)。

我们继续看这两个访问器的CIL代码(对应图9-5中的①部分),如代码清单9-13和代码清单9-14所示。

代码清单9-13 get访问器(get_Speed方法)的CIL代码


1.method public hidebysig specialname instance float64

2 get_Speed()cil managed

3{

4.custom instance void

[mscorlib]System.Runtime.CompilerServices.CompilerGeneratedAttribute:.ctor()=(01 00 00 00)

5//Code size 11(0xb)

6.maxstack 1

7.locals init(float64 V_0)

8 IL_0000:ldarg.0

9 IL_0001:ldfld float64

ProgrammingCSharp4.Car:'<Speed>k__BackingField'

10 IL_0006:stloc.0

11 IL_0007:br.s IL_0009

12 IL_0009:ldloc.0

13 IL_000a:ret

14}//end of method Car:get_Speed


为了突出实质内容,我们将略过无关的部分。关于上述代码的说明如下:

❑第7行:声明一个局部变量V_0,类型为float64(也就是double);

❑第8行:将局部变量V_0装入堆栈;

❑第9行:将编译器生成的<Speed>k__BackingField字段放入堆栈;

❑第10行:将栈中<Speed>k__BackingField的值赋给V_0变量,并弹出栈;

❑第12行:将局部变量V_0的值放入堆栈;

❑第13行:方法返回,因为栈中有值,此值就作为返回值。

代码清单9-14 set访问器(set_Speed方法)的CIL代码


1.method public hidebysig specialname instance void

2 set_Speed(float64'value')cil managed

3{

4.custom instance void

[mscorlib]System.Runtime.CompilerServices.CompilerGeneratedAttribute:.ctor()

=(01 00 00 00)

5//Code size 8(0x8)

6.maxstack 8

7 IL_0000:ldarg.0

8 IL_0001:ldarg.1

9 IL_0002:stfld float64

ProgrammingCSharp4.Car:'<Speed>k__BackingField'

10 IL_0007:ret

11}//end of method Car:set_Speed


关于上述代码的说明如下:

❑第2行:可以看到前面提到的隐式值参数(value),其类型和属性类型相同;

❑第7行:value参数的值放入栈中;

❑第9行:将栈中value参数的值赋给<Speed>k__BackingField字段;

❑第10行:方法返回,赋值结束。

现在,从更深的层次了解了C#中属性访问器的工作原理,内容比较多且有些抽象,请大家多思考、多实践,对CIL感兴趣的读者也可以自行查阅相关资料。

最后,需要注意的是,如果使用自动实现的属性,get和set访问器必须成对出现,如果只有get而没有set,如下面的代码:


class Car

{

public double Speed2

{

//访问器

get;

}

}


上述代码将无法通过编译,产生的编译错误如下:

"CarCar.Speed2.get"必须声明主体,因为它未标记为abstract或extern。自动实现的属性必须同时定义get访问器和set访问器。

4.只读和只写属性

可以提供灵活的访问控制,是我们使用属性的一个重要理由之一。前面谈过只读、只写和读写属性,如下:

❑只读属性:不具有set访问器或者set访问器为private级别的属性,被视为只读属性;

❑只写属性:不具有get访问器或者get访问器为private级别的属性,被视为只写属性;

❑读写属性:具有set访问器和get访问器。

下面查看一下使用访问修饰符实现属性,语法如下:


public[数据类型][属性名]

{

[访问修饰符]get;

[访问修饰符]set;

}


实现一个只读属性,如代码清单9-15所示。

代码清单9-15 只读属性示例


class Car

{

//只读属性

public double Speed

{

//访问器

get;

private set;

}

//只读属性

public double Speed2

{

//访问器

get;

private set;

}

}


如果试图为Speed属性赋值,CLR将会引发一个异常,意为Speed属性再不可用,因为set访问器不可访问:

由于set访问器不可访问,因此不能在此上下文中使用属性或索引器'ProgrammingCSharp4.Car.Speed'

同理,一个只写的属性如代码清单9-16所示。

代码清单9-16 只写属性示例


//只写属性

public double Speed

{

//访问器

private get;

set;

}

//只写属性

public double Speed

{

//访问器

set;

}


一个字段要么可读可写,要么不可读不可写,可见,仅使用字段是无法获得如此灵活的访问控制特性的。

5.执行计算

属性的访问器不但可以为关联字段赋值和返回关联字段的值,还可以根据需要加入更多的逻辑控制代码。例如,还是使用Car这个类,假如为这辆车限速120km/h,当我们试图让速度超过120km/h时,车辆拒绝加速,而将最大速度设为120km/h,用代码表示如下:


set

{

if(value>120)

{

speed=120;

}

}


这里就在set访问器中加入了逻辑计算功能,同理,get访问器也是一样的,比如在get访问器中进行单位换算等,下面我们给出完整的示例代码,如代码清单9-17所示。

代码清单9-17 在属性中进行逻辑计算


1 namespace ProgrammingCSharp4

2{

3 class ClassExample

4{

5 static void Main()

6{

7 Car car=new Car();

8 car.Speed=130f;

9 System.Console.WriteLine(“当前速度为:{0}”,car.Speed);

10}

11}

12

13 class Car

14{

15 private double speed;

16//属性

17 public double Speed

18{

19//访问器

20 get

21{

22 return speed;

23}

24 set

25{

26 if(value>120)

27{

28 speed=120;

29}

30}

31}

32}

33}


说明:

❑第8行:为Speed属性设置130。

❑第26行:检测(130>120)表达式成立,拒绝接受,转而将120赋给了speed字段。

运行结果为:

当前速度为:120

6.静态属性

和前面讲到的静态变量、静态方法类似,属性也可以声明为静态,使用static关键字即可。只不过因为静态属性属于类级别,因此不能通过类的实例进行访问,也不能在静态属性中使用非静态的关联字段,示例如代码清单9-18所示。

代码清单9-18 静态属性示例


1 namespace ProgrammingCSharp4

2{

3 class ClassExample

4{

5 static void Main()

6{

7 Car.Speed=130f;

8 System.Console.WriteLine(“当前速度为:{0}”,Car.Speed);

9}

10}

11

12 class Car

13{

14//如果属性为静态,则关联字段也必须为静态

15 private static double speed;

16//静态属性

17 public static double Speed

18{

19//访问器

20 get

21{

22 return speed;

23}

24 set

25{

26 if(value>120)

27{

28 speed=120;

29}

30}

31}

32

33//自动属性

34 public static int Number

35{

36 get;

37 set;

38}

39}

40}


说明:

类Car的两个属性Speed和Number均为静态属性,其中Speed属性有关联字段speed,此关联字段同样必须为静态。

[1]访问修饰符,包括public、private、protected等,详细内容请参考9.9节。

[2]又叫做自动属性(Automatic Properties)。