C#高级编程第11版

导航

第三章 Objects and Types

3.1 创建及使用类

说到这里,我们已经向你介绍了C#的部分代码块组成,包括变量,数据类型,控制流语句等等,你也看了部分写在Main方法中的简单示例。但是接下来我们将向你展示,如何使用这些元素来构建一个更长更大的完整应用程序。这个关键在于如何使用class,这也是本章的主题。而第四章,C#面向对象程序设计,将会更详细的介绍继承以及与继承相关的特性。

注意:本章介绍的是跟class有关的基础语法。并且,我们假设你已经非常熟悉使用class的基本原理——譬如,你了解什么是构造函数以及什么是属性。本章仅限于介绍在C#代码中应用这些原理。

3.2 类和结构

类(classes)和结构(struct)是你创建对象(object)的必要模版。每一个对象都包含着数据以及处理数据或者访问数据的方法。class定义了使用这个class创建的对象实例(instance)包含了什么数据以及能做到什么(behavior)。举个例子,你定义了一个代表客户(customer)的class,它可能定义了一些字段,像CustomerID,FirstName,LastName和Address等等,这些字段用来标识用户的唯一性。可能还定义了一些功能方法,用来给这些字段赋值,保存相应的数据。然后你就可以通过这个class创建一个客户的对象实例,给它赋值,使它具有唯一性,最后调用它的各种功能方法:

class PhoneCustomer
{
	public const string DayOfSendingBill = "Monday";
	public int CustomerID;
	public string FirstName;
	public string LastName;
}

结构与类不同,因为它们不需要保存在托管堆上(类是引用类型,实际数据保存在托管堆上)。结构是值类型,通常直接保存在栈上。而且,结构不能继承另外一个结构。

出于性能的考虑,你可以为一些较小的数据类型,创建一个结构体来包含它们。保存在栈上的数据不会被垃圾处理器(GC)回收。另外一种使用struct的场景是与原生代码(native code)交互。结构的设计(layout)看起来跟原生数据类型很像。

从语法上来看,struct和class非常类似,只不过结构用struct定义,而类用class定义而已。假如你想让所有的PhoneCustomer实例的内存都分配在栈上而不是分配在托管堆上的话,你可以像下面这么写:

struct PhoneCustomerStruct
{
	public const string DayOfSendingBill = "Monday";
	public int CustomerID;
	public string FirstName;
	public string LastName;
}

class和struct都是使用new关键字来创建实例的,这个关键字创建一个对象实例并且对它进行初始化。在下面这个例子中,初始化的时候会默认地将实例的fields都清零:

var myCustomer = new PhoneCustomer(); // works for a class
var myCustomer2 = new PhoneCustomerStruct();// works for a struct

在大部分情况下,你用class要比使用struct频繁得多,因此本章会首先着重介绍class,然后再介绍它们的区别,以便你能决定何时使用class以及何时用struct。除非特殊说明,你可以假设将同样的代码写在class里跟写在struct里运行效果是一样的。

注意:struct和class之间一个很重要的区别是在传递参数时,class传递的是引用地址,而struct是直接传递的值。

3.3 类

一个类包括很多成员,可以是静态的或者实例成员。一个静态成员属于这个类,而一个实例成员则属于由这个类创建的对象(object)。通过静态成员,这个类的所有实例共享相同的值。通过实例成员,每个对象可以拥有不同的值。我们用static关键字修饰静态成员。

3.3.1 字段

字段(Fields)是与类相关的变量。你已经在前面的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;
}

3.3.2 只读字段

为了保证一个对象的字段不会被修改,你可以用readonly关键字声明该字段。只读字段只允许在构造函数的时候进行赋值,这点跟const声明的常量不同。通过const关键字,编译器将所有引用该常量的地方直接替换成常量值,因为编译器早就知道该常量具体是什么值。而readonly字段则在调用构造函数时,才会进行赋值。跟常量不同的是,readonly字段可以是实例成员,意味着不同的类实例该字段的值也有可能不同。如果你想将一个readonly字段变成类的公共成员,你需要使用static修饰符。

假设你有一个编辑文档的应用程序,出于版权的考虑你可能需要限制能同时打开的文档数量。我们再假设你将一个软件分好几个版本进行出售,需要让客户可以通过升级授权解锁更多的文档数量。明眼人都能看出,这样你不能简单地在代码里写死一个最大文档数,你可能需要一个字段来表示maximum number。这个字段需要从某个地方读取值——可能存在某个文件中——当程序开始运行时。所以你的程序代码可能像下面这样:

public class DocumentEditor
{
	private static readonly uint s_maxDocuments;
	static DocumentEditor()
	{
		s_maxDocuments = DoSomethingToFindOutMaxNumber();
	}
}

在这个案例中,这个字段被标识成static,因为这个最大文档数需要被所有应用程序的实例共用,这也是为什么这个字段由静态构造函数初始化。如果你使用的是readonly修饰的实例字段,你必须在实例构造函数中初始化它,例如每个文档可以有一个创建日期,你不想让用户修改它(只能创建一次),这个时候你就可以将它声明成readonly。

日期类型通常用到是System.DateTime结构体表示,下面的代码在构造函数里初始化了一个_creationTime字段,它被声明成readonly,整个构造函数执行完后,这个字段的值就不允许再被更改:

public class Document
{
	private readonly DateTime _creationTime;
	public Document()
	{
		_creationTime = DateTime.Now;
	}
}

如果你试图在构造函数外修改只读字段,会引发一个编译错误:

void SomeMethod()
{
	s_maxDocuments = 10; // compilation error here. MaxDocuments is readonly
}

值得一提的是,你并不是必须在构造函数里给readonly字段赋值。如果你没有主动赋值,字段将会使用其声明类型的默认值或者你在声明语句中使用的初始值。这个设定对static和实例readonly字段都有效。

最好不要将字段声明为public。如果你修改了一个类的public成员,那么引用这个成员的每个类也同样需要被修改。举个例子,假如你想推行一个新版本的检查最大字符串长度的变量,然后这个public字段被改成了Property。用到这个字段的现有代码就需要因为这个改动而进行重新编译(虽然在语法上,调用一个public字段和一个public属性没什么区别,调用类的代码也不需要更改)。但如果你一开始使用的就是public属性,那么你只需要对其getter进行修改,编译这个类即可,其他调用类不需要重新编译。

将字段声明成private并且用property来访问字段,是一个比较好的编程实践,就像下个小节中介绍的一样。

3.3.3 属性

一提到属性(Property)很容易就想到,它是一个方法(或者一组方法)的封装,看起来和字段很像。让我们将前面例子中的first name字段修改成一个private字段,并命名为_firstName。然后用一个叫FirstName的属性来设置和访问内部字段(back field)的值:

class PhoneCustomer
{
	private string _firstName;
	public string FirstName
	{
		get
		{
			return _firstName;
		}
		set
		{
			_firstName = value;
		}
	}
	//...
}

get访问器没有任何参数并且需要返回一个跟属性同类型的值。你不需要为set属性显式指定一个参数,编译器默认它带有一个参数,用关键字value进行引用。

接下来让我们看看另外一个例子,这里我们声明了一个叫Age的属性,并且它包装了一个叫age的字段。在这个例子中,age就是Age的内部变量(back variables):

private int age;
public int Age
{
	get
	{
		return age;
	}
	set
	{
		age = value;
	}
}

注意这里的命名,在C#里变量名是大小写敏感的,所以你这么声明在C#里没有任何问题——对public属性使用Pascal命名法,对private字段使用Camel命名法。在早期的.NET版本中,这种命名方式是C#组最喜欢的。最近他们改变了这种方式,取而代之的是使用一个下划线开头的变量名称。这种方式也可以很明显地看出这是个字段,与局部变量不同。

注意:Microsoft的开发团队可能使用多个不同的命名方式,没有什么特别严格的规定。然而,一个团队里的命名风格最好能统一。.NET Core团队开始用下划线_开头的方式来命名private字段,你可以在https://github.com/dotnet/corefx/blob/master/Documentation/coding-guidelines/coding-style.md里找到代码风格的说明文档。

3.3.3.1 用表达式来写Property访问器

在C# 7.0,你也可以通过表达式来简写属性的get/set访问器。举个例子,前面演示的FirstName属性我们可以用=>来写,大括号{}和return都可以省略,如下所示:

private string _firstName;
public string FirstName
{
	get => _firstName;
	set => _firstName = value;
}

用表达式的方式可以把几行代码压缩成一行,既简洁又省事。

3.3.3.2 自动实现属性

如果你没有编写任何的get和set逻辑,也没有定义一个内部字段,编译器会自动帮你生成一个内部字段,然后将它与属性关联,这种方式我们称之为自动实现(auto-implemented)属性。前面的Age属性可以像下面这样写:

public int Age { get; set; }

