第2章 C# 语言基础
第2章 C# 语言基础
难点提纲
2.2 语法
2.2.1 标识符和关键字
以下是 C#的所有关键字:
abstract | do | in | protected | true |
---|---|---|---|---|
as | double | int | public | try |
base | else | interface | readonly | typeof |
bool | enum | internal | ref | uint |
break | event | is | return | ulong |
byte | explicit | lock | sbyte | unchecked |
case | extern | long | sealed | unsafe |
catch | false | namespace | short | ushort |
char | finally | new | sizeof | using |
checked | fixed | null | stackalloc | virtual |
class | float | object | static | void |
const | for | operator | string | volatile |
continue | foreach | out | struct | while |
decimal | goto | override | switch | |
default | if | params | this | |
delegate | implicit | private | throw |
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.3 类型基础
2.3.4 值类型与引用类型
2.3.4.4 存储开销
值类型:
值类型实例占用的内存大小就是存储其字段所需的内存。例如,Point 需要占用 8 byte 的内存:
struct Point{
int x;
int y;
}
从技术上说,CLR 用整数倍数字段的大小(4 字节或 8 字节)来分配内存地址。
经测试,CLR 对齐内存不是依据程序位数(32 位或 64 位),而是当前结构体中最大字段的大小。
引用类型:
相较值类型,引用类型需要额外的管理空间,最少需要 8 byte 存储该对象的类型的引用,以及一些固定信息。
2.4 数值类型
下表列出了 C#中索要的预定义数值类型:
C#类型 | 系统类型 | 后缀 | 容量 | 数值范围 |
---|---|---|---|---|
整数——有符号 | ||||
sbyte |
SByte |
8 bit | ||
short |
Int16 |
16 bit | ||
int |
Int32 |
32 bit | ||
long |
Int64 |
L | 64 bit | |
整数——无符号 | ||||
byte |
Byte |
8 bit | ||
ushort |
UInt16 |
16 bit | ||
uint |
UInt32 |
U | 32 bit | |
ulong |
UInt64 |
UL | 64 bit | |
实数 | ||||
float |
Single |
F | 32 bit | |
double |
Double |
D | 64 bit | |
decimal |
Decimal |
M | 128 bit |
2.4.1 数值字面量
实数字面量(double)可以用小数或指数表示,例如:
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)
Console.WriteLine(0xF000000000000000.GetType()); // UInt64 (ulong)
2.4.1.2 数值后缀
表2-1 列出了数值后缀对应的字面量类型。由 2.4.1.1 数值字面量类型接口可知:
-
整型后缀(U、L、UL)很少需要,可以通过类型推断或从
int
类型隐式转换得到。 -
后缀 D 是多余的(从技术上讲),小数默认推断为
double
类型。 -
后缀 F 和 M 最有用,并应该在指定
float
或decimal
字面量时使用。下列代码因缺少后缀,无法编译:float f = 4.5; decimal d = -1.23;
2.4.2 数值转换
2.4.2.3 浮点类型到整数类型的转换
将大的整数类型转换为浮点类型会出现精度丢失:
int i1 = 100000001;
float f1 = i1; // 保留了幅度, 丢失了精度
int i2 = (int)f1; // 100000000
浮点类型虽然拥有比整数类型更大的数值,但有时精度比整型要小。
2.4.2.4 decimal
类型转换
decimal
可以表示所有 C#整数类型值,因此所有整数类型值都能隐式转换为 decimal
类型。其他类型和 decimal
互转时则必须用显式转换。
2.4.3 算术运算符
算术运算符(+、-、*、/、%)可用于除 8、16 位的整数类型外的所有数值类型。
是的,浮点型也可以取余!
2.4.5 特殊整数类型运算
2.4.5.3 整数运算溢出检查运算符
默认情况下,整数运算溢出后不会抛出异常,而是进行“循环”。我们可以使用 checked
运算符触发检查,溢出时将抛出 OverflowException
异常:
int a = 1000000;
int b = 1000000;
// 此处将抛出 OverflowExceptions 异常
int c = checked (a * b); // 检查单句
// 检查块中的所有表达式:
checked
{
int c2 = a * b;
c2.Dump();
}
我们也可以在编译时要求程序进行算术溢出检查,当某个表达式不想检查,则要用 unchecked
运算符,见 C# 编译器选项 - 语言功能规则 - C# | Microsoft Learn:
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; // 编译时出错
int y = unchecked (int.MaxValue + 1); // No errors
2.4.6 8 位和 16 位整数类型
即 byte
、sbyte
、short
、ushort
类型。这些类型不具备算术运算符,运算时 C#会隐式地将其转换为 int
类型。当试图把运算结果赋值给一个小的类型时会产生编译时错误,必须使用显式转换才能通过编译:
short x = 1, y = 1;
short z = x + y; // 编译时出错
short z = (short)(x + y) // OK
2.4.7 特殊的 float
和 double
值:
浮点型包含某些特定运算需要特殊对待的值,这些值如下:
特殊值 | double 类型常量 |
float 类型常量 |
---|---|---|
NaN | double.NaN |
float.NaN |
+∞ | double.PositiveInfinity |
float.PositiveInfinity |
-∞ | double.NegativeInfinity |
float.NegativeInfinity |
-0 | -0.0 | -0.0f |
无穷大
非零值除以零的结果是无穷大:
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 使用比较运算符(==
),其结果永远是 false
。必须使用 float.IsNaN
或 double.IsNaN
判断一个值是否为 NaN:
// 使用 == 进行判断,NaN 永远不等于其他值:
Console.WriteLine (0.0 / 0.0 == double.NaN); // False
// 必须使用IsNaN方法进行判断:
Console.WriteLine (double.IsNaN (0.0 / 0.0)); // True
注意:
double.IsNaN(float.NaN)
结果为true
。
使用 object.Equals
方法时,两个 NaN
是相等的:
Console.WriteLine (object.Equals (0.0 / 0.0, double.NaN));
2.4.8 double
和 decimal
的对比
double
类型适用于科学计算(如空间坐标);decimal
适用于金融计算、人为(十进制)度量。以下是二者的不同:
种类 | double | decimal |
---|---|---|
内部表示 | 基数为 2 | 基数为 10 |
精度 | 15~16 位有效数字 | 28~29 位有效数字 |
范围 | ||
特殊值 | +0、-0、+∞、-∞、NaN | 无 |
速度 | 处理器原生支持 | 非处理器原生支持(大约比 double 慢十倍) |
2.4.9 实数的舍入误差
float
和 double
float
和 double
内部基于 2 表示数值,因此只有基于 2 表示的数值才能够精确表示。很多小数部分的字面量(基于 10)将无法精确表示:
float tenth = 0.1f;
float one = 1f;
Console.WriteLine(tenth * 10f - one) // 1.490116E-08
Tips
在.NET Core 和后续版本中,对 IEEE 754 的遵守更为严格,上述代码将正确获得结果“0”。
decimal
decimal
基于 10,因此可以精确表示基于 10(及其因素)的数值。但 decimal
和 double
都无法精确表示基于 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 布尔类型和运算符
布尔值仅需 1bit 空间,实际运行时仍分配了 1byte 空间。为避免使用数组时空间浪费,.NET Framework 在 System.Collections
空间下提供了 BitArray
类,每个布尔值仅占用 1bit。
2.5.3 条件运算符
对于布尔值,条件运算符 &&
和 ||
会进行短路机制, &
和 |
执行的则是非短路计算。
以如下代码为例,第一段代码输出:“方法 1”;第二段代码输出:“方法 1 方法 2”:
bool Method1() {
"方法1".Dump();
return false;
}
bool Method2() {
"方法2".Dump();
return true;
}
if (Method1() && Method2())
{
"短路".Dump();
}
if (Method1() & Method2())
{
"非短路".Dump();
}
2.6 字符串和字符
C# 的 char
(System.Char
)表示一个 Unicode 字符,使用 UTF16 编码(占用两个字节)。
转义字符 \u
(或 \x
)通过 4 位十六进制代码来指定任意 Unicode 字符:
char copyrightSymbol = '\u00A9';
char omegaSymbol = '\u03A9';
char newLine = '\u000A';
2.6.1 char
转换
char
类型占用 2byte,除无符号整型空间小于 2byte 的类型(byte
、sbyte
、short
)外,都可以作为 char
隐式转换的目标类型。
2.6.2 字符串类型
char
中的转义字符在 string
中同样有效,当需要反斜杠时,需要写两次,较为麻烦:
string a1 = "\\\\server\\fileshare\\helloworld.cs";
为了避免这种情况,C#引入了原意字符串字面量(@ 前缀),它不支持转义字符、可以贯穿多行:
string escaped = "First Line\r\nSecond Line";
string verbatim = @"First Line
Second Line";
// 这两个字符串相等:
Console.WriteLine (escaped == verbatim); // True
原意字符串需要两个双引号表示一个双引号字符:
string xml = @"<customer id=""123""></customer>";
2.6.2.2 字符串插值(C#6)
插值字符串只能在单行内声明,但也可以结合原意字符串进行多行声明。需要注意,$ 运算符必须在 @ 运算符之前:
x = 2;
s = $@"this spans {
x} lines";
若要在插值字符串中表示大括号,需要书写两个大括号:
s = $"{{" // 输出 "{"
2.7 数组
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}
};
Notice
仅能在定义成员时通过这种形式进行初始化
方式二:使用 var 关键字,由编译器自动推断
var rectMatrix = new int[,]
{
{0,1,2},
{3,4,5},
{6,7,8}
};
var jaggedMat = new 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'}; // 编译器推断为char[]
var x = new[] { 1, 10000000000 }; // 编译器推断为long
Notice
数组中的元素必须能隐式转换为一种类型。
Info
C#12 新增了集合表达式初始化集合,见C#12 新增的集合表达式的用法及底层原理
2.8 变量和参数
2.8.1 栈和堆
2.8.1.2 堆
值类型的实例、对象的引用存储在变量声明的地方(栈上),如果声明为类的字段或数组的元素,则该实例会存储在堆上。
静态字段存储在堆上,这些变量一直存活直至应用程序域结束。
2.8.2 明确赋值
C#强制执行明确赋值策略。明确赋值有三种含义:
-
局部变量在读取之前必须赋值。
如下代码将产生编译时错误:
static void Main(){ int x; Console.WriteLine(x); }
-
调用方法时必须为形参赋值。
-
运行时将自动初始化其他变量(例如字段和数组元素)。
2.8.3 默认值 default
default
关键字可用于获取任意类型的默认值,这对泛型非常有用。
2.8.4 参数
2.8.4.2 ref 修饰符
ref 修饰符对于实现交换方法是必要的:
static void Swap (ref string a, ref string b){
string temp = a;
a = b;
b = temp;
}
2.8.4.8 命名参数
我们现有如下方法:
void Foo(int x, int y){
Console.WriteLine(x + ", " + y);
}
命名参数能够以任意顺序出现。下面两种调用 Foo 的方式在语义上是一样的:
Foo(x:1, y:2);
Foo(y:2, x:1);
上述写法的不同之处的是参数表达式将按调用端参数出现的顺序计算。通常,这种不同只出现在非独立的拥有副作用的表达式中。例如下面的代码将输出“0,1”:
int a = 0; Foo(y:++a, x:--a);
当然,在实践中应当避免这种代码。
2.8.4.8.1 命名参数与可选参数混用
命名参数和可选参数可以混合使用,不过,按位置传递的参数必须出现在命名参数之前,因此如下调用无法通过编译:
Foo(x:1, 2); // 编译时出错
2.8.4.8.2 命名参数简化可选参数使用
命名参数可以简化可选参数的使用,譬如如下调用:
Bar(d:3);
static void Bar (int a = 0, int b = 0, int c = 0, int d = 0){
Console.WriteLine (a + " " + b + " " + c + " " + d);
}
2.8.5 引用局部变量(C#7)
该特性允许:可以定义一个引用局部变量(有点像变量的别名),用于引用数组中某一个元素或对象中某一个字段:
int[] numbers = { 0, 1, 2, 3, 4 };
ref int numRef = ref numbers [2];
numRef *= 10;
// 和指针不同,这里输出的是numRef对应的值,而非指针地址
Console.WriteLine (numRef); // 20
Console.WriteLine (numbers [2]); // 20
引用局部变量的目标只能是数组的元素、对象字段或局部变量,而不能是属性。
2.8.6 引用返回值(C#)
从方法中返回的引用局部变量,称为引用返回值(ref return)
static string X = "Old Value";
static ref string GetX() => ref X; // 该方法将返回一个ref
static void Main(){
ref string xRef = ref GetX(); // 赋值给引用局部变量
xRef = "New Value";
Console.WriteLine (X); // 输出”New Value“
}
2.9 表达式和运算符
2.9.3 赋值表达式
赋值表达式用 =
运算符将另一个表达式的值赋值给变量。它不是一个空表达式(即没有值的表达式),它的值即是被赋予的值。因此赋值表达式可以和其他表达式组合,也可以用于初始化多个值:
y = 5 * (x = 2);
a = b = c = d= 0;
2.9.5 运算符表
2.10 null 运算符
2.10.1 null 合并运算符
null 合并运算符写作 ??
。它的意思是:“如果操作数不是 null 则结果为操作数,否则结果为一个默认的值”:
string s1 = null;
string s2 = s1 ?? "nothing"; // s2 赋值为 "nothing"
如果左侧的表达式不是 null,右侧的表达式将不会进行计算。
2.10.2 null 条件运算符(C#6)
详见空引用传播:?.(Null Propagator)
2.11 语句
2.11.3 选择语句
2.11.3.4 switch 语句
switch 语句中,若想要 case 子句结束,必须使用某种跳转指令显式指定下一个执行点,这些指令有:
- break
- goto case x
- goto default
- 其他(return、throw、contiune、goto label)
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;
}
}
2.11.3.5 带有模式的 switch 语句(C#7)
模式变量
每一个 case 子句都指定了一种需要匹配的类型和一个变量(模式变量),如果匹配成功就对变量赋值。和常量不同,对于类型的使用并没有任何限制。
static void Main(){
TellMeTheType (12);
TellMeTheType ("hello");
TellMeTheType (true);
}
static void TellMeTheType (object x){
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;
}
}
模式变量中的 when
子句
还可以使用 when
关键字对 case 进行预测:
object x = true;
switch (x){
case bool b when b == true:
Console.WriteLine ("True!");
break;
case bool b:
Console.WriteLine ("False!");
break;
}
注意:case 子句的顺序会影响类型的选择。
default 子句是一个例外,不论出现在什么地方都会最后执行。
case 子句的堆叠
object x = 3000m;
switch (x){
case float f when f > 1000:
case double d when d > 1000:
case decimal m when m > 1000:
// 我们可以推断出x是否符合条件,却无法判断对应的是f、d还是m。
Console.WriteLine ("We can refer to x here but not f or d or m");
break;
}
上述例子中,我们不清楚到底三个模式变量中哪一个会被赋值,因此编译器会将它们放在作用域之外。
常量模式与模式变量的混用
除此之外,还可以混合使用常量选择和模式选择,甚至可以选择 null 值:
object x = 3000m;
switch (x){
case decimal m when m > 1000:
Console.WriteLine($"The value is {m}");
break;
case "123":
Console.WriteLine("The value is 123");
break;
case null:
Console.WriteLine("Nothing here");
break;
}
2.11.4 迭代语句
2.11.4.2 for 循环
for 循环的三个子句都可以省略,因此可以通过如下代码实现 while(true)功能:
for(;;)
Console.WriteLine("interrupt me");
2.11.5 跳转语句
C#的跳转语句有 break
、continue
、goto
、return
和 throw
。
跳转语句仍然遵守 try 语句的可靠性规则(参见4.5 try 语句和异常)。这意味着:
- 到 try 语句块之外的跳转总是在达到目标之前执行 try 语句的 finally 语句块。
- 跳转语句不能从 finally 语句块内跳到块外(除非使用 throw)。
2.11.5.4 return 语句
return 语句用于退出方法,能够出现在方法的任意位置(finally 块中除外)。
2.12 命名空间
命名空间中的“.”表明了嵌套命名空间的层次结构,如下两种命名空间的定义方式是等价的:
namespace Outer.Middle.Inner { ... }
namespace Outer {
namespace Middle {
namespace Inner {
...
}
}
}
如果类型没有在任何 namespace 中定义,则它存在于全局命名空间(global namespace)中。
2.12.2 using static 指令(C#6)
using static
Math.Cos(1); Math.Round(0.5); Math.Pow(2, 4);
C#6.0中引入了using static用法,上述代码可以简化为:
using static System.Math; Cos(1); Round(0.5); Pow(2, 4);
注意,上述方式相当于将Math中的所有方法都暴露出来,很可能会有方法重名。
2.12.3 命名空间中的规则
2.12.3.1 命名范围(namespace 限定的范围)
外层 namespace 中声明的名称能够直接在内层 namespace 中使用。以下示例中的 Class1
在 Inner
中不需要限定名称:
namespace Outer {
class Class1 {}
namespace Inner {
class Class2 : Class1 { }
}
}
同一 namespace 下的子 namespace,互相调用时要使用部分限定名称:
namespace MyTradingCompany {
namespace Common {
class ReportBase {}
}
namespace ManagementReporting {
class SalesReport : Common.ReportBase {}
}
}
2.12.3.3 名称隐藏
如果同类型名称同时出现在内层和外层 namespace 中,则内层类型优先,如果要使用外层中的类型,必须使用它的完全限定名称:
namespace Outer
{
class Foo { }
namespace Inner
{
class Foo { }
class Test
{
Foo f1; // = Outer.Inner.Foo
Outer.Foo f2; // = Outer.Foo
}
}
}
所有的类型名在编译时都会转换为完全限定名称。中间语言(IL)代码不包含非限定名称和部分限定名称。
2.12.3.4 嵌套的 using 指令(using 指令作用的范围)
我们能够在命名空间中嵌套使用 using 指令,这样可以控制 using 指令在命名空间声明中的作用范围。在以下例子中,Class1
在一个命名空间中可见,但是在另一个命名空间中不可见:
namespace N1 {
class Class1 {}
}
namespace N2 {
using N1;
class Class2 : Class1 {}
}
namespace N2 {
class Class3 : Class1 {} // 编译错误
}
2.12.4 类型和命名空间别名
命名空间可以为其定义别名:
using sys = System;
using M = System.Math;
sys.Console.WriteLine("Hello world!");
M.Cos(1);
我们甚至还可以为类型起别名:
using FolderBrowserDialog = System.Windows.Forms.FolderBrowserDialog;
// 创建一个winform浏览文件夹窗体控件
var dialog = new FolderBrowserDialog();
2.12.5 高级命名空间特性
2.12.5.1 外部别名
假设我们现有如下两个 dll:
// 程序库1(Widgets1.dll)
namespace Widgets
{
public class Widget {}
}
// 程序库2(Widgets2.dll)
namespace Widgets
{
public class Widget {}
}
APP 同时引入这两个 dll 并使用,因存在二义性,如下代码无法通过编译:
using Widgets;
Widget w = new Widget();
此时可以通过别名消除二义性:
extern alias W1;
extern alias W2;
W1.Widgets.Widget w1 = new W1.Widgets.Widget();
W2.Widgets.Widget w2 = new W2.Widgets.Widget();
2.12.5.2 命名空间别名限定符
在 2.12.3.3 名称隐藏中提到,内层 namespace 中的名称会隐藏外层 namespace 中的名称,我们可以通过类型的完全限定解决。不过有些情况类型的完全限定也无法解决冲突:
namespace N {
class A {
public class B{ }
static void Main(){
new A.B(); // 此处将调用内部类B
}
}
}
namespace A {
class B{ }
}
Main 方法将会实例化嵌套类 B 或命名空间 A 中的类 B。编译器总是给当前命名空间中的标识符以更高的优先级;在这种情况下,将会实例化嵌套类 B。
要解决这样的冲突,可以使用“::
”限定命名空间别名,用法如下:
1. 全局命名空间(别名 global)
即所有 namespace 的根命名空间(由上下文关键字 global 指定)
namespace N {
class A {
public class B{ }
static void Main(){
(new A.B()).GetType().Dump();
(new global::A.B()).GetType().Dump();;
}
}
}
namespace A {
class B{ }
}
2.一系列的外部别名
此处代码与2.12.5.1 外部别名稍显不同:
extern alias W1;
extern alias W2;
W1::Widgets.Widget w1 = new W1::Widgets.Widget();
W2::Widgets.Widget w2 = new W2::Widgets.Widget();
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?