C#9 和 .NET5 高级教程(二)
四、核心 C# 编程结构:第二部分
本章从第三章停止的地方开始,完成你对 C# 编程语言核心方面的研究。您将从研究使用 C# 语法操作数组背后的细节开始,并了解相关的System.Array类类型中包含的功能。
接下来,您将研究关于 C# 方法构造的各种细节,探索out、ref和params关键字。在这个过程中,您还将研究可选参数和命名参数的作用。我通过查看方法重载来结束对方法的讨论。
接下来,本章将讨论枚举和结构类型的构造,包括详细检查值类型和引用类型之间的区别。本章最后研究了可空数据类型和相关操作符的作用。
在你完成这一章之后,你将处于学习 C# 的面向对象能力的最佳位置,从第五章开始。
了解 C# 数组
我想你已经知道了,数组是一组数据项,使用数字索引来访问。更具体地说,数组是一组相同类型的连续数据点(一个由int组成的数组,一个由string组成的数组,一个由SportsCar组成的数组,等等)。).用 C# 声明、填充和访问数组都非常简单。举例来说,创建一个名为 FunWithArrays 的新控制台应用项目,其中包含一个名为SimpleArrays();的帮助器方法,如下所示:
Console.WriteLine("***** Fun with Arrays *****");
SimpleArrays();
Console.ReadLine();
static void SimpleArrays()
{
Console.WriteLine("=> Simple Array Creation.");
// Create and fill an array of 3 integers
int[] myInts = new int[3];
// Create a 100 item string array, indexed 0 - 99
string[] booksOnDotNet = new string[100];
Console.WriteLine();
}
仔细看看前面的代码注释。当使用此语法声明 C# 数组时,数组声明中使用的数字表示项目总数,而不是上限。还要注意数组的下限总是从0开始。因此,当您编写int[] myInts = new int[3]时,您最终会得到一个包含三个元素的数组,分别位于0、1和2。
在定义了一个数组变量之后,就可以逐个索引地填充元素了,如更新后的SimpleArrays()方法所示:
static void SimpleArrays()
{
Console.WriteLine("=> Simple Array Creation.");
// Create and fill an array of 3 Integers
int[] myInts = new int[3];
myInts[0] = 100;
myInts[1] = 200;
myInts[2] = 300;
// Now print each value.
foreach(int i in myInts)
{
Console.WriteLine(i);
}
Console.WriteLine();
}
Note
请注意,如果您声明了一个数组,但没有显式填充每个索引,则每个项都将被设置为数据类型的默认值(例如,bool的数组将被设置为false,或者int的数组将被设置为0)。
查看 C# 数组初始化语法
除了逐个元素填充数组,你还可以使用 C# 数组初始化语法来填充数组的元素。为此,在花括号({})的范围内指定每个数组项。当您创建一个已知大小的数组并希望快速指定初始值时,此语法会很有帮助。例如,考虑以下替代数组声明:
static void ArrayInitialization()
{
Console.WriteLine("=> Array Initialization.");
// Array initialization syntax using the new keyword.
string[] stringArray = new string[]
{ "one", "two", "three" };
Console.WriteLine("stringArray has {0} elements", stringArray.Length);
// Array initialization syntax without using the new keyword.
bool[] boolArray = { false, false, true };
Console.WriteLine("boolArray has {0} elements", boolArray.Length);
// Array initialization with new keyword and size.
int[] intArray = new int[4] { 20, 22, 23, 0 };
Console.WriteLine("intArray has {0} elements", intArray.Length);
Console.WriteLine();
}
请注意,当您使用这种“花括号”语法时,您不需要指定数组的大小(在构造stringArray变量时可以看到),因为这将由花括号范围内的项数来推断。还要注意,new关键字的使用是可选的(在构造boolArray类型时显示)。
在intArray声明的情况下,再次回忆一下,指定的数值代表数组中元素的数量,而不是上限的值。如果声明的大小和初始值设定项的数量不匹配(无论您的初始值设定项太多还是太少),就会发出一个编译时错误。下面是一个例子:
// OOPS! Mismatch of size and elements!
int[] intArray = new int[2] { 20, 22, 23, 0 };
理解隐式类型化局部数组
在第三章中,你学习了隐式类型化局部变量的主题。回想一下,var关键字允许您定义一个变量,它的底层类型由编译器决定。类似地,var关键字可以用来定义隐式类型化的局部数组。使用这种技术,您可以分配一个新的数组变量,而无需指定数组本身包含的类型(注意,使用这种方法时,您必须使用new关键字)。
static void DeclareImplicitArrays()
{
Console.WriteLine("=> Implicit Array Initialization.");
// a is really int[].
var a = new[] { 1, 10, 100, 1000 };
Console.WriteLine("a is a: {0}", a.ToString());
// b is really double[].
var b = new[] { 1, 1.5, 2, 2.5 };
Console.WriteLine("b is a: {0}", b.ToString());
// c is really string[].
var c = new[] { "hello", null, "world" };
Console.WriteLine("c is a: {0}", c.ToString());
Console.WriteLine();
}
当然,就像使用显式 C# 语法分配数组一样,数组初始化列表中的项必须是相同的底层类型(例如,所有的int、所有的string或所有的SportsCar)。与您可能期望的不同,隐式类型的本地数组没有默认为System.Object;因此,下面的代码会生成一个编译时错误:
// Error! Mixed types!
var d = new[] { 1, "one", 2, "two", false };
定义对象数组
在大多数情况下,定义数组时,可以通过指定数组变量中的显式项类型来实现。虽然这看起来很简单,但是有一个值得注意的变化。正如你将在第六章中了解到的,System.Object是.NETCore 型系统。鉴于这一事实,如果您要定义一个System.Object数据类型的数组,那么子项可以是任何东西。考虑下面的ArrayOfObjects()方法:
static void ArrayOfObjects()
{
Console.WriteLine("=> Array of Objects.");
// An array of objects can be anything at all.
object[] myObjects = new object[4];
myObjects[0] = 10;
myObjects[1] = false;
myObjects[2] = new DateTime(1969, 3, 24);
myObjects[3] = "Form & Void";
foreach (object obj in myObjects)
{
// Print the type and value for each item in array.
Console.WriteLine("Type: {0}, Value: {1}", obj.GetType(), obj);
}
Console.WriteLine();
}
在这里,当您迭代myObjects的内容时,您使用System.Object的GetType()方法打印每个项目的底层类型,以及当前项目的值。在本文的这一点上,不要涉及太多关于System.Object.GetType()的细节,简单地理解这个方法可以用来获得项目的完全限定名(第十七章详细地讨论了类型信息和反射服务的主题)。下面的输出显示了调用ArrayOfObjects()的结果:
=> Array of Objects.
Type: System.Int32, Value: 10
Type: System.Boolean, Value: False
Type: System.DateTime, Value: 3/24/1969 12:00:00 AM
Type: System.String, Value: Form & Void
使用多维数组
除了你已经看到的一维数组,C# 还支持两种多维数组。第一种叫做矩形阵列,它只是一个多维阵列,其中每行长度相同。要声明并填充多维矩形数组,请执行以下操作:
static void RectMultidimensionalArray()
{
Console.WriteLine("=> Rectangular multidimensional array.");
// A rectangular MD array.
int[,] myMatrix;
myMatrix = new int[3,4];
// Populate (3 * 4) array.
for(int i = 0; i < 3; i++)
{
for(int j = 0; j < 4; j++)
{
myMatrix[i, j] = i * j;
}
}
// Print (3 * 4) array.
for(int i = 0; i < 3; i++)
{
for(int j = 0; j < 4; j++)
{
Console.Write(myMatrix[i, j] + "\t");
}
Console.WriteLine();
}
Console.WriteLine();
}
第二种多维数组被称为交错数组。顾名思义,交错数组包含一定数量的内部数组,每个内部数组可能有不同的上限。这里有一个例子:
static void JaggedMultidimensionalArray()
{
Console.WriteLine("=> Jagged multidimensional array.");
// A jagged MD array (i.e., an array of arrays).
// Here we have an array of 5 different arrays.
int[][] myJagArray = new int[5][];
// Create the jagged array.
for (int i = 0; i < myJagArray.Length; i++)
{
myJagArray[i] = new int[i + 7];
}
// Print each row (remember, each element is defaulted to zero!).
for(int i = 0; i < 5; i++)
{
for(int j = 0; j < myJagArray[i].Length; j++)
{
Console.Write(myJagArray[i][j] + " ");
}
Console.WriteLine();
}
Console.WriteLine();
}
调用每个RectMultidimensionalArray()和JaggedMultidimensionalArray()方法的输出如下所示:
=> Rectangular multidimensional array:
0 0 0 0
0 1 2 3
0 2 4 6
=> Jagged multidimensional array:
0 0 0 0 0 0 0
0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0
使用数组作为参数或返回值
创建数组后,您可以自由地将其作为参数传递或作为成员返回值接收。例如,下面的PrintArray()方法获取一个传入的int数组并将每个成员打印到控制台,而GetStringArray()方法填充一个string数组并将其返回给调用者:
static void PrintArray(int[] myInts)
{
for(int i = 0; i < myInts.Length; i++)
{
Console.WriteLine("Item {0} is {1}", i, myInts[i]);
}
}
static string[] GetStringArray()
{
string[] theStrings = {"Hello", "from", "GetStringArray"};
return theStrings;
}
如您所料,这些方法可以被调用。
static void PassAndReceiveArrays()
{
Console.WriteLine("=> Arrays as params and return values.");
// Pass array as parameter.
int[] ages = {20, 22, 23, 0} ;
PrintArray(ages);
// Get array as return value.
string[] strs = GetStringArray();
foreach(string s in strs)
{
Console.WriteLine(s);
}
Console.WriteLine();
}
至此,您应该对定义、填充和检查 C# 数组变量内容的过程感到满意了。为了使画面完整,现在让我们检查一下System.Array类的角色。
使用系统。数组基类
您创建的每个数组都从System.Array类中收集了许多功能。使用这些通用成员,您可以使用一致的对象模型对数组进行操作。表 4-1 给出了一些更有趣的成员的概要(请务必查看文档以了解全部细节)。
表 4-1。
选择系统成员。排列
|数组类的成员
|
生命的意义
|
| --- | --- |
| Clear() | 这个静态方法将数组中的一系列元素设置为空值(0表示数字,null表示对象引用,false表示布尔值)。 |
| CopyTo() | 此方法用于将源数组中的元素复制到目标数组中。 |
| Length | 该属性返回数组中的项数。 |
| Rank | 该属性返回当前数组的维数。 |
| Reverse() | 这个静态方法反转一维数组的内容。 |
| Sort() | 此静态方法对内部类型的一维数组进行排序。如果数组中的元素实现了IComparer接口,你也可以对你的自定义类型进行排序(参见第八章和第十章)。 |
让我们看看这些成员的一些行动。下面的 helper 方法利用静态的Reverse()和Clear()方法将关于一组string类型的信息抽取到控制台:
static void SystemArrayFunctionality()
{
Console.WriteLine("=> Working with System.Array.");
// Initialize items at startup.
string[] gothicBands = {"Tones on Tail", "Bauhaus", "Sisters of Mercy"};
// Print out names in declared order.
Console.WriteLine("-> Here is the array:");
for (int i = 0; i < gothicBands.Length; i++)
{
// Print a name.
Console.Write(gothicBands[i] + ", ");
}
Console.WriteLine("\n");
// Reverse them...
Array.Reverse(gothicBands);
Console.WriteLine("-> The reversed array");
// ... and print them.
for (int i = 0; i < gothicBands.Length; i++)
{
// Print a name.
Console.Write(gothicBands[i] + ", ");
}
Console.WriteLine("\n");
// Clear out all but the first member.
Console.WriteLine("-> Cleared out all but one...");
Array.Clear(gothicBands, 1, 2);
for (int i = 0; i < gothicBands.Length; i++)
{
// Print a name.
Console.Write(gothicBands[i] + ", ");
}
Console.WriteLine();
}
如果您调用此方法,您将得到如下所示的输出:
=> Working with System.Array.
-> Here is the array:
Tones on Tail, Bauhaus, Sisters of Mercy,
-> The reversed array
Sisters of Mercy, Bauhaus, Tones on Tail,
-> Cleared out all but one...
Sisters of Mercy, , ,
注意,System.Array的许多成员被定义为静态成员,因此在类级别被调用(例如,Array.Sort()和Array.Reverse()方法)。诸如此类的方法在您想要处理的数组中传递。System.Array的其他成员(如Length属性)在对象级绑定;因此,您可以直接在数组上调用成员。
使用指数和范围(新 8.0)
为了简化对序列(包括数组)的处理,C# 8 引入了两种新的类型和两种新的运算符,用于处理数组:
-
System.Index代表一个序列的索引。 -
System.Range表示指数的子范围。 -
结束运算符(
^)的索引指定索引相对于序列的结尾。 -
范围运算符(
...)指定范围的开始和结束作为其操作数。
Note
索引和范围可以与数组、字符串、Span<T>和ReadOnlySpan<T>一起使用。
正如您已经看到的,数组是从零(0)开始索引的。序列的结尾是序列的长度–1。之前打印gothicBands数组的for循环可以更新为:
for (int i = 0; i < gothicBands.Length; i++)
{
Index idx = i;
// Print a name
Console.Write(gothicBands[idx] + ", ");
}
“从末端开始的索引”运算符允许您指定从序列末端开始有多少个位置,从长度开始。记住序列中的最后一项比实际长度小一,所以⁰会导致错误。以下代码反向打印数组:
for (int i = 1; i <= gothicBands.Length; i++)
{
Index idx = ^i;
// Print a name
Console.Write(gothicBands[idx] + ", ");
}
range 操作符指定了开始和结束索引,并允许访问列表中的子序列。范围的开始包括在内,范围的结束包括在内。例如,要取出数组的前两个成员,请创建从 0(第一个成员)到 2(比所需的索引位置多一个)的范围。
foreach (var itm in gothicBands[0..2])
{
// Print a name
Console.Write(itm + ", ");
}
Console.WriteLine("\n");
也可以使用新的Range数据类型将范围传递给序列,如下所示:
Range r = 0..2; //the end of the range is exclusive
foreach (var itm in gothicBands[r])
{
// Print a name
Console.Write(itm + ", ");
}
Console.WriteLine("\n");
可以使用整数或Index变量定义范围。以下代码会产生相同的结果:
Index idx1 = 0;
Index idx2 = 2;
Range r = idx1..idx2; //the end of the range is exclusive
foreach (var itm in gothicBands[r])
{
// Print a name
Console.Write(itm + ", ");
}
Console.WriteLine("\n");
如果该范围的开头被忽略,则使用序列的开头。如果不考虑范围的结尾,则使用范围的长度。这不会导致错误,因为范围末尾的值是唯一的。对于数组中三个项目的前一个示例,所有范围都表示相同的子集。
gothicBands[..]
gothicBands[0..⁰]
gothicBands[0..3]
理解方法
让我们检查定义方法的细节。方法是由访问修饰符和返回类型定义的(或者是没有返回类型的void),可以带参数也可以不带参数。向调用者返回值的方法通常被称为函数,而不返回值的方法通常被称为方法。
Note
方法(和类)的访问修饰符在第五章中介绍。方法参数将在下一节介绍。
在文本的这一点上,您的每个方法都有以下基本格式:
// Recall that static methods can be called directly
// without creating a class instance.
class Program
{
// static returnType MethodName(parameter list) { /* Implementation */ }
static int Add(int x, int y)
{
return x + y;
}
}
正如您将在接下来的几章中看到的,方法可以在类、结构或(C# 8 中的新功能)接口的范围内实现。
了解表达式主体成员
您已经学习了返回值的简单方法,比如Add()方法。C# 6 引入了表达式主体成员,缩短了单行方法的语法。例如,Add()可以使用以下语法重写:
static int Add(int x, int y) => x + y;
这就是通常所说的语法糖,意味着生成的 IL 没有什么不同。这只是编写方法的另一种方式。有些人觉得它更容易阅读,有些人不觉得,所以你(或你的团队)可以选择你喜欢的风格。
Note
不要被=>操作员吓到。这是一个 lambda 操作,在第十二章中有详细介绍。那一章也确切地解释了表情-身体成员是如何工作的。现在,就把它们看作是编写单行语句的捷径。
了解本地函数(新 7.0,更新 9.0)
C# 7.0 中引入的一个特性是在方法中创建方法的能力,官方称为局部函数。局部函数是在另一个函数内部声明的函数,必须是私有的,用 C# 8.0 可以是静态的(见下一节),不支持重载。局部函数支持嵌套:一个局部函数可以在内部声明一个局部函数。
要了解其工作原理,请创建一个名为 FunWithLocalFunctions 的新控制台应用项目。举例来说,假设您想要扩展之前使用的Add()示例,以包括输入的验证。有许多方法可以实现这一点,一个简单的方法是将验证直接添加到Add()方法中。让我们继续,将前面的例子更新为下面的例子(代表验证逻辑的注释):
static int Add(int x, int y)
{
//Do some validation here
return x + y;
}
如你所见,没有大的变化。只有一个注释表明真正的代码应该做一些事情。如果您想将方法的实际原因(返回参数的总和)与参数的验证分开,该怎么办呢?您可以创建额外的方法,并从Add()方法中调用它们。但是这需要创建另一个方法供另一个方法使用。也许这有点过了。本地函数允许您首先进行验证,然后封装在AddWrapper()方法中定义的方法的真正目标,如下所示:
static int AddWrapper(int x, int y)
{
//Do some validation here
return Add();
int Add()
{
return x + y;
}
}
被包含的Add()方法只能从包装AddWrapper()方法中调用。所以,我敢肯定你在想的问题是,“这给我买了什么?”这个具体例子的答案很简单,就是很少(如果有的话)。但是如果AddWrapper()需要从多个地方执行Add()功能呢?现在,您应该开始看到拥有一个代码重用的本地函数的好处,它不会暴露在需要它的地方之外。当我们讨论定制迭代器方法(第八章)和异步方法(第十五章)时,你会看到本地函数带来的更多好处。
Note
AddWrapper()局部函数是具有嵌套局部函数的局部函数的一个例子。回想一下,在顶级语句中声明的函数是作为局部函数创建的。Add()本地函数在AddWrapper()本地函数中。这种功能通常不会在教学示例之外使用,但是如果您需要嵌套本地函数,您知道 C# 支持它。
C# 9.0 更新了局部函数,允许向局部函数、其参数和类型参数添加属性,如下例所示(不要担心NotNullWhen属性,这将在本章后面介绍) :
#nullable enable
private static void Process(string?[] lines, string mark)
{
foreach (var line in lines)
{
if (IsValid(line))
{
// Processing logic...
}
}
bool IsValid([NotNullWhen(true)] string? line)
{
return !string.IsNullOrEmpty(line) && line.Length >= mark.Length;
}
}
理解静态局部函数(新 8.0)
C# 8 中引入的对局部函数的改进是将局部函数声明为静态函数的能力。在前面的例子中,本地Add()函数直接引用主函数中的变量。这可能会导致意想不到的副作用,因为局部函数可以改变变量的值。
要看到这一点,创建一个名为AddWrapperWithSideEffect()的新方法,如下所示:
static int AddWrapperWithSideEffect(int x, int y)
{
//Do some validation here
return Add();
int Add()
{
x += 1;
return x + y;
}
}
当然,这个例子非常简单,在实际代码中可能不会发生。为了防止这种错误,请将 static 修饰符添加到局部函数中。这会阻止局部函数直接访问父方法变量,并导致编译器异常 CS8421,“静态局部函数不能包含对“”的引用。”"
上一种方法的改进版本如下所示:
static int AddWrapperWithStatic(int x, int y)
{
//Do some validation here
return Add(x,y);
static int Add(int x, int y)
{
return x + y;
}
}
了解方法参数
方法参数用于将数据传递给方法调用。在接下来的几节中,您将了解方法(及其调用者)如何处理参数的细节。
了解方法参数修饰符
将参数发送到函数的默认方式是通过值发送*。简而言之,如果没有用参数修饰符标记参数,数据的副本将被传递到函数中。正如本章后面所解释的,确切地说复制什么将取决于参数是值类型还是引用类型。*
虽然 C# 中方法的定义非常简单,但是您可以使用一些方法来控制参数如何传递给方法,如表 4-2 中所列。
表 4-2。
C# 参数修饰符
|参数修改器
|
生命的意义
|
| --- | --- |
| (无) | 如果值类型参数没有用修饰符标记,则假定它是按值传递的,这意味着被调用的方法接收原始数据的副本。没有修饰符的引用类型通过引用传递。 |
| out | 输出参数必须由被调用的方法赋值,因此通过引用传递。如果被调用的方法未能分配输出参数,则会出现编译器错误。 |
| ref | 该值最初由调用者赋值,并可以由被调用的方法随意修改(因为数据也是通过引用传递的)。如果被调用的方法未能分配一个ref参数,则不会产生编译器错误。 |
| in | 在 C# 7.2 中新增的,in修饰符表明一个ref参数对于被调用的方法是只读的。 |
| params | 这个参数修饰符允许您将可变数量的参数作为单个逻辑参数发送。一个方法只能有一个params修饰符,并且它必须是该方法的最终参数。您可能不需要经常使用params修饰符;但是,请注意,基类库中的许多方法确实利用了 C# 语言的这一特性。 |
为了演示这些关键字的用法,创建一个名为 FunWithMethods 的新控制台应用项目。现在,我们来看一下每个关键字的作用。
了解默认的参数传递行为
当参数没有修饰符时,值类型的行为是通过值传入参数,而引用类型的行为是通过引用传入参数。
Note
值类型和引用类型将在本章后面介绍。
值类型的默认行为
将值类型参数发送到函数的默认方式是通过值发送*。简单地说,如果没有用修饰符标记参数,数据的副本将被传递到函数中。将下面的方法添加到对通过值传递的两个数字数据类型进行操作的Program类中:*
// Value type arguments are passed by value by default.
static int Add(int x, int y)
{
int ans = x + y;
// Caller will not see these changes
// as you are modifying a copy of the
// original data.
x = 10000;
y = 88888;
return ans;
}
数值数据属于值类型的范畴。因此,如果您在成员范围内更改参数的值,调用者不会知道,因为您是在调用者原始数据的副本上更改值。
Console.WriteLine("***** Fun with Methods *****\n");
// Pass two variables in by value.
int x = 9, y = 10;
Console.WriteLine("Before call: X: {0}, Y: {1}", x, y);
Console.WriteLine("Answer is: {0}", Add(x, y));
Console.WriteLine("After call: X: {0}, Y: {1}", x, y);
Console.ReadLine();
正如您所希望的,x和y的值在调用Add()前后保持一致,如下图所示,因为数据点是通过值发送的。因此,Add()方法中对这些参数的任何更改都不会被调用者看到,因为Add()方法正在对数据的副本进行操作。
***** Fun with Methods *****
Before call: X: 9, Y: 10
Answer is: 19
After call: X: 9, Y: 10
引用类型的默认行为
将引用类型参数发送到函数中的默认方式是通过对其属性的引用*,但通过其自身的值*。这将在本章后面,在讨论值类型和引用类型之后详细介绍。
Note
尽管 string 数据类型在技术上是一种引用类型,正如第三章所讨论的,但它是一种特殊情况。当字符串参数没有修饰符时,它在中通过值传递。
使用 out 修饰符(更新 7.0)
接下来,你可以使用输出参数。已经被定义为接受输出参数(通过out关键字)的方法有义务在退出方法范围之前将它们赋给一个适当的值(如果您没有这样做,您将收到编译器错误)。
举例来说,下面是另一个版本的Add()方法,它使用 C# out修饰符返回两个整数的和(注意这个方法的物理返回值现在是void):
// Output parameters must be assigned by the called method.
static void AddUsingOutParam(int x, int y, out int ans)
{
ans = x + y;
}
调用带有输出参数的方法也需要使用out修饰符。但是,作为输出变量传递的局部变量在作为输出参数传递之前不需要赋值(如果这样做,原始值在调用后会丢失)。编译器允许你发送看似未赋值的数据是因为被调用的方法必须赋值。要调用更新的Add方法,创建一个int类型的变量,并在调用中使用out修饰符,如下所示:
int ans;
AddUsingOutParam(90, 90, out ans);
从 C# 7.0 开始,out参数不需要在使用前声明。换句话说,它们可以在方法调用中声明,如下所示:
AddUsingOutParam(90, 90, out int ans);
下面的代码是一个使用out参数的内联声明调用方法的示例:
Console.WriteLine("***** Fun with Methods *****");
// No need to assign initial value to local variables
// used as output parameters, provided the first time
// you use them is as output arguments.
// C# 7 allows for out parameters to be declared in the method call
AddUsingOutParam(90, 90, out int ans);
Console.WriteLine("90 + 90 = {0}", ans);
Console.ReadLine();
前面的例子本质上是说明性的;你真的没有理由用一个输出参数来返回你求和的值。然而,C# out修饰符确实有一个有用的目的:它允许调用者从一次方法调用中获得多个输出。
// Returning multiple output parameters.
static void FillTheseValues(out int , out string b, out bool c)
{
a = 9;
b = "Enjoy your string.";
c = true;
}
调用者将能够调用FillTheseValues()方法。请记住,当您调用方法以及实现方法时,您必须使用out修饰符。
Console.WriteLine("***** Fun with Methods *****");
FillTheseValues(out int i, out string str, out bool b);
Console.WriteLine("Int is: {0}", i);
Console.WriteLine("String is: {0}", str);
Console.WriteLine("Boolean is: {0}", b);
Console.ReadLine();
Note
C# 7 还引入了元组,这是从方法调用中返回多个值的另一种方式。在这一章的后面你会学到更多。
请始终记住,定义输出参数的方法必须在退出方法范围之前将参数赋给一个有效值。因此,以下代码将导致编译器错误,因为输出参数尚未在方法范围内赋值:
static void ThisWontCompile(out int a)
{
Console.WriteLine("Error! Forgot to assign output arg!");
}
丢弃参数(新 7.0)
如果您不关心out参数的值,您可以使用一个丢弃作为占位符。丢弃是有意不使用的临时虚拟变量。它们是未分配的,没有值,甚至可能不会分配任何内存。这可以提供性能优势,并使您的代码更具可读性。丢弃可以与out参数、元组(本章后面)、模式匹配(第 6 和 8 章)一起使用,甚至可以作为独立变量使用。
例如,如果您想获得上一个示例中的int的值,但不关心后两个参数,您可以编写以下代码:
//This only gets the value for a, and ignores the other two parameters
FillTheseValues(out int a, out _, out _);
请注意,被调用的方法仍然在为所有三个参数设置值;当方法调用返回时,最后两个参数被丢弃。
构造函数和初始化函数中的 out 修饰符(新 7.3)
C# 7.3 扩展了使用out参数的允许位置。除了方法之外,构造函数的参数、字段和属性初始化器以及查询子句都可以用out修饰符来修饰。这方面的例子将在本书的后面部分讨论。
使用 ref 修饰符
现在考虑 C# ref参数修饰符的使用。当您希望允许一个方法对在调用者作用域中声明的各种数据点(例如排序或交换例程)进行操作(并且通常更改其值)时,引用参数是必需的。请注意输出参数和参考参数之间的区别:
-
输出参数在传递给方法之前不需要初始化。原因是该方法必须在退出前分配输出参数。
-
引用参数在传递给方法之前必须初始化。这是因为您正在传递对现有变量的引用。如果你不把它赋给一个初始值,那就相当于对一个未赋值的局部变量进行操作。
让我们通过交换两个string变量的方法来检查一下ref关键字的用法(当然,这里可以使用任何两种数据类型,包括int、bool、float等)。).
// Reference parameters.
public static void SwapStrings(ref string s1, ref string s2)
{
string tempStr = s1;
s1 = s2;
s2 = tempStr;
}
此方法可以按如下方式调用:
Console.WriteLine("***** Fun with Methods *****");
string str1 = "Flip";
string str2 = "Flop";
Console.WriteLine("Before: {0}, {1} ", str1, str2);
SwapStrings(ref str1, ref str2);
Console.WriteLine("After: {0}, {1} ", str1, str2);
Console.ReadLine();
这里,调用者已经为本地字符串数据分配了一个初始值(str1和str2)。在对SwapStrings()的调用返回后,str1现在包含值"Flop",而str2报告值"Flip"。
Before: Flip, Flop
After: Flop, Flip
使用 in 修饰符(新 7.2)
in修饰符通过引用传递值(对于值类型和引用类型),并防止被调用的方法修改值。这清楚地表明了代码中的设计意图,并有可能减少内存压力。当值类型通过值传递时,它们被被调用的方法(在内部)复制。如果对象很大(比如一个大的结构),为本地使用而制作副本的额外开销可能会很大。此外,即使在没有修饰符的情况下传递引用类型,它们也可以被被调用的方法修改。这两个问题都可以使用in修改器来解决。
回顾前面的Add()方法,有两行代码修改了参数,但是不影响调用方法的值。这些值不会受到影响,因为Add()方法复制了变量x和y供本地使用。虽然调用方法没有任何负面影响,但是如果将Add()方法改为下面的代码会怎么样呢?
static int Add2(int x,int y)
{
x = 10000;
y = 88888;
int ans = x + y;
return ans;
}
不管发送到方法中的数字是多少,运行这段代码都会返回 98888。这显然是个问题。若要更正此问题,请将方法更新为以下内容:
static int AddReadOnly(in int x,in int y)
{
//Error CS8331 Cannot assign to variable 'in int' because it is a readonly variable
//x = 10000;
//y = 88888;
int ans = x + y;
return ans;
}
当代码试图更改参数值时,编译器会引发 CS8331 错误,表明由于in修饰符的原因,这些值不能被修改。
使用参数修改器
C# 支持使用关键字params来使用参数数组。params关键字允许您将可变数量的相同类型的参数(或通过继承相关的类)作为一个单一逻辑参数传递给一个方法。同样,如果调用者发送强类型数组或逗号分隔的项目列表,则可以处理标有params关键字的参数。是的,这可能会令人困惑!为了搞清楚,假设您想要创建一个函数,允许调用者传入任意数量的参数并返回计算出的平均值。
如果您要将这个方法原型化以获取一个由double组成的数组,这将迫使调用者首先定义该数组,然后填充该数组,最后将其传递给该方法。但是,如果您将CalculateAverage()定义为接受double[]数据类型的params,调用者可以简单地传递一个逗号分隔的double列表。这个double列表将在后台打包成一个double数组。
// Return average of "some number" of doubles.
static double CalculateAverage(params double[] values)
{
Console.WriteLine("You sent me {0} doubles.", values.Length);
double sum = 0;
if(values.Length == 0)
{
return sum;
}
for (int i = 0; i < values.Length; i++)
{
sum += values[i];
}
return (sum / values.Length);
}
这个方法被定义为接受一个由double s 组成的参数数组。这个方法实际上说的是“给我发送任意数量的double s(包括零),我将计算平均值。”考虑到这一点,您可以通过以下任何一种方式调用CalculateAverage():
Console.WriteLine("***** Fun with Methods *****");
// Pass in a comma-delimited list of doubles...
double average;
average = CalculateAverage(4.0, 3.2, 5.7, 64.22, 87.2);
Console.WriteLine("Average of data is: {0}", average);
// ...or pass an array of doubles.
double[] data = { 4.0, 3.2, 5.7 };
average = CalculateAverage(data);
Console.WriteLine("Average of data is: {0}", average);
// Average of 0 is 0!
Console.WriteLine("Average of data is: {0}", CalculateAverage());
Console.ReadLine();
如果您没有在CalculateAverage()的定义中使用params修饰符,那么这个方法的第一次调用将会导致编译器错误,因为编译器将会寻找一个带有五个double参数的CalculateAverage()版本。
Note
为了避免任何歧义,C# 要求一个方法只支持单个params参数,该参数必须是参数列表中的最后一个参数。
正如您可能猜到的那样,这种技术只不过是为了方便调用者,因为数组是由。NET 核心运行时。当数组在被调用方法的范围内时,您可以将其视为完全成熟的。NET 核心数组,包含了System.Array基础类库类型的所有功能。考虑以下输出:
You sent me 5 doubles.
Average of data is: 32.864
You sent me 3 doubles.
Average of data is: 4.3
You sent me 0 doubles.
Average of data is: 0
定义可选参数
C# 允许你创建带可选参数的方法。这种技术允许调用者调用单个方法,同时省略被认为不必要的参数,只要调用者对指定的缺省值满意。
为了说明如何使用可选参数,假设您有一个名为EnterLogData()的方法,它定义了一个可选参数。
static void EnterLogData(string message, string owner = "Programmer")
{
Console.Beep();
Console.WriteLine("Error: {0}", message);
Console.WriteLine("Owner of Error: {0}", owner);
}
这里,最后一个string参数通过参数定义中的赋值被赋予默认值"Programmer"。有鉴于此,你可以用两种方式称呼EnterLogData()。
Console.WriteLine("***** Fun with Methods *****");
...
EnterLogData("Oh no! Grid can't find data");
EnterLogData("Oh no! I can't find the payroll data", "CFO");
Console.ReadLine();
因为第一次调用EnterLogData()时没有指定第二个string参数,所以您会发现程序员应该对网格数据的丢失负责,而 CFO 却错放了工资数据(由第二次方法调用中的第二个参数指定)。
需要注意的一件重要事情是,赋给可选参数的值必须在编译时已知,并且不能在运行时解析(如果您试图这样做,将会收到编译时错误!).举例来说,假设您想要用以下额外的可选参数更新EnterLogData():
// Error! The default value for an optional arg must be known
// at compile time!
static void EnterLogData(string message, string owner = "Programmer", DateTime timeStamp = DateTime.Now)
{
Console.Beep();
Console.WriteLine("Error: {0}", message);
Console.WriteLine("Owner of Error: {0}", owner);
Console.WriteLine("Time of Error: {0}", timeStamp);
}
这不会编译,因为DateTime类的Now属性的值是在运行时解析的,而不是在编译时。
Note
为了避免歧义,可选参数必须总是打包在方法签名的端上。将可选参数列在非可选参数之前是一个编译器错误。
使用命名参数(更新 7.2)
C# 中的另一个语言特性是支持命名参数。命名参数允许您通过以任意顺序指定参数值来调用方法。因此,您可以选择使用冒号操作符按名称指定每个参数,而不是只按位置传递参数(大多数情况下都会这样做)。为了说明命名参数的使用,假设您已经向Program类添加了以下方法:
static void DisplayFancyMessage(ConsoleColor textColor,
ConsoleColor backgroundColor, string message)
{
// Store old colors to restore after message is printed.
ConsoleColor oldTextColor = Console.ForegroundColor;
ConsoleColor oldbackgroundColor = Console.BackgroundColor;
// Set new colors and print message.
Console.ForegroundColor = textColor;
Console.BackgroundColor = backgroundColor;
Console.WriteLine(message);
// Restore previous colors.
Console.ForegroundColor = oldTextColor;
Console.BackgroundColor = oldbackgroundColor;
}
现在,按照编写DisplayFancyMessage()的方式,您会期望调用者通过传递两个ConsoleColor变量后跟一个string类型来调用这个方法。但是,使用命名参数,以下调用完全没问题:
Console.WriteLine("***** Fun with Methods *****");
DisplayFancyMessage(message: "Wow! Very Fancy indeed!",
textColor: ConsoleColor.DarkRed,
backgroundColor: ConsoleColor.White);
DisplayFancyMessage(backgroundColor: ConsoleColor.Green,
message: "Testing...",
textColor: ConsoleColor.DarkBlue);
Console.ReadLine();
在 C# 7.2 中,使用命名参数的规则略有更新。在 7.2 版之前,如果开始使用位置参数调用方法,必须在任何命名参数之前列出它们。在 7.2 和更高版本的 C# 中,如果参数的位置正确,命名参数和未命名参数可以混合使用。
Note
仅仅因为在 C# 7.2 和更高版本中可以混合使用命名参数和位置参数,这并不是一个好主意。仅仅因为你能并不意味着你应该!
以下代码是一个示例:
// This is OK, as positional args are listed before named args.
DisplayFancyMessage(ConsoleColor.Blue,
message: "Testing...",
backgroundColor: ConsoleColor.White);
// This is OK, all arguments are in the correct order
DisplayFancyMessage(textColor: ConsoleColor.White, backgroundColor:ConsoleColor.Blue, "Testing...");
// This is an ERROR, as positional args are listed after named args.
DisplayFancyMessage(message: "Testing...",
backgroundColor: ConsoleColor.White,
ConsoleColor.Blue);
除了这个限制,您可能还想知道什么时候需要使用这个语言特性。毕竟,如果您需要为一个方法指定三个参数,为什么要麻烦地改变它们的位置呢?
事实证明,如果您有一个定义可选参数的方法,这个特性会很有帮助。假设DisplayFancyMessage()已经重写,现在支持可选参数,因为您已经指定了拟合默认值。
static void DisplayFancyMessage(ConsoleColor textColor = ConsoleColor.Blue,
ConsoleColor backgroundColor = ConsoleColor.White,
string message = "Test Message")
{
...
}
假设每个参数都有一个默认值,命名参数允许调用者只指定他们不想接收默认值的参数。因此,如果调用者希望值"Hello!"以白色背景包围的蓝色文本显示,他们可以简单地指定以下内容:
DisplayFancyMessage(message: "Hello!");
或者,如果呼叫者希望看到绿色背景、蓝色文本的“测试消息”打印出来,他们可以调用以下内容:
DisplayFancyMessage(backgroundColor: ConsoleColor.Green);
正如您所看到的,可选参数和命名参数往往一起工作。为了总结您对构建 C# 方法的研究,我需要解决方法重载的话题。
理解方法重载
像其他现代面向对象语言一样,C# 允许一个方法被重载。简单地说,当您定义一组名称相同但参数数量(或类型)不同的方法时,这个方法被称为重载。
要理解重载为什么如此有用,请考虑一下作为一名老派 Visual Basic 6.0 (VB6)开发人员的生活。假设您正在使用 VB6 构建一组方法,这些方法返回各种传入数据类型(Integer s、Double s 等)的总和。).鉴于 VB6 不支持方法重载,您需要定义一组独特的方法,这些方法本质上做同样的事情(返回参数的总和)。
' VB6 code examples.
Public Function AddInts(ByVal x As Integer, ByVal y As Integer) As Integer
AddInts = x + y
End Function
Public Function AddDoubles(ByVal x As Double, ByVal y As Double) As Double
AddDoubles = x + y
End Function
Public Function AddLongs(ByVal x As Long, ByVal y As Long) As Long
AddLongs = x + y
End Function
这样的代码不仅变得难以维护,而且调用者现在必须痛苦地意识到每个方法的名称。使用重载,您可以允许调用者调用一个名为Add()的方法。同样,关键是要确保方法的每个版本都有一组不同的参数(仅返回类型不同的方法是不够唯一的)。
Note
正如将在第十章中解释的那样,有可能构建将重载概念提升到下一个层次的泛型方法。使用泛型,您可以为方法实现定义类型占位符,这些占位符是在您调用相关成员时指定的。
为了直接检验这一点,创建一个名为 FunWithMethodOverloading 的新控制台应用项目。添加一个名为AddOperations.cs的新类,并将代码更新如下:
namespace FunWithMethodOverloading {
// C# code.
// Overloaded Add() method.
public static class AddOperations
{
// Overloaded Add() method.
public static int Add(int x, int y)
{
return x + y;
}
public static double Add(double x, double y)
{
return x + y;
}
public static long Add(long x, long y)
{
return x + y;
}
}
}
用以下代码替换Program.cs中的代码:
using System;
using FunWithMethodOverloading;
using static FunWithMethodOverloading.AddOperations;
Console.WriteLine("***** Fun with Method Overloading *****\n");
// Calls int version of Add()
Console.WriteLine(Add(10, 10));
// Calls long version of Add() (using the new digit separator)
Console.WriteLine(Add(900_000_000_000, 900_000_000_000));
// Calls double version of Add()
Console.WriteLine(Add(4.3, 4.4));
Console.ReadLine();
Note
第五章将会涉及using static声明。现在,把它看作是在FunWithMethodOverloading名称空间中包含一个名为AddOperations的静态类的using方法的键盘快捷键。
顶层语句调用了三个不同版本的Add方法,每个都使用不同的数据类型。
调用重载方法进行引导时,Visual Studio 和 Visual Studio 代码都有帮助。当您键入重载方法的名称时(例如您的好朋友Console.WriteLine()),IntelliSense 将列出该方法的每个版本。注意,你可以使用上下箭头键在重载方法的每个版本中循环,如图 4-1 所示。
图 4-1。
用于重载方法的 Visual Studio IntelliSense
如果重载有可选参数,编译器将根据命名和/或位置参数选择与调用代码最匹配的方法。添加以下方法:
static int Add(int x, int y, int z = 0)
{
return x + (y*z);
}
如果调用方没有传入可选参数,编译器将匹配第一个签名(没有可选参数的签名)。虽然方法位置有一个规则集,但是创建仅在可选参数上有所不同的方法通常不是一个好主意。
最后,当使用多个修饰符时,in、ref和out不被认为是方法重载签名的一部分。换句话说,下列重载将引发编译器错误:
static int Add(ref int x) { /* */ }
static int Add(out int x) { /* */ }
然而,如果只有一个方法使用了in、ref或out,编译器可以区分这些签名。所以,这是允许的:
static int Add(ref int x) { /* */ }
static int Add(int x) { /* */ }
这就结束了使用 C# 语法构建方法的初步研究。接下来,让我们看看如何构建和操作枚举和结构。
了解枚举类型
从第一章回忆起。NET 核心类型系统由类、结构、枚举、接口和委托组成。为了开始探索这些类型,让我们使用一个名为 FunWithEnums 的新控制台应用项目来检查一下枚举(或者简称为enum)的角色。
Note
不要混淆术语枚举器和枚举器;它们是完全不同的概念。枚举是名称-值对的自定义数据类型。枚举器是实现名为IEnumerable的. NET 核心接口的类或结构。通常,这个接口是在集合类和System.Array类上实现的。正如你将在第八章中看到的,支持IEnumerable的对象可以在foreach循环中工作。
构建系统时,创建一组映射到已知数值的符号名通常很方便。例如,如果您正在创建一个工资单系统,您可能希望使用诸如副总裁、经理、承包商和普通员工等常量来引用雇员的类型。正因为如此,C# 支持自定义枚举的概念。例如,这里有一个名为EmpTypeEnum的枚举(如果将它放在文件的末尾,可以在与顶级语句相同的文件中定义它):
using System;
Console.WriteLine("**** Fun with Enums *****\n");
Console.ReadLine();
//local functions go here:
// A custom enumeration.
enum EmpTypeEnum
{
Manager, // = 0
Grunt, // = 1
Contractor, // = 2
VicePresident // = 3
}
Note
按照惯例,枚举类型通常以Enum为后缀。这不是必须的,但可以让代码更易读。
EmpTypeEnum枚举定义了四个命名的常量,对应于离散的数值。默认情况下,第一个元素的值设置为零(0),后面是 n+1 级数。你可以随意改变初始值。例如,如果将EmpTypeEnum的成员编号为 102 到 105 是有意义的,您可以这样做:
// Begin with 102.
enum EmpTypeEnum
{
Manager = 102,
Grunt, // = 103
Contractor, // = 104
VicePresident // = 105
}
枚举不一定需要遵循顺序,也不需要具有唯一的值。如果(由于这样或那样的原因)像这里所示的那样建立您的EmpTypeEnum是有意义的,编译器会继续高兴:
// Elements of an enumeration need not be sequential!
enum EmpType
{
Manager = 10,
Grunt = 1,
Contractor = 100,
VicePresident = 9
}
控制枚举的基础存储
默认情况下,用于保存枚举值的存储类型是 aSystem.Int32(c#int);但是,您可以根据自己的喜好随意更改。可以用类似的方式为任何核心系统类型(byte、short、int或long)定义 C# 枚举。例如,如果您想将EmpTypeEnum的底层存储值设置为byte而不是int,您可以编写以下代码:
// This time, EmpTypeEnum maps to an underlying byte.
enum EmpTypeEnum : byte
{
Manager = 10,
Grunt = 1,
Contractor = 100,
VicePresident = 9
}
如果您正在构建一个. NET 核心应用,该应用将被部署到低内存设备上,并且需要尽可能地节省内存,则更改枚举的基础类型会很有帮助。当然,如果您确实建立了您的枚举来使用一个byte作为存储,每个值必须在它的范围之内!例如,以下版本的EmpTypeEnum将导致编译器错误,因为值 999 不适合一个字节的范围:
// Compile-time error! 999 is too big for a byte!
enum EmpTypeEnum : byte
{
Manager = 10,
Grunt = 1,
Contractor = 100,
VicePresident = 999
}
声明枚举变量
一旦建立了枚举的范围和存储类型,就可以用它来代替所谓的幻数。因为枚举只不过是用户定义的数据类型,所以您可以将它们用作函数返回值、方法参数、局部变量等等。假设您有一个名为AskForBonus()的方法,将一个EmpTypeEnum变量作为唯一的参数。根据传入参数的值,您将打印出对支付奖金请求的合适响应。
Console.WriteLine("**** Fun with Enums *****");
// Make an EmpTypeEnum variable.
EmpTypeEnum emp = EmpTypeEnum.Contractor;
AskForBonus(emp);
Console.ReadLine();
// Enums as parameters.
static void AskForBonus(EmpTypeEnum e)
{
switch (e)
{
case EmpType.Manager:
Console.WriteLine("How about stock options instead?");
break;
case EmpType.Grunt:
Console.WriteLine("You have got to be kidding...");
break;
case EmpType.Contractor:
Console.WriteLine("You already get enough cash...");
break;
case EmpType.VicePresident:
Console.WriteLine("VERY GOOD, Sir!");
break;
}
}
请注意,当您给一个enum变量赋值时,您必须将enum名称(EmpTypeEnum)限定为值(Grunt)。因为枚举是一组固定的名称-值对,所以将enum变量设置为不是由枚举类型直接定义的值是非法的。
static void ThisMethodWillNotCompile()
{
// Error! SalesManager is not in the EmpTypeEnum enum!
EmpTypeEnum emp = EmpType.SalesManager;
// Error! Forgot to scope Grunt value to EmpTypeEnum enum!
emp = Grunt;
}
使用系统。枚举类型
有趣的是。NET 核心枚举的一个优点是它们从System.Enum类类型中获得功能。这个类定义了几个方法,允许您查询和转换给定的枚举。一个有用的方法是静态的Enum.GetUnderlyingType(),顾名思义,它返回用于存储枚举类型值的数据类型(在当前的EmpTypeEnum声明中是System.Byte)。
Console.WriteLine("**** Fun with Enums *****");
...
// Print storage for the enum.
Console.WriteLine("EmpTypeEnum uses a {0} for storage",
Enum.GetUnderlyingType(emp.GetType()));
Console.ReadLine();
Enum.GetUnderlyingType()方法要求您传入一个System.Type作为第一个参数。正如在第十七章中详细讨论的那样,Type代表给定的元数据描述.NETCore 实体。
获取元数据的一种可能方式(如前所示)是使用GetType()方法,该方法对于。NET 核心基本类库。另一种方法是使用 C# typeof操作符。这样做的一个好处是,您不需要拥有想要获取其元数据描述的实体的变量。
// This time use typeof to extract a Type.
Console.WriteLine("EmpTypeEnum uses a {0} for storage",
Enum.GetUnderlyingType(typeof(EmpTypeEnum)));
动态发现枚举的名称-值对
除了Enum.GetUnderlyingType()方法,所有 C# 枚举都支持一个名为ToString()的方法,该方法返回当前枚举值的字符串名称。以下代码是一个示例:
EmpTypeEnum emp = EmpTypeEnum.Contractor;
...
// Prints out "emp is a Contractor".
Console.WriteLine("emp is a {0}.", emp.ToString());
Console.ReadLine();
如果您对发现给定枚举变量的值感兴趣,而不是它的名称,您可以简单地将enum变量转换为底层存储类型。以下是一个例子:
Console.WriteLine("**** Fun with Enums *****");
EmpTypeEnum emp = EmpTypeEnum.Contractor;
...
// Prints out "Contractor = 100".
Console.WriteLine("{0} = {1}", emp.ToString(), (byte)emp);
Console.ReadLine();
Note
静态的Enum.Format()方法通过指定一个期望的格式标志提供了一个更精细的格式化选项。有关格式化标志的完整列表,请参考文档。
System.Enum还定义了另一个名为GetValues()的静态方法。这个方法返回一个System.Array的实例。数组中的每一项都对应于指定枚举的一个成员。考虑下面的方法,它将打印出您作为参数传入的任何枚举中的每个名称-值对:
// This method will print out the details of any enum.
static void EvaluateEnum(System.Enum e)
{
Console.WriteLine("=> Information about {0}", e.GetType().Name);
Console.WriteLine("Underlying storage type: {0}",
Enum.GetUnderlyingType(e.GetType()));
// Get all name-value pairs for incoming parameter.
Array enumData = Enum.GetValues(e.GetType());
Console.WriteLine("This enum has {0} members.", enumData.Length);
// Now show the string name and associated value, using the D format
// flag (see Chapter 3).
for(int i = 0; i < enumData.Length; i++)
{
Console.WriteLine("Name: {0}, Value: {0:D}",
enumData.GetValue(i));
}
}
为了测试这个新方法,更新您的code来创建在System名称空间中声明的几个枚举类型的变量(以及一个EmpTypeEnum枚举)。以下代码是一个示例:
Console.WriteLine("**** Fun with Enums *****");
...
EmpTypeEnum e2 = EmpType.Contractor;
// These types are enums in the System namespace.
DayOfWeek day = DayOfWeek.Monday;
ConsoleColor cc = ConsoleColor.Gray;
EvaluateEnum(e2);
EvaluateEnum(day);
EvaluateEnum(cc);
Console.ReadLine();
这里显示了部分输出:
=> Information about DayOfWeek
Underlying storage type: System.Int32
This enum has 7 members.
Name: Sunday, Value: 0
Name: Monday, Value: 1
Name: Tuesday, Value: 2
Name: Wednesday, Value: 3
Name: Thursday, Value: 4
Name: Friday, Value: 5
Name: Saturday, Value: 6
正如您将在本文中看到的,枚举在整个。NET 核心基本类库。当您使用任何枚举时,请记住您可以使用System.Enum的成员与名称-值对进行交互。
使用枚举、标志和位运算
按位运算提供了一种在比特级对二进制数进行运算的快速机制。表 4-3 包含了 C# 位操作符,它们做什么,以及每个操作符的例子。
表 4-3。
位运算
|操作员
|
操作
|
例子
| | --- | --- | --- | | &(和) | 如果一个位在两个操作数中都存在,则复制该位 | 0110 & 0100 = 0100 (4) | | |(或) | 如果一个位在两个操作数中都存在,则复制该位 | 0110 | 0100 = 0110 (6) | | ^(异或) | 如果某个位存在于一个操作数中,但不存在于两个操作数中,则复制该位 | 0110 ^ 0100 = 0010 (2) | | ~(某人的赞美) | 翻转比特 | ~0110 = -7(由于溢出) | | < | 将位左移 | 0110 << 1 = 1100 (12) | | > >(右移) | 将位右移 | 0110 << 1 = 0011 (3) |
为了展示这些操作,创建一个名为 FunWithBitwiseOperations 的新控制台应用项目。将Program.cs文件更新为以下代码:
using System;
using FunWithBitwiseOperations;
Console.WriteLine("===== Fun wih Bitwise Operations");
Console.WriteLine("6 & 4 = {0} | {1}", 6 & 4, Convert.ToString((6 & 4),2));
Console.WriteLine("6 | 4 = {0} | {1}", 6 | 4, Convert.ToString((6 | 4),2));
Console.WriteLine("6 ^ 4 = {0} | {1}", 6 ^ 4, Convert.ToString((6 ^ 4),2));
Console.WriteLine("6 << 1 = {0} | {1}", 6 << 1, Convert.ToString((6 << 1),2));
Console.WriteLine("6 >> 1 = {0} | {1}", 6 >> 1, Convert.ToString((6 >> 1),2));
Console.WriteLine("~6 = {0} | {1}", ~6, Convert.ToString(~((short)6),2));
Console.WriteLine("Int.MaxValue {0}", Convert.ToString((int.MaxValue),2));
Console.readLine();
当您执行代码时,您将看到以下结果:
===== Fun wih Bitwise Operations
6 & 4 = 4 | 100
6 | 4 = 6 | 110
6 ^ 4 = 2 | 10
6 << 1 = 12 | 1100
6 >> 1 = 3 | 11
~6 = -7 | 11111111111111111111111111111001
Int.MaxValue 1111111111111111111111111111111
既然您已经知道了按位运算的基本知识,是时候将它们应用到枚举中了。添加名为ContactPreferenceEnum.cs的新文件,并将代码更新如下:
using System;
namespace FunWithBitwiseOperations
{
[Flags]
public enum ContactPreferenceEnum
{
None = 1,
Email = 2,
Phone = 4,
Ponyexpress = 6
}
}
请注意Flags属性。这允许将一个枚举中的多个值合并到一个变量中。例如,Email和Phone可以这样组合:
ContactPreferenceEnum emailAndPhone = ContactPreferenceEnum.Email | ContactPreferenceEnum.Phone;
这允许您检查其中一个值是否存在于组合值中。例如,如果您想查看哪个ContactPreference值在emailAndPhone变量中,您可以使用下面的代码:
Console.WriteLine("None? {0}", (emailAndPhone | ContactPreferenceEnum.None) == emailAndPhone);
Console.WriteLine("Email? {0}", (emailAndPhone | ContactPreferenceEnum.Email) == emailAndPhone);
Console.WriteLine("Phone? {0}", (emailAndPhone | ContactPreferenceEnum.Phone) == emailAndPhone);
Console.WriteLine("Text? {0}", (emailAndPhone | ContactPreferenceEnum.Text) == emailAndPhone);
执行时,控制台窗口会显示以下内容:
None? False
Email? True
Phone? True
Text? False
理解结构(又名值类型)
现在您已经理解了枚举类型的作用,让我们来研究。NET 核心结构(或者简称为结构)。结构类型非常适合在应用中建模数学、几何和其他“原子”实体。结构(如枚举)是用户定义的类型;然而,结构不仅仅是名称-值对的集合。相反,结构是可以包含任意数量的数据字段和对这些字段进行操作的成员的类型。
Note
如果你有 OOP 的背景,你可以把一个结构想成一个“轻量级类类型”,因为结构提供了一种定义支持封装的类型的方法,但是不能用来构建一系列相关的类型。当你需要通过继承建立一个相关类型的家族时,你将需要利用类类型。
从表面上看,定义和使用结构的过程很简单,但是正如他们所说的,细节决定成败。为了开始理解结构类型的基础,创建一个名为 FunWithStructures 的新项目。在 C# 中,使用struct关键字定义结构。定义一个名为Point的新结构,它定义了两个int类型的成员变量和一组与所述数据交互的方法。
struct Point
{
// Fields of the structure.
public int X;
public int Y;
// Add 1 to the (X, Y) position.
public void Increment()
{
X++; Y++;
}
// Subtract 1 from the (X, Y) position.
public void Decrement()
{
X--; Y--;
}
// Display the current position.
public void Display()
{
Console.WriteLine("X = {0}, Y = {1}", X, Y);
}
}
这里,您已经使用public关键字定义了两个整数字段(X和Y),这是一个访问控制修饰符(第五章继续讨论)。用public关键字声明数据可以确保调用者可以直接访问给定的Point变量中的数据(通过点运算符)。
Note
在类或结构中定义公共数据通常被认为是不好的风格。相反,您会想要定义私有的数据,可以使用公共的属性来访问和更改这些数据。这些细节将在第五章中讨论。
下面是测试使用Point类型的代码:
Console.WriteLine("***** A First Look at Structures *****\n");
// Create an initial Point.
Point myPoint;
myPoint.X = 349;
myPoint.Y = 76;
myPoint.Display();
// Adjust the X and Y values.
myPoint.Increment();
myPoint.Display();
Console.ReadLine();
输出如您所料。
***** A First Look at Structures *****
X = 349, Y = 76
X = 350, Y = 77
创建结构变量
当你想创建一个结构变量时,你有多种选择。在这里,您只需创建一个Point变量,并在调用它的成员之前分配每个公共字段数据。如果您在使用该结构之前没有而不是分配每一个公共字段数据(在本例中是X和Y,您将会收到一个编译器错误。
// Error! Did not assign Y value.
Point p1;
p1.X = 10;
p1.Display();
// OK! Both fields assigned before use.
Point p2;
p2.X = 10;
p2.Y = 10;
p2.Display();
或者,您可以使用 C# new关键字创建结构变量,这将调用结构的默认构造函数。根据定义,默认构造函数不接受任何参数。调用结构的默认构造函数的好处是每一段字段数据都被自动设置为其默认值。
// Set all fields to default values
// using the default constructor.
Point p1 = new Point();
// Prints X=0,Y=0.
p1.Display();
也可以设计一个带有自定义构造器的结构。这允许您在创建变量时指定字段数据的值,而不必逐个字段地设置每个数据成员。第五章将提供对构造者的详细检查;然而,为了说明,用下面的代码更新Point结构:
struct Point
{
// Fields of the structure.
public int X;
public int Y;
// A custom constructor.
public Point(int xPos, int yPos)
{
X = xPos;
Y = yPos;
}
...
}
这样,您现在可以创建Point变量,如下所示:
// Call custom constructor.
Point p2 = new Point(50, 60);
// Prints X=50,Y=60.
p2.Display();
使用只读结构(新 7.2)
如果需要使结构成为不可变的,也可以将它们标记为只读。不可变对象必须在构造时建立,因为它们不能被改变,所以性能更好。当将结构声明为只读时,所有属性也必须是只读的。但是你可能会问,如果一个属性是只读的,如何设置它(因为所有的属性都必须在一个结构上)?答案是该值必须在构造结构的过程中设置。
将点类更新为以下示例:
readonly struct ReadOnlyPoint
{
// Fields of the structure.
public int X {get; }
public int Y { get; }
// Display the current position and name.
public void Display()
{
Console.WriteLine($"X = {X}, Y = {Y}");
}
public ReadOnlyPoint(int xPos, int yPos)
{
X = xPos;
Y = yPos;
}
}
因为变量是只读的,所以已经删除了Increment和Decrement方法。还要注意两个属性,X和Y。它们不是设置为字段,而是创建为只读自动属性。自动属性包含在第五章中。
使用只读成员(新 8.0)
C# 8.0 中的新特性,你可以将一个结构的单个字段声明为readonly。这比将整个结构设为只读更细粒度。readonly修饰符可以应用于方法、属性和属性访问器。将以下结构代码添加到您的文件中,在Program类之外:
struct PointWithReadOnly
{
// Fields of the structure.
public int X;
public readonly int Y;
public readonly string Name;
// Display the current position and name.
public readonly void Display()
{
Console.WriteLine($"X = {X}, Y = {Y}, Name = {Name}");
}
// A custom constructor.
public PointWithReadOnly(int xPos, int yPos, string name)
{
X = xPos;
Y = yPos;
Name = name;
}
}
若要使用这个新结构,请将以下内容添加到顶级语句中:
PointWithReadOnly p3 =
new PointWithReadOnly(50,60,"Point w/RO");
p3.Display();
使用引用结构(新 7.2)
C# 7.2 中也添加了一个修饰符ref,可以在定义一个结构时使用。这要求对该结构的所有实例进行堆栈分配,并且不能作为另一个类的属性进行赋值。技术上的原因是不能从堆中引用ref结构。堆栈和堆之间的区别将在下一节讨论。
以下是ref结构的一些附加限制:
-
它们不能赋给 object 或 dynamic 类型的变量,也不能是接口类型。
-
它们不能实现接口。
-
它们不能用作非
ref结构的属性。 -
它们不能用在异步方法、迭代器、lambda 表达式或局部函数中。
下面的代码创建了一个简单的结构,然后试图在该结构中创建一个类型为ref结构的属性,该代码将不会编译:
struct NormalPoint
{
//This does not compile
public PointWithRef PropPointer { get; set; }
}
readonly和ref修饰符可以结合使用,以获得两者的优点和限制。
使用可处理的引用结构(新 8.0)
如前一节所述,ref结构(和只读ref结构)不能实现接口,因此也不能实现IDisposable。C# 8.0 中的新特性,ref结构和只读ref结构可以通过添加一个公共void Dispose()方法来进行处理。
将以下结构定义添加到主文件中:
ref struct DisposableRefStruct
{
public int X;
public readonly int Y;
public readonly void Display()
{
Console.WriteLine($"X = {X}, Y = {Y}");
}
// A custom constructor.
public DisposableRefStruct(int xPos, int yPos)
{
X = xPos;
Y = yPos;
Console.WriteLine("Created!");
}
public void Dispose()
{
//clean up any resources here
Console.WriteLine("Disposed!");
}
}
接下来,将以下内容添加到顶级语句的末尾,以创建和释放新的结构:
var s = new DisposableRefStruct(50, 60);
s.Display();
s.Dispose();
Note
第九章深入介绍了对象生存期和对象处置。
为了加深您对堆栈和堆分配的理解,您需要探究. NET 核心值类型和. NET 核心引用类型之间的区别。
了解值类型和引用类型
Note
下面对值类型和引用类型的讨论假设您有面向对象编程的背景。如果不是这样,你可能想跳到本章的“理解 C# 可空类型”一节,并在阅读完第 5 和 6 章后回到这一节。
与数组、字符串或枚举不同,C# 结构在。NET 核心库(即没有System.Structure类)但是从System.ValueType隐式派生而来。System.ValueType的作用是确保派生类型(如任何结构)被分配在栈上,而不是被垃圾收集的堆上。简而言之,分配在堆栈上的数据可以被快速地创建和销毁,因为它的生存期是由定义的范围决定的。另一方面,堆分配的数据由。NET 核心垃圾收集器,它的生命周期由许多因素决定,这些因素将在第九章中讨论。
从功能上来说,System.ValueType的唯一目的是覆盖由System.Object定义的虚拟方法,以使用基于值和基于引用的语义。您可能知道,重写是更改基类中定义的虚(或者可能是抽象)方法的实现的过程。ValueType的基类是System.Object。实际上,System.ValueType定义的实例方法和System.Object的是一样的。
// Structures and enumerations implicitly extend System.ValueType.
public abstract class ValueType : object
{
public virtual bool Equals(object obj);
public virtual int GetHashCode();
public Type GetType();
public virtual string ToString();
}
假设值类型使用基于值的语义,结构(包括所有数字数据类型[ int,float ],以及任何enum或结构)的生命周期是可预测的。当一个结构变量超出定义范围时,它会被立即从内存中删除。
// Local structures are popped off
// the stack when a method returns.
static void LocalValueTypes()
{
// Recall! "int" is really a System.Int32 structure.
int i = 0;
// Recall! Point is a structure type.
Point p = new Point();
} // "i" and "p" popped off the stack here!
使用值类型、引用类型和赋值运算符
当您将一种值类型分配给另一种值类型时,将获得字段数据的逐个成员的副本。对于像System.Int32这样的简单数据类型,唯一要复制的成员是数值。然而,在您的Point中,X和Y的值被复制到新的结构变量中。举例来说,创建一个名为 FunWithValueAndReferenceTypes 的新控制台应用项目,然后将之前的Point定义复制到新的名称空间中。接下来,将以下局部函数添加到顶级语句中:
// Assigning two intrinsic value types results in
// two independent variables on the stack.
static void ValueTypeAssignment()
{
Console.WriteLine("Assigning value types\n");
Point p1 = new Point(10, 10);
Point p2 = p1;
// Print both points.
p1.Display();
p2.Display();
// Change p1.X and print again. p2.X is not changed.
p1.X = 100;
Console.WriteLine("\n=> Changed p1.X\n");
p1.Display();
p2.Display();
}
这里,您已经创建了一个类型为Point(名为p1)的变量,然后将它分配给另一个Point ( p2)。因为Point是一个值类型,所以你在堆栈上有两个Point类型的副本,每个都可以被独立操作。因此,当您更改p1.X的值时,p2.X的值不受影响。
Assigning value types
X = 10, Y = 10
X = 10, Y = 10
=> Changed p1.X
X = 100, Y = 10
X = 10, Y = 10
与值类型形成鲜明对比的是,当您将赋值操作符应用于引用类型(意味着所有类实例)时,您是在内存中重定向引用变量所指向的内容。举例来说,创建一个名为PointRef的新类类型,它具有与Point结构相同的成员,除了重命名构造函数以匹配类名。
// Classes are always reference types.
class PointRef
{
// Same members as the Point structure...
// Be sure to change your constructor name to PointRef!
public PointRef(int xPos, int yPos)
{
X = xPos;
Y = yPos;
}
}
现在,在下面的新方法中使用您的PointRef类型。注意,除了使用PointRef类,而不是Point结构,代码与ValueTypeAssignment()方法相同。
static void ReferenceTypeAssignment()
{
Console.WriteLine("Assigning reference types\n");
PointRef p1 = new PointRef(10, 10);
PointRef p2 = p1;
// Print both point refs.
p1.Display();
p2.Display();
// Change p1.X and print again.
p1.X = 100;
Console.WriteLine("\n=> Changed p1.X\n");
p1.Display();
p2.Display();
}
在这种情况下,有两个引用指向托管堆上的同一个对象。因此,当您使用p1参考改变X的值时,p2.X报告相同的值。假设您已经调用了这个新方法,您的输出应该如下所示:
Assigning reference types
X = 10, Y = 10
X = 10, Y = 10
=> Changed p1.X
X = 100, Y = 10
X = 100, Y = 10
使用包含引用类型的值类型
现在,您对值类型和引用类型之间的基本区别有了更好的理解,让我们来看一个更复杂的例子。假设您有下面的引用(类)类型,它维护一个可以使用自定义构造函数设置的信息性string:
class ShapeInfo
{
public string InfoString;
public ShapeInfo(string info)
{
InfoString = info;
}
}
现在假设您想在名为Rectangle的值类型中包含这个类类型的变量。为了允许调用者设置内部ShapeInfo成员变量的值,您还提供了一个定制的构造函数。以下是Rectangle类型的完整定义:
struct Rectangle
{
// The Rectangle structure contains a reference type member.
public ShapeInfo RectInfo;
public int RectTop, RectLeft, RectBottom, RectRight;
public Rectangle(string info, int top, int left, int bottom, int right)
{
RectInfo = new ShapeInfo(info);
RectTop = top; RectBottom = bottom;
RectLeft = left; RectRight = right;
}
public void Display()
{
Console.WriteLine("String = {0}, Top = {1}, Bottom = {2}, " +
"Left = {3}, Right = {4}",
RectInfo.InfoString, RectTop, RectBottom, RectLeft, RectRight);
}
}
此时,您已经在值类型中包含了一个引用类型。这个百万美元的问题现在变成了“如果你将一个Rectangle变量赋给另一个变量会发生什么?”给定你已经知道的关于值类型的知识,你假设整数数据(它确实是一个结构,System.Int32)应该是每个Rectangle变量的独立实体是正确的。但是内部引用类型呢?对象的状态会被完全复制,还是对该对象的引用会被复制?要回答这个问题,请定义以下方法并调用它:
static void ValueTypeContainingRefType()
{
// Create the first Rectangle.
Console.WriteLine("-> Creating r1");
Rectangle r1 = new Rectangle("First Rect", 10, 10, 50, 50);
// Now assign a new Rectangle to r1.
Console.WriteLine("-> Assigning r2 to r1");
Rectangle r2 = r1;
// Change some values of r2.
Console.WriteLine("-> Changing values of r2");
r2.RectInfo.InfoString = "This is new info!";
r2.RectBottom = 4444;
// Print values of both rectangles.
r1.Display();
r2.Display();
}
输出如下所示:
-> Creating r1
-> Assigning r2 to r1
-> Changing values of r2
String = This is new info!, Top = 10, Bottom = 50, Left = 10, Right = 50
String = This is new info!, Top = 10, Bottom = 4444, Left = 10, Right = 50
如您所见,当您使用r2引用更改信息字符串的值时,r1引用显示相同的值。默认情况下,当值类型包含其他引用类型时,赋值会产生引用的副本。这样,你就有了两个独立的结构,每个结构都包含一个指向内存中同一个对象的引用(即浅拷贝)。当你想要执行深度复制时,其中内部引用的状态被完全复制到一个新的对象中,一种方法是实现ICloneable接口(就像你在第八章中将要做的)。
通过值传递引用类型
正如本章前面所述,引用类型或值类型可以作为参数传递给方法。然而,通过引用传递引用类型(例如,类)与通过值传递有很大不同。为了理解其中的区别,假设您在一个名为 FunWithRefTypeValTypeParams 的新控制台应用项目中定义了一个简单的Person类,定义如下:
class Person
{
public string personName;
public int personAge;
// Constructors.
public Person(string name, int age)
{
personName = name;
personAge = age;
}
public Person(){}
public void Display()
{
Console.WriteLine("Name: {0}, Age: {1}", personName, personAge);
}
}
现在,如果您创建一个方法,允许调用者通过值发送Person对象(注意缺少参数修饰符,如out或ref)会怎么样?
static void SendAPersonByValue(Person p)
{
// Change the age of "p"?
p.personAge = 99;
// Will the caller see this reassignment?
p = new Person("Nikki", 99);
}
注意SendAPersonByValue()方法如何试图将传入的Person引用重新分配给新的Person对象,以及更改一些状态数据。现在让我们使用下面的代码来测试这个方法:
// Passing ref-types by value.
Console.WriteLine("***** Passing Person object by value *****");
Person fred = new Person("Fred", 12);
Console.WriteLine("\nBefore by value call, Person is:");
fred.Display();
SendAPersonByValue(fred);
Console.WriteLine("\nAfter by value call, Person is:");
fred.Display();
Console.ReadLine();
以下是该调用的输出:
***** Passing Person object by value *****
Before by value call, Person is:
Name: Fred, Age: 12
After by value call, Person is:
Name: Fred, Age: 99
如您所见,personAge的值已经被修改。既然您已经理解了引用类型的工作方式,前面讨论的这种行为应该更有意义。假设您能够更改传入的Person的状态,那么复制了什么呢?答案是:调用方对象的引用的副本。因此,当SendAPersonByValue()方法与调用者指向同一个对象时,就有可能改变对象的状态数据。不可能的是重新分配引用所指向的。
通过引用传递引用类型
现在假设您有一个SendAPersonByReference()方法,它通过引用传递一个引用类型(注意ref参数修饰符)。
static void SendAPersonByReference(ref Person p)
{
// Change some data of "p".
p.personAge = 555;
// "p" is now pointing to a new object on the heap!
p = new Person("Nikki", 999);
}
如您所料,这使得被调用方能够完全灵活地操作传入参数。被调用者不仅可以改变对象的状态,而且如果它愿意,它还可以将引用重新分配给一个新的Person对象。现在思考下面更新的代码:
// Passing ref-types by ref.
Console.WriteLine("***** Passing Person object by reference *****");
...
Person mel = new Person("Mel", 23);
Console.WriteLine("Before by ref call, Person is:");
mel.Display();
SendAPersonByReference(ref mel);
Console.WriteLine("After by ref call, Person is:");
mel.Display();
Console.ReadLine();
请注意以下输出:
***** Passing Person object by reference *****
Before by ref call, Person is:
Name: Mel, Age: 23
After by ref call, Person is:
Name: Nikki, Age: 999
如您所见,名为Mel的对象在调用后作为名为Nikki的对象返回,因为该方法能够改变传入引用在内存中指向的内容。传递引用类型时要记住的黄金法则如下:
-
如果引用类型是通过引用传递的,则被调用方可以更改对象的状态数据的值,以及它所引用的对象。
-
如果引用类型是通过值传递的,被调用者可以改变对象的状态数据的值,但是不能它所引用的对象。
关于值类型和引用类型的最终细节
为了总结这个主题,考虑表 4-4 中的信息,它总结了值类型和引用类型之间的核心区别。
表 4-4。
值类型和引用类型比较
|有趣的问题
|
值类型
|
参考类型
|
| --- | --- | --- |
| 对象被分配到哪里? | 在堆栈上分配。 | 在托管堆上分配。 |
| 变量是如何表示的? | 值类型变量是本地副本。 | 引用类型变量指向分配的实例所占用的内存。 |
| 基本类型是什么? | 隐式扩展System.ValueType。 | 可以从任何其他类型派生(除了System.ValueType),如果那个类型不是“密封的”(更多细节在第六章)。 |
| 这个类型可以作为其他类型的基础吗? | 不可以。值类型总是密封的,不能从。 | 是的。如果该类型不是密封的,它可能充当其他类型的基。 |
| 默认的参数传递行为是什么? | 变量通过值传递(即,变量的副本被传递到被调用的函数中)。 | 对于引用类型,引用是按值复制的。 |
| 这个类型可以覆盖System.Object.Finalize()吗? | 号码 | 是的,间接的(更多细节在第九章)。 |
| 我可以为这种类型定义构造函数吗? | 是的,但是默认构造函数是保留的(即,您的自定义构造函数必须都有参数)。 | 但是当然! |
| 这种类型的变量什么时候消亡? | 当它们超出定义范围时。 | 当对象被垃圾收集时(参见第九章)。 |
尽管存在差异,值类型和引用类型都可以实现接口,并且可以支持任意数量的字段、方法、重载运算符、常量、属性和事件。
了解 C# 可空类型
让我们使用名为 FunWithNullableValueTypes 的控制台应用项目来检查可空数据类型的角色。众所周知,C# 数据类型有一个固定的范围,并且在System名称空间中被表示为一个类型。例如,可以从集合{true, false}中为System.Boolean数据类型赋值。现在,回想一下所有的数字数据类型(以及Boolean数据类型)都是值类型。值类型永远不能被赋予null的值,因为它被用来建立一个空的对象引用。
// Compiler errors!
// Value types cannot be set to null!
bool myBool = null;
int myInt = null;
C# 支持可空数据类型的概念。简单地说,可空类型可以表示其基础类型的所有值,加上值null。因此,如果你声明一个可空的bool,它可以从集合{true, false, null}中被赋值。这在处理关系数据库时非常有用,因为在数据库表中经常会遇到未定义的列。如果没有可空数据类型的概念,C# 中就没有方便的方式来表示没有值的数字数据点。
为了定义可空变量类型,问号符号(?)作为基础数据类型的后缀。在 C# 8.0 之前,这种语法只有在应用于值类型时才是合法的(在下一节“可空引用类型”中有更多的介绍)。像不可空变量一样,在使用局部可空变量之前,必须给它们分配一个初始值。
static void LocalNullableVariables()
{
// Define some local nullable variables.
int? nullableInt = 10;
double? nullableDouble = 3.14;
bool? nullableBool = null;
char? nullableChar = 'a';
int?[] arrayOfNullableInts = new int?[10];
}
使用可空值类型
在 C# 中,?后缀符号是创建通用System.Nullable<T>结构类型实例的简写。它还用于创建可空的引用类型(在下一节中讨论),尽管行为有点不同。虽然在第十章之前你不会检查泛型,但是理解System.Nullable<T>类型提供了一组所有可空类型都可以利用的成员是很重要的。
例如,您可以使用HasValue属性或!=操作符以编程方式发现可空变量是否确实被赋予了一个null值。可空类型的赋值可以直接获得,也可以通过Value属性获得。事实上,鉴于?后缀只是使用Nullable<T>的简写,您可以如下实现您的LocalNullableVariables()方法:
static void LocalNullableVariablesUsingNullable()
{
// Define some local nullable types using Nullable<T>.
Nullable<int> nullableInt = 10;
Nullable<double> nullableDouble = 3.14;
Nullable<bool> nullableBool = null;
Nullable<char> nullableChar = 'a';
Nullable<int>[] arrayOfNullableInts = new Nullable<int>[10];
}
如上所述,当您与数据库交互时,可空数据类型可能特别有用,因为数据表中的列可能故意为空(例如,未定义)。为了说明,假设下面的类,它模拟了访问一个数据库的过程,该数据库的表包含两个可能是null的列。注意,GetIntFromDatabase()方法没有给可空整数成员变量赋值,而GetBoolFromDatabase()给bool?成员赋值。
class DatabaseReader
{
// Nullable data field.
public int? numericValue = null;
public bool? boolValue = true;
// Note the nullable return type.
public int? GetIntFromDatabase()
{ return numericValue; }
// Note the nullable return type.
public bool? GetBoolFromDatabase()
{ return boolValue; }
}
现在,检查下面的代码,该代码调用了DatabaseReader类的每个成员,并使用HasValue和Value成员以及 C# 相等运算符(确切地说,不等于)发现了分配的值:
Console.WriteLine("***** Fun with Nullable Value Types *****\n");
DatabaseReader dr = new DatabaseReader();
// Get int from "database".
int? i = dr.GetIntFromDatabase();
if (i.HasValue)
{
Console.WriteLine("Value of 'i' is: {0}", i.Value);
}
else
{
Console.WriteLine("Value of 'i' is undefined.");
}
// Get bool from "database".
bool? b = dr.GetBoolFromDatabase();
if (b != null)
{
Console.WriteLine("Value of 'b' is: {0}", b.Value);
}
else
{
Console.WriteLine("Value of 'b' is undefined.");
}
Console.ReadLine();
使用可空引用类型(新 8.0)
C# 8 增加的一个重要特性是支持可空引用类型。事实上,这种变化是如此的显著。无法更新. NET Framework 来支持这项新功能。因此,我们决定在。NET Core 3.0 和更高版本,以及默认情况下禁用可空引用类型支持的决定。在中创建新项目时。NET Core 3.0/3.1 或。NET 5 中,引用类型的工作方式与它们在 C# 7 中的工作方式相同。这是为了防止破坏前 C# 8 生态系统中存在的数十亿行代码。开发人员必须选择在其应用中启用可空引用类型。
可空引用类型遵循许多与可空值类型相同的规则。不可空的引用类型必须在初始化时被赋予一个非空值,并且以后不能被更改为空值。可空引用类型可以是空的,但是在第一次使用之前仍然必须被赋值(或者是某个东西的实际实例,或者是空的值)。
可空引用类型使用相同的符号(?)来表示它们是可空的。然而,这并不是使用System.Nullable<T>的简写,因为只有值类型可以用来代替T。提醒一下,泛型和约束将在第十章中介绍。
选择可空引用类型
对可空引用类型的支持是通过设置可空上下文来控制的。这可以大到整个项目(通过更新项目文件),也可以小到几行代码(通过使用编译器指令)。还可以设置两种上下文:
-
可空注释上下文:为可空引用类型启用/禁用可空注释(
?)。 -
可空警告上下文:启用/禁用可空引用类型的编译器警告。
要查看这些操作,请创建一个名为 FunWithNullableReferenceTypes 的新控制台应用。打开项目文件(如果使用的是 Visual Studio,请在解决方案资源管理器中双击项目名称,或者右击项目名称并选择“编辑项目文件”)。通过添加<Nullable>节点更新项目文件以支持可空引用类型(所有可用选项在表 4-5 中显示)。
表 4-5。
项目文件中可空的值
|价值
|
生命的意义
|
| --- | --- |
| 使能够 | 启用可为空的批注,并启用可为空的警告。 |
| 警告信息 | 可空注释被禁用,可空警告被启用。 |
| Annotations | 启用可空注释,禁用可空警告。 |
| Disable | 可为空的注释被禁用,可为空的警告也被禁用。 |
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
<Nullable>元素影响整个项目。为了控制项目的较小部分,使用表 4-6 中所示的编译器指令。
表 4-6。
#nullable 编译器指令的值
|价值
|
生命的意义
|
| --- | --- |
| 使能够 | 启用注释,并启用警告。 |
| 使残废 | 注释被禁用,警告也被禁用。 |
| Restore | 将所有设置恢复为项目设置。 |
| disable warnings | 警告被禁用,注释不受影响。 |
| enable warnings | 警告已启用,注释不受影响。 |
| restore warnings | 警告重置为项目设置;注释不受影响。 |
| disable annotations | 注释被禁用,警告不受影响。 |
| enable annotations | 批注已启用,警告不受影响。 |
| restore annotations | 批注被重置为项目设置;警告不受影响。 |
可空引用类型的作用
很大程度上是因为这一变化的重要性,可空类型只有在使用不当时才会引发错误。将以下类添加到Program.cs文件中:
public class TestClass
{
public string Name { get; set; }
public int Age { get; set; }
}
如你所见,这只是一个普通的类。当您在代码中使用这个类时,就会出现可空性。以下列声明为例:
string? nullableString = null;
TestClass? myNullableClass = null;
项目文件设置使整个项目成为可空的上下文。可空上下文允许string和TestClass类型的声明使用可空注释(?)。由于在可空的上下文中将 null 赋值给不可空的类型,下面的代码行将生成一条警告(CS8600):
//Warning CS8600 Converting null literal or possible null value to non-nullable type
TestClass myNonNullableClass = myNullableClass;
为了更好地控制可空上下文在项目中的位置,可以使用编译器指令(如前所述)来启用或禁用上下文。下面的代码关闭可空上下文(在项目级别设置),然后通过恢复项目设置重新启用它:
#nullable disable
TestClass anotherNullableClass = null;
//Warning CS8632 The annotation for nullable reference types
//should only be used in code within a '#nullable' annotations
TestClass? badDefinition = null;
//Warning CS8632 The annotation for nullable reference types
//should only be used in code within a '#nullable' annotations
string? anotherNullableString = null;
#nullable restore
最后要注意的是,可空引用类型没有HasValue和Value属性,因为它们是由System.Nullable<T>提供的。
迁移注意事项
将代码从 C# 7 迁移到 C# 8 或 C# 9 时,如果希望利用可空的引用类型,可以结合使用项目设置和编译器指令来处理代码。一种常见的做法是从启用警告和禁用整个项目的可空注释开始。然后,在清理代码区域时,使用编译器指令逐渐启用注释。
对可空类型进行操作
C# 提供了几个运算符来处理可空类型。接下来的会话编写了空合并操作符、空合并赋值操作符和空条件操作符。对于这些示例,请回到 FunWithNullableValueTypes 项目。
零合并算子
下一个需要注意的方面是,任何可能有一个null值的变量都可以使用 C# ??操作符,它的正式名称是空合并操作符。如果检索到的值实际上是null,这个操作符允许您将一个值赋给一个可空类型。对于这个例子,假设如果从GetIntFromDatabase()返回的值是null,你想将一个局部可空整数赋给 100(当然,这个方法被编程为总是返回null,但是我相信你已经明白了大概的意思)。移回 NullableValueTypes 项目(并将其设置为启动项目),并输入以下代码:
//omitted for brevity
Console.WriteLine("***** Fun with Nullable Data *****\n");
DatabaseReader dr = new DatabaseReader();
// If the value from GetIntFromDatabase() is null,
// assign local variable to 100.
int myData = dr.GetIntFromDatabase() ?? 100;
Console.WriteLine("Value of myData: {0}", myData);
Console.ReadLine();
使用??操作符的好处是它提供了传统if / else条件的一个更紧凑的版本。但是,如果您愿意,您可以编写以下功能等效的代码,以确保如果一个值作为null返回,它将确实被设置为值 100:
// Longhand notation not using ?? syntax.
int? moreData = dr.GetIntFromDatabase();
if (!moreData.HasValue)
{
moreData = 100;
}
Console.WriteLine("Value of moreData: {0}", moreData);
零合并赋值运算符(新 8.0)
基于零合并操作符,C# 8 引入了零合并赋值操作符 ( ??=)。仅当左侧为空时,该运算符才将左侧分配给右侧。例如,输入以下代码:
//Null-coalescing assignment operator
int? nullableInt = null;
nullableInt ??= 12;
nullableInt ??= 14;
Console.WriteLine(nullableInt);
nullableInt变量被初始化为null。下一行将值 12 赋给变量,因为左边确实是null。下一行没有没有给变量赋值 14,因为它不是null。
空条件运算符
当你写软件时,通常会检查输入参数,这些参数是从类型成员(方法、属性、索引器)返回的值,对照值null。例如,让我们假设您有一个将字符串数组作为单个参数的方法。为了安全起见,您可能想在继续之前测试一下null。这样,如果数组为空,就不会出现运行时错误。以下是执行这种检查的传统方式:
static void TesterMethod(string[] args)
{
// We should check for null before accessing the array data!
if (args != null)
{
Console.WriteLine($"You sent me {args.Length} arguments.");
}
}
这里,您使用一个条件作用域来确保如果数组是null,那么string数组的Length属性将不会被访问。如果调用方未能生成数据数组并像这样调用您的方法,您仍然是安全的,不会触发运行时错误:
TesterMethod(null);
C# 包含了null条件操作符标记(一个放在变量类型之后、访问操作符之前的问号)来简化前面的错误检查。现在,您可以编写以下代码,而不是显式地构建一个条件语句来检查null:
static void TesterMethod(string[] args)
{
// We should check for null before accessing the array data!
Console.WriteLine($"You sent me {args?.Length} arguments.");
}
在这种情况下,您没有使用条件语句。更确切地说,您是直接在string数组变量后面加上了?操作符的后缀。如果变量是null,它对Length属性的调用将不会抛出运行时错误。如果您想打印一个实际值,您可以利用零合并操作符来分配一个默认值,如下所示:
Console.WriteLine($"You sent me {args?.Length ?? 0} arguments.");
在一些额外的编码领域,C# 6.0 null条件操作符将会非常方便,尤其是在处理委托和事件的时候。这些主题将在本书后面讨论(见第十二章,你将会看到更多的例子。
了解元组(新的/更新的 7.0)
为了总结这一章,让我们使用一个名为 FunWithTuples 的控制台应用项目来研究元组的作用。正如本章前面提到的,使用out参数的一种方法是从一个方法调用中检索多个值。另一种方法是使用称为元组的轻量级结构。
元组是包含多个字段的轻量级数据结构。它们被添加到 C# 6 语言中,但是以一种极其有限的方式。C# 6 实现还有一个潜在的严重问题:每个字段都被实现为一个引用类型,这可能会产生内存和/或性能问题(来自装箱/取消装箱)。
在 C# 7 中,元组使用新的ValueTuple数据类型而不是引用类型,潜在地节省了大量内存。ValueTuple数据类型根据元组的属性数量创建不同的结构。C# 7 中增加的一个额外特性是元组中的每个属性都可以被赋予一个特定的名称(就像变量一样),这极大地增强了可用性。
对于元组,有两个重要的考虑因素:
-
这些字段未经验证。
-
您不能定义自己的方法。
它们实际上被设计成一种轻量级的数据传输机制。
元组入门
理论够了。我们写点代码吧!要创建元组,只需将要分配给元组的值括在括号中,如下所示:
("a", 5, "c")
请注意,它们不必都是相同的数据类型。括号构造也用于将元组赋给变量(或者您可以使用var关键字,编译器将为您分配数据类型)。为了将前面的例子赋给一个变量,下面两行实现了同样的事情。values变量将是一个元组,中间夹着两个string属性和一个int属性。
(string, int, string) values = ("a", 5, "c");
var values = ("a", 5, "c");
默认情况下,编译器给每个属性命名为ItemX,其中X表示元组中从 1 开始的位置。对于前面的例子,属性名是Item1、Item2和Item3。访问它们的方式如下:
Console.WriteLine($"First item: {values.Item1}");
Console.WriteLine($"Second item: {values.Item2}");
Console.WriteLine($"Third item: {values.Item3}");
特定的名称也可以添加到语句右侧或左侧元组中的每个属性。虽然在语句的两边都赋值不是编译器错误,但是如果这样做,右边的名字将被忽略,只使用左边的名字。下面两行代码显示了设置左边和右边的名称以达到相同的目的:
(string FirstLetter, int TheNumber, string SecondLetter) valuesWithNames = ("a", 5, "c");
var valuesWithNames2 = (FirstLetter: "a", TheNumber: 5, SecondLetter: "c");
现在可以使用字段名和ItemX符号来访问元组的属性,如下面的代码所示:
Console.WriteLine($"First item: {valuesWithNames.FirstLetter}");
Console.WriteLine($"Second item: {valuesWithNames.TheNumber}");
Console.WriteLine($"Third item: {valuesWithNames.SecondLetter}");
//Using the item notation still works!
Console.WriteLine($"First item: {valuesWithNames.Item1}");
Console.WriteLine($"Second item: {valuesWithNames.Item2}");
Console.WriteLine($"Third item: {valuesWithNames.Item3}");
注意,在右边设置名称时,必须使用关键字var来声明变量。专门设置数据类型(即使没有自定义名称)会触发编译器使用左侧,使用ItemX符号分配属性,并忽略右侧设置的任何自定义名称。以下两个例子忽略了Custom1和Custom2的名字:
(int, int) example = (Custom1:5, Custom2:7);
(int Field1, int Field2) example = (Custom1:5, Custom2:7);
同样重要的是要指出,自定义字段名称只存在于编译时,在运行时使用反射检查元组时不可用(反射在第十七章中讨论)。
元组也可以作为元组嵌套在元组内部。因为元组中的每个属性都是一种数据类型,而元组也是一种数据类型,所以下面的代码是完全合法的:
Console.WriteLine("=> Nested Tuples");
var nt = (5, 4, ("a", "b"));
使用推断变量名(更新 7.1)
C# 7.1 中对元组的更新是 C# 能够推断元组的变量名,如下所示:
Console.WriteLine("=> Inferred Tuple Names");
var foo = new {Prop1 = "first", Prop2 = "second"};
var bar = (foo.Prop1, foo.Prop2);
Console.WriteLine($"{bar.Prop1};{bar.Prop2}");
了解元组相等/不相等(新 7.3)
C# 7.1 中增加的一个特性是元组等式(==)和不等式(!=).在测试不相等性时,比较运算符将对元组内的数据类型执行隐式转换,包括比较可为空和不可为空的元组和/或属性。这意味着尽管int / long之间存在差异,但以下测试工作正常:
Console.WriteLine("=> Tuples Equality/Inequality");
// lifted conversions
var left = (a: 5, b: 10);
(int? a, int? b) nullableMembers = (5, 10);
Console.WriteLine(left == nullableMembers); // Also true
// converted type of left is (long, long)
(long a, long b) longTuple = (5, 10);
Console.WriteLine(left == longTuple); // Also true
// comparisons performed on (long, long) tuples
(long a, int b) longFirst = (5, 10);
(int a, long b) longSecond = (5, 10);
Console.WriteLine(longFirst == longSecond); // Also true
也可以比较包含元组的元组,但前提是它们具有相同的形状。您不能将一个包含三个int属性的元组与另一个包含两个int和一个元组的元组进行比较。
将元组理解为方法返回值
在本章的前面,out参数被用来从一个方法调用中返回多个值。还有其他方法可以做到这一点,比如创建一个专门用于返回值的类或结构。但是,如果这个类或结构只是用作一个方法的数据传输,那就是额外的工作和额外的代码,不需要开发。元组非常适合这个任务,是轻量级的,并且易于声明和使用。
这是out参数部分的一个例子。它返回三个值,但是需要三个参数作为调用代码的传输机制传入。
static void FillTheseValues(out int a, out string b, out bool c)
{
a = 9;
b = "Enjoy your string.";
c = true;
}
通过使用 tuple,您可以删除参数,但仍然可以获得三个值。
static (int a,string b,bool c) FillTheseValues()
{
return (9,"Enjoy your string.",true);
}
调用这个方法和调用任何其他方法一样简单。
var samples = FillTheseValues();
Console.WriteLine($"Int is: {samples.a}");
Console.WriteLine($"String is: {samples.b}");
Console.WriteLine($"Boolean is: {samples.c}");
也许一个更好的例子是将一个完整的名字分解成各个部分(名、中间名、姓)。以下代码接受一个全名,并返回一个包含不同部分的元组:
static (string first, string middle, string last) SplitNames(string fullName)
{
//do what is needed to split the name apart
return ("Philip", "F", "Japikse");
}
用元组理解丢弃
继续讨论SplitNames()的例子,假设您知道您只需要名和姓,而不关心中间的名字。通过为要返回的值提供变量名,并使用下划线(_)占位符填充不需要的值,可以像这样优化返回值:
var (first, _, last) = SplitNames("Philip F Japikse");
Console.WriteLine($"{first}:{last}");
元组的中间名值被丢弃。
了解元组模式匹配开关表达式(新 8.0)
既然你已经对元组有了透彻的理解,那么是时候用第三章中的元组来重温一下switch表达式了。这又是一个例子:
//Switch expression with Tuples
static string RockPaperScissors(string first, string second)
{
return (first, second) switch
{
("rock", "paper") => "Paper wins.",
("rock", "scissors") => "Rock wins.",
("paper", "rock") => "Paper wins.",
("paper", "scissors") => "Scissors wins.",
("scissors", "rock") => "Rock wins.",
("scissors", "paper") => "Scissors wins.",
(_, _) => "Tie.",
};
}
在这个例子中,当这两个参数被传递给switch表达式时,它们被转换成一个元组。相关值在switch表达式中表示,任何其他情况由最终元组处理,该元组由两个丢弃组成。
也可以编写RockPaperScissors()方法签名来接受一个元组,如下所示:
static string RockPaperScissors(
(string first, string second) value)
{
return value switch
{
//omitted for brevity
};
}
解构元组
解构是在分离出一个元组的属性以单独使用时给出的术语。就这么做了。但是这种模式还有一个有用的用途,那就是解构定制类型。
以本章前面使用的Point结构的较短版本为例。添加了一个名为Deconstruct()的新方法,以名为XPos和YPos的元组的形式返回Point实例的各个属性。
struct Point
{
// Fields of the structure.
public int X;
public int Y;
// A custom constructor.
public Point(int XPos, int YPos)
{
X = XPos;
Y = YPos;
}
public (int XPos, int YPos) Deconstruct() => (X, Y);
}
注意新的Deconstruct()方法,在前面的代码清单中以粗体显示。这个方法可以被命名为任何名称,但是按照惯例,它通常被命名为Deconstruct()。这允许单个方法调用通过返回元组来获取结构的单个值。
Point p = new Point(7,5);
var pointValues = p.Deconstruct();
Console.WriteLine($"X is: {pointValues.XPos}");
Console.WriteLine($"Y is: {pointValues.YPos}");
用位置模式匹配解构元组(新 8.0)
当元组有一个可访问的Deconstruct()方法时,可以在基于元组的开关表达式中使用解构。以Point为例,下面的代码使用生成的元组,并将这些值用于每个表达式的when子句:
static string GetQuadrant1(Point p)
{
return p.Deconstruct() switch
{
(0, 0) => "Origin",
var (x, y) when x > 0 && y > 0 => "One",
var (x, y) when x < 0 && y > 0 => "Two",
var (x, y) when x < 0 && y < 0 => "Three",
var (x, y) when x > 0 && y < 0 => "Four",
var (_, _) => "Border",
};
}
如果用两个out参数定义了Deconstruct()方法,那么switch表达式将自动解构该点。向Point添加另一个Deconstruct方法,如下所示:
public void Deconstruct(out int XPos, out int YPos)
=> (XPos,YPos)=(X, Y);
现在您可以更新(或添加一个新的)GetQuadrant()方法:
static string GetQuadrant2(Point p)
{
return p switch
{
(0, 0) => "Origin",
var (x, y) when x > 0 && y > 0 => "One",
var (x, y) when x < 0 && y > 0 => "Two",
var (x, y) when x < 0 && y < 0 => "Three",
var (x, y) when x > 0 && y < 0 => "Four",
var (_, _) => "Border",
};
}
这种变化非常微妙(并以粗体突出显示)。在switch表达式中只使用了Point变量,而不是调用p.Deconstruct()。
摘要
本章从对数组的研究开始。然后,我们讨论了允许您构建定制方法的 C# 关键字。回想一下,默认情况下,参数是通过值传递的;但是,如果用ref或out标记,您可以通过引用传递参数。您还了解了可选参数或命名参数的作用,以及如何定义和调用采用参数数组的方法。
在您研究了方法重载这一主题之后,本章的大部分讨论了有关枚举和结构如何在 C# 中定义以及如何在?NET 核心基本类库。在此过程中,您研究了关于值类型和引用类型的一些细节,包括当将它们作为参数传递给方法时它们如何响应,以及如何使用?、??和??=操作符与可能是null的可空数据类型和变量(例如,引用类型变量和可空值类型变量)进行交互。
本章的最后一节研究了 C# 中一个期待已久的特性,元组。在理解了它们是什么以及它们是如何工作的之后,您使用它们从方法中返回多个值以及解构自定义类型。
在第五章,你将开始深入探讨面向对象开发的细节。