⭐ 1.5 字符串(上)¶
可能你已经悟出了本章的叙述模式:我们在每节介绍一种新的C#内置类型。在第一节里详细展示了变量、常量的声明和赋值的机理。此后,我们总是用最快的速度说明新类型的声明和赋值方式,然后尽可能详细地介绍这种类型的特性和用法。是的,这节也是如此。
字符串的类型、声明与赋值¶
想要储存一连串的字符,使用字符串string类型再适合不过了。它的声明语句与我们学过的数值类型、布尔类型,又或者是字符类型如出一辙:
在上一节,我们啰啰嗦嗦地聊了很多关于字符的字面量的知识。很遗憾,这方面几乎都是硬性规则——也就是告诉你“应该这么做!”,或者“不能那样做!”。而来到本节,字符串的字面量写法更加复杂,但有了上一节的基础,相信你能掌握的!
Note
本节的结构有意安排得和上一节相似。你可以对照上一节,体会char类型与string类型之间的异同。
常规字符串¶
常规的字符串,也就是最普通的写法,就是用英文双引号"包裹字符串内容。我们在hello world代码里就见识过了,只不过那时我们还不知道它的含义。
这里面的"Hello, world!"就是一个常规的字符串。我们也可以将它用于赋值:
如上,先将"Hello, world!"字面量赋予string类型的变量a,再在控制台输出变量a,结果依然显示"Hello, world!"。
string不但支持Unicode编号转义,而且在上一节中超出char类型范围的那些Unicode字符也能用string轻松表示:
char不能为空字符,但string可以是空字符串:
另一种表示空字符串的方法是string.Empty。注意这里的Empty的第一个字母是大写的E。这个形式的好处是,比""的语义更明确。但缺点是不能用于声明字符串常量(也就是初始化)。
复习:常量的初始化
常量在编译时完成初始化,因此用于初始化的那个值必须在编译时就要定下来。而string.Empty的值在运行时才固定(第3章揭晓原因)。
它俩是相等的:
string a = "";
string b = string.Empty;
if (a == b)
{
Console.WriteLine("a and b are equal");
}
else
{
Console.WriteLine("a and b are not equal");
}
运行上面的代码,将会输出“a and b are equal”。
空字符串的用途
空的字符串有什么用?或者说,我们什么时候需要一个没有内容的字符串呢?
当我们预期从某个地方接受一个字符串(比如用户的输入、通过网络获取的信息)的时候,总是有几率无法获得内容。用户可能提交空的表单、网络可能丢包……空字符串非常适合描述这种无内容的情况。
和字符类似,字符串内如果含有双引号或者反斜杠,也需要转义:
string a = "What do you \"mean\"?";
string b = "C:\\Program Files\\dotnet\\dotnet.exe";
Console.WriteLine(a);
Console.WriteLine(b);
字符串a展示了含有双引号的情形。字符串b则是包含反斜杠的例子。的确,我们经常能在Windows系统的文件路径字符串中看见反斜杠。忘记使用\\的话,会导致路径错误哦!
运行一下上面的案例,看看输出是什么样的。
测验时间¶
问题1:为什么下面的代码,第3行的helloMessage不用加双引号,第4行的"Hello, World!"就要加呢?
查看答案
因为helloMessage是一个字符串类型的变量,储存着"Hello, World!"。
而"Hello, World!"是字符串字面量,需要加双引号。
问题2:下面的字符串能正常工作吗?
string whiteSpaces = " ";
string quote1 = "'";
string quote2 = """;
string filePath = "D:\Music\test.wav";
查看答案
whiteSpaces是一堆空格字符,这是合法的;
quote1是单引号字符,也是合法的;同理,char a = '"';也是合法的;
quote2想表达双引号字符,但忘记加反斜杠转义,不能正常工作;
filePath想表达文件路径,但没有用反斜杠转义反斜杠,不能正常工作。
逐字字符串¶
转义转义,整天转义。转得我都烦了! 难道就不能双引号里面输入什么就是什么,所见即所得吗?
你需要了解逐字字符串!它会使反斜杠\不发挥转义的功能。写法非常简单,只需在字符串前面加一个@符号:
看见了吗?使用逐字字符串以后,我们再也不需要担心字符串里的反斜杠把什么东西给转义了。所以,我们经常用逐字字符串来写文件路径。非常方便!
Note
它的原理非常简单:就是自动用\\替换字面量中的所有\。
逐字字符串的第二个好处是,它支持换行。在常规的字符串里,想要换行只能通过\n实现。我们以信件的结束语为例,看看这是怎么回事。
在此致前面,用了一个制表符。你既可以像上面这样直接用 Tab 输入,也可以使用转义的\t。此致和敬礼之间用了\n来达到换行的效果。OK,输出应该像这样:
换成逐字字符串的话,我们连\n都不需要了:
只需按一下 Enter 就可以让字符串内容也换行了。
不过,如果逐字字符串需要包含双引号的话,为了防止编译器将字符串内的引号看作字符串的结束,你得把引号加倍:
就像上面展示的字符串,它实际上表示What do you "mean"?。以此类推,如果需要2个引号,你就得写4个引号。
原始字符串¶
原始字符串比逐字字符串更加灵活。从它的名字就可以看出来,原始(Raw)表示你输入什么就显示什么,原汁原味。
用连续3个双引号"""包裹字符串内容,就得到了一个原始字符串。你可以把字符串写在一行:
string rawString = """\n This is a "raw" string! \n""";
Console.WriteLine(rawString);
// 输出:\n This is a "raw" string! \n
原始字符串也无视反斜杠转义。并且,由于它通过连续的3个引号"""标记起始和结束位置,你可以在内容里自由地使用双引号"甚至连续的两个双引号""都不会干扰编译器。
万一,只是万一,内容里有连续的三个引号"""呢?
假设真的遇到这种情况,只需保证标记原始字符串起、终点的双引号数量超过内容里最多连续双引号的数量就好。
假设内容里有连续3个双引号,就应该用连续4个双引号""""包围它:""""There's so many """s in this string!""""。
这样一来,字符串内容就不能以双引号"开头或结尾了。""""hello""""会被识别为由4个双引号包围的字符串hello,而不是由3个双引号包围的字符串"hello"。
想要避开这个限制的话,试试多行写法吧。在多行写法中,原始字符串可以被分为3部分:开始的引号、字符串内容、结束的引号。三个部分需要安排在不同行,像下面这样:
开始的引号放在第1行,内容放在2~3行,结束的引号放在第4行。
我们用一张图片说明哪些部分属于字符串内容:

- 开始的引号以下、结束的引号以上,也就是两条红线之间的部分;
- 结束的引号左侧的蓝色线往右的部分;
而下图标出的这些部分不是字符串内容:

- 区域①:开始的引号右侧的部分;
- 区域②:结束的引号左侧的部分;
- 区域③:如图所示。
上述区域可以有空格、制表符,但不会计入字符串内容。放置其他任何文本字符都会引发错误,就连注释都不行。
注意对齐
区域②和区域③由结束的引号的位置决定。为了对齐,请在这两个区域要么使用制表符,要么使用空格,不用混用。
原始字符串适合储存复杂的文本结构,比如JSON、XML、正则表达式等等。你可以在开始的引号的上一行使用注释// lang=json或// lang=regex启用JSON或正则表达式的语法高亮。但随着现在的IDE越来越智能化,不使用这个注释也能检测到对应的格式并高亮。
// lang=json
string exampleJson = """
{
"code": 200,
"data": {
"id": 1,
"title": "原始字符串真好用"
}
}
""";
Console.WriteLine(exampleJson);
语法高亮
你写的代码里面的不同元素被标上了不同的颜色,供你区分。这个功能叫“语法高亮”(Syntax Highlight)。
连接字符串¶
字符串支持的运算符比较有限(本来也没什么可算的)。使用运算符+可以把两个字符串连接起来:
最终会得到字符串helloworld。变量与字面量、变量之间也可以连接:
连加也是可以的:
还记得复合赋值吗?
这相当于a = a + "world";。
可以连接别的类型吗?¶
字符串与其他类型连接,如果那些类型可以被“转换”为字符串,就会被“转换”为字符串后再进行连接。
提前了解
具体来说,就是看那种类型有没有实现ToString()方法。如果实现了,就会通过这个方法“转换”为字符串。
上面这段代码的运算符会把右边的int类型字面量233,通过int类型提供的“转换”为字符串的方法,“转换”为字符串"233"。接着与字符串"hello"相拼接,得到"hello233"。
这是类型转换吗?
3 + 2.2与"3" + 2.2的原理有什么区别?
3 + 2.2是int类型与double类型相加,int类型会被临时转换为double类型后完成相加。这是我们之前所知道的正宗的隐式转换。
而"3" + 2.2是通过double类型提供的转为字符串的方法,造出一个字符串"2.2"后完成字符串拼接。这个过程中,并没有发生从double类型到string类型的转换(事实上也不存在这种类型转换)。所以这一切都是通过那个方法(ToString())实现的,不是类型转换。
因此,这种“转换”被加上了引号,提示你区分它与类型转换。
但是呢,我个人不太喜欢字符串与其他类型的连接。在其他类型比较少的情况下,还比较容易理解:
当其他类型变得更多,代码可能会变得相当令人困惑:
先别运行代码,猜一下会如何输出。会是“我正在学习第1章的第5节!”吗?
答案是:

好家伙,直接从初学者飞升架构师了。虽然我们可以使用括号来改变计算顺序:
但是,C#为我们提供了一种更好用,也更强大的方法——
字符串内插¶
字符串的重要用途之一就是展示信息。很多信息都可以被分为固定部分,以及可变部分。来看一些常见的例子吧。
- 在网页上展示“本网站已经被访问XXX次”
- 邮件系统显示“将在XXX时间定时发送”
- 应用使用方法提示“按XXX键打开XXX功能”
- 游戏显示“你获得了XXX个XXX道具”
- 搜索引擎的智能提示:“猜你想搜:XXX、XXX、XXX”
只要你稍微留意一下现实生活,就能轻松举出100个这样的例子。我们可以用字符串内插,在这些字符串的“XXX”部分插入需要展示的信息。只需2步就能做到:
第一,在字符串的开始的引号前面加上美元符号$:
// 常规字符串
string a = $"";
// 逐字字符串(以下两种都可以)
string b1 = $@"";
string b2 = @$"";
// 原始字符串
string c = $"""
""";
第二,用花括号{}括起可变的部分,在花括号里面尽情写上你想要的任何表达式:
int numOfVisit = 200000;
string a = $"本网站已经被访问{numOfVisit}次";
double userLv = 234.5;
string b = $"当前经验:{userLv},距离升级还需{500 - userLv}经验";
也可以在字符串里面插入字符串。下面的案例展示了简单的应用信息:
三元表达式也是可以的,在需要根据不同条件展示不同信息时很方便:
知道这段代码在干什么吗?首先判断当前音量volume是否大于0,如果是就显示音量值,否则就显示静音。有趣的是,音量不为0的情况也是一个内插字符串$"{volume}%"。可见,内插字符串使用起来相当的灵活,能带给我们很多便利。
唯有一点需要请你记住:因为三元表达式的结构比较复杂,一定要用括号()把整个表达式包裹住,编译器才会正确理解。如果忘记这么做的话,会出现错误 ❌CS8361:不可在字符串内插中直接使用条件表达式,因为内插已 “:” 结尾。请用括号将条件表达式括起来。 这条信息已经非常直白地说明了修正方式。
总结一下。在插值字符串中,花括号{}是具有特殊功能的符号,在它内部的表达式会被计算。得到的结果如果不是字符串类型,就使用这种类型提供的的ToString方法“转换”为字符串。这个字符串再与花括号外部的内容连接起来,形成整体。
和逐字字符串内部的双引号"写法类似,如果我们的信息内容中要使用普通的花括号,只需加倍即可。
连续的两个花括号{{或者}}不会被识别为插值表达式的边界。
设置宽度格式¶
不知你是否使用过命令行应用?无论是在Windows的PowerShell,还是Linux系统的各类终端上,它们常常需要利用文字界面显示表格。

不是吧?这么丑陋的“表格”,就连线框都没有。但这已经是早期计算机图形显示技术所能支持的极限了。当初为了用字符来显示线框,人们还制作了各种制表符号。当然,随着表格软件的进化,如今大家更多地把这些符号用来组装昵称和个性签名:︻┳┱─(一把枪)。
在制作这种“古董”表格的时候,每个单元格内的信息有可能会不一致,导致整个表格歪歪扭扭:
Console.WriteLine($"""
{"Mode"} {"LastWriteTime"} {"Name"}
{"-----"} {"---------------"} {"----"}
""");
Console.WriteLine($"{"d------"} {"2026/01/29 22:43"} {"bin"}");
Console.WriteLine($"{"d-r----"} {"2025/02/28 0:38"} {"obj"}");
Console.WriteLine($"{"da-----"} {"2026/02/27 22:52"} {"data"}");
出来的效果是这样的,杂乱无章:

为了对齐列,设置固定的列宽度是很有用的。只需在内插表达式的后面加上一个逗号,,然后写上宽度就好了。比如{"abc", 4}指定了这个内插值的宽度为4,而它实际上只有"abc"这3个字符,不足的部分就会在左边填充空格,得到 " abc"。
如果你想在右边填充空格,就指定负数宽度:{"abc", -4},它会得到"abc "。(也就是左对齐效果)
Tip
如果字符串的实际长度超过了指定宽度也不会截断信息(相当于没指定宽度)。
我们给刚刚的代码补上宽度:
Console.WriteLine($"""
{"Mode",-8} {"LastWriteTime",-19} {"Name"}
{"-----",-8} {"---------------",-19} {"----"}
""");
Console.WriteLine($"{"d------",-8} {"2026/01/29 22:43",-19} {"bin"}");
Console.WriteLine($"{"d-r----",-8} {"2025/02/28 21:38",-19} {"obj"}");
Console.WriteLine($"{"da-----",-8} {"2026/02/27 22:52",-19} {"data"}");

不错,看起来整齐多了!
复合格式
除了宽度以外,你还可以指定其他格式化参数,让你的插值字符串更美观。参见此文档。
一个应用场景¶
插值字符串在多语言应用中特别有用。它可以让翻译工作变得更轻松。在一些西方的语言中,表达第几天的顺序是Day XXX。如果我们用+运算符连接的话:
int dayNum = 3;
string language = "zh";
string dayText = language switch
{
"en" => "Day ",
"fr" => "Jour ",
"es" => "Día ",
_ => "Day ", // 默认语言
};
string message = dayText + dayNum;
Console.WriteLine(message);
给dayText设计一个switch表达式,切换不同语言的文本,就能表达英语的"Day 3",法语的"Jour 3",以及西班牙语的"Día 3"了。
但是到了中文就不行了。中文应该是"第3天",天数的前后都有字,不可能通过dayText + dayNum的形式表示。难道要为中文单独设计一种表达式吗?这样会导致代码维护起来非常困难。
使用字符串内插,我们只管在字符串里面插入哪些值,把剩下的语序问题交给翻译团队:
int dayNum = 3;
string language = "zh";
string message = language switch
{
"en" => $"Day {dayNum}",
"fr" => $"Jour {dayNum}",
"es" => $"Día {dayNum}",
"zh" => $"第{dayNum}天",
_ => $"Day {dayNum}"
};
Console.WriteLine(message);
让翻译团队设计插值字符串内容,想怎么调整顺序就怎么调整顺序,让用户获得更好的体验。
实际情况
考虑到翻译人员不一定会参与代码编写,开发者可以把参数的序号和含义告知翻译者。比如提示文本“{玩家名称}在{几}分钟前加入游戏”,涉及2个参数。从0开始编号,第0个参数是玩家名称,第1个参数是时间。
译者根据这些信息确定译文,把参数的编号填入对应位置。就像"{0} joined the game {1} minutes ago"、"{0}在{1}分钟前加入游戏"等等。这些翻译内容会放在一个单独的文档里面,由翻译团队进行维护,与开发团队各司其职,互不干扰。
现在,应用仅需从翻译文档里面加载本地语言的字符串,再通过string.Format()方法,依次填入本地语言字符串、按照顺序排列好的内插参数,就可以组装好完整的字符串了。
i18n和l10n
i18n即国际化(internationalization,i和n之间有18个字母),就是在程序设计的时候把多语言、多文化显示问题考虑进来。
l10n即本地化(localization,l和n之间有10个字母),就是把程序内容翻译成本地语言、适配本地文化。例如汉化。
休息时间到了,欣赏一下远处的风景吧。🪟