9.4.5 属性
C#属性是字段的扩展,它配合C#中的字段使用,用以构造一个安全的应用程序。属性提供了灵活的机制来读取、编写或计算私有字段的值,可以像使用公共数据成员一样使用属性,但实际上它们是称做“访问器”的特殊方法,其设计目的主要是为了实现面向对象(Object Oriented,OO)中的封装思想。根据该思想,字段最好设为private,一个设计完善的类最好不要直接把字段声明为公有或受保护的,以阻止客户端直接进行访问,其中一个主要原因是,客户端直接对公有字段进行读写,使得我们无法对字段的访问进行灵活的控制,比如控制字段只读或者只写将很难实现。
下面,我们将进一步学习属性及访问器。
1.属性声明和访问器
属性的声明主要包含以下几个部分:访问修饰符、属性类型、属性名称、访问器。
先来看一个属性声明的例子,如代码清单9-9所示。
代码清单9-9 属性声明
class Car
{
private string name;//私有字段
}
}
属性访问器包括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所示。
注意 从内存分配的角度来看私有字段的声明和初始化,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 反编译工具
工具准备好了,接下来看一个示例程序,如代码清单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-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不允许直接访问一个数据,包括局部变量、方法参数或者类的字段数据。为了实现访问,必须显式地将数据加载到栈中,并在使用时弹出。请务必注意这一点。
预备知识讲完了,我们可以尝试阅读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)。