不需要你再声明一个内部字段,编译器会帮你自动生成。使用自动生成的属性,你无法直接访问它生成的内部字段,因为你不知道编译器生成的字段叫啥。如果你只是想提供一个可读写的属性,你可以使用自动实现,而不需要写很多的内部表达式。

使用自动生成的属性,你无法在setter中对赋值做任何有效性的判断,因为写不了任何判断语句。

自动实现的属性可以像下面这样初始化:

public int Age { get; set; } = 42;
3.3.3.3 属性的访问修饰符

C#允许set和get拥有不同级别的访问修饰符。它允许用public修饰get,而用private或者protected修饰set。这个功能主要是用来控制如何以及何时对属性进行赋值。在接下来的代码示例中,注意set是用private修饰的,但get可不是。在这种情况下,get直接继承属性的访问级别(例子中是public)。

public string Name
{
	get => _name;
	private set => _name = value;
}

它们俩中至少有一个需要继承属性的访问级别,如果此时你在get前面加一个protected修饰的话,就会提示一个编译错误:"不能为属性或索引器“MyClass.Name”的两个访问器同时指定可访问性修饰符"。

public string Name
{
	protected get => _name;
	private set => _name = value;
}

你也可以为自动实现的属性指定不同的访问级别:

public int Age { get; private set; }

注意:有些开发者可能会关心前面各小节中提到的用属性包装字段的方式有没有必要——举个例子,通过属性来实质上访问内部字段,会不会有性能上的损失?因为看起来需要额外的函数调用。答案是否定的。开发人员不用担心C#的这种编程方法。C#将会把这种使用方法编译成IL,并由JIT实时运行。JIT会优化这部分代码,在访问时将它们转换成内联代码(inline code)。

假设我们定义了这么一个属性并在Main方法中调用它:

private static void Main(string[] args)
{
	var myClass = new MyClass();
	myClass.Age = 11;
	Console.WriteLine($"{myClass.Age}");
}

public class MyClass
{
	private int _Age;
	public int Age { get => _Age; set => _Age = value; }
}

我们可以在ILdasm.exe中打开生成好的Main函数:

.method private hidebysig static void  Main(string[] args) cil managed
{
  .entrypoint
  // 代码大小       44 (0x2c)
  .maxstack  2
  .locals init (class Wrox.MyClass V_0)
  IL_0000:  nop
  IL_0001:  newobj     instance void Wrox.MyClass::.ctor()
  IL_0006:  stloc.0
  IL_0007:  ldloc.0
  IL_0008:  ldc.i4.s   11
  IL_000a:  callvirt   instance void Wrox.MyClass::set_Age(int32)
  IL_000f:  nop
  IL_0010:  ldstr      "{0}"
  IL_0015:  ldloc.0
  IL_0016:  callvirt   instance int32 Wrox.MyClass::get_Age()
  IL_001b:  box        [System.Runtime]System.Int32
  IL_0020:  call       string [System.Runtime]System.String::Format(string,
                                                                    object)
  IL_0025:  call       void [System.Console]System.Console::WriteLine(string)
  IL_002a:  nop
  IL_002b:  ret
} // end of method Program::Main

注意IL_000aIL_0016,直接执行的是set_Age(int32)和get_Age(),让我们再看看Age属性:

.property instance int32 Age()
{
  .get instance int32 Wrox.MyClass::get_Age()
  .set instance void Wrox.MyClass::set_Age(int32)
} // end of property MyClass::Age

可以看到它的getter和setter直接就被编译成了get_Age()和set_Age(int32)函数,可以看到getter和setter只是C#为了方便我们快速开发提供的一种语言特性。

大多数时候你不需要强制改变编译器的这种优化处理方式,但假如你需要控制编译器是否生成优化代码的话,你可以使用MethodImpl特性(attribute),你可以通过将一个方法标识为MethodImplOptions.NoInlining来阻止JIT将它生成内联代码,也可以通过标识为MethodImplOptions.AggressiveInlining来要求JIT将它生成内联代码。如果你想在属性上使用这个特性,你需要直接应用到get和set访问器上。让我们把Age属性做一下修改:

public int Age
{
	[MethodImpl(MethodImplOptions.AggressiveInlining)]
	get => _Age;
	[MethodImpl(MethodImplOptions.NoInlining)]
	set => _Age = value;
}

重新生成,可以看见setter的IL变成了这样,注意有个cil managed noinlining

.method public hidebysig specialname instance void 
        set_Age(int32 'value') cil managed noinlining
{
  // 代码大小       8 (0x8)
  .maxstack  8
  IL_0000:  ldarg.0
  IL_0001:  ldarg.1
  IL_0002:  stfld      int32 Wrox.MyClass::_Age
  IL_0007:  ret
} // end of method MyClass::set_Age

而默认生成内联代码的时候它是这样的:

.method public hidebysig specialname instance void 
        set_Age(int32 'value') cil managed
{
  // 代码大小       8 (0x8)
  .maxstack  8
  IL_0000:  ldarg.0
  IL_0001:  ldarg.1
  IL_0002:  stfld      int32 Wrox.MyClass::_Age
  IL_0007:  ret
} // end of method MyClass::set_Age

光从IL上只能看见方法名后面有个标记,方法体是一样的,具体还是要看JIT如何将它转换成机器码,让我们先看一下默认生成的机器码:

MyClass.set_Age(Int32)
    L0000: push ebp
    L0001: mov ebp, esp
    L0003: sub esp, 0x8
    L0006: mov [ebp-0x4], ecx
    L0009: mov [ebp-0x8], edx
    L000c: cmp dword [0x16cec1a8], 0x0
    L0013: jz L001a
    L0015: call 0x71bf10f0
    L001a: mov eax, [ebp-0x4]
    L001d: mov edx, [ebp-0x8]
    L0020: mov [eax+0x4], edx
    L0023: nop
    L0024: mov esp, ebp
    L0026: pop ebp
    L0027: ret

cil managed noinlining生成的则是:

MyClass.set_Age(Int32)
    L0000: push ebp
    L0001: mov ebp, esp
    L0003: sub esp, 0x8
    L0006: mov [ebp-0x4], ecx
    L0009: mov [ebp-0x8], edx
    L000c: cmp dword [0x16dec1a8], 0x0
    L0013: jz L001a
    L0015: call 0x71bf10f0
    L001a: mov eax, [ebp-0x4]
    L001d: mov edx, [ebp-0x8]
    L0020: mov [eax+0x4], edx
    L0023: nop
    L0024: mov esp, ebp
    L0026: pop ebp
    L0027: ret

来回比对几次,生成的内容都是一致的。因此我们得出结论,将某个方法标成MethodImplOptions.NoInlining并不会影响这个方法自身的生成。这部分并不是C#高级编程里提及的内容,不在这里做过多的展开,更多相关的知识可以查看扩展内容。需要注意的是在Debug模式下,默认是禁止JIT内联优化的,而Release模式下则是默认执行JIT内联优化。

3.3.3.4 只读属性

C#允许你创建只有getter而没有setter的只读属性。如下所示:

private readonly string _name;
public string Name
{
	get => _name;
}

提示:C#也支持省略getter只带有setter的只写属性的方式,但这种方式不太推荐,因为在调用的时候会造成困扰。如果要支持只写字段(例如Password),建议是用方法(如SetPassword)代替只写属性。

3.3.3.5 自动实现的只读属性

C#提供了一个简单的语法来创建自动实现的只读属性,它也可以在声明的时候直接初始化,如下所示:

public string Id { get; } = Guid.NewGuid().ToString();

在后台,编译器会为你自动创建一个只读字段,和一个只读属性,并将两者关联,而值的初始化语句将会被移到构造函数里执行。

只读属性也可以显式地在构造函数里声明,如下所示:

public class Person
{
	public Person(string name) => Name = name;
	public string Name { get; }
}
3.3.3.6 表达式属性

从C# 6.0开始,只读属性可以直接使用表达式来实现,跟上面在属性的getter里使用表达式=>的例子不同,只读属性直接连get都可以省略。如下所示:

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}";
}
3.3.3.7 不可变类型

如果一个类型包含可以修改的成员,那么它是一个可变类型。通过使用readonly修饰符,编译器负责解释类型的状态是否发生变化,这种类型仅允许在构造函数里进行初始化。如果一个对象没有任何可以修改的成员——它仅包含readonly成员的话——那么它就是一个不可变类型。它的内容仅可以在初始化的时候发生更改。这种类型对于多线程编程会很有用,因为多线程可以同时访问同一个类,而它的值从来不会改变。因为它的值不变,你就不需要编写额外的同步代码。

一个典型的不可变类型是String类。这个类没有定义任何可修改的成员。它的内部方法,如ToUpper之类的,总是返回一个新的字符串,而通过构造函数创建的原始字符串不会发生任何变化。

3.3.4 匿名类型

