在包含顶级语句的文件中,也可以添加方法和类型。在下面的代码片段中,定义了一个名为Method( )的方法,并在该方法的声明和实现后调用了它:
可以在使用之前或之后声明该方法。在相同的文件中可以添加类型,但需要在顶级语句的后面指定它们。在下面的代码片段中,指定类Book包含一个Title属性和ToString( )方法。在类型声明前,创建了一个新的实例,并将其赋值给变量b1,还设置了Title属性的值,还设置了Title属性的值,然后将该实例写入控制台。当把对象作为实参传递给WriteLine( )方法时,将调用Book类的ToString()方法:
注意:所有顶级语句需要包含在一个文件中,否则编译器将不知道从哪个文件开始。如果使用了顶级语句,则应该让它们易于找到,例如,把它们添加到Program.cs文件中。你不会希望在多个文件中查找顶级语句。
2.2 可空类型
在C#的第一个版本中,值类型不能有null值,但总是可以将null赋值给引用类型。这种情况在C# 2中第一次发生改变,因为C# 2引入了可空值类型。C#8对引用类型做了修改,因为.NET中发生的大部分异常是NullReferenceException。当调用的引用成员的值为null时,就会发生这种这种类型的异常,为了减少这种问题,使它们成为编译错误,C#8引入了可空类型。
2.2.1 可空值类型
对于值类型,如int,不能向其赋值null。当映射到数据库或其它数据源(如XML或JSON)的时候,这可能导致一些难以处理的情况。使用引用类型则会造成额外的开销:对象存储在堆上,当不再使用该对象时,垃圾回收过程需要清理它。可以采用的方法是,在类型定义中使用”?“,这将允许赋值null:
编译器将把这行语句改为是哟个Nullable<T>类型:
Nullable<T>不会增加引用类型的开销。它仍然是一个struct(值类型),只不过添加了一个布尔标志,用来指定值是否为null。
下面的代码片段演示了使用可空类型和不可为空值的赋值。变量n1是可空的int,它被赋值为null。可空值类型定义了HasValue属性,可以用它来检查变量是否被赋值。
使用Value属性可以访问该变量的值。这种方法可用来将值赋值给不可为空的值类型。总是可以把不可为空的值赋值给可空的值类型,这种赋值总会成功:
int? n1=null;
if(n1.HasValue)
{
int n2=n1.Value;
}
int n3=42;
int? n4=n3;
2.2.2 可空引用类型
可空引用类型的目标是减少NullReferenceException类型的异常,这是.NET应用程序中最常发生的异常类型。一直以来都有一个指导原则:应用程序不应该抛出这种异常,而总是应该检查null,但没有编译器的帮助,很容易就会漏掉值为null的情况。
为了能够获得编译器的帮助,需要启用可空引用类型。因为这种特性可能破坏现有代码,所以需要显式启用。为此,需要在项目文件中指定Nullable元素,并将其值设置为enable:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
现在,不能把null赋值给引用类型。如果启用了Nullable,然后编写下面的代码:
string s1=null; //compiler warning
编译器将给出如下错误:CS8600:Converting a null literal or a possible null value to non-nullable type.。
要将null 赋值给字符串,需要在声明类型时加上“?”,就像可空值类型那样:
当使用可空的s1变量时,需要在调用方法或者把该变量赋值给不可为空的字符串之前,检查他的值不是null。否则,编译器将生成警告:
string s2=s1.ToUpper(); //compiler warning
通过使用null条件运算符“?.”,可以在调用方法前检查null。使用该运算符时,只有对象不为null,才会调用方法。其结果不能被写入不可为空的字符串,因为如果s1是null,则右侧表达式的结果为null:
string? s2=s1?.ToUpper( );
通过使用空合并运算符“??”,可以在对象为空时指定一个不同的返回值。在下面的代码片段中,当??左侧的表达式返回null的时候,整个表达式将返回一个空字符串。右侧表达式的完整结果将写入变量s3,它不会为null。如果s1不为null,它就是s1字符串的大写版本,而如果s1是null,它就是一个空字符串:
string s3=s1?.ToUpper() ?? string.Empty;
除了使用这些运算符,还可以使用if语句确认变量不为null。在下面的代码片段的if语句中,使用了C#模式is not来验证s1不为null。只有当s1不为null的时候,才会调用if语句的块。在这里,不需要使用可空条件运算符来调用ToUpper()方法:
if(s1 is not null)
{
string s4=s1.ToUpper();
}
当然,也可以使用不等于运算符!= :
if (s1 != null)
{
string s5=s1.ToUpper();
}
对于类型成员,使用可空引用类型也很重要,如下面的代码片段中的Book类的Title和Publisher成员所示。代码中使用不可为空的string类型声明了Title,所以在创建Book类的新对象时,必须初始化Title。它是由Book类的构造函数初始化的。Publisher属性允许为null,所以不需要初始化:
class Book
{
public Book(string title)=>Title=title;
public string Title{get; set;}
public string? Publisher{get;set;}
}
当声明Book类的变量时,可以把该变量声明为可空(b1),或者使用一个带有构造函数的Book对象进行声明(b2)。可以把Title属性赋值给一个不可为空的string类型。对于Publisher属性,可以将其赋值给一个可空字符串,或者使用前面介绍的运算符:
Book? b1=null;
Book b2=new Book("Professional C#");
string title=b2.Title;
string? publisher=b2.Publisher;
对于可空值类型,后台会使用一个Nullable<T>类型。对于可空引用类型则不是这样。相反,编译器会向这些类型添加注解。可空引用类型关联着Nullable特性。通过这种方式,可以在库中使用可空引用类型,使参数和成员具有可空性。当在新的应用程序中使用这种库时,智能感知可以提供方法或参数是否可为空的信息,编译器就能够相应的给出警告。使用编译器的旧版本(早于C#8)时,仍然可以像使用不带注解的库那样使用这些库。编译器会忽略它不认识的特性。
注意:
在现有应用程序中启用可空引用类型是一种破坏性改变,所以为了允许逐渐迁移到这种新特性,可以使用预处理器指令#nullable来启用或禁用它,以及将它还原为项目文件中的设置。
2.3 使用预定义数据类型
前面介绍了如何声明常量和变量,以及可空性这种极为重要的增强,下面要详细讨论C#中可用的数据类型。
数据类型的C#关键字(如int、short和string)从编译器映射到.NET数据类型。例如,在C#中声明一个int类型的数据时,声明的实际上是.NET struct(System.Int32)的一个实例。所有基本数据类型都提供了可供调用的方法。例如,要把int i转换为string类型,可以编写下面的代码:
string s=i.ToString();
应该强调的是,在这种便利语法的背后,类型实际上仍存储为基本类型。因此,基本类型用C#结构表示,实际上并没有性能损失。
下面看看C#中定义的内置类型。我们将列出每个类型,以及它们的定义和对应.NET类型的名称。还将介绍一些例外的情况:它们是重要的数据类型,但只能通过.NET类型使用,而没有自己的C#关键字。
首先来看预定义的值类型,它们代表基础数据,如整数、浮点数、字符和布尔值。
2.3.1 整型
C#支持使用不同位数的整型,并且区分了只支持正数的类型和支持一定范围内的正数和负数的类型。byte和sbyte类型使用8位。byte类型允许0~255之间的值(只有正值),而sbyte中的s意味着使用符号(sign),所以该类型支持-128~127之间的值(8位只能支持这么多数字)。
short和ushort类型使用16位。short类型的取值范围位-32 768~32 767。ushort类型中的u代表无符号(unsigned),它的取值范围为0~65 535。类似,int类型是带符号32位整数,uint类型为无符号32位整数。long和ulong使用64位。在后台,C#关键字sbyte、short、int和long映射到System.SByte、System.Int16、System.Int32和System.Int64。无符号版本映射到System.Byte、System.UInt16、System.UInt32和System.UInt64。底层.NET类型在类型名称中清晰地列出了使用的位数。
要检查类型的最大值和最小值,可以使用MaxValue和MinValue属性。
2.3.2 BigInteger
如果需要的数字比64位的long类型能够表示的值更大,则可以使用BigInteger类型。该结构对于位数没有限制,可以一直增长下去,直到没有可用的内存。这个类型没有对应的C#关键字,所以需要使用BigInteger。因为它能够无限增长,所以无法提供MinValue和MaxValue属性。该类型提供了内置的计算方法,如Add()、Subtract()、Divide()、Multiply()、Log()、Log10()、Pow()等。
2.3.3 本机整数类型
如果应用程序是32位或64位应用程序,则int、short和long类型的位数和可用大小是独立的。
这与C++中的整数定义不同。C#9为平台特定的值提供了新的关键字:nint和nuint(分别代表本机整数和本机无符号整数)。在64位应用程序中,这些整数类型使用64位,而在32位应用程序中,只使用32位。这些类型对于直接访问内存很重要。
2.3.4 数字分隔符
为了提高数字的可读性,可用使用数字分隔符。可以向数字添加下划线,如下面的代码片段所示。在这个代码片段中,还使用了Ox前缀来指定十六进制值:
long l1=Ox_123_4567_89ab_cedf;
用作分隔符的下划线被编译器忽略。这些分隔符提高了可读性,但并没有添加任何功能。对于前面的示例,每次从右边读取16位(或4个十六进制字符),就添加一个数字分隔符。结果比下面这种写法更容易读懂:
long l2=Ox123456789abcedf;
因为编译器只会忽略下划线,所以你要自己负责确保可读性。可以在任何位置放置下划线,所以不恰当的放置下划线可能对于提高可读性并没有帮助:
long l3=Ox_12345_6789_abc_ed_f;
允许把数字分隔符放在任何位置是有用的,因为这允许把它用于不同的情况--例如,使用十六进制或八进制,或者分离协议所需的不同位(如下一节所示)。
2.3.5 二进制值
除了提供数字分隔符,C#还便于把二进制赋值给整数类型。如果在变量值前面加上Ob字面值作为前缀,只允许使用0和1,如下面的代码片段所示:
uint binary1=Ob_1111_1110_1101_1100_1011_1010_1001_1000;
前面的代码片段使用一个32位的无符号int。数字分隔符对二进制的可读性有很大帮助。这段代码把二进制分隔为4位一组。注意,也可以用十六进制记数法:
使用八进制记数法时,每三位使用一个分隔符会有所帮助,八进制记数法使用0(000)和7(111)之间的字符:
uint binary2=Ob_111_110_101_100_011_010_001_000;
下面的示例展示了如何定义可以在二进制协议中使用的分隔符,其中2位定义了最右边的部分,6位定义了下一部分,最后由2个4位来完成16位:
ushort binary3=Ob1111_0000_101010_11;
2.3.6 浮点类型
C#还基于IEEE754标准,指定了使用不同位数的浮点类型。Half类型(.NET5新增)使用16位,float(映射到.NET中的Single类型)使用32位,double(映射到.NET中的Double类型)使用64位。这些数据类型中有1位用作符号。取决于具体类型,可能有10~52位用作有效位,有5~11位用作指数。
表2-1显示了详细信息。
C#关键字
|
.NET类型
|
说明
|
有效位数
|
指数位数
|
|
System.Half
|
16位单精度浮点数
|
10
|
5
|
float
|
System.Single
|
32位单精度浮点数
|
23
|
8
|
double
| System.Double |
64位双精度浮点数
|
52
|
11
|
赋值的时候,如果硬编码了一个非整数值(如12.3),则编译器一般假定该变量是double。如果想指定该值为float,可以在其后加上字符F(或f):
使用decimal类型(.NET结构Decimal)时,.NET有一个高精度的浮点类型,它使用128位,可用于财务计算。在这128位中,有1位用作符号,96位用作整数。剩下的位指定了比例因子。要把数字指定位decimal类型而不是double、float或整数类型,可以在数字的后面加上字符M(或m),如下所示:
2.3.7 bool类型
C#的bool类型用于包含布尔值true或false。
bool值和整数值不能相互隐式转换。如果变量(或函数的返回类型)声明为bool类型,就只能使用值true或false。如果试图使用0表示false,非0值表示true,就会出错。
2.3.8 字符类型
.NET字符串由两个字节的字符组成。C#关键字char映射到.NET类型Char。使用单引号(如‘A’)创建一个char类型的值;使用双引号则会创建一个字符串。
除了把char表示为字符字面量之外,还可以用4位十六进制的Unicode值(如'\u0041')、带有强制类型转换的整数值(如(char)65)或十六进制数(如'\x0041')表示它们。它们还可以用转义序列表示,如表2-2所示。
表2-2
转义序列
|
字符
|
\'
|
单引号
|
\"
|
双引号
|
\\
|
反斜杠
|
\0
|
空
|
\a
|
警告
|
\b
|
退格
|
\f
|
换页
|
\n
|
换行
|
\r
| 回车 |
\t
|
水平制表符
|
\v
| 垂直制表符 |
2.3.9 数字的字面值
前面的小节显示了数字的字面值,现在用一个表格进行总结,见表2-3。
表2-3
字面值
|
位置
|
说明
|
U
|
后缀
|
unsigned int
|
L
|
后缀
|
long
|
UL
|
后缀
|
unsigned long
|
F
|
后缀
|
float
|
M
|
后缀
|
decimal(货币)
|
Ox
|
前缀
|
十六进制数字,允许使用0~F
|
Ob
|
前缀
|
二进制数字,只允许使用0和1
|
true
|
NA
|
布尔值
|
False
|
NA
|
布尔值
|
2.3.10 object类型
除了值类型,使用C#关键字还可以定义两种引用类型:object关键字映射到Object类,string关键字映射到String类。Object类是所有引用类型最终的基类,它可以以用于两个目的:
-
可以使用object引用来绑定任何特定子类型的对象。例如使用object类型把栈中的值对象装箱,再移动到堆中。object引用也可以用于反射,此时必须有代码来处理类型未知的对象。
-
object类型实现了许多一般用途的基本方法,包括Equals()、GetHashCode()、GetType()和ToString()。用户定义的类可能需要使用一种面向对象技术--重写,来提供其中一些方法的替代实现代码。例如,重写ToString()时,要给类提供一个方法,给出类本身的字符串表示。如果类中没有提供这些方法的实现代码,编译器就会使用object类型中的实现代码,返回类的名称。
2.4 程序流控制
本节将介绍C#语言最基本的重要语句:控制程序流的语句。使用这些语句时,就不必按代码在程序中的出现的顺序执行它们。通过使用条件语句(如 if 和 switch 语句),可以根据是否满足特定条件创建代码分支。使用for、while和foreach语句可以在循环中重复执行语句。
2.4.1 if 语句
使用if语句时,可以在圆括号内指定一个表达式。如果该表达式返回true,就调用花括号指定的块。如果条件不为true,则可以使用else if检查另外一个条件是否为true。通过重复使用else if,可以检查更多条件。如果if指定的表达式不为true,所有else if表达式也都不为true,则将调用else指定的块。
在下面的代码段中,从控制台读入了一个字符串。如果输入一个空字符串,将调用if语句后的代码块。这是因为,当字符串为null或空字符串时,string方法IsNullOrEmpty()将返回true。当输入的长度小于5个字符的时候,将调用else if 语句指定的块。在其它所有情况中,例如输入的长度为5个及以上字符,将调用else块 :
Console.WriteLine("Type in a string");
string? input=Console.ReadLine();
if(string.IsNullOrEmpty(input))
{
Console.WriteLine("You typed in an empty string.");
}
else if(input?.Length<5)
{
Console.WriteLine("The string had less than 5 characters.");
}
else
{
Console.WriteLine("Read any other string");
}
Console.WriteLine("The string was " +input);
注意:
如果if/else 块中只有一条语句,那么并非必须使用花括号。只有当块中包含多个语句的时侯,才必须使用花括号。但是,对于单条语句,使用花括号有助于提高可读性。
在if语句中,else if和else语句是可选的。如果只是需要根据某个条件调用一个代码块,而在该条件不满足时不调用代码块,那么可以使用不带else 的if 语句。
2.4.2 is运算符的模式匹配
C#支持一种名为“模式匹配”的特性,可以通过if 语句和is 运算符使用这种特性。前面的“可空引用类型”小节包含一个使用if语句和is not null 模式的示例。
下面的代码片段将收到的类型为object的实参与null 进行比较。这里使用了常量模式来比较实参和null,并抛出ArgumentNullException。在else if的表达式中,使用了类型模式来检查变量o是不是Book类型。如果是,则将变量o赋值给变量b。因为变量b是Book类型,所以可以访问Book类型指定的Title属性 :
void PatternMatching(object o)
{
if(o is null)throw new ArgumentNullException(nameof(o));
else if(0 is Book b)
{
Console.WriteLine($"received a book:{b.Title}");
}
}
注意:本例在抛出ArgumentNullException的时候使用了nameof表达式。编译器将把nameof表达式解析为其实参的名称(如变量o),然后将结果作为一个字符串进行传递。throw new ArgumentNulException(nameof(o));解析得到的代码与throw new ArgumentNullException("o");相同。但是,如果将变量o重命名为另外一个值,那么重构功能能够自动重命名nameof表达式指定的变量。如果重命名了变量,但没有修改nameof的参数,编译器将给出错误。如果不使用nameof表达式,变量和字符串很容易变得不同步。
下面的代码片段显示了常量模型和类型模式的更多例子:
if(o is 42) //const pattern
if(o is "42") //const pattern
if(o is int i) //type pattern
注意:
可以在is运算符、switch语句和switch表达式中使用模式匹配。可以使用不同类别的模式匹配。
2.4.3 switch语句
switch...case语句适合于从一组互斥的可执行分支中选择一个执行分支。其形式是switch参数的后面跟一组case子句。如果switch参数中的表达式等于某个case子句旁边的某个值,就执行该case子句中的代码。此时不需要使用花括号把语句组合到块中,只需要使用break语句标记每段case代码的结尾即可。也可以在switch语句中包含一条default子句,如果表达式不等于任何case子句的值,就执行default子句的代码。下面的switch语句测试变量x的值 :
void SwitchSample(int x)
{
switch (x)
{
case 1:
Console.WriteLine("integerA = 1");
break;
case 2:
Console.WriteLine("integerA = 2");
break;
case 3:
Console.WriteLine("integerA = 3");
default:
Console.WriteLine("integerA = 4");
break;
}
}
注意:case值必须是常量表达式,不允许使用变量。
在switch语句中,不能删除不同case中的break。与C++和Java编程语言不同,在C#中,不支持在执行完一个case实现后自动执行另外一个case实现(即所谓的贯穿)。但是,虽然不支持自动贯穿,仍可以使用goto关键字选择另外一个case,从而实现显式贯穿。下面是一个示例:
如果多个case的实现完全相同,则可以先指定多个case,然后再指定实现:
switch(country)
{
case "au":
case "uk":
case "us":
language="English";
break;
case "at":
case "de":
language="German";
break;
}
2.4.4 switch语句的模式匹配
在switch语句中也可以使用模式匹配。下面的代码片段显示了使用常量、类型和关系模式的不同case选项。方法SwitchWithPatternMatching()接受类型为object的一个参数。case null是一个常量模式,将o与null进行比较。接下来的3个case指定了类型模式。case int i使用了类型模式,当o是int、并且when子句满足时,就会创建变量i。这个when子句使用关系模式检查它是不是大于42.下一个case匹配其余所有int类型。这里没有指定应该把变量o赋值给哪个变量。如果不需要变量,而只是需要知道它是不是特定类型,就并不是一定要指定一个变量。在匹配Book类型时,使用了变量b。因为在这里声明了变量,所以它的类型是Book :
void SwitchWithPatternMatching(object o)
{
switch(o)
{
case null:
Console.WriteLine("const pattern with null");
break;
case int i when i>42
Console.WriteLine("type pattern with when and a relational pattern");
case int:
Console.WriteLine("type pattern with an int");
break;
case Book b:
Console.WriteLine($"type pattern with a Book {b.Title}");
break;
default:
break;
}
}
2.4.5 switch表达式
接下来的示例显示了一个基于enum类型的switch。enum类型将整数作为基础,但为不同的值指定了名称。类型TrafficLight为交通灯的不同颜色定义了不同的值 :
enum TrafficLight
{
Red,
Amber,
Green
}
到目前为止的switch语句只是在每个case中调用了某种操作。当使用return语句从方法返回时,也可以直接在case中返回一个值,而不继续执行其余case。NextLightClassic()方法接收一个TrafficLight参数,然后返回一个TrafficLight。如果传入的值为TrafficLight.Green,该方法将返回TrafficLight.Amber。如果当前的交通灯值为TrafficLight.Amber,该方法将返回TrafficLight.Red :
TrafficLight NextLightClassic(TrafficLight light)
{
switch(light)
{
case TrafficLight.Green :
return TrafficLight.Amber;
case TrafficLight.Amber :
return TrafficLight.Red;
case TrafficLight.Red :
return TrafficLight.Green;
default :
throw new InvalidOperationException();
}
}
在这种场景中,如果需要基于不同的选项返回值,则可以使用C#8新增的switch表达式。
NextLight()方法和前面的方法类似,接收并返回一个TrafficLight值。该方法的实现使用了一个表达式体成员,因为整个实现是用一条语句完成的。此时,不需要使用花括号和return语句。当使用switch表达式代替switch语句时,变量和switch关键字的顺序颠倒了过来。使用switch语句时,要判断的值跟在switch关键字后面,放在花括号中。使用switch表达式时,变量放在switch关键字的前面。在switch表达式中,使用花括号括起来的块来定义不同的case。但是,其中并不适用case关键字,而是使用=>符号来定义返回的值。功能与之前相同,但是要编写的代码行数少了 :
TrafficLight NextLight(TrafficLight light) =>
light switch
{
TrafficLight.Green => TrafficLight.Amber,
TrafficLight.Amber => TrafficLight.Red,
TrafficLight.Red => TrafficLight.Green,
_=> throw new InvalidOperationException()
};
下面的示例使用了模式组合符来组合多种模式。首先从控制台读取输入。因为这里使用了or组合模式,所以如果输入了two或three,将匹配相同的模式 :
string? input=Console.ReadLine();
string result =input switch
{
"one" =>"the input has the value one",
"two" or "three" =>"the input has the value two or three",
_=>"any other value"
};
使用模式组合符时,可以使用and、or和not关键字组合模式。
2.4.6 for循环
C#提供了4种不同的循环机制(for、while、do...while和foreach),在满足某个条件之前,可以重复执行代码块。for循环提供的迭代循环机制是在执行下一次迭代前,测试是否满足某个条件 :
for(int i=0;i<100;i++)
{
Console.WriteLine(i);
}
for语句的第一个表达式是初始化表达式,这是在执行第一次循环前要计算的表达式。通常会初始化一个局部变量来作为循环计数器。
第二个表达式是条件表达式,这是在for块每次迭代之前检查的表达式。如果这个表达式计算为true,就执行for块。如果它计算为false,则for语句结束,程序将执行for块的结束花括号之后的下一条语句。
执行完for循环体后,将执行第三个表达式,即迭代器。通常会递增循环计数器。i++将把变量 i 加1。执行完第三个表达式后,将再次计算条件表达式,检查是否应该再迭代一次for块。
for循环是所谓的预测试循环,因为循环条件是在执行循环语句前计算的,如果循环条件为假,循环语句就根本不会执行。
嵌套的for循环非常常见,在每次迭代外部循环时,内部循环就要彻底执行完毕。这种模式通常用于在矩形多维数组种遍历每个元素。最外部的循环遍历每一行,内部的循环遍历某行上的每个列。
下面的代码显示多行数字,它还使用另一个Console方法Console.Write(),该方法的作用与Console.WriteLine相同,但不在输出中添加回车换行符 :
//This loop iterates through rows
for(int i=0; i<100; i+= 10)
{
//This loop iterates through columns
for(int j=i;j<i+10; j++)
{
Console.Write($" {j}");
}
Console.WriteLine();
}
上述例子的结果是 :
注意:
尽管在技术上,可以在for循环的测试条件中计算其他变量,而不计算计数器变量,但这不太常见。也可以在for循环中忽略一个表达式(甚至所有表达式),但如果是这种情况,应该考虑使用while循环。
2.4.7 while循环
与for循环一样,while循环也是一个预测试循环。其语法是类似的,但while循环只有一个表达式:
while(condition)
statement(s);
与for循环不同的是,while循环最常用于以下情况:
在循环开始前,不知道重复执行一条语句或语句块的次数。通常,在某次迭代中,while循环体中的语句把布尔标志设置为false,结束循环,如下面的例子所示:
bool condition=false;
while(!condition)
{
//This loop spins until the condition is true.
DoSomeWork();
condition=CheckCondition(); //assume CheckCondition() return a bool
}
2.4.8 do...while循环
do...while循环是while循环的后测试版本。这意味着该循环的测试条件要在执行完循环体之后计算。因此do...while循环适用于循环体至少执行一次的情况,如下例所示:
bool condition;
do
{
//this loop will at least execute once,even if the condition is false.
MustBeCalledAtLeastOnce();
condition=CheckCondition();
} while(condition);
2.4.9 foreach循环
foreach循环可以迭代集合中的每一项。现在,不必考虑集合的准确概念,只需要知道集合是一种包含一系列对象的对象即可。从技术上看,一个对象要成为集合,就必须支持IEnumerable接口。集合的例子有C#数组、System.Collections名称空间中的集合类,以及用户定义的集合类。从下面的代码中可以了解foreach循环的语法,其中假定arrayOfInts是一个int类型的数组 :
foreach( int temp in arrayOfInts)
{
Console.WriteLine(temp);
}
其中,foreach循环每次迭代数组中的一个元素。它把每个元素的值放在int类型的变量temp中,然后执行一次循环迭代。
这里也可以使用类型推断。此时,foreach循环变成 :
foreach(var temp in arrayOfInts)
{
//...
}
temp的类型推断为int,因为这是集合项的类型。
注意,foreach循环不能改变集合中各项(上下文temp)的值,所以下面的代码不会编译 :
foreach(int temp in arrayOfInts)
{
temp++;
Console.WriteLine(temp);
}
如果需要迭代集合中的各项,并改变他们的值,应使用for循环。
2.4.10 退出循环
在循环中,可以使用break语句停止迭代,也可以使用continue语句结束当前迭代,并执行下一次迭代。使用return语句可以退出当前方法,从而退出循环。
2.5 名称空间
对于小型示例应用程序,不需要指定名称空间。当创建库之后,库中的类会在应用程序中使用,此时,为了避免产生二义性,必须指定名称空间。前面使用的Console类是在System名称空间中定义的。要使用Console类,要么需要加上System名称空间作为前缀,要么需要导入System名称空间。
在定义名称空间时,可以采用分层的方式。例如,ServiceCollection类在Microsoft.Extensions.DependencyInjection名称空间中定义。要在Wrox.ProCSharp.CoreCSharp中定义Sample类,可以使用namespace关键字指定这种名称空间层次 :
namespace Wrox
{
namespace ProCSharp
{
namespace CoreCSharp
{
public class Sample
{
}
}
}
}
也可以使用点号标识符指定名称空间 :
namespace Wrox.ProCSharp.CoreCSharp
{
public class Sample
{
}
}
名称空间是一种逻辑构造,完全独立于物理文件或组件。一个程序集可以包含多个名称空间,而一个名称空间可以分布在多个程序集中。它是将不同类型分组到一起的一种逻辑构造。
每个名称空间由其所在的名称空间的名称组成,从最外层名称空间开始,到其自己的短名称结束,每个名称之间用点号分隔。因此,ProCSharp名称空间的完整名称是Wrox.ProCSharp,Sample类的完整名称是Wrox.ProCSharp.CoreCSharp.Sample。
2.5.1 using语句
显然,名称空间可能相当长,输入起来很麻烦,也不总必须用这种方式指定某个类。如本章前面所述,C#允许简写类的全名。为此,要在文件的顶部列出类的名称空间,前面加上using关键字。在文件的其他地方,就可以使用其类型名称来引用名称空间中的类型了。
如果using语句引用的两个名称空间包含同名的类型,就必须使用完整的名称(或者至少较长的名称),确保编译器知道访问哪个类型。例如,假如类Test同时存在于ProCSharp.CoreCSharp和ProCSharp.OOP名称空间中。如果再创建一个类Test,并且导入前面的两个名称空间,编译器就会报出二义性编译错误。此时,需要为类型指定名称空间名称。
2.5.2 名称空间的别名
除了为类指定完整的名称空间来解决二义性的问题,还可以使用using指定指定一个别名,如下面的两个名称空间的不同Timer类所示 :
using TimersTimer = System.Timers.Timer;
using Webtimer = System.Web.UI.Timer;
2.6 使用字符串
string是一个重要的引用类型,提供了许多特性。虽然是一个引用类型,但它是不可变的。string类型提供的所有方法不改变字符串的内容,而是返回一个新的字符串。例如,为了连接字符串,对+运算符进行了重载。表达式s1+""+s2首先创建一个新的字符串,将s1和包含空格字符的字符串组合起来,然后通过将得到的结果字符串与s2组合起来,创建一个新的字符串。最后,用变量s3引用这个结果字符串 :
string s1="Hello";
string s2="World";
string s3=s1+" "+s2;
创建多个字符串时需要知道,不再需要的对象需要被垃圾收集器清理。垃圾收集器会释放托管堆中原本由不再需要的对象占用的内存。但是,并不是当不再使用引用的时候会立即发生垃圾收集;垃圾收集基于特定的内存限制。最好能够避免分配对象,这可以通过使用StringBuilder类动态操作字符串来实现。
2.6.1 使用StringBuilder
StringBuilder允许程序使用Append()、Insert()、Remove()、Replace()方法动态操作字符串,并不会创建新的对象。相反,StringBuilder会使用一个内存缓冲区,在需要的时候修改这个缓冲区。创建StringBuilder时,默认容量为16个字符。如果像下面的代码片段这样追加字符串,将需要更多内存,此时容量将加倍至32个字符 :
void UsingStringBuilder()
{
StringBuilder sb=new ("the quick");
sb.Append(' ');
sb.Append("brown fox jumped over ");
sb.Append("the lazy dogs 1234567890 times");
string s=sb.ToString();
Console.WriteLine(s);
}
如果容量太小,需要增加时,缓冲区大小总是成倍增加,例如,从16增加为32,再增加为64,再增加为128个字符。使用Length属性可以访问字符串的长度。Capacity属性可以返回StringBuilder的容量。创建了必要的字符串后,可以使用ToString()方法,这将创建一个新的字符串,其中包含StringBuilder的内容。
2.6.2 字符串插值
$前缀允许在字符串内计算表达式,称为“字符串插值”。例如,对于字符串s2,将把字符串s1的内容嵌入到s2中,所以最终结果 是Hello World! :
string s1="World";
string s2=$"Hello,{s1}!";
在花括号内,可以编写代码表达式,这些表达式将被计算,其结果将被添加到字符串中。在下面的代码片段中,使用3个占位符来指定一个字符串,其中x的值、y的值和x加y的结果将被添加到字符串中 :
int x=3,y=4;
string s3=$"The result of {x} and {y} is {x+y}";
Console.WriteLine(s3);
结果字符串是The result of 3 and 4 is 7。
编译器会翻译插值字符串,以调用string的Format()方法。在调用该方法时,将传入带有编号的占位符的字符串,其后是额外的实参。在Format()方法的实现中,额外的实参的结果将基于编号传入占位符。字符串后的第一个实参将传递给0占位符,第二个实参将传递给1占位符,以此类推 :
string s3=string.Format("The result of {0} and {1} is {2}",x,y,x+y);
注意:要在插值字符串中转义花括号,可以使用双花括号: {{}} 。
2.6.3 FormattableString
把字符串赋值给FormattableString,就很容易看到插值字符串被翻译成什么,可以把插值字符串直接赋值给这种类型,因为它比正常的字符串更适合这种类型。该类型定义了Format属性(返回得到的格式字符串)、ArgumentCount属性和方法GetArgument()(返回实参值) :
void UsingFormattableString()
{
int x=3,y=4;
FormattableString s=$"The result of {x} + {y} is {x+y}";
Console.WriteLine($"format:{s.Format}");
for(int i=0; i<s.ArgumentCount; i++)
{
Console.WriteLine($"argument:{i}:{s.GetArgument(i)}");
}
Console.WriteLine();
}
运行此代码段,输出结果如下:
format:The result of {0} +{1} is {2}
argument 0 :3
argument 1 :4
argument 2 :7
2.6.4 字符串格式
对于插值字符串,可以为表达式添加一个字符串格式。.NET基于计算机的区域为数字、日期和时间定义了默认格式。下面的代码片段显示了使用不同格式的日期、int值和double值。D用于使用长日期格式显示日期,d则使用短日期格式。显示数字时,分别使用了整数加小数格式(n)、指数表示发(e)、十六进制格式(x)、和货币格式(c)。对于double值,第一个结果将小数点后的位数四舍五入为3位(###.###),第二个结果在小数点前面也显示3位(000.000) :
void UseStringFormat()
{
DateTime day=new (2025,2,14);
Console.WriteLine($"{day:D}");
Console.WriteLine($"{day:d}");
int i=2447;
Console.WriteLine($"{i:n} {i:e} {i:x} {i:c}");
double d=3.1415;
Console.WriteLine($"{d:###.###}");
Console.WriteLine($"{d:000.000}");
Console.WriteLine();
}
运行应用程序时,将得到下面的输出 :
Friday,February 14,2025
2/14/2025
2,477.00 2.477000e+003 9ad $2,477.00
3.142
2.6.5 verbatim字符串
前面的“字符类型”小节中的代码片段包含一些特殊字符,如\t(表示制表位)和\r\n(表示回车换行)。在完整的字符串中,可以利用这些字符的特殊含义。如果需要在字符串输出中显示反斜杠,则可以使用两个反斜杠\\进行转义。如果需要多次用到反斜杠,这种写法会令人厌烦,因为他们可能降低代码的可读性。对于这种场景,例如在使用正则表达式的时候,就可以使用verbatim字符串。
verbatim字符串带有@前缀 :
string s=@"a tab: \t, a carriage return:\r, a newline: \n";
Console.WriteLine(S);
运行上面的代码将得到下面的输出 :
a tab: \t, a carriage return: \r, a newline: \n
2.6.6 字符串的范围
String类型提供了一个Substring()方法,用于获取字符串的一部分内容。从C#8开始,除了使用Substring()方法,还可以使用hat和范围运算符。范围运算符使用“..”表示法来指定一个范围。在字符串中,可以使用索引器来访问一个字符,或者可以将其与范围运算符结合使用,以访问一个子串。 ..运算符左右两侧的数字指定了范围。左侧的数字指定了从字符串中取出的第一个值(从0开始索引),它包含在范围内;右侧的数字指定了从字符串中取出的最后一个值(也是从零开始索引)。在下面的例子中,范围0..3将选出字符串The。从字符串的第一个字符开始取值时,可以省略0,如下面的代码片段所示。范围4..9从第5个字符开始,一直到第8个字符结束。要从字符串末尾开始算起,可以使用hat运算符^ :
void RangesWithString()
{
string s="The quick brown fox jumped over the lazy dogs down" +"1234567890 times";
string the=s[..3];
string quick =s[4..9];
string times=s[^5..^10];
Console.WriteLine(the);
Console.WriteLine(quick);
Console.WriteLine(times);
Console.WriteLine();
}
2.7 注释
2.7.1 源文件中的内部注释
C#使用传统的C风格注释方式:单行注释使用(//...),多行注释使用(/*...*/):
//This is a single --line comment
/* This comment
spans multiple lines.*/
单行注释中的任何内容,即从//开始一直到行尾的内容都会被编译器忽略。多行注释中“/*”和“*/”之间的所有内容也会被忽略。可以把多行注释放在一行代码中:
Console.WriteLine(/* Here's a comment!*/ "The will compile.");
内联注释在调试的时候很有用,例如,你可能想要临时使用一个不同的值运行代码,如下面的代码片段所示。但是,内联注释会让代码变得难以理解,所以使用它们时应该小心。
DoSomething(Width,/*Height*/ 100);
2.7.2 XML文档
除了上一节介绍的C风格的注释外,C#还有一个非常出色的功能:根据特定的注释自动创建XML格式的文档说明。这些注释都是单行注释,但都以3条斜杠(///)开头,而不是通常的两条斜杠。在这些注释中,可以把包含类型和类型成员的文档说明的XML标记放在代码 中。
编译器可以识别表2-4所示的标记。
表2-4
标记
|
说明
|
<c>
| 把行中文本标记位代码,例如<c>int i=10; </c> |
<code>
|
把多行标记为代码
|
<example>
|
标记一个代码示例
|
<exception>
|
说明一个异常类(编译器要验证其语法)
|
<include>
|
包含其他文档说明文件的注释(编译器要验证其语法)
|
<list>
|
把列表插入文档中
|
<para>
|
建立文本的结构
|
<param>
|
标记方法的参数(编译器要验证其语法)
|
<paramref>
|
表面一个单词是方法的参数(编译器要验证其语法)
|
<permission>
|
说明对成员的访问(编译器要验证其语法)
|
<remarks>
|
给成员添加描述
|
<returns>
|
说明方法的返回值
|
<see>
|
提供对另一个参数的交叉引用(编译器要验证其语法)
|
<seealso>
|
提供描述中的“参见”部分(编译器要验证其语法)
|
<summary>
|
提供类型或成员的简短小结
|
<typeparam>
|
用在泛型类型的注释中,以说明一个类型参数
|
<typeparamref>
|
类型参数的名称
|
<value>
|
描述属性
|
下面的代码片段显示了Calculator类,并为该类和Add()类方法指定了文档说明 :
namespace ProCSharp.MathLib
{
///<summary>
///ProCSharp.MathLib.Calculator class.
///Provides a method to add two doubles.
///</summary>
public static class Calculator
{
///<summary>
///The Add method allows us to add two doubles.
///<summary>
///<returns>Result of the addition (double)</returns>
///<param name="x">First number to add</param>
///<param name="y">Second number to add</param>
public static double Add(double x,double y) => x+y;
}
}
要生成XML文档,可以在项目文件中添加GenerateDocumentationFile :
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
</Project>
添加了这个设置后,将在程序的二进制文件(编译应用程序的时候将生成该文件)所在的目录中生成文档文件。也可以指定DocumentationFile元素,定义一个与项目文件不同的名称,还可以指定在一个绝对目录中生成文档文件。
在Visual Studio等工具中使用类和成员时,智能感知功能将把文档中的信息显示为工具提示。
2.8 C#预处理器指令
除了前面介绍的常用关键字外,C#还有许多名为“预处理器指令”的命令。这些命令从来不会转换为可执行代码中的命令,但会影响编译过程的各个方面。例如,使用预处理器指令可以禁止编译器编译代码的某一部分。如果将不同的框架作为目标,并处理了框架之间的区别,就可能采用这种处理。在另外一种场景中,可能想要根据不同的情况启用和禁用可空引用类型(因为修改现有代码库后,不能在短期内修复代码)。
预处理器指令的开头都有符号#。
下面简要介绍预处理器指令的功能。
2.8.1 #define 和#undef
#define 的用法如下所示:
#define DEBUG
它告诉编译器存在给定名称的符号,在本例中是DEBUG。这有点类似于声明一个变量,但这个变量并没有真正的值,只是存在而已。这个符号不是实际代码的一部分,而只在编译器编译代码时存在。在C#代码中它没有任何意义。
#undef正好相反--它删除符号的定义:
#undef DEBUG
如果符号不存在,#undef就没有任何作用。同样,如果符号已经存在,则#define也不起作用。
必须把#define和#undef命令放在C#源文件的开头位置,在声明要编译的任何对象的代码之前。
#define本身并没有什么用,但与其他预处理器指令(特别是#if)结合使用时,它的功能就非常强大了。
默认情况下,在调试构建中,定义DEBUG符号,而在发布代码中,定义RELEASE符号。要对调试和发布构建定义不同的代码路径,并不需要定义这些符号,而只需要使用下一节将介绍的预处理器指令来定义编译器选择的代码路径。
注意:预处理器指令不用分号结束,一般一行上只有一条命令。如果编译器遇到一条预处理器指令,就会假定下一条命令在下一行。
2.8.2 #if、#elif、#else和#endif
这些指令告诉编译器是否要编译代码块。考虑下面的方法:
int DoSomeWork(double x)
{
//do something
#if DEBUG
Console.WriteLine($"x is {x}");
#endif
}
这段代码会像往常那样编译,但Console.WriteLine()方法调用是个例外,它包含在#if子句内。这行代码只有在定义了符号DEBUG后才执行。如前所述,在调试构建中会定义DEBUG符号,或者也可以在前面使用#define指令定义符号DEBUG。当编译器遇到#if指令后,将先检查相关的符号是否存在,如果符号存在,就编译#if子句中的代码。 否则,编译器会忽略所有的代码,直到遇到匹配的#endif指令为止。 一般是在调试时定义符号DEBUG,把与调试相关的代码放在#if子句中。 接近发布软件时,就把#define指令注释掉,所有的调试代码会奇迹般的消失,可执行文件也会变小,最终用户不会被这些调试信息弄糊涂(显然,要做更多的测试,确保代码在没有定义DEBUG的情况下也能工作)。这项技术在C和C++编程中十分常见,称为条件编译(conditional compilation)。
#elif( = else if)和#else指令可以用在#if块中,其含义非常直观。也可以嵌套#if块 :
#define ENTERPRISE
#define W10
//further on in the file
#if ENTERPRISE
//do something
#if W10
//some code that is only relevant to enterprise
//edition running on W10
#endif
#elif PROFESSIONAL
//do something else
#else
//code for the leaner version
#endif
#if和#elif 还支持一组逻辑运算符“!”、“==”、“!=”、“&&”和“||”。如果符号存在,就该任务是true,否则为false,例如 :
#if W10 && !ENTERPRISE //if W10 is defined but ENTERPRISE isn't
2.8.3 #warning 和 #error
另两个非常有用的预处理器指令是#warning和#error。当编译器遇到它们时,会分别产生警告或错误。如果编译器遇到#warning指令,会向用户显示#warning指令后面的文本,之后编译继续进行。
如果编译器遇到#error指令,就会向用户显示后面的文本,作为一条编译错误消息,然后会立即退出编译,不会产生IL代码。
使用这些指令可以检查#define语句是不是做错了什么事,还可以使用#warning语句提醒自己执行某个操作 :
#if DEBUG $$ RELEASE
#error "You"ve defined DEBUG and RELEASE simultaneously!"
#endif
#warning "Don't forget to remove this line before the boss tests the code!"
Console.WriteLine("*I love this job.*");
2.8.4 #region和#endregion
#region和#endregion指令用于把一段代码视为有给定名称的一个块,如下所示:
#region Member Field Declarations
int x;
double d;
decimal balance;
#endregion
编译器会忽略region指令,但Visual Studio代码编辑器等工具会使用该指令。编辑器允许折叠region部分,只显示与该region关联的文本名称。这方便了浏览源代码。但是,更好的做法时编写较短的代码文件。
2.8.5 #line
#line 指令可以用于改变编译器在警告和错误信息中显示的文件名和行号信息。这条指令用得并不多。如果编写代码时使用了某个包,且该包会修改你编写的代码,然后再把代码发送给编译器,那么这种时候该指令最有用,因为这意味着编译器报告的行号或文件名与文件中的行号或你正在编辑的文件名不匹配。#line指令可以用于还原这种匹配。也可以使用语法#line default把行号还原为默认的行号 :
#line 164 "Core.cs" /We happen to know this is line 164 in the file
//Core.cs,before the intermediate
//package mangles it.
//later on
#line default //restores default line numbering
2.8.6 #pragma
#pragma指令可以抑制或还原指定的编译警告。与命令行选项不同,#pragma指令可以在类或方法级别实现,对抑制警告的内容和抑制的时间进行更精细的控制。下面的例子禁止“字段未使用”(field not used)警告,然后在编译MyClass类后还原该警告 :
#pragma warning disable 169
public class MyClass
{
int neverUsedField;
}
#pragma warning restore 169
2.8.7 #nullable
使用#nullable指令可以启用或禁用代码文件内的可空引用类型。无论项目文件中指定什么设置,#nullable enable都将启用可空引用类型。#nullable disable禁用可空引用类型,#nullable restore将设置改回项目文件中的设置。
如何使用这个指令?如果项目文件中启用了可空引用类型,那么在这种编译行为导致问题的代码段,可以临时禁用可空引用类型,然后在存在可空性问题的代码段之后还原为项目文件的设置。
2.9 C#编程准则
2.9.1 关于标识符的规则
标识符是给变量、用户定义的类型(如类和结构)和这些类型的成员指定的名称。标识符区分大小写,所以interestRate和InterestRate是不同的变量。确定在C#中可以使用什么标识符有两条规则 :
C#保留的关键字的列表请参加:https://docs.microsoft.com/en-us/dotnet/csharp/languagereference/keywords/。
如果需要把某一保留字用作标识符(例如,访问一个用另一种语言编写的类),那么可以在标识符的前面加上前缀符号@,告知编译器其后的内容是一个标识符,而不是C#关键字(所以,abstract不是有效的标识符,但@abstract是)。
最后,标识符也可以包含Unicode字符,用语法\uXXXX指定,其中XXXX是Unicode字符的4位十六进制编码。下面是有效标识符的一些例子 :
-
Name
-
_Identifier
-
\u005fIdentifier
最后两个标识符完全相同,可以互换(因为005f是下划线字符的Unicode代码),所以这些标识符在同一个作用域内不能声明两次。
2.9.2 用法约定
在任何开发语言中,通常有一些传统的编程风格。这些风格不是语言自身的一部分,而是约定,例如,变量如何命名,类、方法或函数如何使用等。如果使用某语言的大多数开发人员都遵循相同的约定,不同的开发人员就很容易理解彼此的代码,这一般有助于程序的维护。约定主要取决于语言和环境。例如,在Windows平台上编程的C++开发人员一般使用前缀psz或lpsz表示字符串:char *pszResult;和char *lpszMessage;但在UNIX系统上,则不使用任何前缀:char *Result;和char *Message;。
注意:
在变量名中添加代表其数据类型的前缀字母,这种约定称为Hungarian表示法。这样,其他阅读该代码的开发人员就可以立即从变量名中了解它代表什么数据类型。有了智能编辑器和IntelliSense之后,人们普遍认为Hungarian表示法是多余的。
在许多语言中,用法约定是随着语言的使用逐渐演变而来的,但是对于C#和整个.NET Framework,微软公司编写了非常多的用法准则。这说明,从一开始,.NET程序就有非常高的互操作性,方便开发人员理解代码。在开发这些用法准则时,微软公司参考了20多年来面向对象编程的发展情况。根据相关的新闻组的反馈,这些用法规则在开发时经过深思熟虑,已经为开发社区所接受。所以我们应遵守这些准则。
但要注意,这些准则与语言规范不同。用户应尽可能遵循这些准则,但如果有充分的理由不遵循它们,也不会有什么问题。例如,不遵循这些准则,并不会出现编译错误。一般情况下,如果不遵循用法准则,就必须有充分的理由。如果决定不遵循用法准则,那么这应该是你有意为之,而不应该是因为你懒得遵守准则。
2.9.3 命名约定
使程序易于理解的一个重要方面是给数据项选择命名的方式,包括变量、方法、类、枚举和名称空间的命名方式。
显然,这些名称应反映数据项的目的,且不与其他名称冲突。.NET Framework的一般理念是,变量名要反映该变量实例的目的,而不反映数据类型。例如,height就是一个比较好的变量名,而integerValue就不太好。但是,这种规则是一种理想状态,很难达到。特别是在处理控件时,大多数情况下可能会使用confirmationDialog和chooseEmployeeListBox等变量名,这些变量名确实说明了变量的数据类型。
接下来的小节介绍了在选择名称时应该考虑的一些因素。
1.名称的大小写
在许多情况下,名称都应使用Pascal大小写形式,Pascal大小写形式指名称中单词的首字母大写,如EmployeeSalary、ConfirmationDialog、PlainTextEncoding。注意,名称空间和类,以及基类中的成员等名称都应遵循Pascal大小写规则,最好不要使用带有下划线字符的单词,即名称不应是employee_salary。其他语言中常量的名称常常全部大写,但在C#中最好不要这样,因为这种名称很难阅读,而应全部使用Pascal大小写形式的命名约定:
const int MaximumLength;
还推荐使用另一种大小写模式:camel大小写形式。这种形式类似于Pascal大小写形式,但名称中第一个单词的首字母不大写,如employeeSalary、confirmationDialog、plainTextEncoding。遇到以下3种情况时,可以使用camel大小写形式;
private string employeeName;
public string EmployeeName
{
get
{
return employeeName;
}
}
注意:
从.NET Core开始,.NET团队在私有成员字段的名称前面添加了下划线作为前缀。
如果使用属性封装字段,则私有成员总是使用camel大小写形式,而公有的或受保护的成员总是使用Pascal大小写形式,这样使用这段代码的其他类就只能使用Pascal大小写形式的名称了(除了参数名以外)。
还要注意大小写问题。C#区分大小写,所以在C#中,仅大小写不同的名称在语法上是正确的,如上面的例子所示。但是,有时可能从Visual Basic应用程序中调用你的程序集,而Visual Basic不区分大小写。如果使用仅大小写不同的名称,就必须使这两个名称不能从程序集的外部访问(上例是可行的,因为仅私有变量使用了camel大小写形式的名称)。否则,Visual Basic中的其他代码就不能正确使用你的程序集。
2.名称的风格
名称的风格应保持一致。例如,如果类中的一个方法名为ShowConfirmationDialog(),另一个方法就不能被命名为ShowDialogWarning()或WarningDialogShow(),而应是ShowWarningDialog()。
3.名称空间的名称
名称空间的名称非常重要,一定要仔细考虑,以避免一个名称空间的名称与其他名称空间同名。
记住,名称空间的名称是.NET区分共享程序集中对象名的唯一方式。如果一个软件包的名称空间使用的名称与另一个软件包相同,而这两个软件包都由同一个程序使用,就会出问题。因此,最好用自己的公司名创建顶级的名称空间,再嵌套技术范围较窄的用户所在小组或部门的名称空间,或者类所在软件包的名称空间。Microsoft建议使用这种形式的名称空间:<公司名>.<技术名>。
4.名称和关键字
名称不应与任何关键字冲突,这非常重要。实际上,如果在代码中,试图给某一项指定与C#关键字同名的名称,就会出现语法错误,因为编译器会假定该名称表示一条语句。但是,由于类可能由其他语言编写的代码访问,因此不能使用其他.NET语言中的关键字作为名称。一般来说,C++关键字类似于C#关键字,不太可能与C++混淆,只有Visual C++常用的关键字以两个下划线字符开头。
与C#一样,C++关键字都是小写字母,如果遵循公有类和成员使用Pascal风格名称的约定,则在它们的名称中至少有一个字母大写,这样就不会与C++关键字冲突。另一方面,Visual Basic的问题会多一些,因为Visual Basic的关键字要比C#的关键字多,而且它不区分大小写,这意味着不能依赖于Pascal风格的名称来区分类和成员。
查看Microsoft文档:docs.microsoft.com/dotnet/csharp/language-reference/keywords。在这里,有一个很长的C#关键字列表,不应将这些名称用于类和成员。
2.9.4 属性和方法的使用
类中容易造成困惑的一个方面是,应使用属性还是方法来表示特定的数量。这没有硬性规定,但一般情况下,如果某个东西看起来像变量,并且行为也与变量类似,就应使用属性来表示它,即:
-
客户端代码应能读取它的值。最好不要使用只写属性,例如,应使用SetPassword()方法,而不是Password只写属性。
-
读取该值不应花太长的时间。实际上,如果是属性,通常表明读取过程花的时间相对较短。
-
读取该值不应有任何明显的和意外的副作用。进一步说,设置属性的值,不应有与该属性不直接相关的副作用。设置对话框的宽度会改变该对话框在屏幕上的外观,这是可以的,因为这种结果与该属性明显相关。
-
可以按照任何顺序设置属性。尤其在设置属性时,最好不要因为还没有设置另一个相关的属性而抛出异常。例如,为了使用访问数据库的类,需要设置ConnectionString、UserName和Password,类作者应确保已经恰当实现了该类,使用户能够按照任何顺序设置它们。
-
连续读取属性应得到相同的结果。如果属性的值可能会出现预料不到的改变,就应把它编写为一个方法。在监控汽车运动的类中,把speed设置为属性就不合适,而应使用GetSpeed()方法;另一方面,应把Weight和EngineSize设置为属性,因为对于给定的对象,它们是不变的。
如果要编写的数据项满足上述所有条件,就把它设置为属性,否则就应使用方法。
2.9.5 字段的使用
字段的用法非常简单。字段几乎总应该是私有的,但在某些情况下也可以把常量或只读字段设置为公有。如果把字段设置为公有,就不利于在以后扩展或修改类。
遵循上面的准则就可以培养良好的编程习惯,而且这些准则应与良好的面向对象的编程风格一起使用。
最后要记住以下有用的备注:微软公司在保持一致性方面相当谨慎,在编写.NET基类时遵循了它自己的准则。因此,要直观地感受到在编写.NET代码时应该遵循的约定,只需查看基础类,看看类、成员、名称空间的命名方式,以及类层次结构的工作方式等。在你的类与基础类之间保持一致性有助于提高可读性和可维护性。
注意:
ValueTuple类型包含公共字段,而旧的Tuple类型则使用属性。微软打破了自己为字段定义的准则。由于元组的变量可以简单到只是int变量,而且由于性能非常重要,因此微软决定为值元组使用公共字段。它只是表面,规则总是有例外情况。
2.10 小结
本章介绍了一些C#的基本语法,包括编写简单的C#程序需要掌握的内容。我们讲述了许多基础知识,其中有许多是熟悉C风格语言的开发人员能立即领悟的。C#源于C++、Java和Pascal。
随着时间过去,C#中添加了一些新的特性,这些特性也被其他编程语言采用,而C#自己也采纳了其他编程语言中的一些增强的地方。