听说你想学习 C#?那就从 HelloWorld 程序开始吧

C# 编程语言可以为各种不同的系统平台开发应用软件和程序组件。它支持的系统平台包括:移动设备、游戏主机、Web 应用、物联网、微服务以及桌面应用。

此外,C#是完全免费的。实际上,由于 C# 是开源软件,因此你可以自由地查看其代码,并对其进行修改和重新发布;当然,也可以向开源社区贡献自己对 C# 的改进。

作为一种语言,C# 基于其前身 C 风格编程语言(C、C++、Java)的功能而设计,这使得拥有其他编程经验的开发者可以快速地掌握 C# 程序开发。

本文用传统 HelloWorld 程序介绍 C#,重点是 C# 语法基础,包括定义 C# 程序入口。通过本文的学习,你将熟悉 C# 的语法风格和结构,能开始写最简单的 C# 程序。

讨论 C# 语法基础之前,将简单介绍托管执行环境,并解释 C# 程序在运行时如何执行。

最后讨论变量声明、控制台输入/输出以及基本的 C# 代码注释机制。

一、Hello, World

学习新语言最好的办法就是写代码。第一个例子是经典 HelloWorld 程序,它在屏幕上显示一些文本。代码清单 1 展示了完整 HelloWorld 程序,我们将在之后的小节编译并运行代码。

代码清单 1 用 C# 编写的 HelloWorld

1
2
3
4
5
6
7
public class HelloWorld
{
    public static void Main()
    {
        System.Console.WriteLine("Hello. My name is Astrid.");
    }
}

有 Java、C 或者 C++ 编程经验的读者很快就能看出相似的地方。类似于 Java,C# 也从 C 和 C++ 继承了基本的语法 1

语法标点(比如分号和大括号)、特性(比如区分大小写)和关键字(比如 classpublicvoid)对于这些程序员来说并不陌生。

初学者和其他语言背景的程序员通过这个程序能很快体会到这些构造的直观性。

1.1 创建、编辑、编译和运行 C# 源代码

写好 C# 代码后需要编译和运行。这时要选择使用哪个 .NET 实现(或者说 .NET 框架)。

这些实现通常打包成一个软件开发包(Software Development Kit,SDK),其中包括编译器、运行时执行引擎、“运行时”能访问的语言可访问功能框架(参见 6.1 节),以及可能和 SDK 捆绑的其他工具(比如供自动化生成的生成引擎)。

由于 C# 自 2000 年便已公布,目前有多个不同的 .NET 框架供选择(参见第 6 节)。

取决于开发的目标操作系统以及你选择的 .NET 框架,每种 .NET 框架的安装过程都有所区别。

有鉴于此,建议访问 https://dotnet.microsoft.com/download 了解具体的下载和安装指示。如有必要,先选好 .NET 框架,再根据目标操作系统选择要下载的包。

虽然我可以在这里提供更多细节,但 .NET 下载站点为支持的各种组合提供了最新、最全的指令。

如不确定要使用的 .NET 框架,就默认选择 .NET Core。它可运行于 Linux、macOS 和 Microsoft Windows,是 .NET 开发团队投入最大的实现。

另外,由于它具有跨平台能力,所以本书优先使用 .NET Core。

有许多源代码编辑工具可供选择,包括最基本的 Windows 记事本、Mac/macOS TextEdit 和 Linux vi。但建议选择一个稍微高级点的工具,至少应支持彩色标注。支持 C# 的任何代码编辑器都可以。