在第二章核心C#里,我们讨论了通过关键字var来声明隐式类型的变量。而当你使用var关键字搭配上new,你就创建了一个匿名类型。如下所示:

var captain = new
{
	FirstName = "James",
	MiddleName = "T",
	LastName = "Kirk"
};

匿名类型(anonymouse type)是一个继承于object的匿名(nameless)类。跟隐式变量一样,通过初始化器(initializer),我们知晓上述的captain实际上是一个class,它定义了一个人名的三个组成部分。而如果你创建了另外一个对象像这样:

var doctor = new
{
	FirstName = "Leonard",
	MiddleName = string.Empty,
	LastName = "McCoy"
};

这俩的类型是一样的,因为它们的属性完全一致。

Console.WriteLine(captain.GetType()); //<>f__AnonymousType0`3[System.String,System.String,System.String]
Console.WriteLine(doctor.GetType()); //<>f__AnonymousType0`3[System.String,System.String,System.String]

匿名类型的内部成员也允许是被推断(inferred)出来的——譬如它的成员是某个对象实例的成员。这种情况下,初始化语句可以被简写(abbreviated)。考虑以下代码:

internal class Person
{
	public string FirstName;
	public string MiddleName;
	public string LastName;
}

var person = new Person()
{
	FirstName = "Leonard",
	MiddleName = string.Empty,
	LastName = "McCoy"
};

var captain = new
{
	person.FirstName,
	person.MiddleName,
	person.LastName
};

编译器会推断captain的隐藏类型带有三个属性,名称来自于person实例的各个属性,并且在本例中person实例已经赋了值,captain的三个属性也就有了初值。注意这么写会报错:

var captain = new
{
	person.FirstName="John" //无效的匿名类型成员声明符。匿名类型成员必须使用成员赋值、简单名称或成员访问来声明。
};

匿名类型的实际名称开发者无从得知,编译器自动"生成"了一个类型名称,但每次生成的名称不一定一样,只有编译器自己知道这次生成的叫啥。因此,你不能也不要对匿名类型的对象实例使用任何类型反射的处理,因为你将得到不一样的结果。

3.3.5 方法

注意C#的官方技术文档对于函数(functions)和方法(Methods)是有区分的。在C#的官方定义里,术语function不单只包含methods,还包含类或结构体的其他非数据(nondata)成员,这些成员可能包括索引、操作符、构造函数、析构函数——还有一些可能令人惊讶的属性(Properties)。与非数据成员相对的则是数据成员:如字段、常量或者事件等等。

3.3.5.1 声明方法

在C#里,一个方法的定义包含不少修饰符(例如方法的访问等级),接下来是返回值类型,然后是一个方法的名称,然后是一对小括号包着的一系列输入参数,然后是一组大括号,中间包含着方法体,如下所示:

[modifiers] return_type MethodName([parameters])
{
	// Method body
}

每个参数都包含一个类型名,以及一个可以被方法体引用的参数名。另外,如果方法带有返回值,在代码结束的位置,就需要使用一个return语句,返回方法指定的值类型,如下所示:

public bool IsSquare(Rectangle rect)
{
	return (rect.Height == rect.Width);
}

如果方法不需要返回任何东西,则将方法声明成void类型,因为你不能省略方法的返回值;而就算它不需要任何参数,你也需要在方法名后追加一对小括号()。方法声明成void类型的时候,return语句不是必须的,当程序执行到方法体}的时候,会自动结束方法调用。

3.3.5.2 表达式体的方法

假如方法体仅仅只需要一句代码,C#提供了一种更方便的方式来定义方法:使用=>表达式。你不再需要书写{}return关键字。=>操作符,用来区分左侧的定义和右侧的实现。

下面的例子和上面的IsSquare实质上是同一个方法,只不过我们用=>表达式的语法来重新实现它。Lambda操作符的右侧是方法的具体实现,不需要{}和return关键字,你仅仅需要保证右侧的语句返回的值类型与左侧的方法定义一致即可,在下面的代码实例里返回的是bool类型:

public bool IsSquare(Rectangle rect) => rect.Height ==
rect.Width;
3.3.5.3 调用方法

下面的例子演示了类的定义和实例化语法,以及方法定义和调用。Math类定义了示例和静态成员:

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类,调用了它的静态方法,并且实例化了一个对象来调用静态成员:

using System;
namespace MathSample
{
	class Program
	{
		static void Main()
		{
			// Try calling some static functions.
			Console.WriteLine($"Pi is {Math.GetPi()}");
			int x = Math.GetSquareOf(5);
			Console.WriteLine($"Square of 5 is {x}");
			// Instantiate a Math object
			var math = new Math(); // instantiate a reference type
			// Call instance members
			math.Value = 30;
			Console.WriteLine($"Value field of math variable contains {math.Value}");
			Console.WriteLine($"Square of 30 is {math.GetSquare()}");
		}
	}
}

执行上面这个示例你将会看到以下的结果:

Pi is 3.14159
Square of 5 is 25
Value field of math variable contains 30
Square of 30 is 900

就像你在代码里看到的一样,Math类包含了一个number类型的属性,也包含了一个方法来计算它的平方值。它还包括了两个static方法:一个用来返回π值,一个用来根据传递的参数计算平方值。

这个例子并不是一个良好的C#程序设计应有的风格。举个例子,我们通常会用一个const字段来定义PI而不是特意写一个GetPi方法,只不过使用更规范的设计就会设计到一些还没介绍的概念。

3.3.5.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
	}
}

注意,如果你要使用方法重载,不同返回值的重载不是充分必要条件。而且,参数类型一致,数量一致,却仅仅只有方法参数的名称不同,是不行的。要么带有不同类型的参数,要么参数数量不同,是使用方法重载的必要条件。

3.3.5.5 命名参数

调用方法的时候,通常你可以只把参数值传递过去,而不用特意强调参数名。然而,假如你有一个像这样的方法:

public void MoveAndResize(int x, int y, int width, int height)

然后你像这样调用它:

r.MoveAndResize(30, 40, 20, 40);

一眼看上去,并不知道这些具体值都代表什么含义以及这个方法具体做了哪些改变,这时候我们就可以使用命名参数了:

r.MoveAndResize(x: 30, y: 40,  20, height: 40);

所有的方法都可以使用命名参数,你只需要在传递参数值前写上参数名,然后再加上一个:,编译器会自动忽略参数名和:进行调用,就跟上面那个例子一样,因此在编译好的IL里没有任何区别。C# 7.2允许你命名你需要的参数,而在早期的C#版本中,如果你需要使用命名参数,你就得保证在方法调用的时候为每个参数都写上参数名。

使用命名参数的方式,你可以任意组织参数的书序,编译器会进行重新组织,确保他们的正确调用。使用命名参数的另外一大好处就是我们接下来要介绍的可选(Optional)参数。

3.3.5.6 可选参数

C#允许使用可选(Optional)参数,如果你要使用这个特性,你需要为可选参数提供一个默认值,就像下面这个例子演示的一样:

public void TestMethod(int notOptionalNumber, int optionalNumber = 42)
{
	Console.WriteLine(optionalNumber + notOptionalNumber);
}

这个方法可以使用1个或者2个参数进行调用,如果只给方法传递了一个参数,编译器会认为第二个参数使用它的默认值:

TestMethod(11); //相当于TestMethod(11,42);
TestMethod(11, 22);

你也可以定义多个可选参数,就像这样:

public void TestMethod(int n, int opt1 = 11, int opt2 = 22, int opt3 = 33)
{
	Console.WriteLine(n + opt1 + opt2 + opt3);
}

在这种方式下,你可以随意地使用1,2,3或者4个参数来调用这个方法。如下所示:

TestMethod(1);
TestMethod(1, 2, 3);

第一行只传递了n的值,而剩下三个操作数默认使用11,22,33。而第二行则传递了前面3个参数,最后的opt3使用默认值33。

假如有多个可选参数,默认情况下会按顺序进行调用,如果你想跳过中间的某个参数,让它使用默认值,而仅指定其他的某些参数的话,这个时候命名参数就派上用场了。使用命名参数,你可以仅仅只传递你需要的可选参数,如下所示,我们仅仅将opt3设置为4,而opt1和opt2则使用的默认值11和22:

TestMethod(1, opt3: 4);
3.3.5.7 可变数量的参数

使用可选参数,你可以定义调用不同数量参数的方法。C#还提供了另外一种可变数量参数的语法,并且这种语法没有任何版本问题。

通过将参数定义成一个数组,并且使用关键字params,方法就可以使用任意数量的参数进行调用,以下的例子用的是int数组:

public void AnyNumberOfArguments(params int[] data)
{
	foreach (var x in data)
	{
		Console.WriteLine(x);
	}
}

因为方法定义的参数类型是int数组,你只能传递int数组类型的参数,而由于我们使用了关键字params,你也可以选择传递n个int类型的值,但不管哪种调用方式,你只能使用int类型:

