C#12 口袋参考(一)
原文:
zh.annas-archive.org/md5/97bc15629f1b51a0671040c56db61b92译者:飞龙
前言
C# 是一种通用、类型安全的、主要面向对象的编程语言,其目标是提高程序员的生产力。为此,语言在简单性、表现力和性能之间保持平衡。C# 12 设计用于与 Microsoft .NET 8 运行时配合(而 C# 11 则面向 .NET 7,C# 10 则面向 .NET 6,C# 7 则面向 Microsoft .NET Framework 4.6/4.7/4.8)。
注意
本书中的程序和代码片段与 C# 12 in a Nutshell 的第 2 到第四章中的代码镜像,并且都可以作为交互式示例在 LINQPad 中使用。通过与书籍一起使用这些示例,可以加速学习,您可以编辑示例并立即看到结果,无需在 Visual Studio 中设置项目和解决方案。
要下载示例,请点击 LINQPad 中的 Samples 标签,然后点击“Download more samples”。LINQPad 是免费的,访问 www.linqpad.net。
第一章:第一个 C# 程序
以下是一个将 12 乘以 30 并将结果 360 打印到屏幕的程序。双斜线表示行的剩余部分是注释:
int x = 12 * 30; // Statement 1
System.Console.WriteLine (x); // Statement 2
我们的程序由两个语句组成。C# 中的语句按顺序执行,并以分号终止。第一个语句计算表达式 12 * 30 并将结果存储在名为 x 的变量中,其类型是 32 位整数 (int)。第二个语句在名为 Console 的类上调用 WriteLine 方法,该类定义在名为 System 的命名空间中。这将变量 x 打印到屏幕上的文本窗口中。
方法执行一个功能;类将函数成员和数据成员组合成面向对象的构建块。Console 类组合了处理命令行输入/输出(I/O)功能的成员,比如 WriteLine 方法。类是一种类型,我们在“类型基础”中讨论过。
在最外层,类型被组织到命名空间中。许多常用类型,包括 Console 类,驻留在 System 命名空间中。.NET 库被组织成嵌套命名空间。例如,System.Text 命名空间包含用于处理文本的类型,而 System.IO 包含用于输入/输出的类型。
在每次使用时用 System 命名空间限定 Console 类会增加混乱。using 指令允许您通过导入一个命名空间来避免这种混乱:
using System; // Import the System namespace
int x = 12 * 30;
Console.WriteLine (x); // No need to specify System
代码重用的基本形式是编写调用低级函数的高级函数。我们可以通过一个可重用的方法 FeetToInches 来重构我们的程序,该方法将整数乘以 12,如下所示:
using System;
Console.WriteLine (FeetToInches (30)); // 360
Console.WriteLine (FeetToInches (100)); // 1200
int FeetToInches (int feet)
{
int inches = feet * 12;
return inches;
}
我们的方法包含一系列被大括号包围的语句。这被称为语句块。
方法可以通过指定参数从调用者那里接收输入数据,并通过指定返回类型向调用者返回输出数据。我们的FeetToInches方法具有输入英尺的参数和输出英寸的返回类型:
int FeetToInches (int feet)
...
字面量 30 和 100 是传递给FeetToInches方法的参数。
如果方法不接收输入,请使用空括号。如果方法不返回任何内容,请使用void关键字:
using System;
SayHello();
void SayHello()
{
Console.WriteLine ("Hello, world");
}
方法是 C#中几种函数的一种。我们示例程序中使用的另一种函数是* 操作符,它执行乘法运算。还有构造函数、属性、事件、索引器和终结器。
编译
C#编译器将源代码(一组扩展名为*.cs的文件)编译成一个程序集*。程序集是.NET 中的打包和部署单元。程序集可以是应用程序或库。普通控制台或 Windows 应用程序具有入口点,而库则没有。库的目的是被应用程序或其他库引用。.NET 本身是一组库(以及运行时环境)。
在前面的节中,每个程序都直接以一系列语句开始(称为顶级语句)。顶级语句的存在隐式创建了控制台或 Windows 应用程序的入口点。(没有顶级语句时,Main 方法表示应用程序的入口点—参见“预定义类型和自定义类型的对称性”。)
要调用编译器,您可以使用集成开发环境(IDE)如 Visual Studio 或 Visual Studio Code,也可以从命令行手动调用它。要使用.NET 手动编译控制台应用程序,首先下载.NET 8 SDK,然后按以下步骤创建新项目:
dotnet new console -o MyFirstProgram
cd MyFirstProgram
这将创建一个名为MyFirstProgram的文件夹,其中包含一个名为Program.cs的 C#文件,您可以随后编辑。要调用编译器,请调用dotnet build(或dotnet run,它将编译然后运行程序)。输出将写入bin\debug子目录下,其中包括MyFirstProgram.dll(输出程序集)以及直接运行编译程序的MyFirstProgram.exe。
第二章:语法
C#语法的灵感来源于 C 和 C++语法。在本节中,我们描述了 C#语法的各个元素,使用以下程序:
using System;
int x = 12 * 30;
Console.WriteLine (x);
标识符和关键字
标识符是程序员为其类、方法、变量等选择的名称。以下是我们示例程序中标识符的顺序:
System x Console WriteLine
标识符必须是一个完整的单词,基本上由以字母或下划线开头的 Unicode 字符组成。C# 标识符是区分大小写的。按照惯例,参数、局部变量和私有字段应该是小驼峰命名法(例如,myVariable),而其他所有标识符应该是帕斯卡命名法(例如,MyMethod)。
关键字是对编译器有特殊意义的名称。在我们的示例程序中有两个关键字,using 和 int。
大多数关键字是保留的,这意味着你不能把它们用作标识符。以下是所有 C# 保留关键字的完整列表:
| abstract as
base
bool
break
byte
case
catch
char
checked
class
const
continue
decimal
default
delegate
do
double
else
enum | event explicit
extern
false
finally
fixed
float
for
foreach
goto
if
implicit
in
int
interface
internal
is
lock
long
namespace | new null
object
operator
out
override
params
private
protected
public
readonly
record
ref
return
sbyte
sealed
short
sizeof
stackalloc
static | string struct
switch
this
throw
true
try
typeof
uint
ulong
unchecked
unsafe
ushort
using
virtual
void
volatile
while |
避免冲突
如果你确实想使用与保留关键字冲突的标识符,可以通过在其前面加上 @ 前缀来使用它。例如:
class class {...} // Illegal
class @class {...} // Legal
@ 符号本身不是标识符的一部分。因此 @myVariable 和 myVariable 是相同的。
上下文关键字
有些关键字是上下文关键字,这意味着它们也可以作为标识符使用——无需 @ 符号。这些上下文关键字如下:
| add alias
and
ascending
async
await
by
descending
dynamic
equals | file from
get
global
group
init
into
join
let
managed | nameof nint
not
notnull
nuint
on
or
orderby
partial
remove | required select
set
unmanaged
value
var
with
when
where
yield |
使用上下文关键字,可以在使用它们的上下文中消除歧义。
字面量、标点符号和操作符
字面量是程序中词法上嵌入的原始数据片段。我们在示例程序中使用的字面量是12和30。标点符号帮助标明程序的结构。例如,分号用于终止语句。语句可以跨多行:
Console.WriteLine
(1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10);
操作符用于转换和组合表达式。C# 中的大多数操作符用符号表示,例如乘法操作符 *。以下是我们程序中的操作符:
= * . ()
句点表示某物的成员(或数字字面量的小数点)。在声明或调用方法时使用括号;当方法不接受参数时使用空括号。等号执行赋值(双等号 == 执行相等比较)。
注释
C#提供了两种不同的源代码文档风格:单行注释和多行注释。单行注释以双斜杠开头,直到行尾。例如:
int x = 3; // Comment about assigning 3 to x
多行注释以/*开头,以*/结尾。例如:
int x = 3; /* This is a comment that
spans two lines */
注释可以嵌入 XML 文档标签(见“XML 文档”)。
第三章:类型基础知识
类型定义了值的蓝图。在我们的示例中,我们使用了两个int类型的文字值,分别为 12 和 30。我们还声明了一个类型为int的变量,其名称为x。
变量表示可以随时间包含不同值的存储位置。相比之下,常量始终表示相同的值(稍后详述)。
在 C#中,所有的值都是特定类型的实例。值的含义以及变量可能具有的可能值集由其类型确定。
预定义类型示例
预定义类型(也称为内置类型)是编译器特别支持的类型。int类型是用于表示内存中适合 32 位整数集的预定义类型,范围从−2³¹到 2³¹−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 = 2022;
message = message + x.ToString();
Console.WriteLine (message); // Hello world2022
预定义的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");
在.NET 中,System命名空间包含许多重要类型,这些类型不是由 C#预定义的(例如,DateTime)。
自定义类型示例
就像您可以从简单函数构建复杂函数一样,您可以从原始类型构建复杂类型。在此示例中,我们将定义一个名为UnitConverter的自定义类型——一个作为单位转换蓝图的类:
UnitConverter feetToInches = new UnitConverter (12);
UnitConverter milesToFeet = new UnitConverter (5280);
Console.WriteLine (feetToInches.Convert(30)); // 360
Console.WriteLine (feetToInches.Convert(100)); // 1200
Console.WriteLine (feetToInches.Convert
(milesToFeet.Convert(1))); // 63360
public class UnitConverter
{
int ratio; // Field
public UnitConverter (int unitRatio) // Constructor
{
ratio = unitRatio;
}
public int Convert (int unit) // Method
{
return unit * ratio;
}
}
类型的成员
类型包含数据成员和函数成员。UnitConverter的数据成员是名为ratio的字段。UnitConverter的函数成员是Convert方法和UnitConverter的构造函数。
预定义类型和自定义类型的对称性
C#的一个优点是预定义类型和自定义类型之间几乎没有区别。预定义的int类型用作整数的蓝图。它保存数据——32 位——并提供使用该数据的函数成员,例如ToString。类似地,我们的自定义UnitConverter类型充当单位转换的蓝图。它保存比例数据,并提供用于使用该数据的函数成员。
构造函数和实例化
实例化类型可以创建数据。您可以通过使用文字(例如12或"Hello world")简单地实例化预定义类型。
new 操作符用于创建自定义类型的实例。我们通过创建两个 UnitConverter 类型的实例来启动我们的程序。new 操作符实例化对象后,立即调用对象的 构造函数 进行初始化。构造函数类似于方法,不同之处在于方法名和返回类型简化为封闭类型的名称:
public UnitConverter (int unitRatio) // Constructor
{
ratio = unitRatio;
}
实例成员与静态成员
操作类型 实例 的数据成员和函数成员称为 实例成员。UnitConverter 的 Convert 方法和 int 的 ToString 方法就是实例成员的例子。默认情况下,成员是实例成员。
不操作类型实例的数据成员和函数成员可以标记为 static。要从其类型外部引用静态成员,需指定其 类型 名称,而不是实例。例如 Console 类的 WriteLine 方法。因为这是静态的,我们调用 Console.WriteLine() 而不是 new Console().WriteLine()。
在下面的代码中,实例字段 Name 属于特定 Panda 的实例,而 Population 属于所有 Panda 实例的集合。我们创建了两个 Panda 实例,打印它们的名称,然后打印总体人口:
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
public class Panda
{
public string Name; // Instance field
public static int Population; // Static field
public Panda (string n) // Constructor
{
Name = n; // Instance field
Population = Population + 1; // Static field
}
}
尝试评估 p1.Population 或 Panda.Name 将生成编译时错误。
公共关键字
public 关键字将成员暴露给其他类。在此示例中,如果 Panda 中的 Name 字段未标记为 public,则它将是私有的,无法从类外部访问。将成员标记为 public 是类型通信的方式:“这里是我希望其他类型看到的内容——其他都是我自己的私有实现细节。”从面向对象的角度来看,我们说公共成员 封装 类的私有成员。
创建命名空间
特别是对于较大的程序,将类型组织到命名空间中是有意义的。以下是如何在名为 Animals 的命名空间内定义 Panda 类:
namespace Animals
{
public class Panda
{
...
}
}
我们在 “命名空间” 中详细介绍命名空间。
定义 Main 方法
到目前为止,我们的所有示例都使用了顶级语句,这是在 C# 9 中引入的一个功能。没有顶级语句时,简单的控制台或 Windows 应用程序如下所示:
using System;
class Program
{
static void Main() // Program entry point
{
int x = 12 * 30;
Console.WriteLine (x);
}
}
在不存在顶级语句的情况下,C# 查找名为 Main 的静态方法,该方法成为入口点。Main 方法可以定义在任何类内(只能存在一个 Main 方法)。
Main 方法可以选择性地返回整数(而不是 void),以便向执行环境返回值(非零值通常表示错误)。Main 方法还可以选择性地接受字符串数组作为参数(该数组将填充任何传递给可执行文件的参数);例如:
static int Main (string[] args) {...}
注意
数组(如string[])表示特定类型的固定数量元素。通过在元素类型后面放置方括号来指定数组。我们在“数组”中描述了它们。
(Main方法也可以声明为async并返回Task或Task<int>,以支持异步编程,请参见“异步函数”。)
顶层语句
顶层语句允许您避免静态Main方法和包含类的负担。一个包含顶层语句的文件由以下三部分组成,顺序如下:
-
(可选地)
using指令 -
一系列的语句,可选地与方法声明混合在一起。
-
(可选地)类型和命名空间声明
第二部分的所有内容最终都会位于由编译器生成的“main”方法内,位于一个由编译器生成的类内。这意味着顶层语句中的方法变成了本地方法(我们在“本地方法”中描述了细微差别)。顶层语句可以选择向调用者返回一个整数值,并访问一个名为args的string[]类型的“magic”变量,对应于调用者传递的命令行参数。
由于程序只能有一个入口点,在 C#项目中最多只能有一个包含顶层语句的文件。
类型和转换
C#可以在兼容类型的实例之间进行转换。转换总是从现有值创建一个新值。转换可以是隐式或显式:隐式转换会自动发生,而显式转换需要一个转换。在以下示例中,我们隐式将int转换为long类型(其比int具有两倍的位容量),并显式将int转换为short类型(其比int具有一半的位容量):
int x = 12345; // int is a 32-bit integer
long y = x; // Implicit conversion to 64-bit int
short z = (short)x; // Explicit conversion to 16-bit int
一般情况下,当编译器可以保证隐式转换总是成功且不丢失信息时,允许隐式转换。否则,你必须执行显式转换来在兼容类型之间转换。
值类型与引用类型
C#类型可以分为值类型和引用类型。
值类型包括大多数内置类型(具体来说,所有数值类型、char类型和bool类型),以及自定义的struct和enum类型。引用类型包括所有的类、数组、委托和接口类型。
值类型和引用类型的根本区别在于它们在内存中的处理方式。
值类型
值类型变量或常量的内容仅仅是一个值。例如,内置值类型int的内容是 32 位数据。
您可以使用struct关键字定义自定义值类型(参见图 1):
public struct Point { public int X, Y; }
图 1. 内存中的值类型实例
对于值类型实例的赋值始终会复制该实例。例如:
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 显示 p1 和 p2 具有独立的存储。
图 2. 赋值复制值类型实例
引用类型
引用类型比值类型更复杂,有两部分:对象 和指向该对象的引用。引用类型变量或常量的内容是指向包含值的对象的引用。以下是我们先前示例中 Point 类的类型重写为类(参见 图 3):
public class Point { public int X, Y; }
图 3. 内存中的引用类型实例
将引用类型变量赋值给引用,会复制引用而不是对象实例。这允许多个变量引用同一对象——这在值类型中通常是不可能的。如果我们重复之前的示例,但现在 Point 是一个类,通过 p1 进行的操作会影响 p2:
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
图 4 显示 p1 和 p2 是指向同一对象的两个引用。
图 4. 赋值复制引用
空
可以将引用分配给字面值 null,表示引用指向无对象。假设 Point 是一个类:
Point p = null;
Console.WriteLine (p == null); // True
访问空引用的成员会生成运行时错误:
Console.WriteLine (p.X); // NullReferenceException
注意
在 “可空引用类型” 中,我们描述了 C# 的一个功能,可减少意外的 NullReferenceException 错误。
相比之下,值类型通常不能具有空值:
struct Point {...}
...
Point p = null; // Compile-time error
int x = null; // Compile-time error
为了解决这个问题,C# 使用了一种特殊的结构来表示值类型的空值——请参阅 “可空值类型”。
预定义类型分类
C# 中的预定义类型如下:
值类型
-
数值:
-
有符号整数 (
sbyte,short,int,long) -
无符号整数 (
byte,ushort,uint,ulong) -
实数 (
float,double,decimal)
-
-
逻辑 (
bool) -
字符 (
char)
引用类型
-
字符串 (
string) -
对象 (
object)
C# 中的预定义类型是 .NET 中 System 命名空间的别名。这两个语句之间只有语法上的差异:
int i = 5;
System.Int32 i = 5;
在公共语言运行时(CLR)中,排除 decimal 的预定义值类型被称为基本类型。之所以称为基本类型,是因为它们通过编译代码中的指令直接支持,在底层处理器上通常直接转换为支持。
第四章:数值类型
C# 具有以下预定义的数值类型:
| C# 类型 | 系统类型 | 后缀 | 大小 | 范围 |
|---|---|---|---|---|
| 整数—有符号 | ||||
sbyte | SByte | 8 位 | –2⁷ 到 2⁷–1 | |
short | Int16 | 16 位 | –2¹⁵ 到 2¹⁵–1 | |
int | Int32 | 32 位 | –2³¹ 到 2³¹–1 | |
long | Int64 | L | 64 位 | –2⁶³ 到 2⁶³–1 |
nint | IntPtr | 32/64 位 | ||
| 整数—无符号 | ||||
byte | Byte | 8 位 | 0 到 2⁸–1 | |
ushort | UInt16 | 16 位 | 0 到 2¹⁶–1 | |
uint | UInt32 | U | 32 位 | 0 到 2³²–1 |
ulong | UInt64 | UL | 64 位 | 0 到 2⁶⁴–1 |
nuint | UIntPtr | 32/64 位 | ||
| 实数 | ||||
float | Single | F | 32 位 | ±(~10^(–45) 到 10³⁸) |
double | Double | D | 64 位 | ±(~10^(–324) 到 10³⁰⁸) |
decimal | Decimal | M | 128 位 | ±(~10^(–28) 到 10²⁸) |
在整型类型中,int和long是一流公民,并且在 C#和运行时中受到青睐。其他整型类型通常用于互操作性或在空间效率至关重要时使用。nint和nuint本地大小的整型被调整为与运行时进程的地址空间匹配(32 位或 64 位)。当处理指针时,这些类型可能很有用——我们在《C# 12 权威指南》(O’Reilly)的第四章中描述了它们的微妙之处。
在实数类型中,float和double被称为浮点类型,通常用于科学和图形计算。decimal类型通常用于需要基于十进制的精确算术和高精度的财务计算。 (技术上,decimal也是一种浮点类型,尽管通常不这样称呼。)
数值文字
整型文字可以使用十进制、十六进制或二进制表示法;十六进制以0x前缀表示(例如,0x7f等同于127),二进制以0b前缀表示。实数文字可以使用十进制或指数表示法,如1E06。数字文字中可以插入下划线以提高可读性(例如,1_000_000)。
数值文字类型推断
默认情况下,编译器推断数值文字为double或整型:
-
如果文字包含小数点或指数符号(
E),它是double。 -
否则,文字的类型是列表中可以容纳文字值的第一个类型:
int、uint、long和ulong。
例如:
Console.Write ( 1.0.GetType()); // Double *(double)*
Console.Write ( 1E06.GetType()); // Double *(double)*
Console.Write ( 1.GetType()); // Int32 *(int)*
Console.Write (0xF0000000.GetType()); // UInt32 *(uint)*
Console.Write (0x100000000.GetType()); // Int64 *(long)*
数值后缀
在上表中列出的数值后缀明确定义了文字的类型:
decimal d = 3.5M; // M = decimal (case-insensitive)
U和L后缀很少必要,因为uint、long和ulong类型几乎总是可以从int推断或隐式转换:
long i = 5; // Implicit conversion from int to long
技术上,带有D后缀的文字是多余的,因为带有小数点的所有文字都被推断为double(你可以随时给数字文字加上小数点)。F和M后缀是最有用的,在指定分数float或decimal文字时是必需的。没有后缀的话,以下代码不会编译通过,因为4.5将被推断为double类型,而double类型无法隐式转换为float或decimal:
float f = 4.5F; // Won't compile without suffix
decimal d = -1.23M; // Won't compile without suffix
数值转换
整型到整型的转换
当目标类型能够表示源类型的每一个可能值时,整数转换是隐式的。否则,需要显式转换。例如:
int x = 12345; // int is a 32-bit integral type
long y = x; // Implicit conversion to 64-bit int
short z = (short)x; // Explicit conversion to 16-bit int
实数到实数的转换
float 可以隐式转换为 double,因为 double 能够表示每一个可能的 float 值。反向转换必须显式进行。
在 decimal 和其他实数类型之间的转换必须是显式的。
实数到整数的转换
从整数类型到实数类型的转换是隐式的,而反向转换必须是显式的。将浮点数转换为整数类型会截断任何小数部分;要执行四舍五入转换,请使用静态的 System.Convert 类。
一个注意事项是,将大整数类型隐式转换为浮点数类型会保留大小,但偶尔可能会失去精度:
int i1 = 100000001;
float f = i1; // Magnitude preserved, precision lost
int i2 = (int)f; // 100000000
算术运算符
算术运算符(+、-、*、/、%)适用于除了 8 位和 16 位整数类型之外的所有数值类型。% 运算符在除法后求余数。
自增和自减运算符
自增和自减运算符(++、--)会使数值类型增加或减少 1。操作符可以在变量前或后出现,具体取决于您希望在表达式评估之前还是之后更新变量。例如:
int x = 0;
Console.WriteLine (x++); // Outputs 0; x is now 1
Console.WriteLine (++x); // Outputs 2; x is now 2
Console.WriteLine (--x); // Outputs 1; x is now 1
特定的整数操作
除法
对整数类型的除法操作总是会消除余数(朝零舍入)。除以一个值为零的变量会生成运行时错误(DivideByZeroException)。除以字面值或常量 0 会生成编译时错误。
溢出
在运行时,整数类型的算术操作可能会溢出。默认情况下,这种情况会静默发生 —— 不会抛出异常,并且结果会表现为环绕行为,就好像在较大的整数类型上进行计算并且丢弃了额外的有效位。例如,将最小可能的 int 值递减会导致最大可能的 int 值:
int a = int.MinValue; a--;
Console.WriteLine (a == int.MaxValue); // True
checked 和 unchecked 操作符
当整型表达式或语句超出其类型的算术限制时,checked 操作符会指示运行时生成 OverflowException 而不是静默溢出。checked 操作符影响具有 ++、--、(一元)-、+、-、*、/ 和整型类型之间的显式转换运算符的表达式。溢出检查会带来一定的性能成本。
您可以在表达式或语句块周围使用 checked。例如:
int a = 1000000, b = 1000000;
int c = checked (a * b); // Checks just the expression
checked // Checks all expressions
{ // in statement block
c = a * b;
...
}
您可以通过使用 /checked+ 命令行开关(在 Visual Studio 中,转到高级构建设置)使算术溢出检查成为程序中所有表达式的默认设置。如果您需要仅针对特定表达式或语句禁用溢出检查,则可以使用 unchecked 操作符。
位运算符
C# 支持以下位运算符:
| 运算符 | 含义 | 示例表达式 | 结果 | ||
|---|---|---|---|---|---|
~ | 补码 | ~0xfU | 0xfffffff0U | ||
& | 与 | 0xf0 & 0x33 | 0x30 | ||
| ` | ` | 或 | `0xf0 | 0x33` | 0xf3 |
^ | 异或 | 0xff00 ^ 0x0ff0 | 0xf0f0 | ||
<< | 左移 | 0x20 << 2 | 0x80 | ||
>> | 右移 | 0x20 >> 1 | 0x10 |
从 C# 11 开始,还有一个无符号右移操作符 (>>>)。而右移操作符 (>>) 在操作有符号整数时会复制高位位。
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
特殊浮点和双精度值
与整数类型不同,浮点类型有些特定操作会对特殊值进行处理。这些特殊值包括 NaN(非数字)、+∞、−∞ 和 −0. float 和 double 类有用于 NaN、+∞ 和 −∞ 的常量(以及包括 MaxValue、MinValue 和 Epsilon 在内的其他值)。例如:
Console.Write (double.NegativeInfinity); // -Infinity
将非零数除以零会得到一个无限值:
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.Write ( 0.0 / 0.0); // NaN
Console.Write ((1.0 / 0.0) − (1.0 / 0.0)); // NaN
当使用 == 时,NaN 值永远不等于另一个值,甚至是另一个 NaN 值。要测试一个值是否为 NaN,必须使用 float.IsNaN 或 double.IsNaN 方法:
Console.WriteLine (0.0 / 0.0 == double.NaN); // False
Console.WriteLine (double.IsNaN (0.0 / 0.0)); // True
然而,当使用 object.Equals 时,两个 NaN 值是相等的:
bool isTrue = object.Equals (0.0/0.0, double.NaN);
double 和 decimal 的比较
double 适用于科学计算(如计算空间坐标)。decimal 适用于金融计算和制造值,而不是实际测量结果。以下是它们之间的主要区别总结:
| 特性 | double | decimal |
|---|---|---|
| 内部表示 | 二进制 | 十进制 |
| 精度 | 15–16 有效数字 | 28–29 有效数字 |
| 范围 | ±(~10^(−324) 到 ~10³⁰⁸) | ±(~10^(−28) 到 ~10²⁸) |
| 特殊值 | +0、−0、+∞、−∞ 和 NaN | 无 |
| 速度 | 本地处理器 | 非本地处理器(比 double 慢约 10 倍) |
实数舍入误差
float 和 double 在内部以二进制表示数字。因此,大多数带有小数部分的文字常量(以十进制为基础)不能精确表示,这使它们在财务计算中不合适。相比之下,decimal 使用十进制工作,因此可以精确表示如 0.1 的分数,其十进制表示不会循环。
第五章:布尔类型和运算符
C#的bool类型(别名为System.Boolean类型)是一个逻辑值,可以赋予true或false字面值。
尽管布尔值仅需要一位存储空间,但运行时会使用一个字节的内存,因为这是运行时和处理器能有效处理的最小块。为了避免在数组情况下的空间浪费,.NET 在System.Collections命名空间中提供了BitArray类,设计为每个布尔值仅使用一位。
相等性和比较运算符
==和!=分别测试任何类型的相等和不等,总是返回一个bool值。值类型通常具有非常简单的相等概念:
int x = 1, y = 2, z = 1;
Console.WriteLine (x == y); // False
Console.WriteLine (x == z); // True
对于引用类型,默认情况下,相等性基于引用,而不是底层对象的值。因此,除非特别重载了该类型的==运算符以反映这一点(参见“对象类型”和“运算符重载”),否则具有相同数据的两个对象实例不被视为相等。
相等性和比较运算符==、!=、<、>、>=和<=适用于所有数值类型,但在实数中应谨慎使用(参见上一节中的“实数舍入误差”)。比较运算符也适用于枚举类型成员,通过比较它们的基础整数值。
条件操作符
&&和||运算符测试and和or条件,分别。它们经常与!运算符一起使用,表示not。在以下示例中,UseUmbrella方法如果下雨或晴天(保护我们免受雨水或阳光),只要不刮风就返回true:
static bool UseUmbrella (bool rainy, bool sunny,
bool windy)
{
return !windy && (rainy || sunny);
}
&&和||运算符在可能时会短路评估。在前面的示例中,如果刮风,表达式(rainy || sunny)甚至不会被评估。短路对于允许以下表达式运行而不抛出NullReferenceException至关重要:
if (sb != null && sb.Length > 0) ...
&和|运算符也测试and和or条件:
return !windy & (rainy | sunny);
它们的不同之处在于它们不会短路。因此,它们很少用于条件操作符的替代位置。
三元条件运算符(简称条件运算符)的形式为q ? a : b,如果条件q为真,则评估a,否则评估b。例如:
static int Max (int a, int b)
{
return (a > b) ? a : b;
}
条件运算符在 LINQ 查询中特别有用。
第六章:字符串和字符
C#的char类型(别名为System.Char类型)表示一个 Unicode 字符,占据两个字节(UTF-16)。char字面值用单引号指定:
char c = 'A'; // Simple character
转义序列表示不能以字面方式表达或解释的字符。转义序列是一个反斜杠后跟具有特殊含义的字符。例如:
char newLine = '\n';
char backSlash = '\\';
有效的转义序列如下:
| Char | 含义 | 值 |
|---|---|---|
\' | 单引号 | 0x0027 |
\" | 双引号 | 0x0022 |
\\ | 反斜杠 | 0x005C |
\0 | 空字符 | 0x0000 |
\a | 警报 | 0x0007 |
\b | 退格 | 0x0008 |
\f | 换页符 | 0x000C |
\n | 换行 | 0x000A |
\r | 回车 | 0x000D |
\t | 水平制表符 | 0x0009 |
\v | 垂直制表符 | 0x000B |
\u(或\x)转义序列允许您通过其四位十六进制代码指定任何 Unicode 字符:
char copyrightSymbol = '\u00A9';
char omegaSymbol = '\u03A9';
char newLine = '\u000A';
从char到能容纳无符号short的数值类型的隐式转换适用于其他数值类型,需要显式转换。
字符串类型
C#的string类型(别名System.String类型)表示一种不可变(不可修改)的 Unicode 字符序列。字符串字面量在双引号内指定:
string a = "Heat";
注意
string是引用类型,而不是值类型。然而,它的相等运算符遵循值类型语义:
string a = "test", 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";
逐字字符串字面量也可以跨越多行。您可以通过连续写两次来包含逐字文字中的双引号字符。
原始字符串字面量(C# 11)
用三个或更多引号字符(""")包裹字符串会创建一个原始字符串字面量。原始字符串字面量几乎可以包含任何字符序列,无需转义或重复:
string raw = """<file path="c:\temp\test.txt"></file>""";
原始字符串字面量使得能够轻松表示 JSON、XML 和 HTML 字面量,以及正则表达式和源代码。如果需要在字符串本身中包含三个(或更多)引号字符,则可以用四个(或更多)引号字符包裹该字符串:
string raw = """"We can include """ in this string."""";
多行原始字符串字面量受到特殊规则的约束。我们可以将字符串"Line 1\r\nLine 2"表示如下:
string multiLineRaw = """
Line 1
Line 2
""";
注意,开头和结尾的引号必须位于字符串内容的不同行。此外:
-
在开头的
"""后面的空白会被忽略。 -
在结尾的
"""前面的空白被视为常见缩进并从字符串的每一行中删除。这使得您可以包含源代码可读性的缩进(就像我们在示例中所做的那样),而不使该缩进成为字符串的一部分。
原始字符串字面量可以进行插值,受到“字符串插值”中描述的特殊规则的约束。
字符串连接
+运算符连接两个字符串:
string s = "a" + "b";
操作数中的一个可以是非字符串值,在这种情况下,将对该值调用ToString。例如:
string s = "a" + 5; // a5
反复使用+运算符构建字符串可能效率低下;更好的解决方案是使用System.Text.StringBuilder类型——它表示可变(可编辑)字符串,并具有有效地Append、Insert、Remove和Replace子字符串的方法。
字符串插值
以$字符开头的字符串称为插值字符串。插值字符串可以在大括号内包含表达式:
int x = 4;
Console.Write ($"A square has {x} sides");
// Prints: A square has 4 sides
任何有效的 C#表达式可以出现在大括号内,并且 C#将通过调用其ToString方法或等效方法将表达式转换为字符串。您可以通过在表达式后附加冒号和格式字符串(我们在C# 12 in a Nutshell的第六章中描述了格式字符串)来更改格式:
string s = $"15 in hex is {15:X2}";
// Evaluates to "15 in hex is 0F"
从 C# 10 开始,插值字符串可以是常量,只要插值的值是常量即可:
const string greeting = "Hello";
const string message = $"{greeting}, world";
从 C# 11 开始,插值字符串可以跨多行(无论是标准还是逐字字符串):
string s = $"this interpolation spans {1 +
1} lines";
原始字符串字面量(从 C# 11 开始)也可以进行插值:
string s = $"""The date and time is {DateTime.Now}""";
要在插值字符串中包含大括号文字:
-
使用标准和逐字字符串字面量,重复所需的大括号字符。
-
使用原始字符串字面量,通过重复
$前缀来改变插值序列。
在原始字符串字面量的前缀中使用两个(或更多)$字符可以改变插值序列,从一个大括号变为两个(或更多)。考虑以下字符串:
$$"""{ "TimeStamp": "{{DateTime.Now}}" }"""
这将评估为:
{ "TimeStamp": "01/01/2024 12:13:25 PM" }
字符串比较
string不支持<和>操作符进行比较。您必须使用string的CompareTo方法,该方法返回正数、负数或零,具体取决于第一个值出现在第二个值之后、之前还是与第二个值并列:
Console.Write ("Boston".CompareTo ("Austin")); // 1
Console.Write ("Boston".CompareTo ("Boston")); // 0
Console.Write ("Boston".CompareTo ("Chicago")); // -1
在字符串内搜索
string的索引器返回指定位置的字符:
Console.Write ("word"[2]); // r
IndexOf和LastIndexOf方法用于在字符串中搜索字符。Contains、StartsWith和EndsWith方法用于在字符串中搜索子字符串。
操作字符串
因为string是不可变的,所有“操作”字符串的方法都返回一个新字符串,原始字符串保持不变:
-
Substring提取字符串的一部分。 -
Insert和Remove在指定位置插入和删除字符。 -
PadLeft和PadRight添加空格字符。 -
TrimStart、TrimEnd和Trim移除空白字符。
string类还定义了ToUpper和ToLower方法以更改大小写,Split方法以根据提供的分隔符将字符串拆分为子字符串,并且静态Join方法以将子字符串连接回字符串。
UTF-8 字符串
从 C# 11 开始,您可以使用u8后缀创建使用 UTF-8 而不是 UTF-16 编码的字符串字面量。此功能旨在用于高级场景,例如性能热点中 JSON 文本的低级处理:
ReadOnlySpan<byte> utf8 = "ab→cd"u8;
Console.WriteLine (utf8.Length); // 7
底层类型是ReadOnlySpan<byte>,我们在C# 12 in a Nutshell的第二十三章中介绍了它。您可以通过调用ToArray()方法将其转换为数组。
第七章:数组
数组表示特定类型的固定数量元素。数组中的元素始终存储在连续的内存块中,提供高效的访问。
数组在元素类型后用方括号表示。以下声明了一个包含五个字符的数组:
char[] vowels = new char[5];
方括号还索引数组,访问特定位置的元素:
vowels[0] = 'a'; vowels[1] = 'e'; vowels[2] = 'i';
vowels[3] = 'o'; vowels[4] = 'u';
Console.WriteLine (vowels [1]); // e
这会打印“e”,因为数组索引从 0 开始。您可以使用for循环语句遍历数组中的每个元素。在这个例子中,for循环循环整数i从0到4:
for (int i = 0; i < vowels.Length; i++)
Console.Write (vowels [i]); // aeiou
数组还实现了IEnumerable<T>(见“枚举和迭代器”),因此您也可以使用foreach语句枚举成员:
foreach (char c in vowels) Console.Write (c); // aeiou
运行时对所有数组索引进行边界检查。如果使用无效索引,将抛出IndexOutOfRangeException:
vowels[5] = 'y'; // Runtime error
数组的Length属性返回数组中的元素数。创建数组后,其长度不可更改。System.Collection命名空间和子命名空间提供了更高级的数据结构,如动态大小的数组和字典。
数组初始化表达式允许您在一步中声明并填充数组:
char[] vowels = new char[] {'a','e','i','o','u'};
或者简单地说:
char[] vowels = {'a','e','i','o','u'};
注意
从 C# 12 开始,您可以使用方括号代替大括号:
char[] vowels = ['a','e','i','o','u'];
这被称为集合表达式,其优点是在调用方法时同样适用:
Foo (['a','e','i','o','u']);
void Foo (char[] letters) { ... }
集合表达式也适用于其他集合类型,如列表和集合——见“集合初始化器和集合表达式”。
所有数组都继承自System.Array类,该类为所有数组定义了常见的方法和属性。这包括实例属性如Length和Rank,以及用于执行以下操作的静态方法:
-
动态创建一个数组(
CreateInstance) -
获取和设置元素,无论数组类型如何(
GetValue/SetValue) -
搜索已排序的数组(
BinarySearch)或未排序的数组(IndexOf,LastIndexOf,Find,FindIndex,FindLastIndex) -
对数组进行排序(
Sort) -
复制数组(
Copy)
默认元素初始化
创建数组总是用默认值预初始化元素。类型的默认值是内存位清零的结果。例如,考虑创建一个整数数组。因为int是值类型,这将在一个连续的内存块中分配 1,000 个整数。每个元素的默认值将为 0:
int[] a = new int[1000];
Console.Write (a[123]); // 0
对于引用类型的元素,其默认值为null。
数组本身始终是引用类型对象,无论元素类型如何。例如,以下语句是合法的:
int[] a = null;
索引和范围
索引和范围(从 C# 8 开始)简化了使用数组元素或部分的操作。
注意
索引和范围也适用于 CLR 类型Span<T>和ReadOnlySpan<T>,它们提供对托管或非托管内存的高效低级访问。
您还可以通过定义类型为Index或Range的索引器来使自定义类型与索引和范围一起使用(参见“索引器”)。
索引
索引允许您相对于数组的末尾引用元素,使用^运算符。¹指的是最后一个元素,²指的是倒数第二个元素,依此类推:
char[] vowels = new char[] {'a','e','i','o','u'};
char lastElement = vowels[¹]; // 'u'
char secondToLast = vowels[²]; // 'o'
(⁰等于数组的长度,因此vowels[⁰]会生成错误。)
C#借助Index类型实现索引,因此您也可以执行以下操作:
Index first = 0;
Index last = ¹;
char firstElement = vowels [first]; // 'a'
char lastElement = vowels [last]; // 'u'
范围
范围允许您使用..运算符“切片”数组:
char[] firstTwo = vowels [..2]; // 'a', 'e'
char[] lastThree = vowels [2..]; // 'i', 'o', 'u'
char[] middleOne = vowels [2..3]; // 'i'
范围中的第二个数字是排除的,因此..2返回vowels[2]之前的元素。
您还可以在范围中使用^符号。以下返回最后两个字符:
char[] lastTwo = vowels [²..⁰]; // 'o', 'u'
(这里⁰有效,因为范围中的第二个数字是排除的。)
C#借助Range类型实现范围,因此您也可以执行以下操作:
Range firstTwoRange = 0..2;
char[] firstTwo = vowels [firstTwoRange]; // 'a', 'e'
多维数组
多维数组有两种类型:矩形和锯齿。矩形数组表示一个n维内存块,而锯齿数组则是数组的数组。
矩形数组
要声明矩形数组,请使用逗号分隔每个维度。以下声明一个矩形二维数组,其维度为 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}
};
(在此类声明语句中可以省略粗体显示的代码。)
锯齿数组
要声明锯齿数组,请为每个维度使用连续的方括号对。以下是声明锯齿二维数组的示例,其中最外层维度为 3:
int[][] matrix = new int[3][];
在声明中未指定内部维度,因为与矩形数组不同,每个内部数组的长度都可以是任意的。每个内部数组隐式初始化为 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}
};
(在此类声明语句中可以省略粗体显示的代码。)
简化的数组初始化表达式
我们已经看到如何通过省略new关键字和类型声明来简化数组初始化表达式:
char[] vowels = new char[] {'a','e','i','o','u'};
char[] vowels = {'a','e','i','o','u'};
char[] vowels = ['a','e','i','o','u'];
另一种方法是使用var关键字,该关键字指示编译器隐式地为局部变量确定类型。以下是一些简单的示例:
var i = 3; // i is implicitly of type int
var s = "sausage"; // s is implicitly of type string
相同的原理可以应用于数组,但可以进一步进行。通过在new关键字后省略类型限定符,编译器推断数组类型:
// Compiler infers char[]
var vowels = new[] {'a','e','i','o','u'};
下面我们来看如何将其应用于多维数组:
var rectMatrix = new[,]
{
{0,1,2},
{3,4,5},
{6,7,8}
};
var jaggedMat = new int[][]
{
new[] {0,1,2},
new[] {3,4,5},
new[] {6,7,8,9}
};
第八章:变量和参数
变量 表示具有可修改值的存储位置。变量可以是 局部变量、参数(value、ref、out 或 in)、字段(实例或静态)、或 数组元素。
堆栈与堆
堆栈 和 堆 是变量所在的地方。它们各自具有非常不同的生存期语义。
堆栈
堆栈 是用于存储局部变量和参数的内存块。堆栈在逻辑上随着方法或函数的进入和退出而增长和收缩。考虑以下方法(为避免分心,忽略输入参数检查):
static int Factorial (int x)
{
if (x == 0) return 1;
return x * Factorial (x-1);
}
此方法是 递归 的,意味着它会调用自身。每次进入方法时,在堆栈上分配一个新的 int,每次退出方法时,该 int 被释放。
堆
堆 是对象(即引用类型实例)所在的内存。每当创建一个新对象时,它被分配到堆上,并返回对该对象的引用。在程序执行期间,堆会随着创建新对象而填满。运行时具有垃圾收集器,定期从堆中释放对象,以便程序不会耗尽内存。只要一个对象不被任何活动对象引用,就有资格进行释放。
值类型实例(和对象引用)存在于变量声明的位置。如果实例被声明为类类型的字段或数组元素,则该实例存在于堆上。
注意
在 C# 中不能像在 C++ 中那样显式删除对象。一个未引用的对象最终会被垃圾收集器收集。
堆还存储静态字段和常量。与在堆上分配的对象(可以进行垃圾收集)不同,这些对象一直存在,直到应用程序域被拆除。
明确赋值
C# 强制实施明确赋值策略。在实践中,这意味着在 unsafe 上下文之外,不可能访问未初始化的内存。明确赋值有三个含义:
-
局部变量必须在读取之前赋值。
-
调用方法时必须提供函数参数(除非标记为可选—参见 “可选参数”)。
-
所有其他变量(例如字段和数组元素)都由运行时自动初始化。
例如,以下代码导致编译时错误:
int x; // x is a local variable
Console.WriteLine (x); // Compile-time error
然而,以下输出 0,因为字段隐式分配了默认值(无论是实例还是静态):
Console.WriteLine (Test.X); // 0
class Test { public static int X; } // Field
默认值
所有类型实例都有一个默认值。预定义类型的默认值是通过内存按位清零的结果,对于引用类型是 null,对于数值和枚举类型是 0,对于 char 类型是 '\0',对于 bool 类型是 false。
您可以使用default关键字获取任何类型的默认值(这在与泛型一起使用时特别有用,稍后您将看到)。自定义值类型(即struct)的默认值与自定义类型定义的每个字段的默认值相同:
Console.WriteLine (default (decimal)); // 0
decimal d = default;
参数
一个方法可以有一系列参数。参数定义了必须为该方法提供的参数集。在此示例中,方法Foo有一个名为p的单一参数,类型为int:
Foo (8); // 8 is an argument
static void Foo (int p) {...} // p is a parameter
您可以使用ref、out和in修饰符控制参数的传递方式:
| 参数修改器 | 通过 | 变量必须明确赋值 |
|---|---|---|
| None | 值 | 进入中 |
ref | 引用 | 进入中 |
in | 引用(只读) | 进入中 |
out | 引用 | 进入出 |
通过值传递参数
默认情况下,在 C#中,参数是按值传递的,这是最常见的情况。这意味着当参数传递给方法时会创建一个值的副本:
int x = 8;
Foo (x); // Make a copy of x
Console.WriteLine (x); // x will still be 8
static void Foo (int p)
{
p = p + 1; // Increment p by 1
Console.WriteLine (p); // Write p to screen
}
给p赋一个新值不会更改x的内容,因为p和x位于不同的内存位置。
通过值传递引用类型参数会复制引用而不是对象。在下面的示例中,Foo看到了我们实例化的相同StringBuilder对象(sb),但对它有独立的引用。换句话说,sb和fooSB是引用同一StringBuilder对象的不同变量:
StringBuilder sb = new StringBuilder();
Foo (sb);
Console.WriteLine (sb.ToString()); // test
static void Foo (StringBuilder fooSB)
{
fooSB.Append ("test");
fooSB = null;
}
因为fooSB是引用的副本,将其设置为null不会使sb为 null。(但是,如果fooSB被声明并用ref修饰符调用,则sb将会变为 null。)
ref修饰符
要按引用传递,C# 提供了ref参数修改器。在下面的示例中,p和x引用相同的内存位置:
int x = 8;
Foo (ref x); // Ask Foo to deal
// directly with x
Console.WriteLine (x); // x is now 9
static void Foo (ref int p)
{
p = p + 1; // Increment p by 1
Console.WriteLine (p); // Write p to screen
}
现在给p赋一个新值会改变x的内容。请注意,写入和调用方法时都需要ref修饰符。这使得正在发生的事情非常清晰。
注意
无论参数类型是引用类型还是值类型,参数都可以按引用或按值传递。
out修饰符
out参数类似于ref参数,但以下几点不同:
-
不需要在进入函数之前赋值。
-
它必须在函数退出之前赋值。
out修饰符最常用于从方法中获取多个返回值。
输出变量和丢弃
从 C# 7 开始,您可以在调用具有out参数的方法时即时声明变量:
int.TryParse ("123", out int x);
Console.WriteLine (x);
这等同于:
int x;
int.TryParse ("123", out x);
Console.WriteLine (x);
在调用具有多个out参数的方法时,您可以使用下划线“丢弃”您不感兴趣的任何参数。假设SomeBigMethod已定义为具有五个out参数,则可以忽略除第三个之外的所有参数,如下所示:
SomeBigMethod (out _, out _, out int x, out _, out _);
Console.WriteLine (x);
in修饰符
从 C# 7.2 开始,你可以在参数前加上in修饰符,以防止其在方法内被修改。这允许编译器避免在传递之前复制参数的开销,这在大型自定义值类型的情况下尤为重要(参见“Structs”)。
params 修饰符
如果将params修饰符应用于方法的最后一个参数,该方法将允许接受特定类型的任意数量的参数。参数类型必须声明为(单维)数组。例如:
int Sum (params int[] ints)
{
int sum = 0;
for (int i = 0; i < ints.Length; i++) sum += ints[i];
return sum;
}
你可以这样调用它:
Console.WriteLine (Sum (1, 2, 3, 4)); // 10
如果在params位置上没有参数,则创建一个长度为零的数组。
你还可以将params参数作为普通数组提供。前述调用在语义上等同于:
Console.WriteLine (Sum (new int[] { 1, 2, 3, 4 } ));
可选参数
方法、构造函数和索引器可以声明 可选参数。如果在其声明中指定了 默认值,则该参数是可选的:
void Foo (int x = 23) { Console.WriteLine (x); }
调用方法时可以省略可选参数:
Foo(); // 23
默认参数 23 实际上 传递 给了可选参数 x —— 编译器将值 23 嵌入到调用方的编译代码中。前述对 Foo 的调用在语义上与以下内容相同:
Foo (23);
因为编译器简单地在任何使用处替换可选参数的默认值。
警告
向另一个程序集中的公共方法添加可选参数需要重新编译两个程序集——就像该参数是必需的一样。
可选参数的默认值必须由常量表达式、值类型的无参构造函数或default表达式指定。你不能用ref或out标记可选参数。
强制参数必须在方法声明和方法调用中 之前 出现可选参数(例外情况是params参数,它们始终出现在最后)。在以下示例中,显式值1被传递给x,默认值0被传递给y:
Foo(1); // 1, 0
void Foo (int x = 0, int y = 0)
{
Console.WriteLine (x + ", " + y);
}
通过结合可选参数和命名参数,你可以进行反向操作(向x传递默认值,向y传递显式值)。
命名参数
你可以通过名称而不是位置标识一个参数。例如:
Foo (x:1, y:2); // 1, 2
void Foo (int x, int y)
{
Console.WriteLine (x + ", " + y);
}
命名参数可以以任何顺序出现。对Foo的以下调用在语义上是相同的:
Foo (x:1, y:2);
Foo (y:2, x:1);
你可以混合使用命名参数和位置参数,只要命名参数出现在最后:
Foo (1, y:2);
命名参数在与可选参数结合使用时特别有用。例如,考虑以下方法:
void Bar (int a=0, int b=0, int c=0, int d=0) { ... }
你可以这样调用它,只提供d的值:
Bar (d:3);
在调用 COM API 时特别有用。
var —— 隐式类型的局部变量
通常情况下,你会一步声明和初始化一个变量。如果编译器能够从初始化表达式中推断出类型,则可以使用 var 替代类型声明。例如:
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是强制性的情况。
目标类型化的新表达式
另一种减少词汇重复的方法是使用C# 9中的目标类型化new 表达式:
StringBuilder sb1 = new();
StringBuilder sb2 = new ("Test");
这与以下完全等价:
StringBuilder sb1 = new StringBuilder();
StringBuilder sb2 = new StringBuilder ("Test");
原则是,如果编译器能够明确地推断出来,你可以调用new而不需要指定类型名称。当变量声明和初始化在代码的不同部分时,目标类型化new表达式尤其有用。一个常见的例子是当你想在构造函数中初始化一个字段时:
class Foo
{
System.Text.StringBuilder sb;
public Foo (string initialValue)
{
sb = new (initialValue);
}
}
目标类型化new表达式在以下场景中也非常有帮助:
MyMethod (new ("test"));
void MyMethod (System.Text.StringBuilder sb) { ... }
第九章:表达式和运算符
一个表达式本质上表示一个值。最简单的表达式是常量(如123)和变量(如x)。表达式可以通过运算符进行转换和组合。运算符接受一个或多个输入操作数来输出一个新的表达式:
12 * 30 // * is an operator; 12 and 30 are operands.
复杂的表达式可以被构建,因为操作数本身可以是一个表达式,例如下面例子中的操作数(12 * 30):
1 + (12 * 30)
在 C#中,运算符可以分为一元、二元或三元,取决于它们操作的操作数数量(一个、两个或三个)。二元运算符总是使用中缀表示法,其中运算符被放置在两个操作数之间。
对于语言基本结构中的内置运算符,称为主要运算符;方法调用运算符就是一个例子。一个没有值的表达式称为空表达式:
Console.WriteLine (1)
因为空表达式没有值,所以你不能将其用作构建更复杂表达式的操作数:
1 + Console.WriteLine (1) // Compile-time error
赋值表达式
一个赋值表达式使用=运算符将另一个表达式的结果赋给一个变量。例如:
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
(这条规则的一个微妙的例外是事件,我们稍后描述:这里的+=和-=运算符被特殊对待,并映射到事件的add和remove访问器。)
运算符优先级和结合性
当一个表达式包含多个运算符时,优先级和结合性决定了它们的求值顺序。优先级较高的运算符比优先级较低的运算符先执行。如果运算符具有相同的优先级,则运算符的结合性决定了求值的顺序。
优先级
表达式 1 + 2 * 3 会被评估为 1 + (2 * 3),因为 * 的优先级高于 +。
左关联运算符
二进制运算符(赋值、lambda 和 null-coalescing 运算符除外)是左关联的;换句话说,它们从左到右进行评估。例如,表达式 8/4/2 会被评估为 (8/4)/2。
右关联运算符
赋值、lambda 运算符、null-coalescing 运算符和(三元)条件运算符是右关联的;换句话说,它们从右到左进行评估。右关联允许多个赋值,例如 x=y=3 可以编译:它首先将 3 赋给 y,然后将该表达式的结果 (3) 赋给 x。
操作符表
下表按照 C# 操作符的优先级排序。在同一子标题下列出的操作符具有相同的优先级。我们在 “操作符重载” 部分解释用户可重载的操作符。
| 操作符符号 | 操作符名称 | 示例 | 可重载 | |
|---|---|---|---|---|
| 主要(最高优先级) | ||||
. | 成员访问 | x.y | 否 | |
?. | 空值条件 | x?.y | 否 | |
!(后缀) | 空值容错 | x!.y | 否 | |
-> | 指向结构体的指针(不安全) | x->y | 否 | |
() | 函数调用 | x() | 否 | |
[] | 数组/索引 | a[x] | 通过索引器 | |
++ | 后增量 | x++ | 是 | |
-- | 后减量 | x-- | 是 | |
new | 创建实例 | new Foo() | 否 | |
stackalloc | 堆栈分配 | stackalloc(10) | 否 | |
typeof | 根据标识符获取类型 | typeof(int) | 否 | |
nameof | 获取标识符名称 | nameof(x) | 否 | |
checked | 整数溢出检查开启 | checked(x) | 否 | |
unchecked | 整数溢出检查关闭 | unchecked(x) | 否 | |
default | 默认值 | default(char) | 否 | |
sizeof | 获取结构体大小 | sizeof(int) | 否 | |
| 一元 | ||||
await | 等待 | await myTask | 否 | |
+ | 正值 | +x | 是 | |
- | 负值 | -x | 是 | |
! | 非 | !x | 是 | |
~ | 按位取反 | ~x | 是 | |
++ | 前增量 | ++x | 是 | |
-- | 前减量 | --x | 是 | |
() | 类型转换 | (int)x | 否 | |
^ | 从末尾索引 | array[¹] | 否 | |
* | 地址处的值(不安全) | *x | 否 | |
& | 值的地址(不安全) | &x | 否 | |
| 范围 | ||||
.. ..^ | 索引范围 | x..y x..^y | 否 | |
| Switch 和 with |
| switch | Switch 表达式 | num switch { 1 => true,
_ => false
} | 否 | |
with | With 表达式 | rec with { X = 123 } | 否 | |
|---|---|---|---|---|
| 乘法 | ||||
* | 乘法 | x * y | 是 | |
/ | 除法 | x / y | 是 | |
% | 取余 | x % y | 是 | |
| 加法 | ||||
+ | 加法 | x + y | 是 | |
- | 减法 | x - y | 是 | |
| 移位 | ||||
<< | 左移 | x << 1 | 是 | |
>> | 右移 | x >> 1 | 是 | |
>>> | 无符号右移 | x >>> 1 | 是 | |
| 关系 | ||||
< | 小于 | x < y | 是 | |
> | 大于 | x > y | 是 | |
<= | 小于或等于 | x <= y | 是 | |
>= | 大于或等于 | x >= y | 是 | |
is | 类型为或类型为子类 | x is y | 否 | |
as | 类型转换 | x as y | 否 | |
| 相等性 | ||||
== | 等于 | x == y | 是 | |
!= | 不等于 | x != y | 是 | |
| 按位与 | ||||
& | 与 | x & y | 是 | |
| 按位异或 | ||||
^ | 异或 | x ^ y | 是 | |
| 按位或 | ||||
| | 或 | x | y | 是 | |
| 条件与 | ||||
&& | 条件与 | x && y | 通过 & | |
| 条件或 | ||||
|| | 条件或 | x || y | 通过 | | |
| 空值合并 | ||||
?? | 空值合并 | x ?? y | 否 | |
| 条件(三元) | ||||
? : | 条件 | isTrue ? thenThis : elseThis | 否 | |
| 赋值和 lambda(最低优先级) | ||||
= | 赋值 | x = y | 否 | |
*= | 自身乘法 | x *= 2 | 通过 * | |
/= | 自身除法 | x /= 2 | 通过 / | |
+= | 自身加法 | x += 2 | 通过 + | |
-= | 自身减法 | x -= 2 | 通过 - | |
<<= | 自身左移 | x <<= 2 | 通过 << | |
>>= | 自身右移 | x >>= 2 | 通过 >> | |
>>>= | 无符号自身右移 | x >>>= 2 | 通过 >>> | |
&= | 自身与 | x &= 2 | 通过 & | |
^= | 自身异或 | x ^= 2 | 通过 ^ | |
|= | 自身或 | x |= 2 | 通过 | | |
=> | Lambda | x => x + 1 | 否 |