如果还没有特别喜欢的,推荐开源编辑器 Visual Studio Code(https://code.visualstudio.com)。为了让 Visual Studio Code 更好地支持 C# 程序开发,请参考图 1 下载安装 C# 扩展模块。

如果在 Windows 或 Mac 上工作,也可考虑 Microsoft Visual Studio 2019(或更高版本),详情参考 https://visualstudio.microsoft.com/vs/。两者都是免费的。

为 Visual Studio Code 下载安装 C# 扩展模块

图 1 为 Visual Studio Code 下载安装 C# 扩展模块

后两节我会提供这两种编辑器的操作指示。

Visual Studio Code 依赖命令行接口(CLI)工具 dotnet CLI 创建初始的 C# 程序基架并编译和运行程序。Windows 和 Mac 则一般使用 Visual Studio 2019。

1.1.1 使用 dotnet CLI

dotnet 命令 dotnet.exe 是 dotnet 命令行接口(或称 dotnet CLI),可用于生成 C# 程序的初始代码库并编译和运行程序 2

注意这里的 CLI 代表“命令行接口”(Command-Line Interface)。

为避免和代表“公共语言基础结构”(Common Language Infrastructure)的 CLI 混淆,本文在提到 dotnet CLI 时都会附加 dotnet 前缀。

无 dotnet 前缀的 CLI 才是“公共语言基础结构”。安装好之后,验证可以在命令行上执行 dotnet

以下是在 Windows、macOS 或 Linux 上创建、编译和执行 HelloWorld 程序的指示:

  1. 在 Microsoft Windows 上打开命令提示符,在 Mac/macOS 上打开 Terminal 应用。(也可考虑使用跨平台命令行接口 PowerShell 3。)

  2. 在想要放代码的地方新建一个目录。考虑 ./HelloWorld./EssentialCSharp/HelloWorld 这样的名称。在命令行上执行:

    mkdir ./Helloworld

  3. 导航到新目录,使之成为命令行的当前目录:

    cd ./Helloworld

  4. 在 HelloWorld 目录中执行 dotnet new console 命令来生成程序基架(或称程序项目)。这会生成几个文件,最主要的是 Program.cs 和项目文件:

    dotnet new console

  5. 运行生成的程序。这会编译并运行由 dotnet new console 命令创建的默认 Program.cs 程序。程序内容和代码清单 1 相似,只是输出变成“Hello World!”。

    dotnet run

    虽然没有显式请求、编译(或生成)程序项目,但 dotnet run 命令在执行时隐式执行了这一步。

  6. 编辑 Program.cs 文件并修改代码使之和代码清单 1 一致。用 Visual Studio Code 打开并编辑 Program.cs 会体验到支持 C# 的编辑器的好处,代码会用彩色标注不同类型的构造。

    若想用 Visual Studio Code 打开和编辑项目,请在 HelloWorld 目录中执行 code . 命令。(也可以参考输出 1,那里展示的命令行命令在 Bash 和 PowerShell 中均可使用。)

  7. 重新运行程序:

    dotnet run

    输出 1.1 展示了上述步骤的输出。

输出 1

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
PS D:\Users\Astrid\Desktop> mkdir ./HelloWorld
PS D:\Users\Astrid\Desktop> cd ./HelloWorld/
PS D:\Users\Astrid\Desktop\HelloWorld> dotnet new console
已成功创建模板“控制台应用”。

正在处理创建后操作...
在 D:\Users\Astrid\Desktop\HelloWorld\HelloWorld.csproj 上运行 “dotnet restore”...
  正在确定要还原的项目…
  已还原 D:\Users\Astrid\Desktop\HelloWorld\HelloWorld.csproj (用时 96 ms)。
已成功还原。

PS D:\Users\Astrid\Desktop\HelloWorld> dotnet run
Hello, World!
PS D:\Users\Astrid\Desktop\HelloWorld> echo '
>> class HelloWorld
>> {
>>     static void Main()
>>     {
>>         System.Console.WriteLine("Hello. My name is Astrid.");
>>     }
>> }'>Program.cs
PS D:\Users\Astrid\Desktop\HelloWorld> dotnet run
Hello. My name is Astrid.
PS D:\Users\Astrid\Desktop\HelloWorld>

1.1.2 使用 Visual Studio 2019

在 Visual Studio 2019 中的操作相似,只是不用命令行,而是用集成开发环境(IDE)。有菜单可选,不必一切都在命令行上进行。

  1. 启动 Visual Studio 2019。

  2. 在开始页面上点击“Create a new project”按钮。(如果开始页面未出现,请选择“File”|“Start Window”菜单打开开始页面,或者直接通过“File”|“New Project”(Ctrl+Shift+N)菜单创建项目。)

  3. 在搜索框(Ctrl+E)中输入“Console App”并选择“Console App(.NET Core)——Visual C#”,如图 2 所示。

    “新建项目”对话框

    图 2 “新建项目”对话框

  4. 在“Project name”框中输入 HelloWorld。在“Location”处选择你的工作目录。如图 3 所示。

    设置新项目的对话框

    图 3 设置新项目的对话框

  5. 项目创建好后会打开 Program.cs 文件供编辑,如图 1.4 所示。

    编辑 Program.cs 文件

    图 4 编辑 Program.cs 文件

  6. 选择“Debug”|“Start Without Debugging”(Ctrl+F5)来生成并运行程序。会显示如输出 2 所示的命令窗口,只是第一行目前为“Hello World! ”。

  7. Program.cs 修改成代码清单 1 的样子。

  8. 返回程序并重新运行,获得如输出 2 所示的结果。

    输出 2

    1
    2
    
    Hello. My name is Astrid.
    Press any key to close this window . . .
    

1.1.3 调试

IDE 最重要的一个功能是调试。请在 Visual Studio 或者 Visual Studio Code 中按照以下额外步骤试验其调试功能:

  1. 光标定位到 System.Console.WriteLine 这一行,选择“调试”|“切换断点”(F9)在该行激活断点。

  2. 选择“调试”|“开始调试” (F5)重新启动应用程序,但这次激活了调试功能。

    如果你正在使用 Visual Studio Code,第一次启动程序时会弹出对话框,询问该程序的运行环境。

    请选择“.NET Core”,这样 Visual Studio Code 便会自动生成 launch.jsontask.json 两个与运行有关的配置文件。

    然后再次选择“调试”|“开始调试”(F5)启动应用程序。注意程序会在断点所在行停止执行。

    此时可将鼠标放到某个变量(例如 args)上以观察它的值。还可以拖动左侧黄箭头将程序执行从当前行移动到当前方法内的另一行。

  3. 要继续执行,选择“调试”|“继续”(F5)或者点击工具栏上的“继续”按钮。

若要继续深入学习如何使用 Visual Studio 2019 进行程序调试,请访问网站 https://docs.microsoft.com/zh-cn/visualstudio/debugger/debugger-feature-tour?view=vs-2022

在 Visual Studio Code 中,程序输出会显示在 Debug Console(调试控制台)窗口中。(选择“View”(视图)|“Debug Console”(调试控制台)或 Ctrl+Shift+V 打开调试控制台观察程序输出结果。)

若要继续深入学习如何使用 Visual Studio Code 进行程序调试,请访问网站 https://code.visualstudio.com/docs/editor/debugging

1.2 创建项目

无论 dotnet CLI 还是 Visual Studio 都会自动创建几个文件。

第一个是名为 Program.cs 的 C# 文件。虽然可选择任何名称,但一般都用 Program 这一名称作为控制台程序起点。

.cs 是所有 C# 文件的标准扩展名,也是编译器默认要编译成最终程序的扩展名。

为了使用代码清单 1 中的代码,可打开 Program.cs 文件并将其内容替换成代码清单 1 的内容。

保存更新文件之前,注意代码清单 1 和默认生成的代码相比,唯一功能上的差异就是引号间的文本。还有就是后者多了 using System; 指令,这是一处语义上的差异。

虽然并非一定需要,但通常都会为 C# 项目生成一个项目文件。项目文件的内容随不同应用程序类型和 .NET 框架而变。

但至少会指出哪些文件要包含到编译中、要生成什么应用程序类型(控制台、Web、移动、测试项目等)、支持什么 .NET 框架、调试或启动应用程序需要什么设置,以及代码的其他依赖项(称为库)。

例如,代码清单 2 列出了前面刚创建过的 .NET Core 控制台应用的项目文件。

代码清单 2 示例 .NET Core 控制台应用的项目文件

1
2
3
4
5
6
<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>netcoreapp3.1</TargetFramework>
    </PropertyGroup>
</Project>

注意应用程序标识为 .NET Core 版本 3.1(netcoreapp 3.1)的控制台应用(Exe)。其他所有设置(比如要编译哪些 C# 文件)则沿用默认值。

例如,和项目文件同一目录(或子目录)中的所有 *.cs 文件都会包含到编译中。

1.3 编译和执行

dotnet build 命令生成名为 HelloWorld.dll程序集(assembly)4

扩展名 .dll 代表“动态链接库”(Dynamic Link Library,DLL)。对于 .NET Core,所有程序集都使用 .dll 扩展名。控制台程序也不例外,就像本例这样。

.NET Core 应用程序的编译输出默认放到子目录 ./bin/Debug/netcoreapp3.1/。之所以使用 Debug 这个名称,是因为默认配置就是 debug。该配置造成输出为调试进行优化,而不是为性能而优化。

编译好的输出本身不能直接执行。相反,需用 CLI 来寄宿(host)代码。

对于 .NET Core 应用程序,这要求 dotnet.exe 进程作为应用程序的寄宿进程。这就是为什么总是需要用 dotnet run 命令来运行应用程序。

必要时也可以将应用程序及其所需的运行时文件打包在一起,生成一个可以独立运行且无须另外安装 dotnet 运行时库可执行文件。

具体方法请参考后面的高级主题“发布可独立运行的可执行文件”。

二、C# 语法基础

成功编译并运行 HelloWorld 程序之后,我们来分析代码,了解它的各个组成部分。首先熟悉一下 C# 关键字以及可供开发者选择的标识符。

2.1 C# 关键字

C# 1.0 之后没有引入任何新的保留关键字,但在后续版本中,一些构造使用了上下文关键字,它们在特定位置才有意义,在其他位置则无意义。这样大多数 C# 1.0 代码都能兼容后续版本。

表 1 总结了 C# 关键字。

表 1 C# 关键字

abstracteventnamespacestatic
asexplicitnewstring
baseexternnullstruct
boolFALSEobjectswitch
breakfinallyoperatorthis
bytefixedoutthrow
casefloatoverrideTRUE
catchforparamstry
charforeachprivatetypeof
checkedgotoprotecteduint
classifpubliculong
constimplicitreadonlyunchecked
continueinrefunsafe
decimalintreturnushort
defaultinterfacesbyteusing
delegateinternalsealedvirtual
doisshortvoid
doublelocksizeofvolatile
elselongstackallocwhile
enum

上下文关键字用于在代码中提供特定含义,但它不是 C# 中的保留字。 一些上下文关键字(如 partialwhere)在两个或多个上下文中有特殊含义。

表 2 C# 上下文关键字

addgetnotnullset
andglobalnuintunmanaged(函数指针调用约定)
aliasgrouponunmanaged(泛型类型约束)
ascendinginitorvalue
argsintoorderbyvar
asyncjoinpartial(类型)when(筛选条件)
awaitletpartial(方法)where(泛型类型约束)
bymanaged(函数指针调用约定)recordwhere(查询子句)
descendingnameofremovewith
dynamicnintselectyield
equalsnot
from

2.2 标识符

和其他语言一样,C# 用标识符标识程序员编码的构造。在代码清单 1 中,HelloWorldMain 均为标识符。分配标识符之后,以后将用它引用所标识的构造。

因此,开发者应分配有意义的名称,不要随性而为。

好的程序员总能选择简洁而有意义的名称,这使代码更容易理解和重用。

清晰和一致是如此重要,以至于“框架设计准则”(https://docs.microsoft.com/zh-cn/dotnet/standard/design-guidelines/)建议不要在标识符中使用单词缩写,甚至不要使用不被广泛接受的首字母缩写词。

即使被广泛接受(如 HTML),使用时也要一致。不要一会儿这样用,一会儿那样用。为避免滥用,可限制所有首字母缩写词都必须包含到术语表中。

总之,要选择清晰(甚至是详细)的名称,尤其是在团队中工作,或者开发要由别人使用的库的时候。

标识符有两种基本的大小写风格。

第一种风格被 .NET 框架创建者称为 Pascal 大小写(PascalCase),这是因为它曾经在 Pascal 编程语言中很流行,它要求标识符的每个单词首字母大写,例如 ComponentModelConfigurationHttpFileCollection

注意在 HttpFileCollection 中,由于首字母缩写词 HTTP 的长度超过两个字母,所以仅首字母大写。

第二种风格是 camel 大小写(camelCase),除第一个字母小写,其他约定一样,例如 quotientfirstNamehttpFileCollectionioStreamtheDreadPirateRoberts

下划线虽然合法,但标识符一般不要包含下划线、连字号或其他非字母/数字字符。此外,C# 不像其前辈那样使用匈牙利命名法(为名称附加类型缩写前缀)。

这避免了数据类型改变时还要重命名变量,也避免了数据类型前缀经常不一致的情况。

极少数情况下,有的标识符(比如 Main)可能在 C# 语言中具有特殊含义。

2.3 类型定义

C# 所有代码都出现在一个类型定义的内部,最常见的类型定义以关键字 class 开头。如代码清单 3 所示,类定义class identifier {...} 形式的代码块。

类型名称(本例是 HelloWorld)可以随便取,但根据约定,它应当使用 PascalCase 风格。

就本例来说,可选择的名称包括 GreetingsHelloInigoMontoyaHello 或者简单地称为 Program。(对于包含 Main() 方法的类,Program 是个很好的名称。Main() 方法的详情稍后讲述。)

代码清单 3 基本的类声明

1
2
3
4
public class HelloWorld
{
   // ...
}

程序通常包含多个类型,每个类型包含多个方法。

2.4 Main 方法

C# 程序从 Main 方法开始执行。该方法以 static void Main() 开头。

在命令控制台中输入 dotnet run HelloWorld.exe 执行程序时,程序将启动并解析 Main 的位置,然后执行其中第一条语句。如代码清单 4 所示。

代码清单 4 HelloWorld 分解示意图

HelloWorld 分解示意图

虽然 Main 方法声明可进行某种程度的变化,但关键字 static 和方法名 Main 是始终都需要的。

2.5 语句和语句分隔符

Main 方法只含一条语句,即 System.Console.WriteLine();,它在控制台上输出一行文本。

C# 通常用分号标识语句结束。每条语句都由代码要执行的一个或多个行动构成。声明变量、控制程序流程或调用方法都是语句的例子。

由于换行与否不影响语句的分隔,所以可将多条语句放到同一行,C# 编译器认为这一行包含多条指令。

例如,代码清单 6 在同一行包含了两条语句。执行时在控制台窗口分两行显示 UpDown

代码清单 6 一行上的多条语句

1
System.Console.WriteLine("Up");System.Console.WriteLine("Down");

C# 还允许一条语句跨越多行。同样地,C# 编译器根据分号判断语句结束位置。

在代码清单 7 中,HelloWorld 程序里原本单行的 WriteLine() 代码被分成多行书写。

代码清单 7 一条语句跨越多行

1
2
System.Console.WriteLine(
                "Hello. My name is Inigo Montoya.");

2.6 空白

分号使 C# 编译器能忽略代码中的空白。除少数特殊情况,C# 允许代码随意插入空白而不改变语义。

在代码清单 6 和代码清单 7 中,在语句中或语句间换行,甚至不换行都可以,对编译器最终创建的可执行文件没有任何影响。

程序员经常利用空白对代码进行缩进来增强可读性。来看看代码清单 8 和代码清单 9 展示的两个版本的 HelloWorld 程序。

代码清单 8 不缩进

1
2
3
4
5
6
7
public class HelloWorld
{
public static void Main()
{
System.Console.WriteLine("Hello Inigo Montoya");
}
}

代码清单 9 删除一切可以删除的空白

1
2
public class Program{public static void Main()
{System.Console.WriteLine("Hello Inigo Montoya");}}

虽然这两个版本看起来和原始版本颇有不同,但 C# 编译器认为所有版本无差别。

三、使用变量

前面我们已接触了最基本的 C# 程序,下面声明局部变量。变量声明后可以赋值,可将值替换成新值,并可在计算和输出等操作中使用。

但变量一经声明,数据类型就不能改变。在代码清单 10 中,string max 就是变量声明。

代码清单 10 变量声明和赋值

变量声明和赋值

3.1 数据类型

代码清单 10 声明的是 string 类型的变量。本文还使用了 intchar

  • int 是指 C# 的 32 位整型。

  • char 是字符类型,长度为 16 位,足以表示无代理项的 Unicode 字符 5

3.2 变量的声明

代码清单 10 中的 string max 是变量声明,它声明名为 maxstring 变量。

还可在同一条语句中声明多个变量,办法是指定数据类型一次,然后用逗号分隔不同标识符,如代码清单 11 所示。

代码清单 11 一条语句声明两个变量

1
string message1, message2;

由于声明多个变量的语句只允许提供一次数据类型,因此所有变量都具有相同类型。

C# 变量名可用任何字母或下划线(_)开头,后跟任意数量的字母、数字或下划线。但根据约定,局部变量名采用 camelCase 命名(除了第一个单词外,其他每个单词的首字母大写),而且不包含下划线。

3.3 变量的赋值

局部变量声明后必须在读取前赋值。一个办法是使用 = 操作符,或者称为简单赋值操作符。操作符是一种特殊符号,标识了代码要执行的操作。

代码清单 12 演示了如何利用赋值操作符指定 miracleMaxvalerie 变量要指向的字符串值。

代码清单 12 更改变量值

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public class MiracleMax
{
   public static void Main()
   {
      string valerie;
      string miracleMax = "Have fun storming the castle!";

      valerie = "Think it will work?";

      System.Console.WriteLine(miracleMax);
      System.Console.WriteLine(valerie);

      miracleMax = "It would take a miracle.";
      System.Console.WriteLine(miracleMax);
   }
}

从中可以看出,既可在声明变量的同时赋值(比如变量 miracleMax),也可在声明后用另一条语句赋值(比如变量 valerie)。要赋的值必须放在赋值操作符右侧。

运行编译好的程序生成如输出 3 所示的结果。

输出 3

1
2
3
Have fun storming the castle!
Think it will work?
It would take a miracle.

C# 要求局部变量在读取前“明确赋值”。此外,赋值作为一种操作会返回一个值。所以 C# 允许在同一语句中进行多个赋值操作,如代码清单 13 所示。

代码清单 13 赋值会返回值,该值可用于再次赋值

1
2
3
4
5
6
7
8
9
public static void Main()
{
   // ...
   string requirements, miracleMax;
   requirements = miracleMax = "It would take a miracle.";

   System.Console.WriteLine(miracleMax);

}

3.4 变量的使用

赋值后就能用变量名引用值。因此,在 System.Console.WriteLine(miracleMax) 语句中使用变量 miracleMax 时,程序在控制台上显示:“Have fun storming the castle!”也就是 miracleMax 的值。更改 miracleMax 的值并执行相同的 System.Console.WriteLine(miracleMax) 语句,会显示 miracleMax 的新值,即 It would take a miracle.

四、控制台输入和输出

本文已多次使用 System.Console.WriteLine 将文本输出到命令控制台。除了能输出数据,程序还需要能接收用户输入的数据。

4.1 从控制台获取输入

可用 System.Console.ReadLine() 方法获取控制台输入的文本。它暂停程序执行并等待用户输入。用户按回车键,程序继续。

System.Console.ReadLine() 方法的输出,也称为返回值,其内容即用户输入的文本字符串。代码清单 14 和输出 4 是一个例子。

代码清单 14 使用 System.Console.ReadLine()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public class HeyYou
{
   public static void Main()
   {
      string firstName;
      string lastName;

      System.Console.WriteLine("Hey you!");

      System.Console.Write("Enter your first name: ");
      firstName = System.Console.ReadLine();

      System.Console.Write("Enter your last name: ");
      lastName = System.Console.ReadLine();
   }
}

输出 4

1
2
3
Hey you!
Enter your first name: Astrid
Enter your last name: Lindgren

在每条提示信息后,程序都用 System.Console.ReadLine() 方法获取用户输入并赋给变量。

在第二个 System.Console.ReadLine() 赋值操作完成之后,firstName 引用值 Astrid,而 lastName 引用值 Lindgren。

4.2 将输出写入控制台

代码清单 14 是用 System.Console.Write() 而不是 System.Console.WriteLine() 方法提示用户输入名和姓。

System.Console.Write() 方法不在输出文本后自动添加换行符,而是保持当前光标位置在同一行上。这样用户输入就会和提示内容处于同一行。

代码清单 14 的输出清楚演示了 System.Console.Write() 的效果。

下一步是将通过 System.Console.ReadLine() 获取的值写回控制台。在代码清单 16 中,程序在控制台上输出用户的全名。

但这段代码使用了 System.Console.WriteLine() 的一个变体,利用了从 C# 6.0 开始引入的字符串插值功能。

注意在 Console.WriteLine 调用中为字符串字面值附加的 $ 前缀。它表明使用了字符串插值。输出 5 是对应的输出。

代码清单 16 使用字符串插值来格式化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public class Program
{
   public static void Main()
   {
      string firstName;
      string lastName;

      System.Console.WriteLine("Hey you!");

      System.Console.Write("Enter your first name: ");
      firstName = System.Console.ReadLine();

      System.Console.Write("Enter your last name: ");
      lastName = System.Console.ReadLine();

      System.Console.WriteLine(
            $"Your full name is { firstName } { lastName }.");
   }
}

输出 5

1
2
3
4
Hey you!
Enter your first name: Astrid
Enter your last name: Lindgren
Your full name is Astrid Lindgren.

代码清单 16 不是先用 Write 语句输出 "Your full name is",再用 Write 语句输出 firstName,用第三条 Write 语句输出空格,最后用 WriteLine 语句输出 lastName

相反,是用 C# 6.0 的字符串插值功能一次性输出。字符串中的大括号被解释成表达式。编译器会求值这些表达式,转换成字符串并插入当前位置。

不需要单独执行多个代码段并将结果整合成字符串,该技术允许一个步骤完成全部操作,从而增强了代码的可读性。

C# 6.0 之前则采用不同的方式,称为复合格式化。它要求先提供格式字符串来定义输出格式,如代码清单 17 所示。

代码清单 17 使用复合格式化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public class Program
{
   public static void Main()
   {
      string firstName;
      string lastName;

      System.Console.WriteLine("Hey you!");

      System.Console.Write("Enter your first name: ");
      firstName = System.Console.ReadLine();

      System.Console.Write("Enter your last name: ");
      lastName = System.Console.ReadLine();

      System.Console.WriteLine(
            "Your full name is {0} {1}.", firstName, lastName);
   }
}

本例的格式字符串是 Your full name is {0} {1}.。它为要在字符串中插入的数据标识了两个索引占位符。每个占位符的顺序对应格式字符串之后的实参。

注意索引值从零开始。每个要插入的实参,或者称为格式项,按照与索引值对应的顺序排列在格式字符串之后。

在本例中,由于 firstName 是紧接在格式字符串之后的第一个实参,所以它对应索引值 0。类似地,lastName 对应索引值 1

注意,占位符在格式字符串中不一定按顺序出现。例如,代码清单 18 交换了两个索引占位符的位置并添加了一个逗号,从而改变了姓名的显示方式(参见输出 6)。

代码清单 18 交换索引占位符和对应的变量

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
public class Program
{
   public static void Main()
   {
      string firstName;
      string lastName;

      System.Console.WriteLine("Hey you!");

      System.Console.Write("Enter your first name: ");
      firstName = System.Console.ReadLine();

      System.Console.Write("Enter your last name: ");
      lastName = System.Console.ReadLine();

      System.Console.WriteLine("Your full name is {1}, {0}.", firstName, lastName);
   }
}

输出 6

1
2
3
4
Hey you!
Enter your first name: Astrid
Enter your last name: Lindgren
Your full name is Lindgren, Astrid.

占位符除了能在格式字符串中按任意顺序出现,同一占位符还能在一个格式字符串中多次使用。另外,也可省略占位符。但每个占位符都必须有对应的实参。

4.3 注释

本节修改代码清单 17 来添加注释。注释不会改变程序的执行,只是使代码变得更容易理解。代码清单 19 中展示了新代码,输出 7 是对应的输出。

代码清单 19 为代码添加注释

为代码添加注释

输出 7

1
2
3
4
Hey you!
Enter your first name: Astrid
Enter your last name: Lindgren
Your full name is Astrid Lindgren.

虽然插入了注释,但编译并执行后产生的输出和以前是一样的。

程序员用注释来描述和解释自己写的代码,尤其是在语法本身难以理解的时候,或者是在另辟蹊径实现一个算法的时候。

只有检查代码的程序员才需要看注释,编译器会忽略注释,因而生成的程序集中看不到源代码中的注释的一丝踪影。

表 2 总结了 4 种不同的 C# 注释。代码清单 19 使用了其中两种。

表 2 C# 注释类型

注释类型说明例子
带分隔符的注释正斜杠后跟一个星号,即 /*,用于开始一条带分隔符的注释。结束注释是在星号后跟上一个正斜杠,即 */。这种形式的注释可在代码文件中跨越多行,也可在一行代码中嵌入使用。如星号出现在行首,同时又在 /**/ 这两个分隔符之间,那么它们也是注释的一部分,仅用于对注释进行排版/* 注释 */
单行注释注释也可以放在由两个连续的正斜杠构成的分隔符(//)之后。编译器将从这个分隔符开始到行末的所有文本视为注释。这种形式的注释只占一行。但可以连续使用多条单行注释,就像代码清单 19 最后的注释那样// 注释
XML 带分隔符的注释/** 开头并以 **/ 结尾的注释称为 XML 带分隔符的注释。它们具有与普通的带分隔符的注释一样的特征,只是编译器会注意到 XML 注释的存在,而且可以把它们放到一个单独的文本文件中。XML 带分隔符的注释是 C#2.0 新增的,但它的语法完全与 C#1.0 兼容/** 注释 **/
XML 单行注释XML 单行注释以 // 开头,并延续到行末。除此之外,编译器可将 XML 单行注释和 XML 带分隔符的注释一起存储到单独的文件中/// 注释

编程史上确有一段时期,如代码没有详尽的注释,都不好意思说自己是专业程序员。

然而时代变了。没有注释但可读性好的代码,比需要注释才能说清楚的代码更有价值。

如开发者发现需要写注释才能说清楚代码块的功用,则应考虑重构,而不是洋洋洒洒写一堆注释。

写注释来重复代码本来就讲得清的事情,只会使代码变得臃肿并降低可读性,还容易过时,因为将来代码可能更改了但注释却没有来得及更新。

五、托管执行和 CLI

处理器不能直接解释程序集。程序集用的是另一种语言,即公共中间语言(Common Intermediate Language,CIL),或称中间语言(IL)7

C# 编译器将 C# 源代码文件转换成中间语言。为了将 CIL 代码转换成处理器能理解的机器码,还要完成一个额外的步骤(通常在运行时进行)。

该步骤涉及 C# 程序执行的一个重要元素:VES(Virtual Execution System,虚拟执行系统)。VES 也称为运行时(runtime)。

它根据需要编译 CIL 代码,这个过程称为即时编译JIT 编译(just-in-time compilation)。

如代码在像“运行时”这样的一个“代理”的上下文中执行,就称为托管代码(managed code),在“运行时”的控制下执行的过程则称为托管执行(managed execution)。

之所以称为“托管”,是因为“运行时”管理着诸如内存分配、安全性和 JIT 编译等方面,从而控制了主要的程序行为。

执行时不需要“运行时”的代码称为本机代码(native code)或非托管代码(unmanaged code)。

“运行时”规范包含在一个包容面更广的规范中,即 CLI(Common Language Infrastructure,公共语言基础结构)规范。作为国际标准,CLI 包含了以下几方面的规范。

  • VES 或“运行时”。

  • CIL。

  • 支持语言互操作性的类型系统,称为 CTS(Common Type System,公共类型系统)。

  • 编写通过 CLI 兼容语言访问的库的指导原则。

  • 使各种服务能被 CLI 识别的元数据(包括程序集的布局或文件格式规范)。

在“运行时”执行引擎的上下文中运行,程序员不需要直接写代码就能使用几种服务和功能,包括:

  • 语言互操作性: 不同源语言间的互操作性。语言编译器将每种源语言转换成相同中间语言(CIL)来实现这种互操作性。

  • 类型安全: 检查类型间转换,确保兼容的类型才能相互转换。这有助于防范缓冲区溢出(这是产生安全隐患的主要原因)。

  • 代码访问安全性: 程序集开发者的代码有权在计算机上执行的证明。

  • 垃圾回收: 一种内存管理机制,自动释放“运行时”为数据分配的空间。

  • 平台可移植性: 同一程序集可在多种操作系统上运行。要实现这一点,一个显而易见的限制就是不能使用平台特有的库。所以平台依赖问题需单独解决。

  • BCL(基类库): 提供开发者能(在所有.NET 框架中)依赖的大型代码库,使其不必亲自写这些代码。

5.1 CIL 和 ILDASM

前面说过,C# 编译器将 C# 代码转换成 CIL 代码而不是机器码。处理器只理解机器码,所以 CIL 代码必须先转换成机器码才能由处理器执行。

可用 CIL 反汇编程序将程序集解构为 CIL。

通常使用 Microsoft 特有的文件名 ILDASM 来称呼这种 CIL 反汇编程序(ILDASM 是 IL Disassembler 的简称),它能对程序集执行反汇编,提取 C# 编译器生成的 CIL。

反汇编 .NET 程序集的结果比机器码更易理解。许多开发者害怕即使别人没有拿到源代码,程序也容易被反汇编并曝光其算法。

其实无论是否基于 CLI,任何程序防止反编译唯一安全的方法就是禁止访问编译好的程序(例如只在网站上存放程序,不把它分发到用户机器)。

但假如目的只是减小别人获得源代码的可能性,可考虑使用一些混淆器(obfuscator)产品。这种产品会读取 IL 代码,转换成一种功能不变但更难理解的形式。

这可以防止普通开发者访问代码,使程序集难以被反编译成容易理解的代码。除非程序需要对算法进行高级安全防护,否则混淆器足以确保安全。

六、多个 .NET 框架

本章文之前说过,目前存在多个 .NET 框架。Microsoft 的宗旨是在最大范围的操作系统和硬件平台上提供 .NET 实现,表 1.3 列出了最主要的实现。

表 3 主要 .NET Framework 实现

实现描述
.NET Core真正跨平台和开源的 .NET 框架,为服务器和命令行应用程序提供了高度模块化的 API 集合
Microsoft .NET Framework第一个 .NET 框架,现已逐渐被 .NET Core 取代
Xamarin.NET 的移动平台实现,支持 iOS 和 Android,支持单一代码库的移动应用开发,同时允许访问本机平台 API
Mono最早的 .NET 开源实现,是 Xamarin 和 Unity 的基础。目前 Mono 已被 .NET Core 替代
Unity跨平台 2D/3D 游戏引擎,用于为游戏机、PC、移动设备和网站开发电子游戏(Unity 引擎开创了投射到 Microsoft Hololens 增强现实的先河)

6.1 应用程序编程接口

数据类型(比如 System.Console)的所有方法(常规地说是成员)定义了该类型的应用程序编程接口(Application Programming Interface,API)。

API 定义软件如何与其他组件交互,所以单独一个数据类型还不够。通常,是一组数据类型的所有 API 结合起来为某个组件集合创建一个 API。

以 .NET 为例,一个程序集中的所有类型(及其成员)构成了该程序集的 API。类似地,.NET Core 或 Microsoft .NET Framework 中的所有程序集构成了更大的 API。

通常将这一组更大的 API 称为框架,所以我们用“.NET 框架”一词指代 .NET Core 或 Microsoft .NET Framework 的所有程序集公开的 API。

API 通常包含一组接口和协议(或指令),帮助你使用一系列组件进行编程。事实上,对于 .NET 来说,协议本身就是 .NET 程序集的执行规则。

6.2 C# 和 .NET 版本控制

.NET 框架的开发周期有别于 C# 语言,这造成底层 .NET 框架和对应的 C# 语言使用了不同的版本号。

例如,使用 C# 5.0 编译器将默认基于 Microsoft .NET Framework 4.6 来编译。表 4 简单总结了 Microsoft .NET Framework 和.NET Core 的 C# 和 .NET 版本。

表 4 C# 和 .NET 版本

版本描述
C# 1.0 和 .NET Framework 1.0/1.1(Visual Studio 2002 和 2003)C# 的第一个正式发行版本。Microsoft 团队从无到有创造了一种语言,专门为 .NET 编程提供支持
C# 2.0 和 .NET Framework 2.0(VisualStudio 2005)C# 语言和库开始支持泛型,.NET Framework 2.0 新增了支持泛型的库
NET Framework 3.0新增一套 API 来支持分布式通信(Windows Communication Foundation,WCF)、富客户端表示(Windows Presentation Foundation,WPF)、工作流(Windows Workflow,WF)以及 Web 身份验证(Cardspaces)
C# 3.0 和 .NET Framework 3.5(VisualStudio 2008)添加对 LINQ 的支持,对集合编程 API 进行大幅改进。.NET Framework 3.5 对原有的 API 进行扩展以支持 LINQ
C# 4.0 和 .NET Framework 4(VisualStudio 2010)添加对动态类型的支持,对多线程编程 API 进行大幅改进,强调了多处理器和核心支持
C# 5.0 和 .NET Framework 4.5(VisualStudio 2012)和 WinRT 集成添加对异步方法调用的支持,同时不需要显式注册委托回调。框架的另一个改动是支持与 Windows Runtime(WinRT)的互操作性
C# 6.0 和 .NET Framework 4.6 和 .NET Core 1.X(Visual Studio 2015)添加字符串插值、空传播(空条件)成员访问、异常过滤器、字典初始化器和其他许多功能
C# 7.0 和 .NET Framework 4.7 和 .NETCore 1.1 或 2.0(Visual Studio 2017)添加元组、解构器、模式匹配、嵌套方法(本地函数)、返回引用等功能
C# 8.0 和 .NET Framework 4.8 以及 .Net Core 3.0添加可为 null 的引用类型、增强的模式匹配、using 声明、静态局部函数、disposable ref structs、范围、索引和异步流。.Net Framework 4.8 不支持上述的最后两个特性

随 C# 6.0 增加的最重要的一个框架功能或许是对跨平台编译的支持。

换言之,不仅能用 Windows 上运行的 Microsoft .NET Framework 编译,还能使用 Linux 和 macOS 上运行的 .NET Core 实现来编译。

虽然 .NET Core 的功能比完整的 Microsoft .NET Framework 少,但足以使整个 ASP.NET 网站在非 Windows 和 IIS 的系统上运行。

这意味着同一个代码库可编译并执行在多个平台上运行的应用程序。

.NET Core 是一套完整的 SDK,包含从 .NET Compiler Platform(即“Roslyn”,本身在 Linux 和 macOS 上运行)到 .NET Core“运行时”的一切,另外还提供了像 dotnet 命令行实用程序(dotnet CLI,自 C# 7.0 引入)这样的工具。

6.3 .NET Standard

有这么多不同的 .NET 实现,每个 .NET 框架还有这么多版本,而且每个实现都支持一套不同的、但多少有点重叠的 API,这造成框架分叉得越来越厉害。这增大了写跨 .NET 框架可重用代码的难度,因为要检查特定 API 是否支持。

为降低复杂度,微软推出了 .NET Standard 来定义不同版本的标准应支持哪些 API。

换言之,要相容于某个 .NET Standard 版本,.NET 框架必须支持该标准所规定的 API。但由于许多实现已经发布,所以哪个 API 要进入哪个标准的决策树在一定程度上基于现有实现及其与 .NET Standard 版本号的关联。

写作本文时最新发布的 .NET 标准为 2.1 版,但遗憾的是,最新的 .NET Framework 4.8 仍然执行 .NET Standard 2.0,这意味着它不支持 C# 8.0 里面的范围、索引和异步流。

不过除此之外,.NET Framework 4.8 中的所有基础框架都实现了 .NET Standard 2.0,这意味着它不但为所有的旧 API 统一了执行标准,而且使得采用了 .NET Standard 2.0 的应用程序可以跨平台编译。

在微软的计划中,不同版本的 .NET Framework 源代码都将会被合并到 .NET Core 5.0 代码库中,这使得未来的 .NET Core 5.0 将成为一个集 .NET Framework、.NET Core 和 Xamarin/Mono 于一体的整合产品。

到那时,.NET 标准将会失去意义。

换句话说,将来我们不再需要注意哪些 API 执行的是哪一个标准,因为 .NET Framework 都将来自同一个代码库,并且它们的 API 都执行完全相同的标准。

若要了解更多的信息,请访问网站 https://docs.microsoft.com/zh-cn/dotnet/standard/net-standard

七、小结

本文对 C# 进行了初步介绍。通过本文你熟悉了基本 C# 语法。

由于 C# 与 C++ 风格语言的相似性,本文许多内容可能都是你所熟悉的。但 C# 和托管代码确实有一些独特性,比如会编译成 CIL 等。

C# 的另一个关键特征在于它完全面向对象。即使是在控制台上读取和写入数据这样的事情,也是面向对象的。

C# 中的基本数据类型和数据类型转换 探讨 C# 的基本数据类型,并讨论如何将这些数据类型应用于操作数来构建表达式。

(完)


  1. C# 语言设计者从 C/C++ 规范中删除了他们不喜欢的特性,同时创建了他们喜欢的。开发组还有其他语言的资深专家。 ↩︎

  2. 该命令行工具发布于 C# 7.0 问世前后。它调用 C# 编译器 csc.exe 来编译开发者编写的程序。 ↩︎

  3. https://github.com/PowerShell/PowerShell ↩︎

  4. 如果用 Microsoft .NET Framework 创建控制台应用程序,编译好的代码会放到一个 HelloWorld.exe 文件中。如已安装 Microsoft .NET Framework,可直接执行该文件。 ↩︎

  5. 某些语言的文字编码要用两个 16 位值表示。第一个代码值称为“高位代理项”(high surrogate),第二个称为“低位代理项”(low surrogate)。在代理项的帮助下,Unicode 可以表示 100 多万个不同的字符。美国和欧洲地区很少使用代理项,东亚各国则很常用。 ↩︎

  6. 即 literal,是指以文本形式嵌入的数据。literal 有多种译法,没有一种占绝对优势。最典型的译法是“字面值”“文字常量”和“直接量”。本文采用“字面值”。 ↩︎

  7. CIL 的第三种说法是 Microsoft IL (MSIL)。本文用 CIL 一词,因其是 CLI 标准所采纳的。C# 程序员交流时经常使用 IL 一词,因为他们都假定 IL 是指 CIL 而不是其他中间语言。 ↩︎

  8. “运行时”(runtime)作为名词使用时一律添加引号。 ↩︎

  9. 注意反汇编(disassemble)和反编译(decompile)的区别。反汇编得到的是汇编代码,反编译得到的是所用语言的源代码 ↩︎