AnyNumberOfArguments(1);
AnyNumberOfArguments(1, 3, 5, 7, 11, 13);

如果你需要传递多种类型的参数,那你可以定义一个object数组:

public void AnyNumberOfArguments(params object[] data)
{
	// ...
}

然后它就可以像这样被调用:

AnyNumberOfArguments("text", 42);

如果你的方法不止一个参数,你又想使用params定义的可变参数的话,params只能使用一次,并且它必须是最后一个参数:

Console.WriteLine(string format, params object[] arg);

现在你已经了解了方法的各个方面的内容了,接下来让我们深入了解构造函数constructors,一种特殊类型的方法。

3.3.6 构造函数

定义一个构造函数的基础语法和定义一个方法很类似,只是不需要任何返回值类型,如下所示:

public class MyClass
{
	public MyClass()
	{
	}
	// rest of class definition
}

不一定需要你手动为你创建的类提供构造函数,本书的很多例子就没有提供。通常来讲,如果你没提供构造函数,编译器会为你自动生成一个默认的无参构造函数,然后为所有的字段初始化(为引用类型赋null值,数值类型赋值为0,布尔类型赋值为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(); //未提供与“MyNumber.MyNumber(int)”的必需形参“number”对应的实参	

需要注意的是,将构造器声明成private或者protected也是允许的,这样它们对于无关的类就是不可见的:

public class MyNumber
{
	private int _number;
	private MyNumber(int number) // another overload
	{
		_number = number;
	}
}

上面这个例子实际上没有定义任何public或者protected的构造器。这会使得MyNumber这个类根本无法从外部使用new关键字进行实例化(虽然你可能会在这个类里用public static定义一些属性或者方法可以被外部调用)。这种写法通常在两种情况里会很有用:

  • 如果你的类只是一个提供静态成员或者属性的容器类,那么它不必要也应该不被实例化。在这个应用场景中,其实你可以直接对class使用static声明,这种用static修饰的类智能包含static成员并且无法被实例化。
  • 如果你想让类仅仅通过调用一个static成员函数进行实例化(这种对象实例化就是所谓的工厂模式)。其中一种单例模式的实现代码如下所示:
public class Singleton
{
	private static Singleton s_instance;
	private int _state;
	private Singleton(int state)
	{
		_state = state;
	}
	public static Singleton Instance
	{
		get => s_instance ?? (s_instance = new Singleton(42));
	}
}

Singleton类只包含一个private构造函数,因此只能在它自己内部调用来进行实例化。为了提供该类的实例,提供了一个static修饰的属性Instance,它返回一个私有字段s_instance。如果s_instance未被初始化(null),一个新的实例将会通过私有的构造函数进行创建,并赋值给s_instance。这里我们用到了联结操作符??,如果操作符左侧的值为null,则会执行右侧的语句(此时调用了Singleton的构造函数),并最终返回操作符右侧的值。

3.3.6.1 用表达式书写构造函数

如果构造函数的实现代码仅仅只包含一行表达式,那么它就可以用表达式的方式简写:

public class Singleton
{
	private static Singleton s_instance;
	private int _state;
	private Singleton(int state) => _state = state;
	public static Singleton Instance => s_instance ?? (s_instance = new Singleton(42));
}
3.3.6.2 调用其它构造函数

使用构造函数时你可能会遇到这样的情况,为了提供可选参数而书写多个构造函数,就像下面这样:

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;
	}
	// ...
}

所有的构造函数初始化的是同样的字段,假如你有N个构造函数,那么同样的代码你就要写上N次。众所周知,重复的代码最好提取出来放到一块。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)
	{
	}
	// ...
}

在这种语境(context)里,this关键字简单的调用了最近的参数匹配的(nearest matching)构造函数。注意this调用的内容会在实际函数体执行前先执行。假设我们这么初始化:

var myCar = new Car("Proton Persona");

在这个例子里,实际的实例化顺序应该是:

  • this("Proton Persona", 4) ,也就是执行Car("Proton Persona", 4)。
  • Car("Proton Persona")里的其它代码,只不过刚好这个例子里Car(string description)没有其它代码而已。

C#构造函数可以使用this关键字调用自己类里的其它的构造函数,又或者使用base关键字调用父类的构造函数。但需要注意的是,this或者base都只能有一个。

3.3.6.3 静态构造函数

C#也允许使用static关键字来修饰一个无参构造函数。这个构造函数只会被执行一次,不像其他类型的构造函数,它只会在第一个实例创建的时候,被执行一次:

class MyClass
{
	static MyClass()
	{
		// initialization code
	}
	// rest of class definition
}

使用静态构造函数的其中一个理由可能是某些静态成员在第一次被外部资源(external souce)调用之前需要提前进行初始化。

.NET运行时无法保证静态构造函数会在何时进行执行(有可能在程序集加载的时候初始化),因此你最好不要在其中编写任何需要依赖于某些特殊情况才能运行的代码,否则不同类之间的静态构造函数究竟按什么顺序执行的将是无法预料的。可以保证的是,对于同一个类,静态构造函数最多只会执行一次,并且是在你创建任何类引用之前最先被调用的。

注意静态构造函数没有任何访问修饰符。你无法在C#代码里显式调用它,通常当.NET运行时加载该类时就自动调用了,所以不管是public还是private修饰符对于它来说都是毫无意义的。同样的,静态构造函数也没有必要有任何参数,也因此每个类只能有一个静态构造函数。显而易见的是,静态构造函数里初始化的都是静态成员,而非其他实例成员,或者类。

注意即使你声明了静态构造函数,你依然可以正常声明另外一个无参构造函数。虽然它们签名一样(都没有任何参数),但是它们不会冲突,因为静态构造函数是在一个类加载时执行的,而无参构造函数则是在某个实例创建时调用的。因此,编译器不会对何时执行何种构造函数感到困惑。

如果你在多个类里都写了静态构造函数,这些静态构造函数的执行次序是不定的。因此,你不能在静态构造函数里编写任何需要依赖另外一个静态构造函数是否执行的代码。虽然如此,如果某些静态字段提前声明了默认值的话,那么它们在静态构造函数调用之前,就已经在内存里分配并存在了。

下面这个例子演示了如何使用静态构造函数:

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;
		}
	}
}

这个例子基于用户偏好设置(大概存储在某些配置文件里),为了简单起见,假设仅仅只有一个配置项——BackColor,用来表示(represent)应用程序使用的背景色。因为我们不想过度的深入读取资源文件的细节,这里我们假设这个配置项仅仅会根据日期改变颜色,周末是绿色,工作日则是红色。虽然例子中我们只是在控制台窗口输出内容,但就演示static构造函数是否起效而言已经足够了:

class Program
{
	static void Main()
	{
		Console.WriteLine($"User-preferences: BackColor is:{UserPreferences.BackColor}");
	}
}

注意UserPreferences类用了static修饰符,这意味着它无法被实例化,并且它只能拥有静态成员。它的静态构造函数根据当天处于一周中的第几天进行初始化。代码里使用了.NET Framework提供的System.DateTime结构体。DateTime实现了一个static属性Now返回当前时间。DayOfWeek则是DateTime的一个实例属性,返回的是DayOfWeek枚举中的某个值。这里的Color是我们自定义的一个枚举变量,只包含了有限的几个值:

public enum Color
{
	White,
	Red,
	Green,
	Blue,
	Black
}

编译执行前面的Main函数,你可能会看到以下结果,取决于你自己运行时是星期几,如果是周末的话,结果就是Color Green:

User-preferences: BackColor is: Color Red

3.4 结构

到这里,你已经了解了class是如何为你的应用程序提供多样化的对象封装。你也了解了它们是如何存储在托管堆上的,在数据生命周期中,为你提供尽可能多的灵活性,同时又尽量减少性能损耗。性能方面得益于托管堆的优化而没有太大的影响。然而,在某些特殊的情况下,你可能需要一个小型的数据结构。在这种情况下,class能提供的功能太多,为了最佳的性能,你可能仅仅只想用一个简单的结构体struct。考虑以下的例子,使用引用类型的class:

public class Dimensions
{
	public Dimensions(double length, double width)
	{
		Length = length;
		Width = width;
	}
	public double Length { get; set; }
	public double Width { get; set; }
}

这段代码定义了一个叫Dimensions的class,只是简单的存储了一个Item的长度和宽度。假定你编写了一个家具整理(furniture-arranging)程序,允许用户在电脑中重新布置家具,然后你想存储所有新家具的尺寸。一个尺寸拥有两个数字(长和宽),将它们作为一个整体存储比将它们分开显然更实用(convenient)。既没有需要实现各种方法,也没有必要继承其他类,所以你当然不想将它们存储在托管堆上,因为这需要更多的开销。你所有的诉求,仅仅只是存储两个double类型的变量。

