第2章
C#语言基础
本章将介绍一些C#语言的基础知识。
本章和接下来的两章中的所有程序和代码片段都可以作为交互式示例在LINQPad中运行。阅读本书时使用这些示例可以加快你的学习进度。在LINQPad中编辑执行这些示例可以立即看到结果,无须在Visual Studio中建立项目和解决方案。
若要下载这些示例,请点击LINQPad中的Samples选项卡,然后点击“Download more samples”。LINQPad是免费程序,详见http://www.linqpad.net
2.1 第一个C#程序
以下程序计算12乘以30,并将结果360打印到屏幕上。双斜线“//”表示其后的内容是注释:
using System; // Importing namespace
class Test // Class declaration
{
static void Main() // Method declaration
{
int x = 12 * 30; // Statement 1
Console.WriteLine (x); // Statement 2
} // End of method
} // End of class
该程序的核心是以下两个语句:
int x = 12 * 30;
Console.WriteLine (x);
在C#中,语句按顺序执行,每个语句都以分号(或者代码块,详见本章后续内容)结尾。第一个语句计算表达式12*30的值,并把结果存储到一个局部变量x中,该变量是一个整数类型。第二个语句调用Console类的WriteLine方法,将变量x的值输出到屏幕上的文本窗口中。
方法(method)是由一系列语句(语句块)组成的行为。语句块由一对大括号,及其中的零个或者多个语句组成。示例中定义了一个名为Main的方法:
static void Main()
{
...
}
编写高层函数来调用低层函数可令程序得到简化。可重构(refactor)该程序,使用一个可重用的方法来计算某个整数乘以12的结果:
using System;
class Test
{
static void Main()
{
Console.WriteLine (FeetToInches (30)); // 360
Console.WriteLine (FeetToInches (100)); // 1200
}
static int FeetToInches (int feet)
{
int inches = feet * 12;
return inches;
}
}
方法可以通过参数来接受调用者输入的数据,并通过指定的返回类型向调用者返回输出数据。上述代码中定义了一个FeetToInches方法,该方法有一个用于输入英尺的参数和一个用于输出英寸的返回类型:
static int FeetToInches (int feet ) {...}
示例中的字面量30和100是传递给FeedToInches方法的实际参数(argument)。而Main方法后的括号中是空的,因而没有任何参数。其返回类型是void说明它不向调用者返回任何值:
static void Main()
C#将Main方法作为程序执行的默认入口点。Main方法也可以返回整数值(而非void)从而将其返回给程序的执行环境(非0返回值往往代表一个错误)。Main方法还可以接受一个字符串数组作为参数(数组中包含了传递给可执行程序的任何实际参数)。例如:
static int Main (string[] args) {...}
数组(例如string[])是固定数量的某种特定类型元素的集合。数组由元素类型和它后面的方括号指定。相关内容将在2.7节介绍。
方法是C#中的诸多种类的函数之一。另一种函数是我们用来执行乘法运算的*运算符。其他的函数种类还包括构造器、属性、事件、索引器和终结器。
本例将两个方法组合到一个类中。类由函数成员和数据成员组成,并形成面向对象的构件块。Console类将处理命令行输入/输出功能的成员,例如WriteLine方法,聚集在一起。Test类则由Main方法和FeetToInches两个方法组成。类是类型之一,我们将在2.3节中介绍它。
程序的最外层将类型组织到了命名空间中。为了使System命名空间在应用程序中生效,并能够使用Console类,需要使用using指令。应将所有的类定义在TestPrograms命名空间中,例如:
using System;
namespace TestPrograms
{
class Test {...}
class Test2 {...}
}
.NET Framework由若干嵌套的命名空间组织而成。例如,以下命名空间中包含处理文本的类型:
using System.Text;
使用using指令仅仅是为了方便;也可以使用命名空间加类型名称(例如System.Text.StringBuilder)这种完整限定名称来引用类型。
编译
C#编译器将一系列.cs扩展名的源代码文件编译成程序集。程序集是.NET中的最小打包和部署单元。程序集可以是一个应用程序或者是一个库。普通的控制台程序或Windows应用程序是一个.exe文件,包含一个Main方法。而库是一个.dll文件,即一个没有入口点的.exe文件。库可以被应用程序或其他的库调用(引用)。.NET Framework就是由一系列库组成的。
C#编译器是csc.exe。我们既可以使用像Visual Studio这样的IDE来编译程序,也可以在命令行中手动调用csc命令编译C#程序(编译器本身通过库调用,详情参见第27章)。如需手动编译C#程序,首先将程序保存成文件(例如MyFirstProgram.cs),然后进入命令行并调用csc命令(csc位于%ProgramFiles(X86)%msbuild14.0bin)译注1,如下所示:
csc MyFirstProgram.cs
这个命令将生成名为MyFirstProgram.exe的应用程序。
奇怪的是,.NET Framework 4.6和4.7仍然包含C# 5的编译器。若要使用C# 7命令行编译器,必须安装Visual Studio 2017或MSBuild 15。
如需生成库(.dll),请使用如下命令:
csc /target:library MyFirstProgram.cs
我们将在第18章详细介绍程序集。
2.2 语法
C#的语法基于C和C++语法。在本节中,我们将使用下面的程序介绍C#的语法元素:
using System;
class Test
{
static void Main()
{
int x = 12 * 30;
Console.WriteLine (x);
}
}
2.2.1 标识符和关键字
标识符是程序员为类、方法、变量等选择的名字。下面按顺序列出了上述示例中的标识符:
System Test Main x Console WriteLine
标识符必须是一个完整的词,它由以字母和下划线开头的Unicode字符构成。C#标识符是区分大小写的。通常约定参数、局部变量以及私有字段应该以小写字母开头(例如myVariable),而其他类型的标识符则应该以大写字母开头(例如MyMethod)。
关键字是对编译器有特殊意义的名字。以下是示例中用到的关键字:
using class static void int
大部分关键字是保留的,这意味着它们不能用作标识符。以下列出了C#的所有关键字:
2.2.1.1 避免冲突
如果希望用关键字作为标识符,需在关键字前面加上@前缀。例如:
class class {...} // Illegal
class @class {...} // Legal
@并不是标识符的一部分,所以@myVariable和myVariable是一样的。
@前缀在调用使用其他拥有不同关键字的.NET语言编写的库时非常有用。
2.2.1.2 上下文关键字
一些关键字是上下文相关的,它们有时不用添加@前缀就可以用作标识符。它们是:
add dynamic in orderby var
ascending equals into partial when
async from join remove where
await get let select yield
by global nameof set
descending group on value
使用上下文关键字作为标识符时,应避免与上下文中的关键字混淆。
2.2.2 字面量、标点与运算符
字面量是静态的嵌入程序中的原始数据片段。上述示例中用到的字面量有12和30。
标点有助于划分程序结构。以下是示例中用到的标点:
{ } ;
大括号可将多条语句形成一个语句块。
分号用于结束一条语句。(但语句块并不需要分号。)这意味着语句也可以放在多行中:
Console.WriteLine
(1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10);
运算符用于改变和结合表达式。大多数C#运算符都以符号表示,例如乘法运算符*。我们将在本章后续内容中详细介绍运算符。在上述示例中出现的运算符有:
. () * =
点号(.)表示某个对象的成员(或者数字字面量的小数点)。括号在声明或调用方法时使用;空括号在方法没有参数时使用。(本章后续还会介绍括号的其他的用途。)等号用于赋值操作。(双等号==用于相等比较,请参见本章后续内容)。
2.2.3 注释
C#提供了两种方式源代码文档:单行注释和多行注释。单行注释由双斜线开始,到本行结束为止。例如:
int x = 3; // Comment about assigning 3 to x
多行注释由/*开始,由*/结束。例如:
int x = 3; /* This is a comment that
spans two lines */
注释也可以嵌入XML文档标签中,我们将在4.17节中介绍。
2.3 类型基础
类型是值的蓝图。在以下示例中,我们使用了两个int类型的字面量12和30,并声明了一个int类型的变量x:
static void Main()
{
int x = 12 * 30;
Console.WriteLine (x);
}
变量表示一个存储位置,其中的值可能会不断变化。与之对应,常量总是表示同一个值(后面会详细介绍):
const int y = 360;
C#中的所有值都是某一种类型的实例。值或者变量所包含的可能取值均由其类型决定。
2.3.1 预定义类型示例
预定义类型是指那些由编译器特别支持的类型。int就是一种预定义类型,它代表一系列能够存储在32位内存中的整数集,其范围从-231到231-1,并且它是该范围内数字字面量的默认类型。我们能够对int类型的实例执行算术运算等功能:
int x = 12 * 30;
C#中的另一个预定义类型是string。string类型表示字符序列,例如“.NET”或者“http://oreilly.com” 。我们可以通过以下方式调用函数来操作字符串:
string message = "Hello world";
string upperMessage = message.ToUpper();
Console.WriteLine (upperMessage); // HELLO WORLD
int x = 2015;
message = message + x.ToString();
Console.WriteLine (message); // Hello world2015
预定义类型bool只有两种值:true和false。bool类型通常与if语句一起控制条件分支执行流程。例如:
bool simpleVar = false;
if (simpleVar)
Console.WriteLine ("This will not print");
int x = 5000;
bool lessThanAMile = x < 5280;
if (lessThanAMile)
Console.WriteLine ("This will print");
在C#中,预定义类型(也称为内置类型)拥有相应的C#关键字。在.NET Framework中的System命名空间下也包含了很多不是预定义类型的重要类型(例如DateTime)。
2.3.2 自定义类型示例
我们能使用简单函数来构造复杂函数,同样也可以使用基元类型来构建复杂类型。以下示例定义了一个名为UnitConverter的自定义类型。这个类将作为单位转换的蓝图:
using System;
public class UnitConverter
{
int ratio; // Field
public UnitConverter (int unitRatio) {ratio = unitRatio; } // Constructor
public int Convert (int unit) {return unit * ratio; } // Method
}
class Test
{
static void Main()
{
UnitConverter feetToInchesConverter = new UnitConverter (12);
UnitConverter milesToFeetConverter = new UnitConverter (5280);
Console.WriteLine (feetToInchesConverter.Convert(30)); // 360
Console.WriteLine (feetToInchesConverter.Convert(100)); // 1200
Console.WriteLine (feetToInchesConverter.Convert(
milesToFeetConverter.Convert(1))); // 63360
}
}
2.3.2.1 类型的成员
类型包含数据成员和函数成员。UnitConverter的数据成员是ratio字段,函数成员是Convert方法和UnitConverter的构造器。
2.3.2.2 预定义类型和自定义类型
C#的优点之一是其中的预定义类型和自定义类型非常相近。预定义int类型是整数的蓝图。它保存了32位的数据,提供像ToString这种函数成员来使用这些数据。类似地,我们自定义的UnitConverter类型也是单位转换的蓝图。它保存比率数据,还提供了函数成员来使用这些数据。
2.3.2.3 构造器和实例化
将类型实例化即可创建数据。预定义类型可以简单地通过字面量进行实例化,例如12或"Hello World"。而自定义类型则需要使用new运算符来创建实例。以下的语句创建并声明了一个UnitConverter类型的实例:
UnitConverter feetToInchesConverter = new UnitConverter (12);
使用new运算符后会立刻实例化一个对象,调用对象的构造器进行初始化。构造器的定义像方法一样,不同的是方法名和返回类型简化为所属的类型名称:
public class UnitConverter
{
...
public UnitConverter (int unitRatio) { ratio = unitRatio; }
...
}
2.3.2.4 实例与静态成员
由类型的实例操作的数据成员和函数成员称为实例成员。UnitConverter的Convert方法和int的ToString方法就是实例成员的例子。在默认情况下,成员就是实例成员。
那些不是由类型的实例操作,而是由类型本身操作的数据成员和函数成员必须标记为static。Test.Main和Console.WriteLine就是静态方法。事实上,Console类是一个静态类,它的所有成员都是静态的。由于Console类型无法实例化,因此控制台将在整个应用程序内共享使用。
我们来对比实例成员和静态成员。在下面的代码中,实例字段Name属于特定的Panda实例,而Population则属于所有Panda实例:
public class Panda
{
public string Name; // Instance field
public static int Population; // Static field
public Panda (string n) // Constructor
{
Name = n; // Assign the instance field
Population = Population + 1; // Increment the static Population field
}
}
下面的代码创建了两个Panda实例,先打印它们的名字,再打印总数:
using System;
class Test
{
static void Main()
{
Panda p1 = new Panda ("Pan Dee");
Panda p2 = new Panda ("Pan Dah");
Console.WriteLine (p1.Name); // Pan Dee
Console.WriteLine (p2.Name); // Pan Dah
Console.WriteLine (Panda.Population); // 2
}
}
如果试图求p1.Population或者Panda.Name的值,则会生成一个编译时错误。
2.3.2.5 public关键字
public关键字将成员公开给其他类。在上述示例中,如果Panda类中的Name字段没有标记为公有(public)的,那么它就是私有的,且Test类就不能访问它。将成员标记为public就是类型的通信手段:“这就是我想让其他类型看到的,而其他的都是我私有的实现细节。”在面向对象的术语中,称之为类的公有成员封装了私有成员。
2.3.3 转换
C#可以转换兼容类型的实例。转换始终会根据一个已经存在的值创建一个新的值。转换可以是隐式或显式的:隐式转换自动发生而显式转换需要强制转换。在以下的示例中,我们把一个int隐式转换为long类型(其存储位数是int的两倍);并将一个int显式转换为一个short类型(其存储位数是int的一半):
int x = 12345; // int is a 32-bit integer
long y = x; // Implicit conversion to 64-bit integer
short z = (short)x; // Explicit conversion to 16-bit integer
隐式转换只有在以下条件都满足时才能进行:
编译器能确保转换总能成功。
没有信息在转换过程中丢失。注1
相对地,只有在满足下列条件时才需要显式转换:
编译器不能保证转换总是成功。
信息在转换过程中有可能丢失。
(如果编译器可以确定某个转换一定会失败,那么这两种转换都无法执行。包含泛型的转换在特定情况下也会失败,请参见3.9.11节)
以上的数值转换是C#中内置的。C#还支持引用转换、装箱转换(见第3章)与自定义转换(请参见4.14节)。对于自定义转换,编译器并没有强制要求上述规则,因此没有良好设计的类型有可能在转换时出现意想不到的效果。
2.3.4 值类型与引用类型
所有的C#类型可以分为以下几类:
- 值类型
- 引用类型
- 泛型参数
- 指针类型
本节将介绍值类型和引用类型。泛型参数将在3.9节介绍,指针类型将在4.15节中介绍。
值类型包含大多数的内置类型(具体包括所有数值类型、char类型和bool类型)以及自定义的struct类型和enum类型。
引用类型包含所有的类、数组、委托和接口类型。(这其中包括了预定义的string类型。)
值类型和引用类型最根本的不同在于它们在内存中的处理方式。
2.3.4.1 值类型
值类型的变量或常量的内容仅仅是一个值。例如,内置的值类型int的内容是32位的数据。
可以通过struct关键字定义自定义值类型(参见图2-1):
public struct Point { public int X; public int Y; }
或采用更简短的形式:
public struct Point { public int X, Y; }
值类型实例的赋值总是会进行实例复制。例如:
static void Main()
{
Point p1 = new Point();
p1.X = 7;
Point p2 = p1; // Assignment causes copy
Console.WriteLine (p1.X); // 7
Console.WriteLine (p2.X); // 7
p1.X = 9; // Change p1.X
Console.WriteLine (p1.X); // 9
Console.WriteLine (p2.X); // 7
}
图2-2中展示了p1和p2拥有不同的存储空间。
2.3.4.2 引用类型
引用类型比值类型复杂,它由两部分组成:对象和对象引用。引用类型变量或常量中的内容是一个含值对象的引用。以下示例将前面例子中的Point类型重新书写,令其成为一个类而非struct(请参见图2-3):
public class Point { public int X, Y; }
给引用类型变量赋值只会复制引用,而不是对象实例。这允许不同变量指向同一个对象,而值类型通常不会出现这种情况。如果Point是一个类,那么若重复之前的示例,则对p1的操作就会影响到p2了:
static void Main()
{
Point p1 = new Point();
p1.X = 7;
Point p2 = p1; // Copies p1 reference
Console.WriteLine (p1.X); // 7
Console.WriteLine (p2.X); // 7
p1.X = 9; // Change p1.X
Console.WriteLine (p1.X); // 9
Console.WriteLine (p2.X); // 9
}
图2-4展示了p1和p2是指向同一对象的两个不同引用。
2.3.4.3 Null
引用可以赋值为字面量null,表示它并不指向任何对象:
class Point {...}
...
Point p = null;
Console.WriteLine (p == null); // True
// The following line generates a runtime error
// (a NullReferenceException is thrown):
Console.WriteLine (p.X);
相对地,值类型通常不能有null的取值:
struct Point {...}
...
Point p = null; // Compile-time error
int x = null; // Compile-time error
C#中也有一种代表值类型为null的结构,称为可空(nullable)类型(请参见4.7节)。
2.3.4.4 存储开销
值类型实例占用的内存大小就是存储其字段所需的内存。例如,Point需要占用8字节的内存:
struct Point
{
int x; // 4 bytes
int y; // 4 bytes
}
从技术上说,CLR用整数倍字段的大小(最大到8字节)来分配内存地址。因此,下面的定义的对象实际上会占用16字节的内存(第一个字段的7个字节被“浪费了”):
struct A { byte b; long l; }
这种行为可以通过指定StructLayout属性来重写(请参见25.6节)。
引用类型要求为引用和对象单独分配存储空间。对象除占用了和字段一样的字节数外,还需要额外的管理空间开销。管理开销的精确值本质上属于.NET运行时实现的细节,但最少也需要8个字节来存储该对象的类型的键,以及一些诸如多线程锁的状态、是否可以被垃圾回收器固定等临时信息。根据.NET运行时是工作在32位抑或64位平台上,每一个对象的引用都需要额外的4到8个字节。
2.3.5 预定义类型分类
C#中的预定义类型有:
值类型
-
数值
- 有符号整数(sbyte、short、int、long)
- 无符号整数(byte、ushort、uint、ulong)
- 实数(float、double、decimal)
- 逻辑值(bool)
- 字符(char)
引用类型
- 字符串(string)
- 对象(object)
C#的预定义类型又称为框架类型,它们都在System命名空间下。下面的两个语句仅在拼写上有所不同:
int i = 5;
System.Int32 i = 5;
在CLR中,除了decimal之外的一系列预定义值类型属于基元类型。之所以将其称为基元类型是因为它们在编译过的代码中有直接的指令支持。而这种指令通常翻译为底层处理器直接支持的指令。例如:
// Underlying hexadecimal representation
int i = 7; // 0x7
bool b = true; // 0x1
char c = 'A'; // 0x41
float f = 0.5f; // uses IEEE floating-point encoding
System.IntPtr以及System.UIntPtr类型也是基元类型(参见第25章)。
2.4 数值类型
表2-1中列出了C#中所有的预定义数值类型。
在整数类型中,int和long是最基本的类型,C#和运行时都对其有良好的支持。其他的整数类型通常用于实现互操作性或存储空间使用效率要求更高的情况。
在实数类型中,float和double称为浮点类型,注2并通常用于科学和图形计算。decimal类型通常用于金融计算这种十进制下的高精度算术运算。
2.4.1 数值字面量
整数类型字面量可以使用十进制或者十六进制表示。十六进制辅以0x前缀。例如:
int x = 127;
long y = 0x7F;
从C# 7开始,可以在数值字面量的任意位置加入下划线以方便阅读:
int million = 1_000_000;
C# 7还可以用0b前缀使用二进制表示数值:
var b = 0b1010_1011_1100_1101_1110_1111;
实数字面量可以用小数或指数表示,例如:
double d = 1.5;
double million = 1E06;
2.4.1.1 数值字面量类型接口
默认情况下,编译器将数值字面量推断为double类型或是整数类型。
- 如果这个字面量包含小数点或者指数符号(E),那么它是double。
- 否则,这个字面量的类型就是下列能满足这个字面量的第一个类型:int、uint、long和ulong。
例如:
Console.WriteLine ( 1.0.GetType()); // Double (double)
Console.WriteLine ( 1E06.GetType()); // Double (double)
Console.WriteLine ( 1.GetType()); // Int32 (int)
Console.WriteLine ( 0xF0000000.GetType()); // UInt32 (uint)
Console.WriteLine (0x100000000.GetType()); // Int64 (long)
2.4.1.2 数值后缀
数值后缀显式定义了字面量的类型。后缀可以是下列小写或大写字母:
一般U和L后缀是很少需要的。因为uint、long和ulong总是可以推断出来或者从int类型隐式转换过来:
long i = 5; // Implicit lossless conversion from int literal to long
从技术上讲,后缀D是多余的。因为所有带小数点的字面量都会推定为double类型。因此可以直接在数值字面量后加上小数点:
double x = 4.0;
后缀F和M是最有用的,并应该在指定float或decimal字面量时使用。下面的语句不能在没有后缀F时进行编译。这是因为4.5会认定为double而double是无法隐式转换为float的:
float f = 4.5F;
同样的规则也适用于decimal字面量:
decimal d = -1.23M; // Will not compile without the M suffix.
我们将在下一节详细介绍数值转换的语义。
2.4.2 数值转换
2.4.2.1 整数类型到整数类型的转换
整数类型转换在目标类型能够表示源类型的所有可能值时是隐式转换,否则需要显式转换。例如:
int x = 12345; // int is a 32-bit integer
long y = x; // Implicit conversion to 64-bit integral type
short z = (short)x; // Explicit conversion to 16-bit integral type
2.4.2.2 浮点类型到浮点类型的转换
double能表示所有可能的float值,因此float能隐式转换为double。反之则必须是显式转换。
2.4.2.3 浮点类型到整数类型的转换
所有整数类型可以隐式转换为浮点数类型:
int i = 1;
float f = i;
反之则必须是显式转换:
int i2 = (int)f;
将浮点数转换为整数时,小数点后的数值将被截去而不会舍入。静态类System.Convert提供了在不同值类型之间转换的舍入方法(见第6章)。
将大的整数类型隐式转换为浮点类型会保留数值部分,但是有时会丢失精度。这是因为浮点类型虽然拥有比整数类型更大的数值,但是有时其精度却比整数类型要小。以下代码用一个更大的数重复上述示例展示了这种精度丢失的情况:
int i1 = 100000001;
float f = i1; // Magnitude preserved, precision lost
int i2 = (int)f; // 100000000
2.4.2.4 decimal类型转换
所有的整数类型都能隐式转换为decimal类型。这是因为decimal可以表示所有可能的C#整数类型值。其他所有的数值类型转换为decimal或从decimal类型进行转换都必须是显式转换。
2.4.3 算术运算符
算式运算符(+、-、*、/、%)可用于除8位和16位的整数类型之外的所有数值类型:
+ Addition
- Subtraction
* Multiplication
/ Division
% Remainder after division
2.4.4 自增和自减运算符
自增和自减运算符(++、--)分别给数值类型加1或者减1。具体要将其放在变量之前还是之后则取决于需要得到变量在自增/自减之前的值还是之后的值。例如:
int x = 0, y = 0;
Console.WriteLine (x++); // Outputs 0; x is now 1
Console.WriteLine (++y); // Outputs 1; y is now 1
2.4.5 特殊整数类型运算
(整数类型指int、uint、long、ulong、short、ushort、byte和sbyte。)
2.4.5.1 整数除法
整数类型的除法运算总是会截断余数(向0舍入)。用一个值为0的变量做除数将产生运行时错误(DivideByZeroException):
int a = 2 / 3; // 0
int b = 0;
int c = 5 / b; // throws DivideByZeroException
用字面量式常量0做除数将产生编译时错误。
2.4.5.2 整数溢出
在运行时执行整数类型的算术运算可能会造成溢出。默认情况下,溢出会默默地发生而不会抛出任何异常,且其溢出行为是“循环”的。就像是运算发生在更大的整数类型上,而超出部分的进位就被丢弃了。例如,减少最小的整数值将产生最大的整数值:
int a = int.MinValue;
a--;
Console.WriteLine (a == int.MaxValue); // True
2.4.5.3 整数运算溢出检查运算符
checked运算符的作用是:在运行时当整数类型表达式或语句超过相应类型的算术限制时不再默默地溢出,而是抛出OverflowException。checked运算符可在有++、--、+、-(一元运算符和二元运算符)、*、/和整数类型间显式转换运算符的表达式中起作用。
checked运算符对double和float类型没有作用(它们会溢出为特殊的“无限”值,这会在后面介绍),对decimal类型也没有作用(这种类型总是会进行溢出检查)。
checked运算符能和表达式或语句块结合使用,例如:
int a = 1000000;
int b = 1000000;
int c = checked (a * b); // Checks just the expression.
checked // Checks all expressions
{ // in statement block.
...
c = a * b;
...
}
可以在编译时加上/checked+命令行开关(在Visual Studio中,可以在“Advanced Build Settings”中设置)来默认使程序中所有表达式都进行算术溢出检查。如果你只想禁用指定表达式或语句的溢出检查,可以用unchecked运算符。例如,下面的代码即使在编译时使用了/checked+也不会抛出异常:
int x = int.MaxValue;
int y = unchecked (x + 1);
unchecked { int z = x + 1; }
2.4.5.4 常量表达式的溢出检查
无论是否使用了/checked编译器开关,编译时的表达式计算总会检查溢出,除非应用了unchecked运算符。
int x = int.MaxValue + 1; // Compile-time error
int y = unchecked (int.MaxValue + 1); // No errors
2.4.5.5 位运算符
C#支持以下的位运算符:
2.4.6 8位和16位整数类型
8位和16位整数类型指byte、sbyte、short、ushort。这些类型自己并不具备算术运算符,所以C#隐式地将它们转换为所需的更大一些的类型。当试图把运算结果赋给一个小的整数类型时会产生编译时错误:
short x = 1, y = 1;
short z = x + y; // Compile-time error
在以上情况下,x和y会隐式转换成int以便进行加法运算。因此运算结果也是int,它不能隐式转换回short(因为这可能会造成数据丢失)。我们必须使用显式转换才能令其通过编译:
short z = (short) (x + y); // OK
2.4.7 特殊的float和double值
不同于整数类型,浮点类型包含某些特定运算需要特殊对待的值。这些特殊的值是NaN(Not a Number,非数字)、+∞、-∞和-0。float和double类型包含表示NaN、+∞、-∞值的常量。其他的常量还有MaxValue、MinValue以及Epsilon。例如:
Console.WriteLine (double.NegativeInfinity); // -Infinity
double和float类型的特殊值的常量表如下:
非零值除以零的结果是无穷大。例如:
Console.WriteLine ( 1.0 / 0.0); // Infinity
Console.WriteLine (-1.0 / 0.0); // -Infinity
Console.WriteLine ( 1.0 / -0.0); // -Infinity
Console.WriteLine (-1.0 / -0.0); // Infinity
零除以零或无穷大减去无穷大的结果是NaN。例如:
Console.WriteLine ( 0.0 / 0.0); // NaN
Console.WriteLine ((1.0 / 0.0) - (1.0 / 0.0)); // NaN
使用比较运算符(==)时,一个NaN的值永远也不等于其他的值,甚至不等于其他的NaN值:
Console.WriteLine (0.0 / 0.0 == double.NaN); // False
必须使用float.IsNaN或double.IsNaN方法来判断一个值是否为NaN:
Console.WriteLine (double.IsNaN (0.0 / 0.0)); // True
但使用object.Equals方法时,两个NaN却是相等的:
Console.WriteLine (object.Equals (0.0 / 0.0, double.NaN)); // True
NaN在表示特殊值时很有用。在WPF中,double.NaN表示值为“Automatic”(自动)。另一种表示方法是使用可空类型(nullable,见第4章)。还可以使用一个包含数值类型和一个额外字段的自定义结构体(见第3章)。
float和double遵循IEEE 754格式类型规范。几乎所有的处理器都原生支持此规范。如需此类型行为的详细信息,可参考http://www.ieee.org。
2.4.8 double和decimal的对比
double类型在科学计算(例如计算空间坐标)时很有用。decimal类型在金融计算和计算那些“人为”的而非真实世界度量的结果时很有用。下面是这两种类型的不同之处:
2.4.9 实数的舍入误差
float和double在内部都是基于2来表示数值的。因此只有基于2表示的数值才能够精确表示。事实上,这意味着大多数有小数部分的字面量(它们都基于10)将无法精确表示。例如:
float tenth = 0.1f; // Not quite 0.1
float one = 1f;
Console.WriteLine (one - tenth * 10f); // -1.490116E-08
这就是为什么float和double不适合金融运算。相反,decimal基于10,它能够精确表示基于10的数值(也包括它的因数,基于2和基于5的数值)。因为实数的字面量都是基于10的,所以decimal能够精确表示像0.1这样的数。然而,double和decimal都不能精确表示那些基于10的循环小数:
decimal m = 1M / 6M; // 0.1666666666666666666666666667M
double d = 1.0 / 6.0; // 0.16666666666666666
这将会导致积累性的舍入误差:
decimal notQuiteWholeM = m+m+m+m+m+m; // 1.0000000000000000000000000002M
double notQuiteWholeD = d+d+d+d+d+d; // 0.99999999999999989
这也将影响相等和比较操作:
Console.WriteLine (notQuiteWholeM == 1M); // False
Console.WriteLine (notQuiteWholeD < 1.0); // True
2.5 布尔类型和运算符
C#中的bool(System.Boolean类型的别名)类型是能赋值为true和false字面量的逻辑值。
尽管布尔类型的值仅需要1位的存储空间,但是运行时却使用了1字节内存空间。这是因为字节是运行时和处理器能够有效使用的最小单位。为避免在使用数组时的空间浪费,.NET Framework在System.Collections命令空间下提供了BitArray类,其中的每一个布尔值仅占用一位。
2.5.1 布尔类型转换
bool类型不能转换为数值类型,反之亦然。
2.5.2 相等和比较运算符
==和!=用于判断任意类型的相等与不等,并总是返回一个bool值。注3值类型通常有很简单的相等定义:
int x = 1;
int y = 2;
int z = 1;
Console.WriteLine (x == y); // False
Console.WriteLine (x == z); // True
对于引用类型,默认情况下相等是基于引用的,而不是底层对象的实际值(更多内容请参见第6章):
public class Dude
{
public string Name;
public Dude (string n) { Name = n; }
}
...
Dude d1 = new Dude ("John");
Dude d2 = new Dude ("John");
Console.WriteLine (d1 == d2); // False
Dude d3 = d1;
Console.WriteLine (d1 == d3); // True
相等和比较运算符==、!=、<、>、>=和<=可用于所有的数值类型,但是用于实数时要特别注意(请参见2.4.9节)。比较运算符也可以用于枚举(enum)类型的成员,它比较的是表示枚举成员的整数值,我们将在3.7节中介绍。
我们将在4.14节、6.11节和6.12节中详细介绍相等和比较运算符。
2.5.3 条件运算符
&&和||运算符用于判断与和或条件。它们常常与代表“非”的!运算符一起使用。在下面的例子中,UseUmbrella方法在下雨或阳光充足(雨伞可以保护我们不会经受日晒雨淋),以及无风(因为雨伞在有风的时候不起作用)的时候返回true:
static bool UseUmbrella (bool rainy, bool sunny, bool windy)
{
return !windy && (rainy || sunny);
}
&&和||运算符会在可能的情况下执行短路计算。在上面的例子中。如果刮风,(rainy || sunny)将不会计算。短路计算在某些表达式中是非常必要的,它可以允许如下表达式运行而不会抛出NullReferenceException异常:
if (sb != null && sb.Length > 0) ...
&和|运算符也可用于判断与和或条件:
return !windy & (rainy | sunny);
不同之处是&和|运算符不支持短路计算。因此它们很少用于替代条件运算符。
不同于C和C++,&和|运算符在用于布尔表达式时执行布尔比较(非短路计算)。而&和|运算符仅在用于数值运算时才执行位运算。
(三元)条件运算符
三元条件运算符(由于它是唯一一个使用三个操作数的运算符,因此也简称为三元运算符)使用q ? a : b的形式。它在q为真时计算a否则计算b。例如:
static int Max (int a, int b)
{
return (a > b) ? a : b;
}
条件运算符在LINQ语句中尤其有用(见第8章)。
2.6 字符串和字符
C#的char(System.Char类型的别名)类型表示一个Unicode字符并占用两个字节。char字面量应位于两个单引号之间:
char c = 'A'; // Simple character
转义字符指那些不能用字面量表示或解释的字符。转义字符由反斜线和一个表示特殊含义的字符组成,例如:
char newLine = 'n';
char backSlash = '\';
表2-2中列出了转义字符序列。
u(或x)转义字符通过4位十六进制代码来指定任意Unicode字符:
char copyrightSymbol = '\u00A9';
char omegaSymbol = '\u03A9';
char newLine = '\u000A';
2.6.1 char转换
从char类型到数值类型的隐式转换只在这个数值类型可以容纳无符号short类型时有效。对于其他的数值类型,则需要显式转换。
2.6.2 字符串类型
C#中的字符串类型(System.String类型的别名,我们将在第6章详细介绍)表示不可变的Unicode字符序列。字符串字面量应位于两个双引号(")之间:
string a = "Heat";
string类型是引用类型而不是值类型。但是它的相等运算符却遵守值类型的语义。
string a = "test";
string b = "test";
Console.Write (a == b); // True
对char字面量有效的转义字符在字符串中同样有效:
string a = "Here's a tab:\t";
这意味着当需要一个反斜杠时,需要写两次才可以:
string a1 = "\\\\server\\fileshare\\helloworld.cs";
为避免这种情况,C#引入了原意字符串字面量。原意字符串字面量要加@前缀,它不支持转义字符。下面的原意字符串和之前的字符串是一样的。
string a2 = @"\\server\fileshare\helloworld.cs";
原意字符串可以贯穿多行:
string escaped = "First Line\r\nSecond Line";
string verbatim = @"First Line
Second Line";
// True if your IDE uses CR-LF line separators:
Console.WriteLine (escaped == verbatim);
原意字符串中需要用两个双引号来表示一个双引号字符:
string xml = @"<customer id=""123""></customer>";
2.6.2.1 连接字符串
+运算符可连接两个字符串:
string s = "a" + "b";
如果操作数之一是非字符串值,则会调用其ToString方法,例如:
string s = "a" + 5; // a5
重复使用+运算符来构建字符串是低效的。更好的解决方案是使用System.Text.StringBuilder类型(将在第6章介绍)。
2.6.2.2 字符串插值(C# 6)
以$字符为前缀的字符串称为插值字符串。插值字符串可以在大括号内包含表达式:
int x = 4;
Console.Write ($"A square has {x} sides"); // Prints: A square has 4 sides
大括号内可以是任意类型的合法C#表达式。C#会调用其ToString方法或等价方法将表达式转换为字符串。若要更改表达式的格式,可以使用冒号,并继以格式字符串(我们将在6.1.2.7节中对其详细介绍):
string s = $"255 in hex is {byte.MaxValue:X2}"; // X2 = 2-digit Hexadecimal
// Evaluates to "25 in hex is FF"
插值字符串只能是在单行内声明,除非使用原意字符串运算符。需要注意,$运算符必须在@运算符之前:
int x = 2;
string s = $@"this spans {
x} lines";
若要在插值字符串中表示大括号字符只需书写两个大括号字符即可。
2.6.2.3 字符串比较
string类型不支持<和>的比较。必须使用字符串的CompareTo方法。这部分内容将在第6章介绍。
2.7 数组
数组是固定数量的特定类型的变量集合(称为元素)。为了实现高效访问,数组中的元素总是存储在连续的内存块中。
C#中的数组用元素类型后加方括号的方式表示。例如:
char[] vowels = new char[5]; // Declare an array of 5 characters
方括号也可用于检索数组,通过位置访问特定元素:
vowels[0] = 'a';
vowels[1] = 'e';
vowels[2] = 'i';
vowels[3] = 'o';
vowels[4] = 'u';
Console.WriteLine (vowels[1]); // e
因为数组索引从0开始,所以上面的语句打印“e”。我们可以使用for循环语句来遍历数组中的每一个元素。下面例子中的for循环将把整数变量i从0到4进行循环:
for (int i = 0; i < vowels.Length; i++)
Console.Write (vowels[i]); // aeiou
数组的Length属性返回数组中的元素数目。一旦数组创建完毕,它的长度将不能更改。System.Collection命名空间和子命名空间提供了可变长度数组和字典等高级数据结构。
数组初始化表达式可以让你一次性声明并填充数组:
char[] vowels = new char[] {'a','e','i','o','u'};
或者简写为:
char[] vowels = {'a','e','i','o','u'};
所有的数组都继承自System.Array类。它为所有数组提供了通用服务。这些成员包括与数组类型无关的获取和设定数组元素的方法,我们将在第7章介绍。
2.7.1 默认数组元素初始化
创建数组时其元素总会用默认值初始化。类型的默认值是按位取0的内存表示的值。例如,若定义一个整数数组,由于int是值类型,因此该操作会在连续的内存块中分配1000个整数。每一个元素的默认值都是0:
int[] a = new int[1000];
Console.Write (a[123]); // 0
值类型和引用类型的区别
数组元素的类型是值类型还是引用类型对其性能有重要的影响。若元素类型是值类型,每个元素的值将作为数组的一部分进行分配,例如:
public struct Point { public int X, Y; }
...
Point[] a = new Point[1000];
int x = a[500].X; // 0
若Point是类,创建数组则仅仅分配了1000个空引用:
public class Point { public int X, Y; }
...
Point[] a = new Point[1000];
int x = a[500].X; // Runtime error, NullReferenceException
为避免这个错误,我们必须在实例化数组之后显式实例化1000个Point实例:
Point[] a = new Point[1000];
for (int i = 0; i < a.Length; i++) // Iterate i from 0 to 999
a[i] = new Point(); // Set array element i with new point
不论元素是何种类型,数组本身总是引用类型对象。例如,下面的语句是合法的:
int[] a = null;
2.7.2 多维数组
多维数组分为两种类型:矩形数组和锯齿形数组。矩形数组代表n维的内存块,而锯齿形数组则是数组的数组。
2.7.2.1 矩形数组
矩形数组声明时用逗号分隔每个维度。下面的语句声明了一个矩形二维数组,它的维度是3×3:
int[,] matrix = new int[3,3];
数组的GetLength方法返回给定维度的长度(从0开始):
for (int i = 0; i < matrix.GetLength(0); i++)
for (int j = 0; j < matrix.GetLength(1); j++)
matrix[i,j] = i * 3 + j;
矩形数组可以按照如下方式进行初始化(以下示例创建了一个和上例一样的数组):
int[,] matrix = new int[,]
{
{0,1,2},
{3,4,5},
{6,7,8}
};
2.7.2.2 锯齿形数组
锯齿形数组在声明时用一对方括号对表示每个维度。以下例子声明了一个最外层维度是3的二维锯齿形数组:
int[][] matrix = new int[3][];
有意思的是,这里是new int[3][]而非new int[][3]。Eric Lippert有一篇文章详细解释了这个问题,请参见:http://albahari.com/jagged 。
不同于矩形数组,锯齿形数组内层维度在声明时并未指定。每个内层数组都可以是任意长度。每一个内层数组都隐式初始化为null而不是一个空数组,因此都需要手动创建:
for (int i = 0; i < matrix.Length; i++)
{
matrix[i] = new int[3]; // Create inner array
for (int j = 0; j < matrix[i].Length; j++)
matrix[i][j] = i * 3 + j;
}
锯齿形数组可以按照如下方式进行初始化(以下例子创建了一个和前面例子类似的数组,但是在最后额外追加了一个元素):
int[][] matrix = new int[][]
{
new int[] {0,1,2},
new int[] {3,4,5},
new int[] {6,7,8,9}
};
2.7.3 简化数组初始化表达式
有两种方式可以简化数组初始化表达式。第一种是省略new运算符和类型限制条件:
char[] vowels = {'a','e','i','o','u'};
int[,] rectangularMatrix =
{
{0,1,2},
{3,4,5},
{6,7,8}
};
int[][] jaggedMatrix =
{
new int[] {0,1,2},
new int[] {3,4,5},
new int[] {6,7,8}
};
第二种是使用var关键字,使编译器隐式确定局部变量类型:
var i = 3; // i is implicitly of type int
var s = "sausage"; // s is implicitly of type string
// Therefore:
var rectMatrix = new int[,] // rectMatrix is implicitly of type int[,]
{
{0,1,2},
{3,4,5},
{6,7,8}
};
var jaggedMat = new int[][] // jaggedMat is implicitly of type int[][]
{
new int[] {0,1,2},
new int[] {3,4,5},
new int[] {6,7,8}
};
数组类型可以进一步应用隐式类型转换规则:可以直接在new关键字之后忽略类型限定符,而由编译器推断数组类型:
var vowels = new[] {'a','e','i','o','u'}; // Compiler infers char[]
为了使上述机制工作,数组中的所有元素必须能够隐式转换为一种类型(至少有一个元素是目标类型,而且最终只有一种最佳类型),例如:
var x = new[] {1,10000000000}; // all convertible to long
2.7.4 边界检查
运行时会为所有数组的索引操作进行边界检查。如果是用了不合法的索引值,就会抛出IndexOutOfRangeException异常。
int[] arr = new int[3];
arr[3] = 1; // IndexOutOfRangeException thrown
与Java一样,数组边界检查对类型安全和调试简化都是非常必要的。
通常,边界检查的性能开销很小,且JIT(即时编译器)也会对其进行优化。例如,在进入循环之前预先确保所有的索引操作的安全性来避免每次循环中都进行检查。另外C#还提供了unsafe代码来显式绕过边界检查(请参见4.15节)。
2.8 变量和参数
变量表示存储着可变值的存储位置。变量可以是局部变量、参数(value、ref或out),字段(实例或静态)以及数组元素。
2.8.1 栈和堆
栈和堆是存储变量和常量的地方。它们分别具有不同的生命周期语义。
2.8.1.1 栈
栈是存储局部变量和参数的内存块。逻辑上,栈会在函数进入和退出时增加或减少。考虑下面的方法(为了避免干扰,该范例省略了输入参数检查):
static int Factorial (int x)
{
if (x == 0) return 1;
return x * Factorial (x-1);
}
这个方法是递归的,即它调用其自身。每一次进入这个方法的时候,就在栈上分配一个新的int,而每一次离开这个方法,就会释放一个int。
2.8.1.2 堆
堆是保存对象(例如引用类型的实例)的内存块。新创建的对象会分配在堆上并返回其引用。程序执行过程中,堆就被新创建的对象不断填充。.NET运行时的垃圾回收器会定期从堆上释放对象,因此应用程序不会内存不足。只要对象没有被“存活”的对象引用,它就可以被释放。
下面的例子中,我们创建了一个StringBuilder对象并将其引用赋值给ref1变量,之后在其中写入内容。StringBuilder对象在后续没有使用的情况下可立即被垃圾回收器释放。
之后,我们创建另一个StringBuilder对象赋值给ref2,再将引用复制给ref3。虽然ref2之后便不再使用,但是由于ref3保持着同一个StringBuilder对象的引用,因此在ref3使用完毕之前它不会被垃圾回收器回收。
using System;
using System.Text;
class Test
{
static void Main()
{
StringBuilder ref1 = new StringBuilder ("object1");
Console.WriteLine (ref1);
// The StringBuilder referenced by ref1 is now eligible for GC.
StringBuilder ref2 = new StringBuilder ("object2");
StringBuilder ref3 = ref2;
// The StringBuilder referenced by ref2 is NOT yet eligible for GC.
Console.WriteLine (ref3); // object2
}
}
值类型的实例(和对象的引用)就存储在变量声明的地方。如果声明为类的字段或数组的元素,则该实例会存储在堆上。
C#中无法像C++那样显式删除对象。未引用的对象最终将被垃圾回收器回收。
静态字段也会存储在堆上。与分配在堆上的对象(可以被垃圾回收)不同,这些变量一直存活直至应用程序域结束。
2.8.2 明确赋值
C#强制执行明确赋值策略。实践中这意味着在unsafe上下文之外无法访问未初始化的内存。明确赋值有三种含义:
局部变量在读取之前必须赋值。
调用方法时必须提供函数的实际参数(除非标记为可选参数,参见2.8.4.7节)。
运行时将自动初始化其他变量(例如字段和数组元素)。
例如,以下示例将产生编译时错误:
static void Main()
{
int x;
Console.WriteLine (x); // Compile-time error
}
字段和数组元素会自动初始化为其类型的默认值。以下代码输出0,就是因为数组元素会隐式赋为默认值:
static void Main()
{
int[] ints = new int[2];
Console.WriteLine (ints[0]); // 0
}
以下代码输出0,因为字段会隐式赋值为默认值:
class Test
{
static int x;
static void Main() { Console.WriteLine (x); } // 0
}
2.8.3 默认值
所有类型的实例都有默认值。预定义类型的默认值是按位取0的内存表示的值。
default关键字可用于获得任意类型的默认值(这对泛型非常有用,我们将在第3章介绍泛型)。
decimal d = default (decimal);
自定义值类型(例如struct)的默认值等同于每一个字段都取其默认值。
2.8.4 参数
方法可以有一连串的参数(parameter)。在调用方法时必须为这些参数提供实际值(argument)。在下面的例子中,Foo方法仅有一个类型为int的参数p:
static void Foo (int p)
{
p = p + 1; // Increment p by 1
Console.WriteLine (p); // Write p to screen
}
static void Main()
{
Foo (8); // Call Foo with an argument of 8
}
使用ref和out修饰符可以控制参数的传递方式:
2.8.4.1 按值传递参数
默认情况下,C#中的参数默认按值传递,这是最常用的方式。这意味着在将参数值传递给方法时将创建一份参数值的副本:
class Test
{
static void Foo (int p)
{
p = p + 1; // Increment p by 1
Console.WriteLine (p); // Write p to screen
}
static void Main()
{
int x = 8;
Foo (x); // Make a copy of x
Console.WriteLine (x); // x will still be 8
}
}
为p赋一个新的值并不会改变x的值,因为p和x分别存储在不同的内存位置中。
按值传递引用类型参数复制的是引用而非对象本身。下例中,Foo方法中的StringBuilder对象和Main方法中实例化的是同一个对象,但是它们的引用是不同的。换句话说,变量sb和fooSB是引用同一个StringBuilder对象的不同变量:
class Test
{
static void Foo (StringBuilder fooSB)
{
fooSB.Append ("test");
fooSB = null;
}
static void Main()
{
StringBuilder sb = new StringBuilder();
Foo (sb);
Console.WriteLine (sb.ToString()); // test
}
}
由于fooSB是引用的一份副本,因此将它赋值为null并不会把sb也赋值为null(然而,如果在声明和调用fooSB时使用ref修饰符,则sb会变成null)。
2.8.4.2 ref修饰符
在C#中,若按引用传递参数则应使用ref参数修饰符。下面的例子中,p和x指向同一块内存位置:
class Test
{
static void Foo (ref int p)
{
p = p + 1; // Increment p by 1
Console.WriteLine (p); // Write p to screen
}
static void Main()
{
int x = 8;
Foo (ref x); // Ask Foo to deal directly with x
Console.WriteLine (x); // x is now 9
}
}
现在给p赋新值将改变x的值。注意ref修饰符在声明和调用时都是必须的,注4这样就清楚地表明了程序将如何执行。
ref修饰符对于实现交换方法是必要的。(3.9节将介绍如何编写适用于所有类型的交换方法):
class Test
{
static void Swap (ref string a, ref string b)
{
string temp = a;
a = b;
b = temp;
}
static void Main()
{
string x = "Penn";
string y = "Teller";
Swap (ref x, ref y);
Console.WriteLine (x); // Teller
Console.WriteLine (y); // Penn
}
}
无论参数是引用类型还是值类型,都可以按引用传递或按值传递。
2.8.4.3 out修饰符
out参数和ref参数类似,但在以下几点上不同:
不需要在传入函数之前进行赋值。
必须在函数结束之前赋值。
out修饰符通常用于获得方法的多个返回值,例如:
class Test
{
static void Split (string name, out string firstNames,
out string lastName)
{
int i = name.LastIndexOf (' ');
firstNames = name.Substring (0, i);
lastName = name.Substring (i + 1);
}
static void Main()
{
string a, b;
Split ("Stevie Ray Vaughan", out a, out b);
Console.WriteLine (a); // Stevie Ray
Console.WriteLine (b); // Vaughan
}
}
与ref参数一样,out参数按引用传递。
2.8.4.4 out变量及丢弃变量(C# 7)
从C# 7开始,允许在调用含有out参数的方法时直接声明变量。因此我们可以将前面例子中的Main方法简化为:
static void Main()
{
Split ("Stevie Ray Vaughan", out string a, out string b);
Console.WriteLine (a); // Stevie Ray
Console.WriteLine (b); // Vaughan
}
当调用含有多个out参数的方法时,若我们并非关注所有参数的值,那么可以使用下划线来“丢弃”那些不感兴趣的参数:
Split ("Stevie Ray Vaughan", out string a, out _); // Discard the 2nd param
Console.WriteLine (a);
此时,编译器会将下划线认定为一个特殊的符号,称为丢弃符号。一次调用可以引入多个丢弃符号。假设SomeBigMethod定义了7个out参数,除第4个之外其他的全部被丢弃:
SomeBigMethod (out _, out _, out _, out int x, out _, out _, out _);
出于向后兼容性的考虑,如果在作用域内,已经有一个名为下划线的变量的话,这个语言特性就失效了。
string _;
Split ("Stevie Ray Vaughan", out string a, _); // Will not compile
2.8.4.5 按引用传递的含义
按引用传递参数是为现存变量的存储位置起了一个别名而不是创建一个新的存储位置。下面的例子中,变量x和y代表相同的实例:
class Test
{
static int x;
static void Main() { Foo (out x); }
static void Foo (out int y)
{
Console.WriteLine (x); // x is 0
y = 1; // Mutate y
Console.WriteLine (x); // x is 1
}
}
2.8.4.6 params修饰符
params参数修饰符只能修饰方法中的最后一个参数,它能够使方法接受任意数量的指定类型参数。参数类型必须声明为数组,例如:
class Test
{
static int Sum (params int[] ints)
{
int sum = 0;
for (int i = 0; i < ints.Length; i++)
sum += ints[i]; // Increase sum by ints[i]
return sum;
}
static void Main()
{
int total = Sum (1, 2, 3, 4);
Console.WriteLine (total); // 10
}
}
也可以将普通的数组提供给params参数。因此Main方法的第一行从语义上等价于:
int total = Sum (new int[] { 1, 2, 3, 4 } );
2.8.4.7 可选参数
从C# 4.0开始,方法、构造器和索引器(见第3章)中都可以声明可选参数。只要在参数声明中提供默认值,这个参数就是可选参数:
void Foo (int x = 23) { Console.WriteLine (x); }
可选参数在调用方法时可以省略:
Foo(); // 23
默认参数23实际上传递给了可选参数x,编译器在调用端将值23传递到编译好的代码中。上例中调用Foo的代码语义上等价于:
Foo (23);
这是由于编译器在用到可选参数的地方使用默认值代替可选参数而造成的结果。
若public方法对其他程序集可见,则在添加可选参数时双方均需重新编译,就像参数是必须提供的一样。
可选参数的默认值必须由常量表达式或者无参数的值类型构造器指定,可选参数不能标记为ref或者out。
必填参数必须在可选参数方法声明和调用之前出现(params参数例外,它总是最后出现)。下面的例子将1显式传递给参数x,而将默认值0传递给参数y:
void Foo (int x = 0, int y = 0) { Console.WriteLine (x + ", " + y); }
void Test()
{
Foo(1); // 1, 0
}
相反,如需传递默认值给x而传递显式值给y,则必须联合使用命名参数与可选参数。
2.8.4.8 命名参数
除了用位置确定参数外,还可以用名称来确定参数,例如:
void Foo (int x, int y) { Console.WriteLine (x + ", " + y); }
void Test()
{
Foo (x:1, y:2); // 1, 2
}
命名参数能够以任意顺序出现。下面两种调用Foo的方式在语义上是一样的:
Foo (x:1, y:2);
Foo (y:2, x:1);
上述写法的不同之处的是参数表达式将按调用端参数出现的顺序计算。通常,这种不同只出现在非独立的拥有副作用的表达式中。例如下面的代码将输出0,1:
int a = 0;
Foo (y: ++a, x: --a); // ++a is evaluated first
当然,在实践中应当避免这种代码。
命名参数和可选参数可以混合使用:
Foo (1, y:2);
然而这里有一个限制,按位置传递的参数必须出现在命名参数之前。因此不能这样调用Foo方法:
Foo (x:1, 2); // Compile-time error
命名参数在和可选参数混合使用时特别有效。例如,考虑下面的方法:
void Bar (int a = 0, int b = 0, int c = 0, int d = 0) { ... }
我们可以用以下方式在调用它的时候仅提供d的值:
Bar (d:3);
这个特性在调用COM API时非常有用。我们将在5.2.17节详细讨论。
2.8.5 引用局部变量(C# 7)
C# 7添加了一个令人费解的特性:即定义一个用于引用数组中某一个元素或对象中某一个字段的局部变量:
int[] numbers = { 0, 1, 2, 3, 4 };
ref int numRef = ref numbers [2];
在这个例子中,numRef是numbers[2]的引用。当我们更改numRef的值时,也相应更改了数组中的元素值:
numRef *= 10;
Console.WriteLine (numRef); // 20
Console.WriteLine (numbers [2]); // 20
引用局部变量的目标只能是数组的元素、对象字段或者局部变量;而不能是属性(见第3章)。引用局部变量适用于在特定的场景下进行小范围优化,并通常和引用返回值合并使用。
2.8.6 引用返回值(C# 7)
从方法中返回的引用局部变量,称为引用返回值
(ref return):
static string X = "Old Value";
static ref string GetX() => ref X; // This method returns a ref
static void Main()
{
ref string xRef = ref GetX(); // Assign result to a ref local
xRef = "New Value";
Console.WriteLine (X); // New Value
}
2.8.7 var隐式类型局部变量
我们通常会在一步中完成变量的声明和初始化。如果编译器能够从初始化表达式中推断出变量的类型,就能够使用var关键字(C# 3.0引入)来代替类型声明,例如:
var x = "hello";
var y = new System.Text.StringBuilder();
var z = (float)Math.PI;
它们完全等价于:
string x = "hello";
System.Text.StringBuilder y = new System.Text.StringBuilder();
float z = (float)Math.PI;
因为是完全等价的,所以隐式类型变量仍是静态类型的。例如,下面的代码将产生编译时错误:
var x = 5;
x = "hello"; // Compile-time error; x is of type int
当无法直接从变量声明语句中看出变量类型的时候,var关键字将降低代码的可读性。例如:
Random r = new Random();
var x = r.Next();
变量x的类型是什么呢?
在4.9节我们将介绍必须使用var的情况。
2.9 表达式和运算符
表达式本质上是值。最简单的表达式是常量和变量。表达式能够用运算符进行转换和组合。运算符用一个或多个输入操作数来输出一个新的表达式。
以下是一个常量表达式的例子:
12
可以使用*运算符来组合两个操作数(字面量表达式12和30):
12 * 30
由于操作数本身可以是表达式,所以可以创造出更复杂的表达式。例如,(12 * 30)是下面的表达式中的操作数。
1 + (12 * 30)
C#中的运算符分为一元运算符、二元运算符和三元运算符,这取决于它们使用的操作数数量(1、2或3)。二元运算符总是使用中缀表示法,运算符在两个操作数之间。
2.9.1 基础表达式
基础表达式由C#语言内置的基础运算符表达式组成,例如:
Math.Log (1)
这个表达式由两个基础表达式构成,第一个表达式执行成员查找(用.运算符),而第二个表达式执行方法调用(用()运算符)。
2.9.2 空表达式
空表达式(void expression)是没有值的表达式,例如:
Console.WriteLine (1)
因为空表达式没有值,所以不能作为操作数来创建更复杂的表达式:
1 + Console.WriteLine (1) // Compile-time error
2.9.3 赋值表达式
赋值表达式用=运算符将另一个表达式的值赋值给变量,例如:
x = x * 5
赋值表达式不是一个空表达式,它的值即是被赋予的值。因此赋值表达式可以和其他表达式组合。下面的例子中,表达式将2赋给x并将10赋给y:
y = 5 * (x = 2)
这种类型的表达式也可以用于初始化多个值:
a = b = c = d = 0
复合赋值运算符是由其他运算符组合而成的简化运算符。例如:
x *= 2 // equivalent to x = x * 2
x <<= 1 // equivalent to x = x << 1
(这条规则的例外是第4章中介绍的事件(event)。它的+=和-=运算符会特殊对待并映射至事件的add和remove访问器上。)
2.9.4 运算符优先级和结合性
当表达式包含多个运算符时,运算符的优先级和结合性决定了计算的顺序。优先级高的运算符先于优先级低的运算符执行。如果运算符的优先级相同,那么运算符的结合性决定计算的顺序。
2.9.4.1优先级
以下的表达式:
1 + 2 * 3
由于*的优先级高于+,因此它将按下面的方式计算:
1 + (2 * 3)
2.9.4.2 左结合运算符
二元运算符(除了赋值运算符、Lambda运算符、null合并运算符)是左结合运算符。换句话说,它们是从左往右计算。例如,下面的表达式:
8 / 4 / 2
由于左结合性将按如下的方式计算:
( 8 / 4 ) / 2 // 1
插入括号可以改变实际的计算顺序:
8 / ( 4 / 2 ) // 4
2.9.4.3 右结合运算符
赋值运算符、Lambda运算符、null合并运算符和条件运算符是右结合的。换句话说,它们是从右往左计算。右结合性允许多重赋值,例如:
x = y = 3;
首先将3赋值给y,之后再将表达式(3)的结果赋值给x。
2.9.5 运算符表
表2-3按照优先级列出了C#的运算符。同一类别的运算符的优先级相同。我们将在4.14节介绍用户可重载的运算符。
2.10 null运算符
C#提供了两个简化null处理的运算符:null合并运算符和null条件运算符。
2.10.1 null合并运算符
null合并运算符写作??。它的意思是“如果操作数不是null则结果为操作数,否则结果为一个默认的值。”例如:
string s1 = null;
string s2 = s1 ?? "nothing"; // s2 evaluates to "nothing"
如果左侧的表达式不是null,则右侧的表达式将不会进行计算。null合并运算符同样适用于可空的值类型(请参见4.7节)。
2.10.2 null条件运算符(C# 6)
C# 6中引入了“?.”运算符,称为null条件运算符或者Elvis运算符(从Elvis表情符号而来)。该运算符可以像标准的“.”运算符那样访问成员以及调用方法。当运算符的左侧为null的时候,该表达式的运算结果也是null而不会抛出NullReferenceException异常。
System.Text.StringBuilder sb = null;
string s = sb?.ToString(); // No error; s instead evaluates to null
上述代码的最后一行等价于:
string s = (sb == null ? null : sb.ToString());
当遇到null时,Elvis运算符将直接略过表达式的其余部分。在接下来的例子中,即使ToString()和ToUpper()方法使用的是标准的.运算符,s的值仍然为null。
System.Text.StringBuilder sb = null;
string s = sb?.ToString().ToUpper(); // s evaluates to null without error
仅当直接的左侧运算数有可能为null的时候才有必要重复使用Elvis运算符。因此下述表达式在x和y都为null时依然是健壮的:
x?.y?.z
它等价于(唯一的不同在于x.y仅执行了一次):
x == null ? null
: (x.y == null ? null : x.y.z)
需要指出,最终的表达式必须能够处理null,因此下面的范例是不合法的:
System.Text.StringBuilder sb = null;
int length = sb?.ToString().Length; // Illegal : int cannot be null
我们可以使用可空类型(请参见4.7节)来修正这个问题。例如:
int? length = sb?.ToString().Length; // OK : int? can be null
我们也可以使用null条件运算符调用返回值为void的方法:
someObject?.SomeVoidMethod();
如果someObject为null,则表达式将“不执行指令”而不会抛出NullReference-Exception异常。
null条件运算符可以和第3章介绍的常用类型成员一起使用,包括方法、字段、属性和索引器。而且它也可以和null合并运算符配合使用。
System.Text.StringBuilder sb = null;
string s = sb?.ToString() ?? "nothing"; // s evaluates to "nothing"
2.11 语句
函数是语句构成的。语句按照出现的字面顺序执行。语句块则是在大括号({})中的一系列语句。
2.11.1 声明语句
声明语句可以声明新的变量,并可以用表达式初始化变量。声明语句以分号结束。可以用逗号分隔的列表声明多个同类型的变量。例如:
string someWord = "rosebud";
int someNumber = 42;
bool rich = true, famous = false;
常量的声明和变量类似,但是它的值无法在声明之后改变,并且变量初始化必须和声明同时进行(请参见3.1.8节):
const double c = 2.99792458E08;
c += 10; // Compile-time Error
局部变量
局部变量和常量的作用范围在当前的语句块中。在当前语句块或者嵌套的语句块中声明另一个同名的局部变量是不行的,例如:
static void Main()
{
int x;
{
int y;
int x; // Error - x already defined
}
{
int y; // OK - y not in scope
}
Console.Write (y); // Error - y is out of scope
}
变量的作用范围是它所在的整个代码块(前向和后向都包含)。这意味着虽然在变量或常量声明之前引用它是不合法的,但即使将示例中的x初始化移动到方法的末尾我们也会得到相同的错误,这个奇怪的规则和C++是不同的。
2.11.2 表达式语句
表达式语句既是表达式也是合法的语句。表达式语句必须改变状态或者执行某些可能改变状态的调用。状态改变本质上指改变一个变量的值。可能的表达式语句有:
赋值表达式(包括自增和自减表达式)
(有返回值的和没有返回值的)方法调用表达式
对象实例化表达式
例如:
// Declare variables with declaration statements:
string s;
int x, y;
System.Text.StringBuilder sb;
// Expression statements
x = 1 + 2; // Assignment expression
x++; // Increment expression
y = Math.Max (x, 5); // Assignment expression
Console.WriteLine (y); // Method call expression
sb = new StringBuilder(); // Assignment expression
new StringBuilder(); // Object instantiation expression
当调用有返回值的构造器或方法时,并不一定要使用它的返回值。因此除非构造器或方法改变了某些状态,否则以下这些语句完全没有用处:
new StringBuilder(); // Legal, but useless
new string ('c', 3); // Legal, but useless
x.Equals (y); // Legal, but useless
2.11.3 选择语句
C#使用以下几种机制来有条件地控制程序的执行流:
选择语句(if、switch)
条件语句(?:)
循环语句(while、do..while、for和foreach)
本节介绍了两种最简单的结构:if-else语句和switch语句。
2.11.3.1 if语句
if语句在bool表达式为真时执行其中的语句。例如:
if (5 < 2 * 3)
Console.WriteLine ("true"); // true
if中的语句可以是代码块:
if (5 < 2 * 3)
{
Console.WriteLine ("true");
Console.WriteLine ("Let's move on!");
}
2.11.3.2 else子句
if语句之后可以紧跟else子句:
if (2 + 2 == 5)
Console.WriteLine ("Does not compute");
else
Console.WriteLine ("False"); // False
在else子句中,能嵌套另一个if语句:
if (2 + 2 == 5)
Console.WriteLine ("Does not compute");
else
if (2 + 2 == 4)
Console.WriteLine ("Computes"); // Computes
2.11.3.3 用大括号改变执行流
else子句总是与它之前的语句块中紧邻的未配对的if语句结合。例如:
if (true)
if (false)
Console.WriteLine();
else
Console.WriteLine ("executes");
语义上等价于:
if (true)
{
if (false)
Console.WriteLine();
else
Console.WriteLine ("executes");
}
可以通过改变大括号的位置来改变执行流:
if (true)
{
if (false)
Console.WriteLine();
}
else
Console.WriteLine ("does not execute");
大括号可以明确表明结构,这能提高嵌套if语句的可读性(虽然编译器并不需要)。需要特别指出的是下面的模式:
static void TellMeWhatICanDo (int age)
{
if (age >= 35)
Console.WriteLine ("You can be president!");
else if (age >= 21)
Console.WriteLine ("You can drink!");
else if (age >= 18)
Console.WriteLine ("You can vote!");
else
Console.WriteLine ("You can wait!");
}
这里,我们参照其他语言的elseif结构(以及C#本身的#elif预处理指令)来安排if和else语句。Visual Studio自动识别这个模式并保持代码缩进。从语义上讲,紧跟着每一个if语句的else语句从功能上都是嵌套在else子句之中的。
2.11.3.4 switch语句
switch语句可以根据变量可能的取值来转移程序的执行。switch语句可以拥有比嵌套if语句更加简洁的代码,因为switch语句仅仅需要一次表达式计算,例如:
static void ShowCard (int cardNumber)
{
switch (cardNumber)
{
case 13:
Console.WriteLine ("King");
break;
case 12:
Console.WriteLine ("Queen");
break;
case 11:
Console.WriteLine ("Jack");
break;
case -1: // Joker is -1
goto case 12; // In this game joker counts as queen
default: // Executes for any other cardNumber
Console.WriteLine (cardNumber);
break;
}
}
这个例子演示了最一般的情形,即针对常量的switch。当指定常量时,只能指定内置的整数类型、bool、char、enum类型以及string类型。
每一个case子句结束时必须使用某种跳转指令显式指定下一个执行点(除非你的代码本身就是一个无限循环)。这些跳转指令有:
break(跳转到switch语句的最后)
goto case x(跳转到另外一个case子句)
goto default(跳转到default子句)
其他的跳转语句,例如return、throw、continue或者goto label
当多个值要执行相同的代码时,可以按照顺序列出共同的case条件:
switch (cardNumber)
{
case 13:
case 12:
case 11:
Console.WriteLine ("Face card");
break;
default:
Console.WriteLine ("Plain card");
break;
}
switch语句的这种特性可以写出比多个if-else更加简洁的代码。
2.11.3.5 带有模式的switch语句(C# 7)
C# 7开始支持按类型switch:
static void Main()
{
TellMeTheType (12);
TellMeTheType ("hello");
TellMeTheType (true);
}
static void TellMeTheType (object x) // object allows any type.
{
switch (x)
{
case int i:
Console.WriteLine ("It's an int!");
Console.WriteLine ($"The square of {i} is {i * i}");
break;
case string s:
Console.WriteLine ("It's a string");
Console.WriteLine ($"The length of {s} is {s.Length}");
break;
default:
Console.WriteLine ("I don't know what x is");
break;
}
}
(object类型允许其变量为任何类型。这部分内容将在3.2节和3.3节详细讨论。)
每一个case子句都指定了一种需要匹配的类型和一个变量(模式变量),如果类型匹配成功就对变量赋值。和常量不同,对于类型的使用并没有任何限制。
还可以使用when关键字对case进行预测,例如:
switch (x)
{
case bool b when b == true: // Fires only when b is true
Console.WriteLine ("True!");
break;
case bool b:
Console.WriteLine ("False!");
break;
}
case子句的顺序会影响类型的选择(这和选择常量的情况有些不同)。如果交换case的顺序,则上述示例可以得到完全不同的结果(事实上,上述程序甚至无法编译,因为编译器发现第二个case子句是永远不会执行的)。但default子句是一个例外,不论它出现在什么地方都会在最后才执行。
堆叠多个case子句也是没有问题的。下面的例子中,Console.WriteLine会在任何浮点类型的值大于1000时执行:
switch (x)
{
case float f when f > 1000:
case double d when d > 1000:
case decimal m when m > 1000:
Console.WriteLine ("We can refer to x here but not f or d or m");
break;
}
上述例子中,编译器仅允许在when子句中使用模式变量f、d和m。当调用Console.WriteLine时,我们并不清楚到底三个模式变量中的哪一个会被赋值,因而编译器会将它们放在作用域之外。
除此以外,还可以混合使用常量选择和模式选择,甚至可以选择null值:
case null:
Console.WriteLine ("Nothing here");
break;
2.11.4 迭代语句
C#中可以使用while、do-while、for和foreach语句重复执行一系列语句。
2.11.4.1 while和do-while循环
while循环在其bool表达式为true的情况下重复执行循环体中的代码。这个表达式在循环体执行之前进行检测。例如:
int i = 0;
while (i < 3)
{
Console.WriteLine (i);
i++;
}
OUTPUT:
0
1
2
do-while循环在功能上不同于while循环的地方是它在语句块执行之后才检查表达式的值(保证语句块至少执行过一次)。以下将上述例子用do-while循环重新书写了一遍:
int i = 0;
do
{
Console.WriteLine (i);
i++;
}
while (i < 3);
2.11.4.2 for循环
for循环就像一个有特殊子句的while循环。这些特殊子句用于初始化和迭代循环变量。for循环有以下三个子句:
for (initialization-clause; condition-clause; iteration-clause)
statement-or-statement-block
初始化子句:在循环之前执行,初始化一个或多个迭代变量。
条件子句:它是一个bool表达式,当其为true时,将执行循环体。
迭代子句:在每次语句块迭代之后执行,通常用于更新迭代变量。
例如,下面的例子将打印0到2的数字:
for (int i = 0; i < 3; i++)
Console.WriteLine (i);
下面的代码将打印前10个斐波那契数(每一个数都是前面两个数的和):
for (int i = 0, prevFib = 1, curFib = 1; i < 10; i++)
{
Console.WriteLine (prevFib);
int newFib = prevFib + curFib;
prevFib = curFib; curFib = newFib;
}
for语句的这三个部分都可以省略,因而可以通过下面的代码来实现无限循环(也可以用while (true)来代替):
for (;;)
Console.WriteLine ("interrupt me");
2.11.4.3 foreach循环
foreach语句遍历可枚举对象的每一个元素。大多数C#和.NET Framework中表示集合或元素列表的类型都是可枚举的。例如,数组和字符串都是可枚举的。以下示例从头到尾枚举了字符串中的每一个字符:
foreach (char c in "beer") // c is the iteration variable
Console.WriteLine (c);
OUTPUT:
b
e
e
r
我们将在4.6节详细介绍。
2.11.5 跳转语句
C#的跳转语句有break、continue、goto、return和throw。
跳转语句仍然遵守try语句的可靠性规则(参见4.5节)。这意味着:
到try语句块之外的跳转总是在达到目标之前执行try语句的finally语句块。
跳转语句不能从finally语句块内跳到块外(除非使用throw)。
2.11.5.1 break语句
break语句用于结束迭代或switch语句的执行:
int x = 0;
while (true)
{
if (x++ > 5)
break ; // break from the loop
}
// execution continues here after break
...
2.11.5.2 continue语句
continue语句放弃循环体中其后的语句,继续下一轮迭代。例如,以下的循环跳过了偶数:
for (int i = 0; i < 10; i++)
{
if ((i % 2) == 0) // If i is even,
continue; // continue with next iteration
Console.Write (i + " ");
}
OUTPUT: 1 3 5 7 9
2.11.5.3 goto语句
goto语句将执行点转移到语句块中的指定标签处。格式如下:
goto statement-label;
或用于switch语句内:
goto case case-constant; // (Only works with constants, not patterns)
标签语句仅仅是代码块中的占位符,位于语句之前,用冒号后缀表示。下面的代码模拟for循环来遍历从1到5的数字:
int i = 1;
startLoop:
if (i <= 5)
{
Console.Write (i + " ");
i++;
goto startLoop;
}
OUTPUT: 1 2 3 4 5
goto case case-constant会将执行点转移到switch语句块中的另一个条件上(参见本章2.11.3.4节)。
2.11.5.4 return语句
return语句用于退出方法。如果这个方法有返回值,则必须返回方法指定返回类型的表达式。
static decimal AsPercentage (decimal d)
{
decimal p = d * 100m;
return p; // Return to the calling method with value
}
return语句能够出现在方法的任意位置(除finally块中)。
2.11.5.5 throw语句
throw语句抛出异常来表示有错误发生(参见4.5节):
if (w == null)
throw new ArgumentNullException (...);
2.11.6其他语句
using语句用一种优雅的语法在finally块中调用实现了IDisposable接口对象的Dispose方法。(请参见4.5节和12.1节)
C#重载了using关键字,使它在不同上下文中有不同的含义。特别注意using指令和using语句是不同的。
lock语句是调用Mintor类型的Enter和Exit方法的简化写法。(请参见第14章和第23章。)
2.12 命名空间
命名空间是一系列类型名称的领域。通常情况下,类型组织在分层的命名空间里,既避免了命名冲突又更容易查找。例如,处理公钥加密的RSA类型就定义在如下的命名空间下:
System.Security.Cryptography
命名空间组成了类型名的基本部分。下面代码调用了RSA类型的Create方法:
System.Security.Cryptography.RSA rsa =
System.Security.Cryptography.RSA.Create();
命名空间是独立于程序集的。程序集是像.exe或者.dll一样的部署单元(参见第18章)。命名空间并不影响成员的public、internal、private的可见性。
namespace关键字为其中的类型定义了命名空间。例如:
namespace Outer.Middle.Inner
{
class Class1 {}
class Class2 {}
}
命名空间中的“.”表明了嵌套命名空间的层次结构。下面的代码在语义上和上一个例子是等价的:
namespace Outer
{
namespace Middle
{
namespace Inner
{
class Class1 {}
class Class2 {}
}
}
}
类型可以用完全限定名称(fully qualified name),也就是包含从外到内的所有命名空间的名称,来指定。例如,上述例子中,可以使用Outer.Middle.Inner.Class1来指代Class1。
如果类型没有在任何命名空间中定义,则它存在于全局命名空间(global namespace)中。全局命名空间也包含了顶级命名空间,就像前面例子中的Outer命名空间。
2.12.1 using指令
using指令用于导入命名空间。这是避免使用完全限定名称来指代某种类型的快捷方法。以下例子导入了前一个例子的Outer.Middle.Inner命名空间:
using Outer.Middle.Inner;
class Test
{
static void Main()
{
Class1 c; // Don't need fully qualified name
}
}
在不同命名空间中定义相同类型名称是合法的(而且通常是需要的)。然而,这种做法通常出现在开发者不会同时导入两个命名空间时。在.NET Framework中的TextBox类就是一个典型的例子。这个名称在System.Windows.Controls(WPF)和System.Web.UI.WebControls(ASP.NET)命名空间中都有定义。
2.12.2 using static指令(C# 6)
从C# 6开始,我们不仅可以导入命名空间还可以使用using static指令导入特定的类型。这样就可以类型接使用类型静态成员而不需要指定类型的名称了。在接下来的例子中,我们这样调用Console类的静态方法WriteLine:
using static System.Console;
class Test
{
static void Main() { WriteLine ("Hello"); }
}
using static指令将类型的可访问的静态成员,包括字段、属性以及嵌套类型(参见第3章),全部导入进来。同时,该指令也支持导入枚举类型的成员(见第3章)。因此如果导入了以下的枚举类型:
using static System.Windows.Visibility;
我们就可以直接使用Hidden而不是Visibility.Hidden了:
var textBox = new TextBox { Visibility = Hidden }; // XAML-style
C#编译器还没有聪明到可以基于上下文来推断出正确的类型,因此在导入多个静态类型导致二义性时会发生编译错误。
2.12.3 命名空间中的规则
2.12.3.1 名称范围
外层命名空间中声明的名称能够直接在内层命名空间中使用。以下示例中的Class1在Inner中不需要限定名称:
namespace Outer
{
class Class1 {}
namespace Inner
{
class Class2 : Class1 {}
}
}
使用统一命名空间分层结构中不同分支的类型需要使用部分限定名称。在下面的例子中,SalesReport类继承Common.ReportBase:
namespace MyTradingCompany
{
namespace Common
{
class ReportBase {}
}
namespace ManagementReporting
{
class SalesReport : Common.ReportBase {}
}
}
2.12.3.2 名称隐藏
如果相同类型名称同时出现在内层和外层命名空间中,则内层类型优先。如果要使用外层命名空间中的类型,必须使用它的完全限定名称。
namespace Outer
{
class Foo { }
namespace Inner
{
class Foo { }
class Test
{
Foo f1; // = Outer.Inner.Foo
Outer.Foo f2; // = Outer.Foo
}
}
}
所有的类型名在编译时都会转换为完全限定名称。中间语言(IL)代码不包含非限定名称和部分限定名称。
2.12.3.3 重复的命名空间
只要命名空间内的类型名称不冲突就可以重复声明同一个命名空间:
namespace Outer.Middle.Inner
{
class Class1 {}
}
namespace Outer.Middle.Inner
{
class Class2 {}
}
上述例子也可以分为两个不同的源文件,并将每一个类都编译到不同的程序集中。
源文件1:
namespace Outer.Middle.Inner
{
class Class1 {}
}
源文件2:
namespace Outer.Middle.Inner
{
class Class2 {}
}
2.12.3.4 嵌套的using指令
我们能够在命名空间中嵌套使用using指令,这样可以控制using指令在命名空间声明中的作用范围。在以下例子中,Class1在一个命名空间中可见,但是在另一个命名空间中不可见:
namespace N1
{
class Class1 {}
}
namespace N2
{
using N1;
class Class2 : Class1 {}
}
namespace N2
{
class Class3 : Class1 {} // Compile-time error
}
2.12.4 类型和命名空间别名
导入命名空间可能导致类型名称的冲突,因此可以只导入需要的特定类型而不是整个命名空间,并给它们创建别名。例如:
using PropertyInfo2 = System.Reflection.PropertyInfo;
class Program { PropertyInfo2 p; }
下面代码为整个命名空间创建别名:
using R = System.Reflection;
class Program { R.PropertyInfo p; }
2.12.5 高级命名空间特性
2.12.5.1 外部别名
使用外部别名就可以引用两个完全限定名称相同的类型(例如,命名空间和类型名称都相同)。这种特殊情况只在两种类型来自不同的程序集时才会出现。请考虑下面的例子:
程序库1:
// csc target:library /out:Widgets1.dll widgetsv1.cs
namespace Widgets
{
public class Widget {}
}
程序库2:
// csc target:library /out:Widgets2.dll widgetsv2.cs
namespace Widgets
{
public class Widget {}
}
应用程序:
// csc /r:Widgets1.dll /r:Widgets2.dll application.cs
using Widgets;
class Test
{
static void Main()
{
Widget w = new Widget();
}
}
这个应用程序无法编译,因为Widget类型是有二义性的。外部别名则可以消除应用程序中的二义性:
// csc /r:W1=Widgets1.dll /r:W2=Widgets2.dll application.cs
extern alias W1;
extern alias W2;
class Test
{
static void Main()
{
W1.Widgets.Widget w1 = new W1.Widgets.Widget();
W2.Widgets.Widget w2 = new W2.Widgets.Widget();
}
}
2.12.5.2 命名空间别名限定符
之前提到,内层命名空间中的名称隐藏外层命名空间中的名称。但是,有时即使使用类型的完全限定名也无法解决冲突。请考虑下面的例子:
namespace N
{
class A
{
public class B {} // Nested type
static void Main() { new A.B(); } // Instantiate class B
}
}
namespace A
{
class B {}
}
Main方法将会实例化嵌套类B或命名空间A中的类B。编译器总是给当前命名空间中的标识符以更高的优先级;在这种情况下,将会实例化嵌套类B。
要解决这样的冲突,可以使用如下的方式限定命名空间中的名称:
全局命名空间,即所有命名空间的根命名空间(由上下文关键字global指定)
一系列的外部别名
“::”用于限定命名空间别名。下面的例子中,我们使用了全局命名空间(这通常出现在自动生成的代码中,以避免名称冲突)
namespace N
{
class A
{
static void Main()
{
System.Console.WriteLine (new A.B());
System.Console.WriteLine (new global::A.B());
}
public class B {}
}
}
namespace A
{
class B {}
}
以下例子使用了别名限定符(2.12.5.1一节中例子的修改版本):
extern alias W1;
extern alias W2;
class Test
{
static void Main()
{
W1::Widgets.Widget w1 = new W1::Widgets.Widget();
W2::Widgets.Widget w2 = new W2::Widgets.Widget();
}
}