导航
第二章 Core C#
2.1 C#基础
通过上一章你了解了C#都能做啥,接下来我们需要了解怎么使用C#。在接下的各个小节里将向你介绍C#编程的一些基础知识,引导你开始使用C#。在本章的结尾你应该会对C#有个简单的认识,在后续的章节我们会持续介绍继承和其他一些面向对象的特性。
2.1.1 Hello,World!
第一章的时候我们演示了如何通过.NET Core CLI工具创建一个HelloWorld应用程序。现在让我们将注意力集中到C#源代码上。首先,我必须先对C#的基本语法说一个说明。在C#中,与其他C风格语言相同的是,代码语句是以分号;
结尾的,而且可以不使用连接符书写多行的代码语句。代码可以被一对大小括号{}
包起来形成一段代码块。单行注释是以//
开头的,而多行注释则以/*
开始,以*/
结束。以上的这些内容,C#和C++还有Java是完全一样的,却与Visual Basic截然不同。分号和大小括号在VB里的定义与C#完全不同。如果你曾经是一个VB程序员,使用C#的时候要注意为每行代码的末尾加上分号;
。忘记写分号是C风格语言的新手最容易犯的错误了。另外需要注意的是C#里是大小写区分的,这意味着myVar和MyVar是两个完全不同的变量。
代码头的数行是对命名空间的声明,namespace关键字定义了你的class在程序集里将会如何组织和联动。namespace后紧跟的一对大括号里的所有代码将被编译器认为是属于同一个命名空间下的。using声明则告诉编译器,当代码中出现当前namespace中不存在的类时,应该去哪些命名空间中查找。这个方式与Java的import语句,C++的using namespace
语句效果是一样的。请看一下HelloWroldApp/Program.cs中的源代码:
using System;
namespace Wrox.HelloWorldApp
{
// ...
Console.WriteLine("Hello World!");
}
在Program.cs文件头声明了using System;
,这是因为在接下来的代码里你将使用Console类,这个类是由命名空间System下提供的,全称是System.Console
。using System;
声明语句使得你可以在代码里直接使用Console.WriteLine
而不需要写成System.Console.WriteLine
,假如你需要在代码里多次使用Console,提前声明会让你的代码变得更加简洁易读。
使用using static
声明你甚至可以直接使用相应类里的静态成员,通过using static System.Console
声明,你可以直接调用Console的WriteLine方法,而不需要再写上Console,就如下所示:
using static System.Console;
// ...
WriteLine("Hello World!");
作为最基础的类库,System命名空间几乎是.NET里引用次数最多的。你用C#编写的所有代码几乎都依赖于.NET基础类。在本例中,你用的是System命名空间下的Console类,将文本内容输出到控制台窗口中。C#没有任何内置的关键字定义输入和输出,它完全依赖于.NET类库。
在源代码里,我们声明了一个叫Program的类。因为它被包含在命名空间Wrox.HelloWorldApp中,因此这个类有效的名称应该是Wrox.HelloWorldApp.Program:
namespace Wrox.HelloWorldApp
{
class Program
{
//...
}
}
所有的C#代码都必须包含在某个类中。一个类的声明由class关键字和随后的类名组成,然后是一对大括号,所有跟这个类有关联的代码需要写在这对大括号内部。
Program类里包含了一个叫Main的方法。每个C#可执行程序(如控制台程序,Windows程序,Windows服务或者Web应用程序)都需要一个入口点——Main方法,注意开头的M是大写的。
static void Main()
{
//...
}
当程序运行的时候,会首先调用这个方法。这个方法的返回类型只能是空(void)或者整型(int)。
[modifiers] return_type MethodName([parameters])
{
// 方法体,这里是伪代码
}
第一个方括号代表可选的关键字,Modifiers用来指定你定义的Method的某些特性,比如这个方法将会从哪里调用。在我们的示例代码里,Main方法并没有加上public访问修饰符。你可以给它加上public修饰符假如你需要对Main方法进行单元测试的话。运行时不需要关注Main方法的可访问性就可以直接调用这个方法。static修饰符修饰的方法告诉运行时,在调用此方法时,不需要创建类的实例。示例中的返回值被设置为void,并且parameters是空的,也就是说方法不带有任何参数。
最后,让我们看看实际的执行代码:
Console.WriteLine("Hello World!");
在这个例子中,你简单的从System.Console类中调用了它的WriteLine方法,将一行文本输出到控制台窗口中。WriteLine是一个static方法,所以你不必要实例化一个Console类的实例就可以直接调用它。
现在你已经初步体会过C#的语法了,可以给你演示更多的细节了。因为在实际应用过程中,你不可能一个变量都用不到的,因此我们接下来讲解C#的变量。
2.2 变量
在C#里你将使用以下的语法定义变量:
datatype identifier
举个例子:int i;
,这个语句定义了一个叫i的int整型变量。在你给这个变量赋值之前,编译器不允许你直接在表达式里使用这个变量。声明变量后,你可以使用=
给这个变量赋值,如下所示:
i = 10;
你也可以在定义这个变量的时候同时初始化它的值:
int i = 10;
如果你想一次性定义多个同类型的变量值并写在一行里的话,可以像下面这样写:
int x = 10,y = 20;//x和y都是int类型
如果你想定义不同类型的变量,你需要使用多个独立的定义语句,你不能在一个语句里同时定义不同类型的变量:
int x=10; bool y=true; //正确的,一个分号代表一个语句的结束
int x=10, bool y=true; //错误的,会提示bool前面应该使用;号
注意上面示例中的//
,后续的文本属于注释内容。//
字符告诉编译器,从这个字符往后的字符直接忽略,直到遇到换行符。注释主要是方便研发人员查看的,并不属于程序的一部分,所以不需要编译。
2.2.1 变量初始化
所有变量必须先初始化证明了C#是一门强调安全的语言。简单来讲,C#编译器要求所有变量在进行任何操作前都先赋初值。大部分的现代编译器将操作没有赋值的变量仅仅标记为warning,但是严谨的C#编译器将这个行为认为是error。
C#有两个方法负责确认变量在使用前是否正确初始化:
- 变量通常是class或者struct里的字段,如果没有显式初始化,当创建变量的时候会尝试给它们赋初值0(class和struct的初值我们延后讨论)。
- 方法里的局部变量在任何语句使用它们之前必须显式初始化,这种情况下,变量不需要在声明的时候直接赋初值,但是编译器会检查方法里的所有可能赋值的地方,假如在初始化前就对变量进行其他操作,编译器会直接标记一个错误。
举个例子,以下的代码是不行的:
static int Main()
{
int d;
Console.WriteLine(d); // 编译时会提示使用了未赋值的变量d
return 0;
}
注意上述代码演示了一个带有int返回值的Main方法。考虑以下语句:
Something objSomething;
在C#里,这行代码创建了一个Something类的实例引用,但这个引用还没有指向任何实际的对象,任何尝试调用objSomething方法或者属性的操作都会报错。
对象初始化你需要用到new关键字,你可以像上面一样创建一个引用,然后将这个引用指向托管堆上用new关键字创建的一个实例对象:
objSomething = new Something();
2.2.2 类型诊断
诊断类型是通过使用var关键字,代替实际的数据类型。编译器会检查该变量在何时初始化,并且根据初始值的类型,决定该变量的实际数据类型是啥。举个例子:
var someNumber = 0;
//实际上就是
int someNumber = 0;
一开始我以为这个关键字和Javascript一样,定义的是一个可变类型,于是我这么写:
var i = 10;
i = "test"; //错误:无法将类型string隐式的转换成int
关于var的使用,有这么几条规则:
- 变量必须初始化,否则编译器无法知道变量究竟对应什么类型。
- 变量不能初始化为null。
- 变量初始化必须是表达式,常量也算表达式。
- 你不能将实例对象直接赋值给var变量,你需要显式创建一个新的实例。
//失败示例1
var i;
object o = new object();
i = o;
//失败示例2
var j;
j = new object();
//成功示例
var k = new object();
第三章我们会更进一步的解释这种匿名类型的使用。
2.2.3 变量的作用域
所谓变量作用域就是代码中的变量在哪些代码里有效能用。简单来讲有以下几条规则:
- 字段field(也可以认为是成员变量)在一个类中的作用域就跟在这个类中定义的任何内部(local)变量一样。
- 内部变量的作用域为用一对大括号包起来的代码段或者方法内。
- 定义在for,while或者类似的语句中的内部变量的作用域为循环体。
内部变量的作用域冲突
在一个大型程序中,在不同部分的代码中使用相同的变量名也不罕见。这种做法是允许的,只要你能明确同一个变量名的作用域,不要引起冲突和歧义即可。然而请谨记,在相同的作用域内,同名变量不能声明两次,下面这样子明显就是错的:
int x = 20;
int x = 30;
考虑以下的示例代码:
using System;
namespace VariableScopeSample
{
class Program
{
static int Main()
{
for (int i = 0; i < 10; i++)
{
Console.WriteLine(i);
}
for (int i = 9; i >= 0; i –)
{
Console.WriteLine(i);
}
return 0;
}
}
}
这段代码先是在控制台上输出0-9,然后输出9-0,使用了两个for循环。需要注意的是我们在同一个方法中声明了两次i,之所以能这么做是因为i是定义在两个分开的循环体内的,对每个for循环来说,i都是它的内部变量。
再来看看以下这个例子:
static int Main()
{
int j = 20;
for (int i = 0; i < 10; i++)
{
int j = 30; // 无法在此范围中声明名为"j"的局部变量或参数
Console.WriteLine(j + i);
}
return 0;
}
这个错误提示是因为在for循环外,我们已经声明了变量j,它的作用域为整个Main方法的内部代码,就相当于在for循环内部已经存在一个变量j了,所以编译器不允许在for循环内重复声明同一个变量j。
内部变量和字段的作用域冲突
在特定环境下,你可以区分两个同名标识符(虽然不一定完全相同)以及相同的作用域,而且在这种情况下编译器允许你声明第二个变量。这是因为C#对于变量有一个称之为Type Level的基础划分。变量可以被声明为类的内部字段(fields),属于class-level,或者声明为方法的内部变量(local variables)。考虑以下代码:
using System;
namespace Wrox
{
class Program
{
static int j = 20;
static void Main()
{
int j = 30;
Console.WriteLine(j);
return;
}
}
}
这段代码可以被正常编译,虽然你也声明了两个同名变量j而且他们的作用域在Main方法内有所交叉,编译器是这么做的判断,定义在Program的内部字段j属于class-level,而Main方法内定义的属于内部变量(原文扯了一大堆两者生命周期不同之类的,我觉得关系不太大,最重要的还是因为字段属于class-level,比内部变量高级,嗯,官大一级那个啥,所以我们不是同一级的存在,可以并存。而先前的例子,两个j都是内部变量,平级的,对不起,只能活一个)。在这种情况下,运行Main方法的时候,Main方法内部的变量j会隐藏外部的字段j,所以当你运行这段代码的时候,你会看到控制台输出的是30。
那假如你想引用外部的字段j怎么办?你可以使用object.fieldname。如上面的实例你可以这样引用:
Console.WriteLine(j);//30
Console.WriteLine(Program.j);//20
因为上面的Main是静态方法,所以我们声明的字段是用static修饰符修饰的静态字段,可以通过类名.字段直接引用。如果想在普通的class实例中引用被隐藏的字段的话,你可以使用this关键字。
2.2.4 常量
就跟名字暗示的一样,常量就是生命周期里一直保持一个值的变量。当某个变量需要被定义和初始化为常量时,在变量前面加上const关键字修饰即可:
const int a = 100; //值不能修改
常量有以下的一些特性:
- 常量声明的时候必须初始化,并且一旦它初始化了,值就不允许再次改变。
- 常量必须是在编译时能明确计算出结果的,例如你不能初始化常量为某个变量名。如果你有需要这么做的话,请使用read-only字段。
- 常量往往是隐式static类型。然而你并不需要(事实上也不允许)在声明常量的时候加上static关键字修饰。
使用常量至少有三个好处:
- 常量可以使你的程序代码更容易阅读,因为你把固定的数字和字符串用有意义的变量名称代替了,名字比数值更容易让人理解。
- 常量使你的程序代码更容易修改。举个例子,假如你在代码里定义了一个SalesTax常量,最初的时候它是6%。然后你引用它写了许多代码。某天税率突然改变了,你只需要修改一处常量的值即可,而不需要修改大量的代码。
- 常量能够帮你避免一些错误。假如你在程序的某个地方想声明一个同名变量,而这个变量已经被声明过了,编译器就会直接给你提示一个错误。
2.3 预定义数据类型
现在你知道如何定义变量和常量了,让我们更深入的了解一下C#里面有哪些数据类型。就像你看到的,C#比其他语言在类型和定义上都要严格的多。
2.3.1 值类型和引用类型
在研究C#究竟有哪些数据类型之前,我们非常有必要了解C#将数据类型分为两大类:值类型和引用类型。
接下来的小节会主要介绍值类型和引用类型的语法区别。而从概念上来讲,两者的区别在于值类型是直接存储的变量值,存储在栈(stack)上;引用类型的存储的是一个引用地址,它的实际值存在托管堆(managed heap)上。你必须能清楚的分辨一个类型究竟是值类型还是引用类型,因为两者的有效性(effect)和资源分配(assignment)完全不一样。举个例子,int是一个值类型,以下语句会在内存栈空间里划出两块空间,存储的都是值20:
int i = 20;
int j = i;
我们再看看下面这个例子,在代码里,假设你定义了一个类,名字叫Vector。Vector是一个引用类型,并且它拥有一个int型的成员变量叫Value:
Vector x, y;
x = new Vector();
x.Value = 30;
y = x;
Console.WriteLine(y.Value);//30
y.Value = 50;
Console.WriteLine(x.Value);//50
理解这段代码为什么会得出这样的结果的关键点在于,至始至终,只存在一个Vector实例,x和y是指向同一实例的两个地址引用(point to the same memory location)。因为x和y都是引用类型的变量,声明引用变量只会简单的返回一个地址引用——它不会按照声明的class类型创建一个包含数据的实例对象。只有使用new关键字,一个对象实例(object instance)才会被创建。因为x和y指向的是同一个实例,因此对x和y成员的修改事实上都会互相影响。所以上面例子的先输出30然后输出50。
如果一个变量是引用类型,你可以通过给它赋值为null来说明它没有指向任何实例对象:
y = null;
如果一个引用类型的变量被设置成null了,那么它就不能调用任何非静态的成员方法或者字段,否则运行时会提示一个System.NullReferenceException。
在C#里,基础类型(如bool和long)都是值类型。这意味着如果你声明了一个bool类型的变量,并且将它赋值给另外一个bool类型的变量,你将会在内存里分配两个bool值的空间。然后,当你修改第一个bool的值的时候,第二个bool变量的值并不会改变。值类型是值的拷贝。
与之相对应的是,在C#中大部分的复合数据类型,也包括你自己定义的class,都是引用类型。他们的实例存储在托管堆上,可以被各种方法调用,也可以通过别名引用。CLR实现了一套精准的算法来追踪引用类型中哪些变量仍然在用以及哪些变量已经不再被引用。CLR会周期性地销毁这些不再引用的实例,回收它们占用的系统内存。这套机制称之为垃圾回收(GC,Garbage Collector)。
C#里将基础类型(如int和bool)当做值类型,将包含有很多字段属性方法的大类型(通常就是class)当做引用类型处理,这种设计拥有最好的性能(high performance is best served)。如果你想自定义一个值类型的数据类型,你可以使用struct关键字定义一个结构体。
注意:基础数据类型的设计(layout of primitive data types)通常与机器设计(native layout)一致。这样使得可以在托管代码和机器代码之间共享内存。
2.3.2 .NET 类型
C#关键字定义的数据类型,如int,short和sring等等,实际上映射的是各种.NET数据类型。举个例子,当你在C#里声明了一个int类型,你实际上创建的是一个.NET struct实例:System.Int32。可能这听起来无关紧要,事实上它意义重大。这意味着你可以将基础数据类型,在语法上当做class类型一样,支持各种方法调用。举个例子,为了将int类型的变量i转换成一个字符串类型string,你可以像下面这么写:
string s = i.ToString();
需要强调的是,在这个便利的语法背后,int数据类型确实是存储成基础数据类型的。你这么写不会有任何的性能损失,因为实际上存储的类型是C#的结构体struct:System.Int32,与它相关的ToString方法早就加载到内存中了。
接下来的章节会带你回顾C#内置的数据类型。下面列举的各种基础数据类型,每个都有自己对应的.NET类型。C#拥有15个预定义的数据类型,包括13个值类型与2个引用类型(string和object)。
2.3.3 预定义的值类型
内置的.NET值类型代表各种基础数据类型,比如整数、浮点数、字符和布尔值。
整型
C#支持8个预定义的整型类型,如下表所示:
关键字 | .NET struct | 描述 |
---|---|---|
sbyte | System.SByte | 8位有符号整数 |
short | System.Int16 | 16位有符号整数 |
int | System.Int32 | 32位有符号整数 |
long | System.Int64 | 64位有符号整数 |
byte | System.Byte | 8位无符号整数 |
ushort | System.Int16 | 16位无符号整数 |
uint | System.Int32 | 32位无符号整数 |
ulong | System.Int64 | 64位无符号整数 |
有些C#类型和C++或者Java的类型有相同的名称,但是它们的定义却不一样。举个例子,在C#里面,int类型总是32位带符号数。在C++里int虽然也是带符号数,但它代表多少位bits取决于它运行的平台(32位系统它是32位,64位系统则是64位)。在C#里,所有的数据类型都被定义为平台无关的类型,以便C#和.NET程序可以后续运行在其他平台上。
byte类型是一个标准的8-bit类型,取值范围为0-255。值得注意的是,作为一门强调类型安全的语言,C#里,byte类型和char类型是完全不同的两种类型,这两种数据类型要进行运算的话,需要显式地进行转换。另外,与其他语言不同的是,C#里的byte默认是无符号数。带符号的8位整数在C#里被定义为sbyte。
在.NET中,short类型不再是那么短了(有些语言short是8位),它足足有16位长。int类型则代表了32位长度的整数,long类型则提供64位长的整数。所有的整型变量都可以由10进制数,16进制数或者二进制进行初始化。二进制数需要以0b开头,而16进制数则要求以0x开头,如:
long x = 0x12ab;
假如判断一个整数究竟属于int,uint还是long类型存在歧义,C#则默认这个整数是int类型。为了指定一个整数值究竟是哪种特定类型,你可以给它们添加相应的后缀,如下所示:
uint ui = 1234U;
long l = 1234L;
ulong ul = 1234UL;
当然你也可以用小写的u和l,只是小写的l容易和数字1混淆。
数字分隔符
C# 7.0提供的新特性。这些分隔符只是为了增加数字的可读性而已,并未添加任何其他的功能。举个例子,你可以在数字中添加一个下划线,如下面所示:
long l1 = 0x1234_5678_90ab_cedf;
编译器会自动忽略这个分隔符,其实上面这个值就等效于:
long l2 = 0x1234567890abcdef;
Console.WriteLine((l1 == l2).ToString());//true
因为这个特性,你可以在数字的任意位置加分隔符号,以增强数据可读性,但注意请不要任性地在一些毫无意义的地方添加:
long l3 = 0x12345_67890_abc_ed_f;
这个特性在很多地方都有用,譬如使用16进制或者8进制的时候,又或者使用一些协议中的数据位的时候。
注意:C# 7.0特性刚推出的时候,不允许在数字第一位之前和数字最后一位之后添加分隔符。在C# 7.2的时候允许在数字第一位之前加分隔符。
在二进制数中使用
除了数字分隔符的特性,C# 7.0还让你更容易地进行二进制赋值。如果你在一个数字前面敲了0b,接下来就只允许使用0和1,只有二进制数值允许进行赋值。让我们来试试输入了非0和1的值会怎么样:
uint binary1 = 0b123456789;
//只有assignment、increment、decrement和new对象表达式可以作为语句。
//应输入;
为什么编辑器会这么提示呢?这是因为C# 7.0之后,0b开头的数字后面,非0和1的数字直接被认为不属于有效数字,上面其实就相当于我们在编辑器里敲了:
uint binary1 = 0b1
23456789;
那么编辑器的提示就好理解了,第一行应该输入;
结束,而第二行不是有效的语句,所以才有了前面注释里的那两行提示。
让我们再考虑以下的二进制初始化:
uint binary1 = 0b1111_1110_1101_1100_1011_1010_1001_1000;
上述代码片段定义了一个32位的无符号整型数。数字分隔符可以帮你更轻松地看懂这串二进制数的值。这串二进制数每4位被分在一起并由下划线分隔开。当然你也可以像下面这么写,但明显上面的更直观:
uint hex1 = 0xfedcba98;
通常我们在使用八进制数的时候会按每3位进行分隔,如:
uint binary2 = 0b111_110_101_100_011_010_001_000;
接下来的例子演示某种协议中的二进制位写法,该协议按照具体用途分为最右两位,接下来6位为一小节,再接下来两个4位的16位二进制数,那么我们可以这么写:
ushort binary3 = 0b1111_0000_101010_11;
注意:
- 使用的时候要使用精确的整型类型,例如16位的二进制用ushort,32位的用uint,64位的用ulong。
- 二进制字面量从C# 7.0开始。
浮点数
C#不仅提供了尽可能详细的整型数据类型,同样也提供了小数类型。
类型 | .NET struct | 描述 | 小数位 |
---|---|---|---|
float | System.Single | 32位,单精度浮点数 | 7 |
double | System.Double | 64位,双精度浮点数 | 15/16 |
float主要用于值不太大,也不要求太高精度(7位)的浮点数。而double则比float大多了,并且提供了2倍的精度(15位)。
假如你在程序中直接使用一个非整型的数值(比如12.3),编译器会默认你想使用一个double类型的浮点数。如果你想指定该值只是一个float类型的浮点数,你需要在数字后面加上一个字符F,如下所示:
float f = 12.3F;
Decimal类型
decimal类型代表了更高精度的浮点数,如下表所示:
类型 | .NET struct | 描述 | 小数位 |
---|---|---|---|
decimal | System.Decimal | 128位,高精度浮点数 | 28 |
在C#和.NET中decimal最常用的就是进行金融计算,你可以使用高达28位的小数位的高精度浮点数。换句话说,你可以追踪更高精度的美元数值或者大宗交易里的分数部分。但你要记住的是,decimal在底层并不是作为基础数据实现的,因此使用decimal计算会对你程序的性能有所影响。
要指定你的数据是一个decimal类型而不是默认的其他数值类型,你需要在你的数值后面加上一个字符M(或者m):
decimal d = 12.30M;
布尔值
C#里bool类型,对应的.NET类型为struct System.Boolean,仅包含两个值true和false。
你不能将一个整型数值隐式的转换为一个bool值。如果你使用了一个bool变量或者返回bool值的方法,你只能使用true或者false。如果你试图用0代表false或者用非0的数值代表true都编译器都会给你提示一个error。
char类型
为了表示单个字符,C#提供了char类型。它对应的.NET类型是struct System.Char。char的字面量赋值通常由单引号括起来,例如:
char c = 'A';
假如你尝试将一个包裹在双引号里的字符赋值给char类型,编译器会认为那是一个字符串,然后会提示你类型转换错误。如:
char c = "A"; //无法将类型string隐式地转换成char
因为代表的是单独一个字符,你也可以用4位的Unicode来表示它如,'\u0041',或者用一个整型数值的强制转换,如 (char)65,也可以用16进制值来表示,如'\x0041'。你可以使用转义字符,如\'
。
2.3.4 预定义的引用类型
C#支持两种预定义的引用类型,object和string,如下表所示:
类型 | .NET class | 描述 |
---|---|---|
object | System.Object | 根类型,所有其他的类型,包括值类型,都继承自object。 |
string | System.String | Unicode编码的字符串。 |
object类型
许多的程序设计语言和类体系都提供了一个基类(root type),其它的类型都派生于这个基类。C#和.NET也不例外。在C#里,object类型是最顶级的类(ultimate parent type),所有系统的类和用户自定义的类型都派生自它。这意味着object有两个用途:
- 你可以使用object引用来表示任何子类型。举个例子,在第六章,"运算符和转换"中,你可以了解到,如何使用object类型对一个值类型进行装箱(box a value object),把它从栈移动到托管堆上。object引用在反射(reflection)中也很有用,当代码需要用到一些不确定具体类型的类时。
- object类型实现了不少基础通用的方法,包括Equals,GetHashCode,GetType和ToString。用户自定义的类在某些时候可能需要实现自定义的基础方法,你可以使用一项面向对象的技术,叫作重写(override),第四章将详细介绍这部分内容。举个例子,有时候你需要重写ToString方法,来精确地(intelligently)提供你自定义类的具体含义(string representation)。如果你没有提供你自己的实现,编译器会自动调用object类型的ToString方法,输出的内容可能对于自定义类的上下文来说不正确也没有任何意义。
你将会在后续的章节接触到更多关于object类型的细节。
string类型
C#能识别string关键字,在底层,它被转义成.NET类System.String。通过它,字符串的连接操作或者拷贝只是小菜一碟:
string str1 = "Hello ";
string str2 = "World";
string str3 = str1 + str2;
除了这种赋值方式以外,string是一个引用类型。事实上,一个string类型的实例被创建在托管堆上,而不是栈。当你将一个字符串类型的变量赋值给另外一个字符串变量,按照引用类型的定义来说的话,你将得到的是两个指向同一字符串的变量引用。然而,string类型和常见的引用类型的表现不同。举个例子,string串的文本内容是保持不变的。对字符串进行的任何操作,都会创建一个新的string对象实例,原始的string内容并不会发生任何改变。考虑以下的代码:
using System;
class Program
{
static void Main()
{
string s1 = "a string";
string s2 = s1;
Console.WriteLine("s1 is " + s1); //a string
Console.WriteLine("s2 is " + s2); //a string
s1 = "another string";
Console.WriteLine("s1 is now " + s1);//another string
Console.WriteLine("s2 is now " + s2);//a string
}
}
修改s1的值并不会影响到s2,你会想,不对啊,string不是引用类型嘛!为什么s2的值没有变化呢?我们来详细描述一下每一句赋值语句都做了啥:
- 第一句,在托管堆上创建了一个字符串"a string",在栈上创建了一个引用s1,将s1指向堆上的"a string";
- 第二句,在栈上创建一个引用s2,指向s1引用对应的堆,因此在打印s2的时候,输出的也是"a string",注意此时s1和s2指向的是同一块内存空间。
- 关键在于第三句,将"another string"赋值给s1的时候,并非在堆上直接修改"a string"的内存,而是在堆上重新创建了一个"another string"的string对象实例,并且把s1指向新的字符串。
- 因为s2指向的还是旧的内存地址,内容仍然是"a string"没有发生更改。
这涉及到String类对运算符"="的重载(overload),第六章我们将详细介绍这部分。总的来说,string类被重新设计实现了,因此你可以简单直观地将它当成一个值类型来使用(事实上它是引用类型)。
String的字面量被包含在两个双引号""
中。如果你尝试用单引号赋值,编译器会报错,如下所示:
string s = 'test'; //字符文本中的字符太多
string s = 's'; //无法将char类型隐式转换成string类型
String一样可以使用Unicode和十六进制作为其中的某个字符组成。因为这种转义序列是以反斜杠\
开头的,所以你在string中不能直接使用这个字符。不过,你可以用两个反斜杠\\
代替,它代表的是一个字符\
,如:
string filepath = "C:\\ProCSharp\\First.cs";
Console.WriteLine(filepath.Length.ToString()); //21
string s = "\\";
Console.WriteLine(s.Length.ToString()); //1
注意:\
和C盘分区操作文件目录仅适用于Windows操作系统。在C#代码里,可以使用斜杠/
来统一分隔文件目录。第22章"文件和流"会介绍更多详细信息。
就算你自信你可以在编写文件和目录路径的时候,每次都记得不同的系统怎么写,但要记住当前是\\
还是/
都是一件非常烦人的事情。幸运的是,C#提供了一个更方便的方式。你可以在字符串前面敲一个@字符,这样,字符串里的所有字符都会当成原始值(face value)进行处理,\
不会进行任何转义:
string filepath = @"C:\ProCSharp\First.cs";
这个特性甚至允许你在字符串里使用回车换行而不用手动敲入\r\n
之类的转义字符:
string jabberwocky = @"'Twas brillig and the slithy toves
Did gyre and gimble in the wabe."; //直接就输出带回车换行的两行字符串
C#还定义了用$前缀表示的一种转义格式。你可以将早先的代码片段修改成使用这种字符串转义格式。用$前缀标识的允许你在字符串里用{}
包裹的形式书写变量或者表达式,实际输出的是变量或者表达式的值,如下所示:
public static void Main()
{
string s1 = "a string";
string s2 = s1;
Console.WriteLine($"s1 is {s1}");
Console.WriteLine($"s2 is {s2}");
s1 = "another string";
Console.WriteLine($"s1 is now {s1}");
Console.WriteLine($"s2 is now {s2}");
string s3 = s1 + ' ' + s2;
Console.WriteLine($"s3 is now {s3}");
Console.WriteLine($"s1 concat s2 is now {s1 + ' ' + s2}");
}
更多的关于string类型的转义操作将在第9章进行介绍。
2.4 程序流控制
本小节主要介绍语言的基本组成部分:控制流语句,它可以使你的程序代码按照你的需要而不是逐行全部执行。
2.4.1 条件语句
条件语句使得你可以将你的代码根据真实的情况分成不同的执行部分。C#拥有两个条件语句的构造器:if语句可以用来判断指定条件是否满足,而switch语句则比较表达式产生可能产生的多个结果值。
if语句
C#继承了C和C++的if.else
条件选择语句。相信有过一些编程经验的读者一定很熟悉这个结构:
if (condition){
statement(s)
}
else{
statement(s)
}
如果每个条件下需要执行多个语句,这些语句需要用大括号{}
包裹起来(这个要求和C#的其他语句构造体一样,如for循环和while循环):
bool isZero;
if (i == 0)
{
isZero = true;
Console.WriteLine("i is Zero");
}
else
{
isZero = false;
Console.WriteLine("i is Non-zero");
}
根据需要,你可以只写一个if语句而不带有任何else,你也可以使用else if当你需要处理更复杂的条件判断时:
using System;
namespace Wrox
{
internal class Program
{
private static void Main()
{
Console.WriteLine("Type in a string");
string input;
input = Console.ReadLine();
if (input == "")
{
Console.WriteLine("You typed in an empty string.");
}
else if (input.Length < 5)
{
Console.WriteLine("The string had less than 5 characters.");
}
else if (input.Length < 10)
{
Console.WriteLine(
"The string had at least 5 but less than 10 Characters.");
}
Console.WriteLine("The string was " + input);
}
}
}
if语句后面的else if数量没有任何限制。上面的例子定义了一个input变量,用来接收控制台输入的字符,并且通过input.Length来判断输入了多个字符,输出相应的提示信息。这个例子向你演示了在C#里操作字符串有多么的方便。
(原文中有这么一段)另外需要注意的一点是,关于if语句,当判断条件只有一个表达式的时候,你可以省略()
括号。然而为了代码一致性,大部分的程序员都不会在使用if语句的时候省略括号:
if (i == 0)
Console.WriteLine("i is Zero"); // 只有i等于0的时候会输出
if i == 0
Console.WriteLine("i can be anything"); // 不管i是什么值都会输出
将书里提供的例子敲到VS2019里,会提示"语法错误:需要输入(",可能在某些地方修改配置能使编译通过并执行,但这种特性容易引起更多的错误,不建议使用。
另外就是再次强调一下,作为if的判断条件,只能是true或者false或者返回true/false的表达式,C#不允许使用整型代替。
switch语句
switch / case 语句提供了一种更方便的多分支条件选择。它的格式是一个switch语句附带一系列的case语句。当switch语句的表达式算出确定的值的时候,代码会迅速转到相应的case语句进行执行。下面是一个例子,你不需要为每个case语句所属的代码段都用{}
包裹,只需要在结尾写上break语句即可。你还可以在switch中添加default语句,当任何case都不满足表达式条件时,就会进入default里执行。下面例子演示了如何判断一个变量integerA的值:
switch (integerA)
{
case 1:
Console.WriteLine("integerA = 1");
break;
case 2:
Console.WriteLine("integerA = 2");
break;
case 3:
Console.WriteLine("integerA = 3");
Console.WriteLine("integerA is not 1 or 2.");
break;
default:
Console.WriteLine("integerA is not 1, 2, or 3");
break;
}
注意case之后的值必须是常量,不能是表达式,也不能是任何变量。
虽然switch.case语句和C还有C++很像,但是C#的switch.case比他们要稍微安全一些。具体来说就是,C#禁止从一个case语句跳转到另外一个case语句。这意味着除非你主动使用goto语句从一个case才能跳到另外一个case。编译器会检查每个case的末尾是否带有break语句,如果你试图像其他语言那样通过不写break一次执行多个case的话,会直接报错:"控制不能从一个case标签贯穿到另外一个case标签"。
虽然从一个case跳转到另外一个case的语法在某些情境中能用上,然而再更多的情况下它容易引起逻辑错误,而它在生产环境引起的错误会让你很难定位。比起未知的Exception是不是按照笨一点的规范来做更好呢?
使用goto语句,你可以在你的case语句中来回跳转。然而当你确信你真的想这么做的话,你最好再细致的考虑一次。考虑以下的代码:
// assume country and language are of type string
switch(country)
{
case "America":
CallAmericanOnlyMethod();
goto case "Britain";
case "France":
language = "French";
break;
case "Britain":
language = "English";
break;
}
虽然这样的代码没有任何的Exception,但是这只是最简单的一种情况,当你代码里的goto语句一多,你就会发现这样的代码有多么的凌乱。当某个case中不需要任何语句的时候,C#允许你直接留空,这种方式让你可以将2个或者多个case语句进行同一种处理(而不需要使用goto语句):
switch(country)
{
case "au":
case "uk":
case "us":
language = "English";
break;
case "at":
case "de":
language = "German";
break;
}
C#的switch还有一点非常有趣的就是,case书写的顺序完全没有任何关系,你甚至可以将default写在第一行。因此,不允许你书写两个带有同样常量的case语句,如果你这么写:
// assume country is of type string
const string england = "uk";
const string britain = "uk";
switch(country)
{
case england:
case britain: // 错误:switch语句包含多个具有标签值"uk"的情况
language = "English";
break;
}
上面的代码也展示了C#和C++不同的地方,在C#里允许你使用字符串作为case的常量进行判断。
2.4.2 循环
C#提供了4种不同的循环语句(for, while, do...while和foreach),这使得你可以按照设定的条件多次执行某些语句。
for循环
C#的for循环提供了一种循环机制,根据指定的判断条件是否为true决定是否进入下一次循环。通常格式如下:
for ([initializer]; [condition]; [iterator])
{
statement(s);
}
- initializer是一个表达式,会在第一次循环开始执行前执行一次(仅执行一次,通常用来定义一个循环内部使用的计数器)。
- condition是每次循环开始前都需要检查的一个判断条件(当这个表达式为true时,才会再执行一次循环体)。
- iterator会在每次循环体运算结束后执行(通常用来操作计数器)。
当condition表达式返回false时,跳出for循环。
for循环被称为前测循环(pretest loop)因为它在执行循环体内的语句前,会先判断condition是否成立。因此如果condition为false的话,循环体将完全不执行。
for循环在重复处理number型数据方面表现亮眼。接下来的例子演示了for循环最典型的应用。它输出0到99共100个数字:
for (int i = 0; i < 100; i = i + 1)
{
Console.WriteLine(i);
}
在这里,你首先定义了一个int类型变量i
,并且将它初始化为0。它被用作循环计数器。接下来你测试它是否小于100,因为此时i=0
小于100,结果为true,因此执行循环体里的代码,输出i
此时的值,在控制台上显示0。接下来,让i
自增1,然后重复整个过程。当i
增加到100的时候,循环体停止。
C#提供了一种更简单的方法,你可以用i++代替i=i+1这样的写法。
嵌套使用for循环也很常见,这种使用方法就像在遍历一个矩阵的所有元素。外部循环按行进行读取,而内部循环则按列读取。接下来的代码演示了如何显示整个矩阵内容。注意到我们使用了新的方法Console.Write,它跟Console.WriteLine唯一的区别就是它往控制台上输出字符串时不带回车换行:
using System;
namespace Wrox
{
class Program
{
static void Main()
{
// 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();
}
}
}
}
虽然j
是一个int类型,但是它会被自动转换成string,所以上面的代码执行完你会在控制台看见这样的输出:
0 1 2 3 4 5 6 7 8 9
10 11 12 13 14 15 16 17 18 19
20 21 22 23 24 25 26 27 28 29
30 31 32 33 34 35 36 37 38 39
40 41 42 43 44 45 46 47 48 49
50 51 52 53 54 55 56 57 58 59
60 61 62 63 64 65 66 67 68 69
70 71 72 73 74 75 76 77 78 79
80 81 82 83 84 85 86 87 88 89
90 91 92 93 94 95 96 97 98 99
虽然for循环最后的计算语句你也可以不对计数器进行操作,写点别的你喜欢的代码,但这不是for循环的常用方式。for循环也允许你缺省任意一个(或者全部)表达式,但这种情况下,你可以考虑使用while循环代替。
while循环
跟for循环一样,while循环也是一个前测循环。语法很相似,但while循环只需要一个表达式:
while(condition)
statement(s);
跟for循环不同的是,while循环更多地用于你事先不知道执行次数的情况下,去重复执行一段语句。通常在while循环体内运算到某次循环的时候,就会修改condition计算出来的值,使得整个循环体能够结束。就好像下面这样:
bool condition = false;
while (!condition)
{
// This loop spins until the condition is true.
DoSomeWork();
// assume CheckCondition() returns a bool, it
// will return true at a time then end the loop.
condition = CheckCondition();
}
do...while循环
do...while循环是while循环的后测(post-test)版本。这意味着使用它,你会先执行循环体,然后再检测condition是否为true。因此do...while循环对于那些必须至少执行一次的语句很有用,如下所示:
bool condition;
do
{
// This loop will at least execute once, even if Condition
// is false.
MustBeCalledAtLeastOnce();
condition = CheckCondition();
} while (condition);
foreach循环
foreach循环允许你循环访问一个集合里的所有元素(item)。现在你不用在意到底什么是集合(第10章的时候我们会详细介绍),你只需要知道集合是一组对象就可以,集合里的对象必须实现IEnumerable接口。常见的集合例子有C#的数组,在System.Collections命名空间下定义的类,或者用户自定义的集合类。让我们看看以下的例子,你可以假设arryaOfInts是一个int类型的数组:
foreach (int temp in arrayOfInts)
{
Console.WriteLine(temp);
}
在这里,foreach会每次都读取一个arrayOfInts里的元素,然后将它赋值给temp,然后执行循环体,再读取下一个,直到读完所有元素。
一个非常重要的点是,在foreach循环里所有元素都是只读的,你不能进行任何修改操作,像下面这样的代码会提示编译错误:
foreach (int temp in arrayOfInts)
{
temp++; //无法为temp赋值,它是foreach迭代变量
Console.WriteLine(temp);
}
如果你在循环体内需要修改元素的值,你可以使用for循环代替。
2.4.3 跳转语句
C#提供了一些语句使得你可以在程序里进行跳转。首先就是,众所周知的goto语句。
goto语句
goto语句允许你直接跳到任意行,只要它声明了一个标签(label,就是一个英文名称然后紧跟着一个:
):
goto Label1;
Console.WriteLine("This won't be executed");
Label1:
Console.WriteLine("Continuing execution from here");
goto语句有着一系列的限制。首先,你不能使用它跳进一个for循环的代码块中,也不能跳出整个class,在try...catch的finally块里不允许存在goto语句。
声名狼藉的goto语句在大部分情况下都是让人深恶痛绝的。通常来说,它跟良好的面向对象编程实践完全不搭边。
break语句
前面你已经简单的体验过break语句了——用来跳出switch的case。事实上,break语句也可以用于跳出像for,foreach,while和do...while这样的循环体。
如果你在嵌套循环里使用了break,它仅仅会跳出它所在的那一层循环体,外部循环依然可以正常使用。如果你在switch或者循环体外使用break,系统会直接提示一个编译错误:"没有要中断或继续的封闭循环"。
continue语句
continue语句和break语句很像,但你只能在循环里使用它。当编译器遇到continue的时候,他会中止本次循环,直接开始下一次循环执行。
return语句
return语句通常用于结束一个class的方法,将控制权交回给方法的调用者。如果方法有返回类型,return语句必须返回该类型的值。而如果方法声明为void,则不需要返回任何值,仅仅书写一个return即可。
2.5 命名空间
就像早先章节讨论的一样,命名空间提供了一种组织相关class和其他types的方式。命名空间是一种逻辑上的分组方式,跟物理上的文件或者组件的组织方式不同。当你定义了一个class并将它保存到某个C#文件中的时候,你可以为它指定一个命名空间,创建一个逻辑分组,它能提示其他的开发者如何使用和关联这些类:
using System;
namespace CustomerPhoneBookApp
{
public struct Subscriber
{
// Code for struct here..
}
}
将一个类型放置在某个命名空间下会让它的全名很长,包括各级命名空间,并且用.
进行分隔。在上面到示例里,结构体Subscriber的全名其实是:CustomerPhoneBookApp.Subscriber。使用完整名称,可以让你在同一个程序里使用两个同名类型,而不会引起歧义。全名也称为完全限定名(fully qualified name)。
你也可以将命名空间写在其他命名空间下,形成一种多层分级的命名空间,如下所示:
namespace Wrox
{
namespace ProCSharp
{
namespace Basics
{
class NamespaceExample
{
// Code for the class here..
}
}
}
}
每个命名空间都是class全名的一个组成部分,从它最外部的命名空间开始,直到它本身结束。因此,ProCSharp的全名是,Wrox.ProCSharp,而类NamespaceExample的全名则是Wrox.ProCSharp.Basics.NamespaceExample。
你也可以使用这种语法来定义多层命名空间结构,如上面的例子我们可以简写成:
namespace Wrox.ProCSharp.Basics
{
class NamespaceExample
{
// Code for the class here..
}
}
注意在这种写法下,不允许内部再嵌套别的长命名空间(multipart namespace),但是再一个个嵌套是允许的,这个限制在VS2019里试的时候就已经失效了,例如下面这样写也是允许编译执行的:
namespace Wrox.ProCSharp.Basics
{
namespace Test.Test2
{
internal class NamespaceExample
{
// Code for the class here..
}
}
}
2.5.1 using 语句
显而易见的是,命名空间有时候会让一个类型的全名变得特别的长,如果你需要多次使用某个类型的时候,每次都书写完整的名称是很烦人的。幸运的是,就像前面章节提到的,C#允许你在代码头使用using关键字声明你需要用到的命名空间,这样你就可以简单的用类型的名称进行调用:
using System;
using Wrox.ProCSharp;
就像之前很多地方提到过的,很多C#源代码开头都会有一个using System;这是因为大部分常用的基础类都定义在这个System这个命名空间下。
假如两个同名类型都在文件头使用using语句声明了自己所在的命名空间,那就需要对这两个类型使用全名(或者至少是一部分名称),以便编译器能够区分你在不同位置想使用的究竟是哪个类。举个例子,假如我们有个类叫NamespaceExample,在命名空间Wrox.ProCSharp.Basics和Wrox.ProCSharp.OOP下都有这个类,在开头我们又声明了对这两个命名空间的引用,此时我们就需要使用全名来告诉编译器我们究竟要用的是哪个命名空间下的NamespaceExample类:
using Wrox.ProCSharp.OOP;
using Wrox.ProCSharp.Basics;
namespace Wrox.ProCSharp
{
class Test
{
static void Main()
{
Basics.NamespaceExample nSEx = new Basics.NamespaceExample();
// do something with the nSEx variable.
}
}
}
你所在的组织应该会愿意花上一点时间来规划它们自己的命名空间架构,以便为它工作的开发者能够快速定位他们需要的基础功能,并且不会与第三方的类库冲突。在后续的章节里我们会继续讨论和建议如何为您的公司/组织创建属于您自己的命名空间体系。
2.5.2 命名空间的别名
using关键字的另外一个用法就是为类和命名空间指定别名。假如你需要在你的代码里多次使用一个很长名字的类,还不得不敲全它的全名,你可以为这个类前面的那一串命名空间指定一个别名,就像这样:
using alias = NamespaceName;
然后你就可以通过alias::className来进行定义和调用了。请看一下下面这个例子:
using System;
using Introduction = Wrox.ProCSharp.Basics;
class Program
{
static void Main()
{
Introduction::NamespaceExample NSEx = new Introduction::NamespaceExample();
Console.WriteLine(NSEx.GetNamespace());
}
}
internal class Introduction
{
}
namespace Wrox.ProCSharp.Basics
{
class NamespaceExample
{
public string GetNamespace()
{
return this.GetType().Namespace;
}
}
internal class Introduction
{
}
}
注意::
符号,这会告诉编译器在别名里查找相应的命名空间。就算在同一个调用域内有一个叫Introduction的同名类也没有关系。
2.6 Main()方法
跟前面提到的一样,C#程序从Main方法开始执行,根据执行环境的需要,Main方法有几个特别的地方:
- 必须是static方法;
- 可以在任何一个class里,但是方法名必须叫Main;
- 返回值是int类型或者void类型。
虽然将Main方法声明成public的做法很普遍,因为它要被"外部"调用,但实际上对于CLR查找入口点没有任何影响,甚至你将它声明成private都行。
前面我们提到的Main方法的例子里都不带任何参数。事实上,当程序被调用的时候,你可以通过CLR给它传递一些命名行参数,这些参数是一个字符串数组,我们通常把它们叫做args(事实上它叫啥都行)。程序可以使用这个数组获得从命令行传递过来的任何参数。
接下来的例子向你演示了如何获取参数值并将它们输出到控制台上:
using System;
namespace Wrox
{
class Program
{
static void Main(string[] args)
{
for (int i = 0; i < args.Length; i++)
{
Console.WriteLine(args[i]);
}
}
}
}
如果你想使用Visual Studio直接给应用程序传递参数,你可以在"项目->属性->调试->应用程序参数"里输入你需要的参数值,用空格分开,数量不限,如下图所示,上面的例子将会挨个输出:
而如果你想用我们前面介绍到CLI的工具,你可以直接在dotnet run命令后面输入参数,就像这样:
dotnet run arg1 arg2 arg3
有时候run指令也会有一些自己的运行参数,假如你想让你的自定义参数和命令参数区分开来,更容易分别的话,你可以在命令参数后使用--
符号,如下所示:
dotnet run [command args] -- arg1 arg2 arg3
2.7 使用注释
下一个主题是——为你的代码添加注释——看起来似乎很简单,但它也可以很复杂。注释可以对其它需要了解你代码的人很有用。而且,你可以通过注释来生成说明文档以便其它人能复用你的代码。
2.7.1 源文件中的内部注释
就像前面说的,C#用的是传统C类型语言的注释,单行用//
,多行用/*...*/
:
// This is a single-line comment
/* This comment
spans multiple lines. */
在//
号之后的单行内容,都会被编译器忽略,而在/*
和*/
之间包裹的任何内容都会不会被编译。显而易见到是,你不能在一组/*..*/
中再次插入*/
,因为它会被编译器当成是注释的结束符。
请不要在代码行的内容里插入注释,因为它会让代码变得很难阅读。而需要注意的是,包含在""
之间的/**/
则会被当成正常字符串。
string s = "/* This is just a normal string .*/";
2.7.2 XML 文档
在前面章节提到的,C#拥有一个非常精巧(neat)的扩展:从指定格式的代码注释里自动生成XML格式的文档。这些注释都是单行注释,但它们以三斜杠///
开头而不是通常的双斜杠//
。在这种类型的注释里,你可以使用一些XML标签,用来说明你代码里的类型和成员,下面是一个编译器能识别的标签表:
标签 | 描述 |
---|---|
<c> | 将一行注释文本标记成代码格式,如<c>int i = 10;</c>。 |
<code> | 将多行注释包含在标记成代码块。 |
<example> | 标记一个代码例子。 |
<exception> | 记录一个异常类(语法由编译器验证)。 |
<include> | 包含其他说明文档里的注释(语法由编译器验证)。 |
<list> | 在文档中插入一个列表。 |
<para> | 给出文本的结构。 |
<param> | 标记方法的参数(语法由编译器验证)。 |
<paramref> | 标识一个单词是否为一个方法参数(语法由编译器验证)。 |
<permission> | 记录一个成员的可访问性(语法由编译器验证)。 |
<remarks> | 追加成员的描述。 |
<returns> | 记录方法的返回值。 |
<see> | 提供另外一个参数的交叉引用(语法由编译器验证)。 |
<seealso> | 在描述里提供一个"另外参见"小节(语法由编译器验证)。 |
<summary> | 提供一个类型或成员的简要介绍。 |
<typeparam> | 描述一个泛型类型需要的类型参数。 |
<typeparamref> | 提供类型参数的名称。 |
<value> | 描述一个属性。 |
下面让我们给MathLib.cs文件添加一些XML注释,使得它看上去如下所示:
// MathLib.cs
namespace Wrox.MathLib
{
///<summary>
/// Wrox.MathLib.Calculator class.
/// Provides a method to add two doublies.
///</summary>
public 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文档文件"里配置要生成注释的XML文档:
2.8 C#预处理器指令
除了大部分你已经接触到的常见关键字,C#还提供了一系列的预处理指令。这些指令不会实际转换成可执行代码里的任何内容,但是它们会影响整个编译过程。
举个例子,你可以使用预处理指令,不让编译器编译你程序的某些部分。你可能会发布两个版本的程序,一个基础版和一个企业版,企业版拥有更多的功能特性。你可以使用预处理指令,阻止编译器编译属于企业版的那部分功能代码,这样就能简单地生成基础版程序。
另外一种情况是,你可能写了一些代码为你提供一些调试信息,但这些对用户来说是没有任何意义的所以你不想让他们看到。
所有的预处理器指令都是以# symbol开头的。
注意:C++程序员可能会将预处理器指令当做是C++中重要的组成部分。然而,C#的预处理器指令没那么多,也不会经常使用。C#提供另外的一些机制,如自定义属性Attributes,它跟C++里某些预处理指令的效果是一样的。另外,C#跟C++不一样,C#并没有实际上提供一个另外执行的预处理器。虽然C#里的指令叫预处理器指令,但实际上它只是在编译时处理的内容。不过C#还是把这些指令称之为预处理器指令因为确实在你的感觉里它们干的也是预处理的事情。
接下来的几小节我们会详细介绍这些预处理器指令都是干啥的。
2.8.1 #define 和#undef
#define指令通常会像这么用:
#define DEBUG
这个指令告诉编译器,有这么一个名称的标记(Symbol)存在(如这里叫DEBUG)。这种语法有点像声明一个变量,除了这个"变量"没有值——但它存在。另外,这个标记不属于你实际业务代码的组成部分,它只是对编译器编译的时候有用而已,而且光定义一个名称的话实际上也没有任何意义。
#undef指令则相反,用来移除相应的标记:
#undef DEBUG
如果不存在这样的标记,#undef指令则不会生效。同样,用#define再次定义一个同名的标记也不会有任何变化。你需要将#define和#undef指令写在实际业务代码开始之前的位置。
#define指令经常会跟#if指令配合使用,它们是一对给力的排挡。
顺带一提,你可能注意到预处理指令和通常的C#语法不同,它不需要用;
结尾。这是因为它通常只有一行,当编译器发现当前这行是预处理指令时,它会自动认为下一行是一个新的指令,所以你不需要在结尾添加;
。
2.8.2 #if、#elif、#else 和#endif
这些指令告诉编译器是否要编译包含在其中的代码块。考虑以下这个方法:
int DoSomeWork(double x)
{
// do something
#if DEBUG
Console.WriteLine($"x is {x}");
#endif
}
在#if和#endif中包含了一句Console.WriteLine的方法调用。只有在前面通过#define指令定义过DEBUG这个标记,#if块里的内容才会被编译,Console.WriteLine方法才会被执行。当编译器遇到#if标记时,它会去检查其后定义的Symbol是否存在。如果不存在,编译器就会简单地忽略随后的代码直到它遇到#endif指令。最典型的应用就是,你用#define指令定义一个DEBUG标记,然后用#if DEBUG指令包含很多需要在调试时候做的事情。然后,当你完成你的代码,需要打包的时候,你可以简单地注释掉你的#define指令,然后相关的调试代码就都神奇的不见了,你的安装包会变得很轻巧,你的用户也不必为各种调试信息感到困扰(当然,你还需要做更多的工作确保你到代码没有DEBUG块一样能正常运行)。这项技术在C和C++编程中其实挺常见的,叫条件编译。
#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==false) // if W10 is defined but ENTERPRISE isn't
2.8.3 #warning 和 # error
另外两个非常有用的预处理器指令是#warning和#error。当编译器遇到这俩指令时会分别提示一个警告或者异常。考虑以下的代码:
#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.*");
假如编译器遇到一个#warning指令,它会在编译结束后,在错误列表窗口里显示这个警告文本;而如果编译器遇到一个#error指令,则会直接显示这个错误文本,并且终止编译,因此不会生成任何的IL。
你可以使用#error指令检查自己是不是用#define语句做了什么愚蠢的事情,或者写个#warning指令提醒自己别忘记做啥事情。
2.8.4 #region 和#endregion
#region和#endregion指令被用来标识一段代码主要是用来干啥的,就像这样:
#region Member Field Declarations
int x;
double d;
Currency balance;
#endregion
看起来它们没啥用,也不会影响编译过程。然而,这些指令能被特定的编辑器,如VS识别,它可以折叠和展开相应的代码块,让你的代码看上去更井然有序。
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指令只能用在class或者method级别的实现上,它让你能够更细致地控制何时输出警告。在下面的例子里我们先禁用了"field not used"(字段从未被使用过)的警告并随后恢复了它:
#pragma warning disable 169
public class MyClass
{
int neverUsedField;
}
#pragma warning restore 169
2.9 C#编程规范
本章最后介绍了一些你编写C#应用时需要遵循的规范,这些规范也是大部分C#开发者共同遵守的,如果你按照规范书写代码,其他人在看你代码的时候就会更加轻松。
2.9.1 关于标识符的规则
这个小节主要说的是变量,类,方法等等内容的命名规则。注意本章提到的规则不单单是规范:它们也是C#编译器强制要求的。
你为变量,自定义类型,类,结构体,类型成员指定了名称,这些名字称之为标识符(identifiers),它们是大小写区分的,举个例子,interestRate和InterestRate是两个完全不同的变量,因为它们的首字母不同。以下是标识符命名的规则:
- 必须以字母或者下划线,但不能是数字作为标识符开头。
- 不能使用C#的关键字作为标识符名。
假如你确实需要使用关键字作为标识符名字的话(举个例子,如果你需要访问其他语言写的类,类名恰好就是C#关键字),你可以在关键字前加一个@
字符,来告诉编译器,其后紧跟的关键字需要当成标识符来对待(所以abstract是C#的关键字,而@abstract是一个有效的标识符名称)。
最后,标识符甚至可以包含Unicode字符,通过\uXXXX来指定,其中XXXX是Unicode规定的4位16进制码。以下是一些有效的标识符:
Name
Überfluß
_Identifier
\u005fIdentifier
因为在Unicode中\u005f对应到就是下划线_
,所以最后两个标识符它们实质上是同名的,因此他们不能在同一个作用域里同时声明。 虽然_
开头的标识符名称在语法上是允许的,但是大部分情况下都不建议你声明下划线开头的标识符。因为它跟Microsoft为了提升代码可读性发布的编码规范其实是不符合的。
2.9.2 用法约定
在很多开发语言里,有很多约定俗成的开发风格。这些风格并不是编程语言本身要求的,而是一些规范。举个例子,变量怎么命名,相关的类、方法、函数如何使用。如果某一语言大部分的开发者遵从相同的约定,会让他们更轻易地读懂别人的代码——这会使得代码更具有可维护性。遵循什么样的规范,主要取决于开发环境。例如,C++的程序员在开发Windows平台应用程序的时候,就会使用前缀psz或者lpsz来代表两种字符串——如char *pszResult; char *lpszMessage;但是在Unix机器上则可能会不使用任何前缀——如char *Result; char *Message;。
本书中的C#例子在命名局部变量的时候也是不加任何前缀的:string result; string message;。
Hungarian命名法在以前很有用,通过在变量前面加上类型缩写,使得读代码的人能马上明白该变量的数据类型。但在今天的编辑器智能提示下,这种写法就有些多余了。
尽管很多语言的开发规范都是在使用的过程中慢慢形成的,但是对于C#和所有.NET Framework开发语言来说,Microsoft编写了一份非常详尽的使用指南,详细地介绍了.NET/C#的文档规范。这意味着,在一开始,.NET程序就为开发者提供了很高的代码交互性。这套指南也总结了一些用了20年之久的面向对象编程语言中有用的部分。通过语言讨论组的研究判断与仔细考量,这套规范最终被开发者社区欣然采纳了。因此,这套规范值得遵循。
注意这只是一套开发规范而不是语言限制,然而在任何时候你都应该尽量遵循它。尽管你不照着做,编译器也不会给你报任何编译错误。一旦你决定不照着指南来,你需要有足够令人信服的理由,你不能仅仅因为规范有点麻烦就弃之不用。当然,如果你细心地比较本书后续章节中的很多示例代码,你可能会发现我也没有严格地按照规范来做。这是因为规范主要是为了大型软件开发而设计的,而不是简单的示例。虽然规范非常有用,但如果你只是写一个很小的独立软件包,就几十行代码的话,就没必要大材小用了。在绝大多数的情况下,遵循规范会让一些示例代码更复杂,更难以理解要演示的某个小知识点。
一套好的代码风格的开发规范是非常广泛的。本章仅仅介绍最常用最重要的部分,如果你想保证你的代码完全100%符合使用指南里的要求,你需要自己查阅Microsoft的说明文档。
2.9.3 命名约定
你的代码是否能够让别人很好的理解,很大一部分取决于你代码里的命名——包括变量、方法、类、枚举以及命名空间。
你的命名必须直观明显地体现对应项(Item)的使用目的,并且不会跟其他项(Item)的内容混淆。在.NET体系里,变量命名需要体现它的使用目的而不是代表它的数据类型。举个例子,height就是一个很好的命名,而integerValue则不是。然而你可能会发现这个原则有时候也很难遵循。当你使用某些用户控件的时候,你可能更愿意使用confirmationDialog和chooseEmployeeListBox这样的变量名,既描述使用目的,也提示了它的具体类型。接下来的几小节我们会介绍在命名的时候你需要考虑的一些问题。
名称的大小写
在大部分情况下你需要使用Pascal命名法,每个单词的第一个字母通常是大写的:如EmployeeSalary,ConfirmationDialog,PlainTextEncoding。注意Namespace、Class和Members基本都遵循Pascal命名法。不建议在变量名中使用分隔线_
,如 employee_salary这样的命名。在其它语言里常量建议使用全大写的字母,但在C#里没有这种要求,因为全大写的单词并不容易阅读——C#的常量仍然建议使用Pascal大小写命名法:
const int MaximumLength;
另外一种建议的命名方式叫驼峰命名法(camel casing)。Camel命名法和Pascal命名法很像,除了第一个单词的首字母是使用小写的之外,如:employeeSalary,confirmationDialog,plainTextEncoding。在下述的3种情况里建议使用驼峰命名法:
- 用private声明的私有字段field,然而,这种字段更常用的方式是,前头带下划线;
- 方法的参数;
- 区分含有同样名称的包装类型。例如属性和其对应的字段:
private string employeeName;
public string EmployeeName
{
get
{
return employeeName;
}
}
当你为属性封装了一个相应的字段,通常你可以使用Camel命名法声明一个private field,用Pascal命名法声明一个public或者protected Property,所以那些外部调用的类里能看到的基本都是Pascal命名法命名的单词(除了方法参数以外)。
你需要注意字母的大小写,因为C#是大小写敏感的语言,就像前面列举的例子一样,不同的大小写声明的名称代表的是不同的项。然而,你的程序集有时候会为VB应用程序提供一些调用功能,而VB里则是大小写无关的。因此,如果你使用仅仅只有大小写不同的英文单词作为标识符,你需要注意外部程序调用你的程序集时,不能同时访问这两个标识符,否则你无法让VB这样的程序正确的调用你的程序集。
命名风格的统一
请尽量保持你的命名风格是一致的。举个例子,如果你有个方法叫ShowConfirmationDialog,那你之后需要创建类似的方法的时候,就不应该起一个像ShowDialogWarning或者WarningDialogShow的名字,正确的名字应该叫做ShowWarningDialog。
命名空间的名称
选择命名空间名称的时候也要谨慎,以免与其他人用的名字重复。记住,命名空间的名称是.NET体系分辨不同程序集里各种类型的唯一方式。因此,如果你在不同的包里使用了同样的命名空间和类,然后又在同一个程序里同时调用了这些包,那肯定就报错了。因为这个原因,创建一个以你公司名称为顶级,按技术,分组,部门,逐级划分的命名空间体系是一个很好的方式。Microsoft推荐命名空间以<公司名>.<技术名>开头,如下所示:
WeaponsOfDestructionCorp.RayGunControllers
WeaponsOfDestructionCorp.Viruses
名称与关键字
需要注意你起的名字不能和任何关键字冲突。事实上,当你试图在代码里使用关键字作为变量名的时候,编译器会向你提示一个语法错误。而且,因为你写的类很有可能会被其他的语言调用,你最好也不要使用其他语言里用到的关键字作为变量名。总的来说,C++的关键字和C#很像,想跟C++的关键字混淆也不太可能,而那些经常被使用的C++特有的关键字通常也会以两个下划线__
开头。跟C#一样,C++的关键字也都是小写单词,所以只要你遵循Pascal命名法声明你的public类和成员,他们至少都会有一个大写字母,完全不可能跟C++关键字有冲突。因此,更多的问题可能来自VB程序,它拥有比C#更多的关键字,而且还是大小写无关的,这意味着你就算用Pascal命名法声明你public的类和方法也没什么用。
你可以在https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/查阅更多的不允许声明为公有类型的关键字。
2.9.4 属性和方法的使用
class里很容易混淆的一点是属性(Property)和方法(Method)的使用。没有什么特别严格的规则来区分,总的来说,如果你觉得某个用途更像是一个变量的话,你就可以将它定义成一个属性(第三章我们会更细致地介绍属性)。这意味着:
- 客户端的代码可以访问到属性的值。如果你需要提供一个只允许写的属性,请用方法代替。例如,定义一个SetPassword方法,而不是定义一个Write-Only的Password属性。
- 读取属性的值不应该花太多的计算步骤和时间。事实上声明属性的目的就是想让它能被快速访问。
- 读取属性值的时候不应该有任何可见的或者不可预料的影响。更进一步地说,设置一个属性值的时候,不应该直接影响跟这个属性没有任何直接关系的其他变量的值。设置对话框宽度很明显会影响对话框在屏幕中的显示效果,但它是允许的,因为宽度本来就是显示相关的属性。
- 设置属性的时候必须跟先后顺序无关。特别是,因为某个属性没设置而导致设置另外一个属性的时候程序报错。举个例子,使用一个数据库访问类的时候,你需要设置ConnectionString,UserName和Password。写这个类的人需要保证你想按任何顺序设置这三个属性的值都是可行的。
- 读取属性时必须返回相同的值。如果一个属性的值可能会因为不同的情况返回不同的值,你最好声明一个方法代替它。例如,Speed,是用来检测智能手机的运行速度的,将它定义成一个属性就不太合适,因为在不同情况下得到的处理速度可能不同,这里最好使用一个GetSpeed方法来代替。但是Weight和EngineSize却是一个很好的属性选择,因为它们是固定的值不会发生任何改变。
如果某个项(Item)满足以上所述的前提条件,它可能是一个合格的属性。不然你最好还是用方法来声明它。
2.9.5 字段的使用
字段(Field)的使用指南非常的简单。大部分的字段都是private类型的,虽然在某些特定情况下它可能会被声明成public类型的常量或者只读字段以供外部调用。将一个字段声明成public可能会对你以后扩展或者修改类功能的时候造成阻碍。
前面各小节介绍的规范能给你更好的开发体验,你可以配合面向对象的编程风格使用他们。
最后一个有用的说明是,Microsoft在编写.NET基础类的时候,也非常小心地按照它发布的规范书写代码,所以想直观地体验整个规范的一种很好的方式就是——在你书写.NET代码的时候,看看基础类库的类,成员,命名空间是怎么命名,各个层次关系是如何构建的。与基础类库保持一致性会让你的代码更具有可读性和可扩展性。
注意:新的ValueTuple类型包含public字段(Field),尽管旧的Tuple类型则使用的是属性(Properties)。看起来Microsoft打破了它自己定义的关于fields的规范。因为Tuple的变量可以像int类型的变量一样简单地使用,而考虑到最重要的性能,因此在设计时决定在Value Tuples中使用public声明的field。这个例子是想告诉你,规范并不是强制的规则必须死死遵守不能变通的,只要有正当的理由,不会引发其它异常,就可以灵活应用。第十三章我们会介绍更多关于Tuples的内容。
2.10 小结
本章主要演示了一些C#的基础语法,包括编写一个简单的C#应用所需要的方方面面。我们还介绍了不少底层实现,不过这些内容对于熟悉C风格或者Javascript的开发者来说应该是驾轻就熟的。
你已经看到,C#的语法和C++还有Java非常的类似,但它们又有细微的不同。你可以在更多的地方发现C#的语法能更快更简洁——例如,高质量的字符串处理功能。C#拥有一套强定义的类型系统,我们会在接下来的C#面向对象编程特性中详细介绍值类型和引用类型。