就像前面章节提到的,你仅仅只需要将你代码里的关键字class改成struct即可:

public struct Dimensions
{
	public Dimensions(double length, double width)
	{
		Length = length;
		Width = width;
	}
	public double Length { get; set; }
	public double Width { get; set; }
}

为struct定义功能函数就跟在class里定义方法一样,上面你已经看到了Dimensions结构体的构造函数了,下面我们将为它添加一个属性Diagonal,调用Math类里的Sqrt方法,计算它的对角线值:

public struct Dimensions
{
	public Dimensions(double length, double width)
	{
		Length = length;
		Width = width;
	}
	public double Length { get; set; }
	public double Width { get; set; }
	public double Diagonal => Math.Sqrt(Length * Length + Width * Width);
}

struct是值类型,而非引用类型。这意味着,它们要不单独存储在栈Stack里,要不就在托管堆Heap里的某一行(当它们作为某个实例对象的成员时),并且跟简单数据类型(如int,bool等)拥有相同的生命周期限制:

  • 结构体不支持继承。
  • 如果你没有提供任何构造函数,编译器同样会为结构体创建一个无参构造函数,并初始化各个成员。就算你提供了带参构造函数,你也可以通过无参构造函数来new一个结构体,而class不行。
  • 使用结构体,你可以指定字段在内存中如何存储(lay out),在第16章特性的时候我们会提到如何做。

因为结构体就是用来组织一组数据项的,你会发现绝大多数时候,它的字段被声明成public。严格来讲,这点跟Microsoft编写的.NET指南里的fields(常量以外的字段)需要声明成private并且封装在public的属性中相悖。然而,为了使结构体更加简单,很多开发者都觉得将struct里的字段声明成public也是可以接受的。

int类型(System.Int32)结构体就是带有public字段的类型。新类型System.ValueType也是一个结构体,包含了多个public的字段,我们将在第13章,C#函数式编程中详细讨论。接下来的章节我们主要是探讨struct和class之间的具体差异。

3.4.1 结构是值类型

虽然struct是值类型,但在大部分时候,你可以简单地将它当成class来看。举个例子,假如你将Dimensions定义成class,你可能会这么实例化和赋值:

var point = new Dimensions();
point.Length = 3;
point.Width = 6;

而当你将它声明成struct时,因为struct是值类型,因此new操作符对于struct来说,跟类和其它引用类型的工作机制不同。new操作符会根据传递给它的参数值,调用最合适的构造函数,初始化所有字段,不用在托管堆上分配内存。事实上对于上面定义的结构体Dimensions,虽然你可以这么写:

Dimensions point = new Dimensions();
point.Length = 3;
point.Width = 6;

但更推荐下面这两种写法:

Dimensions point; //直接省略new
point.Length = 3;
point.Width = 6;

或者

Dimensions point = new Dimensions(3, 6);

如果Dimensions是一个class,那么你省略new会引发一个编译错误,因为point是一个空引用——尚未在托管堆上分配任何内存,因此你无法为它的任何字段进行赋值。但是对于struct来说,声明变量的时候,就已经在栈里分配好了相应的结构体内存,所以可以开始赋值。但是,下面这样的代码,也会引发一个编译错误,因为你使用了未初始化的变量:

Dimensions point; //struct中不能实例属性或字段初始值设定项,我们无法像类那样提前初始化Length和Width
double d = point.Length; //使用了未赋值的局部变量point

struct和其他数据类型遵循同样的规则:变量使用前必须先初始化。struct的任何一个构造函数里必须对所有字段进行初始化,这样你就可以用new操作符调用构造函数完成整个struct结构体的初始化。又或者你声明一个struct类型变量,然后手动为所有的字段进行赋值。当然,如果一个struct声明为某个类的成员的话,则在类的实例初始化的时候,会将该struct初始化为初始值(如果没有其它显式初始化struct的代码的话)。

一个事实是结构体作为值类型来说,它可能会影响程序性能,当然这取决于你如何使用它。从好的方面来讲,为结构体分配内存非常快,因为它要么作为托管堆的行内内容(take place inline)或者直接就分配在栈上。它们同样有自己的作用域,当它超出作用域的时候,内存会被马上回收,不用等待垃圾回收机制GC的处理。而消极的方面同样也存在,当你将结构体作为方法参数进行传递,或者将它赋值给另外一个结构体的时候,整个结构体的内容都会被完整地拷贝一份。如果你的结构体非常大,这会导致不小的性能损失。所以我们再次强调,结构体是用来存储那些足够小的关联数据结构的。

注意,当你将结构体作为方法参数进行传递时,为了避免性能损失,你可以使用ref关键字修饰方法形参——在这种情况下,只会将原结构体的地址传递到方法里进行操作,方法里对参数的任何操作都会影响到原结构体。后续的小节会更详细地介绍这种情况。

3.4.2 只读结构

当你将某个struct类型的属性,提供给外部类或者程序调用的时候,调用者获得的只是一个拷贝(copy)。对这个返回值进行任何操作仅仅影响这个拷贝值,对原始的struct没有任何影响。这对于调用该属性的开发者来说会感到混淆(对该属性操作半天,完全不影响结构体的内部值)。这就是为何.NET指南里要求struct定义的值类型必须是不可变的。当然,这份指南没有强制要求所有的值类型(仅仅针对struct),因为int,short,double...等等都不是不可变的,并且新增的ValueType也不是不可变的(immutable)。然而,大部分的struct类型都是当做不可变类型来进行实现的。

当你使用C# 7.2往后的特性的时候,编译器允许你给struct添加一个readonly的修饰符,因此编译器可以确保struct的不可变性。前面的Dimensions结构体可以用readonly进行修饰,因为它拥有能初始化所有值的构造函数方便你进行赋值,但是所有的属性不能拥有set访问器,因为修改是不允许的:

public readonly struct Dimensions
{
	public Dimensions(double length, double width)
	{
		Length = length;
		Width = width;
	}
	public double Length { get; }
	public double Width { get; }
	public double Diagonal => Math.Sqrt(Length * Length + Width * Width);
}

因为你使用了readonly修饰符,当结构体内部拥有构造函数以外的赋值入口的时候,会提示一个编译器错误。同样的通过readonly操作符,编译器会优化相关代码,在调用readonly结构体时,不会拷贝整个struct内容,而是传递struct的引用,因为它的内容是肯定不会变的。

3.4.3 结构和继承

struct没有设计成可继承的,这意味着你无法继承一个结构体。唯一例外的就是,struct和其他所有的C#类型一样,最终都派生自System.Object。因此,struct同样可以访问System.Object拥有的方法,并且可以在struct里override。最简单的例子就是重写ToString方法。实际上每个struct都派生于System.ValueType类,而ValueType又派生于System.Object。ValueType类相对于其父类Object来说并没有新增任何成员,只是提供了一些Object方法的override实现,以便更适合struct使用。注意你无法为struct指定另外的基类:所有的struct都直接派生自ValueType。

注意:

  • 只有当structs当objects一样使用的时候,才会用到ValueType的继承性。而ref structs无法当成objects进行使用。本章3.4.5会更详细的解释ref structs,这里的ref structs说的不是在方法里用ref修饰的形参,而是另外一种结构体。
  • 为了比较两个结构体值,最好的方式是实现IEquatable<T>接口,第六章将会讨论这部分。

3.4.4 结构的构造函数

你可以像定义类的构造函数那样定义结构体的构造函数。

尽管如此,结构体总是隐式提供了用来给所有字段赋初值的无参构造函数。这意味着两点:

  • 不管你是否提供带参的构造函数,结构体都拥有一个无参构造函数。而如果你只为某个class提供了带参构造函数,那么该class并不存在无参构造函数。
  • 你无法再显式地为结构体指定一个无参构造函数。
public readonly struct Test
{
	public Test() //编译错误,结构体不能包含显式的无参构造函数
	{
        
	}
}

顺带一提的是,你可以像为class那样为struct提供Close或者Dispose方法。这部分内容将在第17章进行详细讲解。

3.4.5 ref 结构

struct并非总是存储在栈里的。它们也可能存在于堆中。你可以将一个struct赋值给一个object,这将会在堆上创建一个object对象。这种行为可能会引发某些类型问题。从.NET Core 2.1开始,Span类型允许访问栈上的内存。Span类型的拷贝必须是原子性的(atomic),但这也只能保证该类型何时存在于栈上。并且,Span类型里的字段可以用来存储托管指针(managed pointers),而在堆上存储这些指针会在GC启动的时候导致整个应用程序崩溃。因此,需要保证指针类型永远是存储在栈上的。

而从新的C# 7.2版本开始,引用类型还是存储在堆上,值类型虽然大部分时候存储在栈上,但也允许在堆上存储。为此还设计了第三种类型——只允许存在于栈上的值类型。

这种类型就是用ref关键字修饰的结构体,通常像下面这样声明:

