3.3 类
类包含成员,成员可以是静态或实例成员。静态成员属于类;实例成员属于对象。静态字段的值对每个对象都是相同的。而每个对象的实例字段都可以有不同的值。静态成员关联了static修饰符。成员的种类见表3-1。
表3-1
下面详细介绍类成员。
3.3.1 字段
字段是与类相关的变量。前面的例子已经使用了PhoneCustomer类中的字段。
一旦实例化PhoneCustomer对象,就可以使用语法Object.FieldName来访问这些字段,如下例所示:
var customer1 = new PhoneCustomer(); customer1.FirstName = "Simon";
常量与类的关联方式和变量与类的关联方式相同。使用const关键字来声明常量。如果把它声明为public,就可以在类的外部访问它。
class PhoneCustomer { public const string DayOfSendingBill = "Monday"; public int CustomerID; public string FirstName; public string LastName; }
最好不把字段声明为public。如果修改类的公共成员,使用这个公共成员的每个调用程序也需要更改。例如,如果希望在下一个版本中检查最大的字符串长度,公共字段就需要更改为一个属性。使用公共字段的现有代码,必须重新编译,才能使用这个属性(尽管在调用程序看来,语法与属性相同)。如果只在现有的属性中改变检查,那么调用程序不需要重新编译就能使用新版本。
最好把字段声明为private,使用属性来访问字段,如下一节所述。
3.3.2 属性
属性(property)的概念是:它是一个方法或一对方法,在客户端代码看来,它(们)是一个字段。
下面把前面示例中变量名为_firstName的名字字段改为私有。FirstName属性包含get和set访问器,来检索和设置支持字段的值:
class PhoneCustomer { private string _firstName; public string FirstName { get { return _firstName; } set { firstName = value; } } // etc. }
get访问器不带任何参数,且必须返回属性声明的类型。也不应为set访问器指定任何显式参数,但编译器假定它带一个参数,其类型也与属性相同,并表示为value。
下面的示例使用另一个命名约定。下面的代码包含一个属性Age,它设置了一个字段age。在这个例子中,age表示属性Age的后备变量。
private int age; public int Age { get { return age; } set { age = value; } }
注意这里所用的命名约定。我们采用C#的区分大小写模式,使用相同的名称,但公有属性采用Pascal大小写形式命名,如果存在一个等价的私有字段,则它采用camel大小写形式命名。在早期.NET版本中,此命名约定由微软的C#团队优先使用。最近他们使用的命名约定是给字段名加上下划线作为前缀。这会为识别字段而不是局部变量提供极大的便利。
注意:微软团队使用一种或另一种命名约定。使用类型的私有成员时,.NET没有严格的命名约定。然而,在团队里应该使用相同的约定。.NET Core团队转向使用下划线作为字段的前缀,这是本书大多数地方使用的约定(参见https://github.com/dotnet/corefx/blob/master/Documentation/coding-guidelines/coding-style.md)。
1.自动实现的属性
如果属性的set和get访问器中没有任何逻辑,就可以使用自动实现的属性。这种属性会自动实现后备成员变量。前面Age示例的代码如下:
public int Age { get; set; }
不需要声明私有字段。编译器会自动创建它。使用自动实现的属性,就不能直接访问字段,因为不知道编译器生成的名称。
使用自动实现的属性,就不能在属性设置中验证属性的有效性。所以在上面的例子中,不能检查是否设置了无效的年龄。
自动实现的属性可以使用属性初始化器来初始化:
public int Age { get; set; } = 42;
2.属性的访问修饰符
C#允许给属性的get和set访问器设置不同的访问修饰符,所以属性可以有公有的get访问器和私有或受保护的set访问器。这有助于控制属性的设置方式或时间。在下面的代码示例中,注意set访问器有一个私有访问修饰符,而get访问器没有任何访问修饰符。这表示get访问器具有属性的访问级别。在get和set访问器中,必须有一个具备属性的访问级别。如果get访问器的访问级别是protected,就会产生一个编译错误,因为这会使两个访问器的访问级别都不是属性。
public string Name { get { return _name; } private set { _name = value; } }
通过自动实现的属性,也可以设置不同的访问级别:
public int Age { get; private set; }
注意:也可以定义只有get或set访问器的属性。在创建只有set访问器的属性之前,最好创建一个方法来代替。可以将只有get访问器的属性用于只读访问。自动实现的、只有get访问器的属性是C# 6新增的,参见“只读成员”一节。
注意:一些开发人员可能会担心,前面我们列举了许多情况,其中标准C#编码方式导致了大材小用,例如,通过属性访问字段,而不是直接访问字段。这些额外的函数调用是否会增加系统开销,导致性能下降?其实,不需要担心这种编程方式会在C#中带来性能损失。C#代码会编译为IL,然后在运行时JIT编译为本地可执行代码。JIT编译器可生成高度优化的代码,并在适当的时候随意地内联代码(即,用内联代码来替代函数调用)。如果实现某个方法或属性仅是调用另一个方法,或返回一个字段,则该方法或属性肯定是内联的。
通常不需要改变内联的行为,但在通知编译器有关内联的情况时有一些控制。使用属性MethodImpl可以定义不应用内联的方法 (MethodImplOptions.NoInlining),或内联应该由编译器主动完成(MethodImplOptions.AggressiveInlining)。对于属性,需要直接将这个属性应用于get和set访问器。
3.3.3 方法
注意,正式的C#术语区分函数和方法。在C#术语中,“函数成员”不仅包含方法,也包含类或结构的一些非数据成员,如索引器、运算符、构造函数和析构函数等,甚至还有属性。这些都不是数据成员,字段、常量和事件才是数据成员。
1.方法的声明
在C#中,方法的定义包括任意方法修饰符(如方法的可访问性)、返回值的类型,然后依次是方法名、输入参数的列表(用圆括号括起来)和方法体(用花括号括起来)。
[modifiers] return_type MethodName([parameters]) { // Method body }
每个参数都包括参数的类型名和在方法体中的引用名称。但如果方法有返回值,则return语句就必须与返回值一起使用,以指定出口点,例如:
public bool IsSquare(Rectangle rect) { return (rect.Height == rect.Width); }
如果方法没有返回值,就把返回类型指定为void,因为不能省略返回类型。如果方法不带参数,仍需要在方法名的后面包含一对空的圆括号()。此时return语句就是可选的——当到达右花括号时,方法会自动返回。
2.表达式体方法
如果方法的实现只有一个语句,C# 6为方法定义提供了一个简化的语法:表达式体方法。使用新的语法,不需要编写花括号和return关键字,而使用运算符= >( lambda操作符)区分操作符左边的声明和操作符右边的实现代码。
下面的例子与前面的方法IsSquare相同,但使用表达式体方法语法实现。lambda操作符的右侧定义了方法的实现代码。不需要花括号和返回语句。返回的是语句的结果,该结果的类型必须与左边方法声明的类型相同,在下面的代码片段中,该类型是bool:
public bool IsSquare(Rectangle rect) => rect.Height == rect.Width;
3.调用方法
在下面的例子中,说明了类的定义和实例化、方法的定义和调用的语法。类Math定义了静态成员和实例成员(代码文件MathSample/Math.cs ):
public class Math { public int Value { get; set; } public int GetSquare() => Value * Value; public static int GetSquareOf(int x) => x * x; public static double GetPi() => 3.14159; }
Program类利用Math类,调用静态方法,实例化一个对象,来调用实例成员(代码文件MathSample/ Program.cs):
using static System.Console; namespace MathSample { class Program { static void Main() { // Try calling some static functions. WriteLine($"Pi is {Math.GetPi()}"); int x = Math.GetSquareOf(5); WriteLine($"Square of 5 is {x}"); // Instantiate a Math object var math = new Math(); // instantiate a reference type // Call instance members math.Value = 30; WriteLine($"Value field of math variable contains {math.Value}"); WriteLine($"Square of 30 is {math.GetSquare()}"); } }
运行MathSample示例,会得到如下结果:
Pi is 3.14159 Square of 5 is 25 Value field of math variable contains 30 Square of 30 is 900
从代码中可以看出,Math类包含一个属性和一个方法,该属性包含一个数字,该方法计算该数字的平方。这个类还包含两个静态方法,一个返回pi的值,另一个计算作为参数传入的数字的平方。
这个类有一些功能并不是设计C#程序的好例子。例如,GetPi()通常作为const字段来执行,而好的设计应使用目前还没有介绍的概念。
4.方法的重载
C#支持方法的重载——方法的几个版本有不同的签名(即,方法名相同,但参数的个数和/或数据类型不同)。为了重载方法,只需要声明同名但参数个数或类型不同的方法即可:
class ResultDisplayer { public void DisplayResult(string result) { // implementation } public void DisplayResult(int result) { // implementation } }
不仅参数类型可以不同,参数的数量也可以不同,如下一个示例所示。一个重载的方法可以调用另一个重载的方法:
class MyClass { public int DoSomething(int x) { return DoSomething(x, 10); // invoke DoSomething with two parameters } public int DoSomething(int x, int y) { // implementation } }
注意:对于方法重载,仅通过返回类型不足以区分重载的版本。仅通过参数名称也不足以区分它们。需要区分参数的数量和/或类型。
5.命名的参数
调用方法时,变量名不需要添加到调用中。然而,如果有如下的方法签名,用于移动矩形:
public void MoveAndResize(int x, int y, int width, int height)
用下面的代码片段调用它,就不能从调用中看出使用了什么数字,这些数字用于哪里:
r.MoveAndResize(30, 40, 20, 40);
可以改变调用,明确数字的含义:
r.MoveAndResize(x: 30, y: 40, width: 20, height: 40);
任何方法都可以使用命名的参数调用。只需要编写变量名,后跟一个冒号和所传递的值。编译器会去掉变量名,创建一个方法调用,就像没有变量名一样——这在编译后的代码中没有差别。
还可以用这种方式更改变量的顺序,编译器会重新安排,获得正确的顺序。其真正的优势是下一节所示的可选参数。
6.可选参数
参数也可以是可选的。必须为可选参数提供默认值。可选参数还必须是方法定义的最后的参数:
public void TestMethod(int notOptionalNumber, int optionalNumber = 42) { WriteLine(optionalNumber + notOptionalNumber); }
这个方法可以使用一个或两个参数调用。传递一个参数,编译器就修改方法调用,给第二个参数传递42。
TestMethod(11); TestMethod(11, 22);
注意:因为编译器用可选参数改变方法,传递默认值,所以在程序集的新版本中,默认值不应该改变。在新版本中修改默认值,如果调用程序在没有重新编译的另一个程序集中,就会使用旧的默认值。这就是为什么应该只给可选参数提供永远不会改变的值。如果默认值更改时,总是重新编译调用的方法,这就不是一个问题。
可以定义多个可选参数,如下所示:
public void TestMethod(int n, int opt1 = 11, int opt2 = 22, int opt3 = 33) { WriteLine(n + opt1 + opt2 + opt3); }
这样,该方法就可以使用1、2、3或4个参数调用。下面代码中的第一行给可选参数指定值11、22和33。第二行传递了前三个参数,最后一个参数的值是33:
TestMethod(1); TestMethod(1, 2, 3);
通过多个可选参数,命名参数的特性就会发挥作用。使用命名参数,可以传递任何可选参数,例如,下面的例子仅传递最后一个参数:
TestMethod(1, opt3: 4);
注意:注意使用可选参数时的版本控制问题。一个问题是在新版本中改变默认值;另一个问题是改变参数的数量。添加另一个可选参数看起来很容易,因为它是可选的。然而,编译器更改调用代码,填充所有的参数,如果以后添加另一个参数,早期编译的调用程序就会失败。
7.个数可变的参数
使用可选参数,可以定义数量可变的参数。然而,还有另一种语法允许传递数量可变的参数——这个语法没有版本控制问题。
声明数组类型的参数(示例代码使用一个int数组),添加params关键字,就可以使用任意数量的int参数调用该方法。
public void AnyNumberOfArguments(params int[] data)
{
foreach (var x in data)
{
WriteLine(x);
}
}
注意:数组参见第7章。
AnyNumberOfArguments方法的参数类型是int[],可以传递一个int数组,或因为params关键字,可以传递一个或任何数量的int值:
AnyNumberOfArguments(1); AnyNumberOfArguments(1, 3, 5, 7, 11, 13);
如果应该把不同类型的参数传递给方法,可以使用object数组:
public void AnyNumberOfArguments(params object[] data)
{
// etc.
现在可以使用任何类型调用这个方法:
AnyNumberOfArguments("text", 42);
如果params关键字与方法签名定义的多个参数一起使用,则params只能使用一次,而且它必须是最后一个参数:
WriteLine(string format, params object[] arg);
前面介绍了方法的许多方面,下面看看构造函数,这是一种特殊的方法。
3.3.4 构造函数
声明基本构造函数的语法就是声明一个与包含的类同名的方法,但该方法没有返回类型:
public class MyClass { public MyClass() { } // rest of class definition
没有必要给类提供构造函数,到目前为止本书的例子中没有提供这样的构造函数。一般情况下,如果没有提供任何构造函数,编译器会在后台生成一个默认的构造函数。这是一个非常基本的构造函数,它只能把所有的成员字段初始化为标准的默认值(例如,引用类型为空引用,数值数据类型为0, bool为false)。这通常就足够了,否则就需要编写自己的构造函数。
构造函数的重载遵循与其他方法相同的规则。换言之,可以为构造函数提供任意多的重载,只要它们的签名有明显的区别即可:
public MyClass() // zeroparameter constructor { // construction code } public MyClass(int number) // another overload { // construction code }
但是,如果提供了带参数的构造函数,编译器就不会自动提供默认的构造函数。只有在没有定义任何构造函数时,编译器才会自动提供默认的构造函数。在下面的例子中,因为定义了一个带单个参数的构造函数,编译器会假定这是可用的唯一构造函数,所以它不会隐式地提供其他构造函数:
public class MyNumber { private int _number; public MyNumber(int number) { _number = number; } }
如果试图使用无参数的构造函数实例化MyNumber对象,就会得到一个编译错误:
var numb = new MyNumber(); // causes compilation error
注意,可以把构造函数定义为private或protected,这样不相关的类就不能访问它们:
public class MyNumber { private int _number; private MyNumber(int number) // another overload { _number = number; } }
这个例子没有为MyNumber定义任何公有的或受保护的构造函数。这就使MyNumber不能使用new运算符在外部代码中实例化(但可以在MyNumber中编写一个公有静态属性或方法,以实例化该类)。这在下面两种情况下是有用的:
● 类仅用作某些静态成员或属性的容器,因此永远不会实例化它。在这种情况下,可以用static修饰符声明类。使用这个修饰符,类只能包含静态成员,不能实例化。
● 希望类仅通过调用某个静态成员函数来实例化(这就是所谓对象实例化的类工厂方法)。单例模式的实现如下面的代码片段所示:
public class Singleton { private static Singleton s_instance; private int _state; private Singleton(int state) { _state = state; } public static Singleton Instance { get { return s_instance ? ? (s_instance = new MySingleton(42); } } }
Singleton类包含一个私有构造函数,所以只能在类中实例化它本身。为了实例化它,静态属性Instance返回字段s_instance。如果这个字段尚未初始化(null),就调用实例构造函数,创建一个新的实例。为了检查null,使用合并操作符。如果这个操作符的左边是null,就处理操作符的右边,调用实例构造函数。
注意:合并操作符参见第8章。
1.从构造函数中调用其他构造函数
有时,在一个类中有几个构造函数,以容纳某些可选参数,这些构造函数包含一些共同的代码。例如,下面的情况:
class Car { private string _description; private uint _nWheels; public Car(string description, uint nWheels) { _description = description; _nWheels = nWheels; } public Car(string description) { _description = description; _nWheels = 4; } // etc.
这两个构造函数初始化相同的字段,显然,最好把所有的代码放在一个地方。C#有一个特殊的语法,称为构造函数初始化器,可以实现此目的:
class Car
{
private string _description;
private uint _nWheels;
public Car(string description, uint nWheels)
{
_description = description;
_nWheels = nWheels;
}
public Car(string description): this(description, 4)
{
}
// etc
这里,this关键字仅调用参数最匹配的那个构造函数。注意,构造函数初始化器在构造函数的函数体之前执行。现在假定运行下面的代码:
var myCar = new Car("Proton Persona");
在本例中,在带一个参数的构造函数的函数体执行之前,先执行带两个参数的构造函数(但在本例中,因为在带一个参数的构造函数的函数体中没有代码,所以没有区别)。
C#构造函数初始化器可以包含对同一个类的另一个构造函数的调用(使用前面介绍的语法),也可以包含对直接基类的构造函数的调用(使用相同的语法,但应使用base关键字代替this)。初始化器中不能有多个调用。
2.静态构造函数
C#的一个新特征是也可以给类编写无参数的静态构造函数。这种构造函数只执行一次,而前面的构造函数是实例构造函数,只要创建类的对象,就会执行它。
class MyClass { static MyClass() { // initialization code } // rest of class definition }
编写静态构造函数的一个原因是,类有一些静态字段或属性,需要在第一次使用类之前,从外部源中初始化这些静态字段和属性。
.NET运行库没有确保什么时候执行静态构造函数,所以不应把要求在某个特定时刻(例如,加载程序集时)执行的代码放在静态构造函数中。也不能预计不同类的静态构造函数按照什么顺序执行。但是,可以确保静态构造函数至多运行一次,即在代码引用类之前调用它。在C#中,通常在第一次调用类的任何成员之前执行静态构造函数。
注意,静态构造函数没有访问修饰符,其他C#代码从来不显式调用它,但在加载类时,总是由.NET运行库调用它,所以像public或private这样的访问修饰符就没有任何意义。出于同样原因,静态构造函数不能带任何参数,一个类也只能有一个静态构造函数。很显然,静态构造函数只能访问类的静态成员,不能访问类的实例成员。
无参数的实例构造函数与静态构造函数可以在同一个类中定义。尽管参数列表相同,但这并不矛盾,因为在加载类时执行静态构造函数,而在创建实例时执行实例构造函数,所以何时执行哪个构造函数不会有冲突。
如果多个类都有静态构造函数,先执行哪个静态构造函数就不确定。此时静态构造函数中的代码不应依赖于其他静态构造函数的执行情况。另一方面,如果任何静态字段有默认值,就在调用静态构造函数之前分配它们。
下面用一个例子来说明静态构造函数的用法。该例子的思想基于包含用户首选项的程序(假定用户首选项存储在某个配置文件中)。为了简单起见,假定只有一个用户首选项——BackColor,它表示要在应用程序中使用的背景色。因为这里不想编写从外部数据源中读取数据的代码,所以假定该首选项在工作日的背景色是红色,在周末的背景色是绿色。程序仅在控制台窗口中显示首选项——但这足以说明静态构造函数是如何工作的。
类UserPreferences用static修饰符声明,因此它不能实例化,只能包含静态成员。静态构造函数根据星期几初始化BackColor属性 (代码文件StaticConstructorSample / UserPreferences.cs):
public static class UserPreferences { public static Color BackColor { get; } static UserPreferences() { DateTime now = DateTime.Now; if (now.DayOfWeek == DayOfWeek.Saturday || now.DayOfWeek == DayOfWeek.Sunday) { BackColor = Color.Green; } else { BackColor = Color.Red; } } }
这段代码使用了.NET Framework类库提供的System.DateTime结构。DateTime结构实现了返回当前时间的静态属性Now, DayOfWeek属性是DateTime的实例属性,返回一个类型DayOfWeek的枚举值。
Color定义为enum类型,包含几种颜色。enum类型详见“枚举”一节(代码文件StaticConstructor-Sample/Enum.cs)::
public enum Color { White, Red, Green, Blue, Black }
Main方法调用WriteLine方法,把用户首选的背景色写到控制台(代码文件StaticConstructorSample/Program.cs):
class Program { static void Main() { WriteLine( $"User-preferences: BackColor is: {UserPreferences.BackColor}"); } }
编译并运行这段代码,会得到如下结果:
User-preferences: BackColor is: Color Red
当然,如果在周末执行上述代码,颜色首选项就是Green。
3.3.5 只读成员
如果不希望在初始化后修改数据成员,就可以使用readonly关键字。下面详细描述只读字段和只读属性。
3.3.6 只读字段
为了保证对象的字段不能改变,字段可以用readonly修饰符声明。带有readonly修饰符的字段只能在构造函数中分配值。它与const修饰符不同。编译器通过const修饰符,用其值取代了使用它的变量。编译器知道常量的值。只读字段在运行期间通过构造函数指定。与常量字段相反,只读字段可以是实例成员。使用只读字段作为类成员时,需要把static修饰符分配给该字段。
如果有一个用于编辑文档的程序,因为要注册,所以需要限制可以同时打开的文档数。现在假定要销售该软件的不同版本,而且顾客可以升级他们的版本,以便同时打开更多的文档。显然,不能在源代码中对最大文档数进行硬编码,而是需要一个字段来表示这个最大文档数。这个字段必须是只读的——每次启动程序时,从注册表键或其他文件存储中读取。代码如下所示:
public class DocumentEditor { private static readonly uint s_maxDocuments; static DocumentEditor() { s_maxDocuments = DoSomethingToFindOutMaxNumber(); } }
在本例中,字段是静态的,因为每次运行程序的实例时,只需要存储最大文档数一次。这就是在静态构造函数中初始化它的原因。如果只读字段是一个实例字段,就要在实例构造函数中初始化它。例如,假定编辑的每个文档都有一个创建日期,但不允许用户修改它(因为这会覆盖过去的日期)。
如前所述,日期用基类System.DateTime表示。下面的代码在构造函数中使用DateTime结构初始化_creationTime字段。初始化Document类后,创建时间就不能改变了:
public class Document { private readonly DateTime _creationTime; public Document() { _creationTime = DateTime.Now; } }
在上面的代码段中,CreationDate和MaxDocuments的处理方式与任何其他字段相同,但因为它们是只读的,所以不能在构造函数外部赋值:
void SomeMethod() { s_maxDocuments = 10; // compilation error here. MaxDocuments is readonly }
还要注意,在构造函数中不必给只读字段赋值。如果没有赋值,它的值就是其特定数据类型的默认值,或者在声明时给它初始化的值。这适用于只读的静态字段和实例字段。
1.只读属性
在属性定义中省略set访问器,就可以创建只读属性。因此,如下代码把Name变成只读属性:
private readonly string _name; public string Name { get { return _name; } }
用readonly修饰符声明字段,只允许在构造函数中初始化属性的值。
同样,在属性定义中省略get访问器,就可以创建只写属性。但是,这是不好的编程方式,因为这可能会使客户端代码的作者感到迷惑。一般情况下,如果要这么做,最好使用一个方法替代。
2.自动实现的只读属性
C# 6提供了一个简单的语法,使用自动实现的属性创建只读属性,访问只读字段。这些属性可以使用属性初始化器来初始化。
public string Id { get; } = Guid.NewGuid().ToString();
在后台,编译器会创建一个只读字段和一个属性,其get访问器可以访问这个字段。初始化器的代码进入构造函数的实现代码,并在调用构造函数体之前调用。
当然,只读属性也可以在构造函数中初始化,如下面的代码片段所示:
public class Person { public Person(string name) { Name = name; } public string Name { get; } }
3.表达式体属性
C# 6中与属性相关的另一个扩展是表达式体属性。类似于表达式体方法,表达式体属性不需要花括号和返回语句。表达式体属性是带有get访问器的属性,但不需要编写get关键字。只是get访问器的实现后跟lambda操作符。对于Person类,FullName属性使用表达式体属性实现,通过该属性返回FirstName和LastName属性值的组合:
public class Person
{
public Person(string firstName, string lastName)
{
FirstName = firstName;
LastName = lastName;
}
public string FirstName { get; }
public string LastName { get; }
public string FullName => $"{FirstName} {LastName}";
}
4.不可变的类型
如果类型包含可以改变的成员,它就是一个可变的类型。使用readonly修饰符,编译器会在状态改变时报错。状态只能在构造函数中初始化。如果对象没有任何可以改变的成员,只有只读成员,它就是一个不可变类型。其内容只能在初始化时设置。这对于多线程是非常有用的,因为多个线程可以访问信息永远不会改变的同一个对象。因为内容不能改变,所以不需要同步。
不可变类型的一个例子是String类。这个类没有定义任何允许改变其内容的成员。诸如ToUpper(把字符串更改为大写)的方法总是返回一个新的字符串,但传递到构造函数的原始字符串保持不变。