ref struct ValueTypeOnly
{
	//...
}

你可以在里面添加属性,字段,引用类型和方法——就跟其他普通的struct一样。

而唯一不能做的就是,将这个结构体赋值给某个对象变量——举个例子,调用基类Object的ToString方法。这个操作会导致一个装箱(boxing)操作,运行时在后台为struct类型创建了一个相应的引用类型,跟普通的struct不同的是,ref struct并不支持这么干。

ValueTypeOnly vt;
vt.ToString(); //无法将类型隐式地转换成"System.ValueType"

注意:在大部分应用程序中你并不需要用到ref struct,但对于某些要求高性能的应用程序,需要尽可能地减少GC的工作量的时候,则需要用到此类型。第17章我们讲Span类型的时候,会更详细地提到ref关键字修饰的细节,还包括ref return和ref locals。

3.5 按值和按引用传递参数

让我们假设你有一个类型叫A,并且它拥有一个int类型的属性X。方法ChangeA接收一个A类型的参数,并且修改其属性X的值为2,如下所示:

public static void ChangeA(A a)
{
	a.X = 2;
}

Main方法里我们创建一个A的实例,并将其X值初始化为1,并调用ChangeA方法:

static void Main()
{
	A a1 = new A { X = 1 };
	ChangeA(a1);
	Console.WriteLine($"a1.X: {a1.X}");
}

那么,最终输出的结果会是什么呢?是1还是2呢?

答案是,取决于A到底是什么类型,你需要知道A究竟是一个class还是一个struct。

让我们先说当A是一个struct的时候:

public struct A
{
	public int X { get; set; }
}

struct作为参数时是当作值类型传递的。因此ChangeA方法中获得的参数a,其实是栈里存储的a1的一个copy。因此只有该copy发生了改变,并且当ChangeA方法结束是,参数a超出其作用域,因此它就被销毁了,对a1没有任何的影响,因此a1的值仍然是1。

而这跟A是class的情况时截然不同:

public class A
{
	public int X { get; set; }
}

class作为参数时传递的是引用地址。在这种方式下,参数a存储的是与a1一样的地址,并且最终指向堆上的实例对象。当方法ChangeA修改a指向的X属性时,实际上修改的就是a1.X,因为它俩指向的是同一个对象,因此调用后的X结果为2。

注意:为了避免struct和class之间的误用,其实最好的方式是保证struct是不可外部赋值(immutable)的。尽管使用readonly的struct,你不会改变其中的任何成员状态,也不再会遇到这种容易混淆的情况,但很多时候你并不能确保只使用不变的struct。C# 7中新出现的ValueType就是一个可变的struct,只不过ValueType里使用了public声明的fields代替了以前的Properties,这点跟Microsoft给出的编程指南其实是相悖的,但出于元组的重要性(significance of tuples),将struct像int和float之类的基础类型一样使用是值得的。

3.5.1 ref 参数

你也可以将struct当做引用类型进行传递。通过修改ChangeA方法,我们为参数加上ref修饰符,这样即便A是struct类型,它也会传递引用地址:

public static void ChangeA(ref A a)
{
	a.X = 2;
}

而为了让调用方能明确知道自己是传递的参数地址,在调用方法的时候,你需要显式地写上ref修饰符,如下所示:

static void Main()
{
	A a1 = new A { X = 1 };
	ChangeA(ref a1); // 注意这里要加上ref
	Console.WriteLine($"a1.X: {a1.X}");
}

现在结构体也是按地址传递的参数了,就跟类一样,因此调用后的结果为2。

让我们再深入考虑一下A为class的情况,假如我们把ChangeA方法改成下面这样子:

public static void ChangeA(A a)
{
	a.X = 2;
	a = new A { X = 3 };
}

那么输出的结果会是什么呢?显而易见的是Main方法打印的X值肯定不会是1,因为传递的是a1的堆的内存地址值给a,已经修改过该内存地址存储的X值了,将a.X设置成了2,源对象a1.X访问的是同一个地址,得到的就是变化后的2。接下来的一行代码a = new A { X = 3 }现在在堆上创建了一个新的对象,并且a指向了新对象的内存地址。而Main方法里的变量a1则仍然指向原地址,因此a1.X值仍然是2。方法ChangeA调用完成后,参数a得到释放,其指向的堆上的对象因为没有任何引用,所以会被GC回收。但这对于a1来说没有任何影响,a1.X还是2。

而如果我们在方法里添加了ref修饰符的话,这个时候我们传递就不再仅仅是a1指向的堆内存地址了,而是存储了a1这个变量的栈内存地址(用C++的话来说,就是指向指针的指针,a pointer to a pointer),这个时候参数a和参数a1没有任何俩样,因此方法调用后,Main方法里的a1.X就变成了3:

public static void ChangeA(ref A a)
{
	a.X = 2;
	a = new A { X = 3 };
}

最后,最重要的是给方法传递的参数都必须先进行初始化,不管它是通过值传递还是引用传递的。

注意,C# 7.0开始,你可以将ref关键字修饰在局部变量和return返回值上,但这是另外一种新特性,我们将在第17章进行介绍。

3.5.2 out 参数

如果一个方法需要一个返回值,这个方法往往定义成该返回值的类型,并且返回相应类型的结果。那么要返回多个值的时候怎么办,兴许还是不同类型的不同值?有很多方式可以实现这个需求。一个方式是定义一个类或者结构体,并且将所需的返回值都定义成它们的成员,最终返回这个类和结构体。另外一种方式是可以使用元组类型,这个我们在第13章的时候再详细讲述。而第三种方式,则是使用out关键字。

接下来让我们用一个例子来说明,示例中将会用到Int32类型的Parse方法,代码如下所示:

string input1 = Console.ReadLine();
int result1 = int.Parse(input1);
Console.WriteLine($"result: {result1}");

其中,ReadLine方法获取一个用户输入,我们假定用户输入的就是数字,int.Parse方法则负责将输入的字符串转换成int数字。

然而,用户并不会总是如你所愿的每次都输入数字。万一因为手误或者其它原因,用户输入的不是一个正常的数字,那么Parse方法就会抛出一个异常。当然,捕获这个异常并相应地进行处理也不是不能做到(我们将在第14章介绍异常的处理),只是这并非是一个最好的方式来处理"正常"情况,这里我们假设用户会输入错误数据就是一种"正常"的情况。

一种更好的实现方式是使用Int32类型提供的另外一种方法:TryParse。TryParse方法会返回一个bool值,用来判断字符串的转换是否成功。而转换的结果(假如能成功转换成数字的话)则会通过参数进行返回,通过out关键字:

public static bool TryParse(string s, out int result);

调用这个方法,out修饰的result变量不需要提前初始化,这个变量由方法内部赋值。C# 7.0以后,你甚至不用提前声明这个变量,而是由调用方法的内部为你自动创建。跟ref关键字相似,方法调用的时候,传递参数前需要显式使用out关键字:

string input2 = ReadLine();
if (int.TryParse(input2, out int result2))
{
	Console.WriteLine($"result: {result2}");
}
else
{
	Console.WriteLine("not a number");
}

3.5.3 in 参数

C# 7.2开始为参数新增了一个in修饰符,out修饰符允许你通过参数返回值,而in操作符则保证传递给方法的数据不会发生任何改变(当传递的是一个值类型时)。

让我们定义一个简单的可变struct,名字就叫AValueType好了,它带有一个public的可变field:

struct AValueType
{
	public int Data;
}

现在我们将这个struct作为in参数传递给CantChange方法,并且试图为它赋值,将会提示一个编译错误:

static void CantChange(in AValueType a)
{
	a.Data = 43; // 错误:无法分配到变量'in AValueType'的成员,因为它是只读变量
	Console.WriteLine(a.Data);
}

跟ref和out不同的是,当你调用CantChange方法时,写或者不写in修饰符并没有任何影响。为值类型的参数使用in修饰符,不单单可以确保该变量不会发生任何改变,编译器还能创建更好的优化代码。因为是只读的值类型,编译器选择直接传递数据引用,而不是拷贝一份新的值,这样可以减少内存的使用,变相的提高了性能。

注意:虽然in操作符主要是用在值类型上,然而你也可以将它用在引用类型上。在这种情况下,你只能修改引用类型内部成员,而不能修改引用类型自身,假定我们将上面的AValueType修改为class,那么:

private static void CantChange(in AValueType a)
{
	a = new AValueType(); // 错误: 无法分配到变量'in AValueType',因为它是只读变量
	a.Data = 43;  // 允许修改的,没有问题
	Console.WriteLine(a.Data);
}

3.6 可空类型

引用类型的变量(如class)可以是null值而值类型的变量(如structs)则不行。这在某些场景中可能是个问题,譬如将C#类型与数据库或者XML类型进行映射时。数据库或者XML里的int元素可能是空的,但int类型或者double类型却无法赋值为null。

一个解决这种冲突的方式就是为数值类型也使用class进行映射(Java就是这么干的)。使用引用类型在跟数据库之间进行映射的时候确实是允许null值的存在了,但它有一个很不好的地方,就是创建了额外的开销(overhead)。因为用的是引用类型,GC垃圾回收就会时不时扫描它们决定是否需要回收。而值类型则不需要GC进行回收,当它们超出变量作用域时就会立即被销毁并回收占用的内存。

C#的解决方案是:可空类型(nullable types)。可空类型是值类型,但又允许设置成null值。你只需要在类型(必须是struct)后面加上?标识即可。这种方案唯一的开销就是为结构体创建了一个Boolean成员,以便分辨内在的(underlying)实际的struct是否为空值。让我们考虑以下的代码:

int x1 = 1;
int? x2 = null;

这里x1是一个普通的int类型,而x2则是一个可空的int类型,它可以被赋null值。

因为一个普通的int没有任何不能赋给int?的值,因此用int变量直接给int?类型的变量赋值对于编译器来说是允许的:

int? x3 = x1;

但反过来则不行,int?不能直接赋值给int,除非你显式的指定一个强制转换:

int x4 = (int)x3;

当然,这种强制转换在x3为null的时候肯定会引发一个异常。一个更好的方式是使用可空类型的两个属性HasValue和Value。HasValue顾名思义返回一个true或者false,取决于可空类型是否为null,而Value属性则返回实际值。通过使用条件运算符?,下面这么赋值将没有任何异常:

int x5 = x3.HasValue ? x3.Value : -1;

而我们通过联结运算符??更是可以将赋值简写成下面这样,因为x3为null的时候直接返回-1,仅当它拥有int值的时候才会直接赋值给x6:

int x6 = x3 ?? -1;

注意:通过可空类型,你可以使用所有的运算符,只要它内部实际值能进行运算即可——如+,-,*,/或者更多。你可以为所有的struct类型使用可空类型,不仅限于C#预定义的那些。在第五章泛型的时候我们将会介绍更多与可空类型有关的内容。

3.7 枚举类型

枚举类型是值类型,包含了一系列命名常量(named constants),譬如下面所示的Color就是一个枚举类型:

public enum Color
{
	Red,
	Green,
	Blue
}

你可以通过关键字enum定义一个枚举类型,并且通过枚举类型定义相关的变量,并且用某个命名常量为其赋值,如下所示:

private static void ColorSamples()
{
	Color c1 = Color.Red; 
	Console.WriteLine(c1); // Color [Red]
}

默认的,enum类型的内在类型是int。内在类型可以转换成其他任意的整型类型(如byte,short,int,long,无符号数或者有符号数)。枚举类型第一个常量默认从0开始,但也可以自定义,如下所示:

public enum Color : short
{
	Red = 1,
	Green = 2,
	Blue = 3
}

你也可以将一个数字强制转换成相应的枚举类型,如下所示:

Color c2 = (Color)2;
short number = (short)c2;

你也可以使用枚举类型里的多个常量同时给一个变量进行赋值,只不过想这么做,你需要将枚举类型里的常量都定义成bit类型,并且对枚举类型使用Flags进行修饰。让我们看看下面这个枚举DaysOfWeek:

[Flags]
public enum DaysOfWeek
{
	Monday = 0x1,
	Tuesday = 0x2,
	Wednesday = 0x4,
	Thursday = 0x8,
	Friday = 0x10,
	Saturday = 0x20,
	Sunday = 0x40
}

DaysOfWeek为其中的每个常量都定义了不同的值,想设置不同的bit值可以轻松地通过0x开头的16进制数来赋值。Flags特性则告诉编译器创建不同的字符串来表示相应的数字——例如,你为一个DaysOfWeek类型的变量赋值为3,当Flags起效时,你将会得到Monday, Tuesday,如下所示:

DaysOfWeek d1 = (DaysOfWeek)3;
Console.WriteLine(d1); // Monday, Tuesday

通过这样的enum声明,你可以为一个变量赋值多个枚举常量,通过逻辑运算符|进行连接,如下所示:

DaysOfWeek mondayAndWednesday = DaysOfWeek.Monday | DaysOfWeek.Wednesday;
Console.WriteLine(mondayAndWednesday); // Monday, Wednesday

通过设置不同的bit值,我们还可以将这些bit用|组合起来代表特定值,如下所示:

[Flags]
public enum DaysOfWeek
{
	Monday = 0x1,
	Tuesday = 0x2,
	Wednesday = 0x4,
	Thursday = 0x8,
	Friday = 0x10,
	Saturday = 0x20,
	Sunday = 0x40,
	Weekend = Saturday | Sunday
	Workday = 0x1f,
	AllWeek = Workday | Weekend
}

其中,Weekend的值将会是Saturday的0x20通过|运算符加上Sunday的0x40之后,变成0x60。而Workday则是从Monday到Friday之间所有日期的bit相加,所以是0x1f。最后,AllWeek则是由Workday和Weekend通过|组合而成。

通过这种方式,我们也可以直接将DaysOfWeek.Weekend赋值给某个变量,也可以用|组合DaysOfWeek.Saturday和DaysOfWeek.Sunday,它们的结果是一样的。

DaysOfWeek weekend = DaysOfWeek.Saturday | DaysOfWeek.Sunday;
Console.WriteLine(weekend); // Saturday, Sunday

使用枚举类型的过程中,Enum类可能会给你提供不小的帮助,当你需要动态的转换某些枚举类型时。Enum类提供了将字符串转换成对应的枚举常量和获取某个枚举类型所有常量名和值的方法。

下面这个例子将会演示如何使用Enum的TryParse方法来将一个string类型的字符串转换成相应的Color枚举:

if (Enum.TryParse<Color>("Red", out red))
{
	Console.WriteLine($"successfully parsed {red}");
}

注意:Enum.TryParse<T>()是一个泛型方法,其中T是泛型参数类型。T需要在方法调用时进行定义,更多有关泛型方法的细节我们将在第五章进行讨论。

Enum.GetNames方法则会返回一个带有所有常量名称的string[]数组:

foreach (var day in Enum.GetNames(typeof(Color)))
{
	Console.WriteLine(day);
}

执行后你将会看到:

Red
Green
Blue

如果想获取枚举类型的所有常量的值的话,你可以使用Enum.GetValues方法。这个方法会返回一个Array,注意你还需要进行强制转换才能获取相应的数值,就像下面这样用short类型,输出的就是0,1,2:

foreach (short val in Enum.GetValues(typeof(Color)))
{
	Console.WriteLine(val);
}

而如果你使用的是var修饰val的话,默认输出的内容与Enum.GetNames完全一样。

3.8 部分类

partial关键字让你能够讲class,struct,method或者interface分别存到不同文件中。典型的例子就是,某些类型的代码生成器只用来生成class类中的某些特定部分,因此如果能见一个class分隔成多个不同文件进行存储将会很有用。让我们假设你想为某个工具自动生成的class追加一些功能,假如这个工具重启了,那么你所做的改动自然就丢失了。这个时候partial关键字就很有用了,它可以将这个class分隔成两个文件,一个是工具自动生成的文件,在另外一个文件里编辑你想追加的内容,这样你就可以随心修改不怕丢失了。

使用partial关键字,你只需要在class,struct或者interface前面写上partial修饰符即可。在接下来的例子里,SampleClass类被分别存在两个文件SampleClassAutogenerated.cs和SampleClass.cs里:

//SampleClassAutogenerated.cs
partial class SampleClass
{
	public void MethodOne() { }
}
//SampleClass.cs
partial class SampleClass
{
	public void MethodTwo() { }
}

当项目编译的时候,会生成一个带有MethodOne和MethodTwo方法的SampleClass类型。

假如你为某个partial类使用了以下任何一种修饰符,那么其他的partial类的修饰符也必须是完全一样的(他们本来就是同一个类,只不过存在不同地方而已):

  • public
  • private
  • protected
  • internal
  • abstract
  • sealed
  • new
  • generic constraints

在class上使用嵌套partial也是允许的。特性,XML注释,接口,泛型类型参数属性以及成员都会被合并到一起,当不同的partial类编译成一个的时候。考虑以下的例子:

// SampleClassAutogenerated.cs
[CustomAttribute]
partial class SampleClass: SampleBaseClass, ISampleClass
{
	public void MethodOne() { }
}
// SampleClass.cs
[AnotherAttribute]
partial class SampleClass: IOtherSampleClass
{
	public void MethodTwo() { }
}

实际上它等价于:

[CustomAttribute]
[AnotherAttribute]
partial class SampleClass: SampleBaseClass, ISampleClass, IOtherSampleClass
{
	public void MethodOne() { }
	public void MethodTwo() { }
}

注意:虽然将一个大的class分隔成很多文件并且可能有不同的开发者各自维护不同的部分,这看起来很诱人,但实际上,partial关键字并不是设计来干这个的。在这种情况下,更建议你直接将这个大class直接分成若干个小的class,保证每个class只完成一类功能(just for one purpose)。

partial类同样可以包含partial方法。当生成的代码需要调用某个事实上不存在的方法时候这点极其有用。当程序员继承该partial类的时候,可以决定是否为partial方法创建一个自己的实现或者啥也不干。看一下下面这个示例:

//SampleClassAutogenerated.cs
partial class SampleClass
{
	public void MethodOne()
	{
		APartialMethod();
	}
	//分部方法不能有任何修饰符,所以原书例子中这里的public不能有
	//另外partial后面只能跟class,struct,interface或者void
	//public partial void APartialMethod(); 
	partial void APartialMethod();
}

这里的方法MethodOne调用了一个partial方法APartialMethod,因为是用partial修饰的,所以方法不一定需要在这个类里实现。而如果编译的时候发现这个方法没有任何实现,编译器就会从MethodOne中移除对APartialMethod这个方法的调用。

而假如你在其他partial类中实现了这个partial方法,如下所示:

// SampleClass.cs
partial class SampleClass
{
	/*原书例子,错误同上,而且这里甚至连partial都没有写
	public void APartialMethod()
	{
		// implementation of APartialMethod
	}
	*/
	
	partial void APartialMethod(); //最终我们必须在这里加上这一句,原书中并没有    
	//注释掉上面那句会报错:没有为分部方法“SampleClass.APartialMethod()”的实现声明找到定义声明	
	partial void APartialMethod()
	{
		// implementation of APartialMethod
		// 这里的实现我随便写了点,通过ILdasm你可以看到确实合并了这俩方法,但你写成两个文件的时候,
		// 互相之间的成员,方法体都不可见,根本调用不了,还得重复声明
		// 写在同一个文件里的话,其实就是一个占位符的用处,感觉这个特性意义不大...
	}
}

编译器就会为MethodOne方法创建一个相应的APartialMethod调用。

注意partial方法的返回类型必须是void类型,否则每个partial方法都返回值的话,编译器就无法正确判断究竟哪个是对的,也无法移除相应的调用入口了。

3.9 扩展方法

我们有很多种方法可以用来扩展一个类,譬如说第四章我们将谈到的继承,就是一个为类提供更多功能的好方式。而扩展方法(Extension Method)则是另外一种为某个类追加新功能的方案(这种方法甚至可以做到继承无法达成的部分,譬如当一个类被声明成sealed时,它就无法被继承)。

注意:扩展方法甚至可以用来扩展接口,通过这种方式你可以为所有实现了该接口的类统一追加新功能。这部分我们同样会在第四章讲到。

扩展方法是static方法,看起来像是所属类中的一部分,事实上,编译后它并不存在这个类中,但这点不太重要。

让我们先看一个简单的例子。假如你想为string扩展一个功能,让每个string都可以通过一个叫GetWordCount的方法,获取string里有多少个单词。这里我们用到Split()方法,它会按空格对字符串进行划分。我们可以这么写:

public static class StringExtension
{
	public static int GetWordCount(this string s) => s.Split().Length;
}

你可以注意到第一个参数里的this关键字,这个关键字声明了我们要扩展的是随后的string类型。

注意虽然这里的GetWordCount是static方法,但是跟平常我们通过'类名.方法名'调用不同的是,我们可以这么调用:

string fox = "the quick brown fox jumped over the lazy dogs down " +
"9876543210 times";
int wordCount = fox.GetWordCount();// 直接调用GetWordCount
Console.WriteLine($"{wordCount} words");

事实上在后台,编译器是这么执行的:

int wordCount = StringExtension.GetWordCount(fox);

比起编译器实际执行的语法,上面那种写法是不是看起来更加直观。这种写法还有一个好处就是你可以随时更换扩展类里的方法实现,只需要编译器重新编译一下即可。

那么编译器是怎么判断指定类型的扩展方法是哪个的呢?不仅仅需要this关键字,还需要扩展类的命名空间,以便编译器知道打开哪个扩展类。只有当你声明了扩展类的命名空间时,编译器才能在相应的空间下找到相应的扩展类和扩展方法。万一除了扩展方法以外,被扩展的类型自己就有跟扩展方法同名的实例方法,编译器会优先调用类型自身定义的实例方法,而舍弃扩展方法。

当你在不同的扩展类里定义了同一个类型的同名扩展方法的时候,并且你又同时引用了这些扩展类的命名空间,这个时候编译器就会抛出一个错误,表示它不知道应该应用哪个扩展方法好。然而,如果调用方法的代码刚好跟某个扩展类在同一个命名空间下的话,编译器就会优先调用这个命名空间下的扩展方法。

注意:LINQ使用了很多扩展方法,我们将在第12章介绍它。

3.10 Object 类

就像我们前面提到的,.NET所有的类都派生自System.Object。事实上,当你定义一个类又没有指定任何父类的时候,编译器自动认为这个类派生自System.Object。因为继承不是本章节要讲的内容,但你所看到的所有例子里的类实际上就是派生自System.Object。结构体struct则是直接继承自System.ValueType,而System.ValueType又继承自System.Object,所以struct最终也派生自System.Object。

这一点非常重要——不仅方法,属性又或者其他一切你定义的内容,都可以访问到Object类里定义的public或者protected成员。这些方法同样适用于那些不是你定义的类。

下面总结了Object类中的方法和它们的用途:

  • ToString:一个相当基础,快捷,简单的字符串显示方法。使用这个方法,你可以快速的了解某个对象的内容,譬如在调试的时候。关于如何格式化数据它几乎没有提供太多的选择。举个例子,日期类型的数据可能会有各种各样的格式,但是DateTime.ToString方法在这方面没有给你提供任何选择。如果你需要一个更丰富的字符串展示——例如,当你考虑根据指定的格式或者不同的文化(或者区域)显示不同风格的日期的时候——你可能需要实现IFormattable接口(第九章将会涉及这部分内容)。
  • GetHashCode:如果对象存储在某些特定的数据结构,譬如图,又或者哈希表和字典中时,它需要通过创建这些对象的类来决定在数据结构中的何处存储这些对象。假如你想让你的类作为字典中的键值,你需要重写该类的GetHashCode方法。我们将在第十章的时候介绍这部分,因为如何实现这个方法的重载有一些相当严格的要求。
  • Equals(全版本)和ReferenceEquals:你可能已经注意到,在.NET中存在三种不同的方法用来比较两个对象是否相等,包括操作符==在内,他们仨有一些细微的区别。此外,如何重写带有一个参数版本的Equals虚方法还存在一定的限制,因为System.Collections命名空间里的基类会调用这个方法,因此它需要能得出一个确定的结果。第六章的时候你将会探索这些方法的使用。
  • Finalize:第十七章将会包括这部分内容,这个C#方法非常像C++风格的析构函数(destructors)。当GC垃圾回收需要清理无用的引用对象时被调用。Object类定义了Finalize方法但是里面没有任何实现,所以GC会忽略这一部分。通常你可以在一个对象引用非托管资源的时候,重写此方法,因为默认GC只会处理托管资源,而非托管资源如何释放取决于你提供的Finalize方法。
  • GetType:这个方法返回一个派生自System.Type的类实例,因此它可以提供更加广泛具体的信息,你调用GetType方法的对象将会成为这个实例中的一个成员,方法还会提供给你其它信息,譬如基类,方法名,属性等等。System.Type还是.NET反射技术的入口。第十六章将会详细介绍这部分。
  • MemberwiseClone:唯一一个本书没有详细介绍的方法。这是因为这个方法从概念上看就相当简单。它只是单纯地拷贝一个对象,并返回拷贝后的引用(对于值类型,则装箱后返回装箱对象的引用)。注意这个拷贝只是浅拷贝,这意味着它只拷贝了目标class中所有的值类型,假如目标class中还含有任何引用,那么它只拷贝引用,而不拷贝引用指向的实际对象。这个方法是protected修饰的,因此并不能用来拷贝外部对象。并且它也不是virtual方法,所以你也无法重写它。

3.11 小结

本章你体验了声明和操作对象的C#语法。你也了解了如何声明静态和实例字段,属性,方法还有构造函数。你还了解了C# 7.0新增的一部分新特性,譬如用表达式来创建各种类成员(构造函数,属性访问器,输出变量等)。

你也了解了C#里所有的类型都派生自System.Object,这意味着所有的类型都可以使用Object类里定义的常用方法,如ToString等。

本章多次提到了继承,你也部分体验了它的实现,接口继承和其他面向对象相关的内容我们将在下一章进行讲解。

扩展

原文地址:https://www.cnblogs.com/zenronphy/p/ProfessionalCSharp7Chapter3.html