Skip to content

⭐ 1.5 字符串(上)

可能你已经悟出了本章的叙述模式:我们在每节介绍一种新的C#内置类型。在第一节里详细展示了变量、常量的声明和赋值的机理。此后,我们总是用最快的速度说明新类型的声明和赋值方式,然后尽可能详细地介绍这种类型的特性和用法。是的,这节也是如此。

字符串的类型、声明与赋值

想要储存一连串的字符,使用字符串string类型再适合不过了。它的声明语句与我们学过的数值类型、布尔类型,又或者是字符类型如出一辙:

string a;

在上一节,我们啰啰嗦嗦地聊了很多关于字符的字面量的知识。很遗憾,这方面几乎都是硬性规则——也就是告诉你“应该这么做!”,或者“不能那样做!”。而来到本节,字符串的字面量写法更加复杂,但有了上一节的基础,相信你能掌握的!

Note

本节的结构有意安排得和上一节相似。你可以对照上一节,体会char类型与string类型之间的异同。

常规字符串

常规的字符串,也就是最普通的写法,就是用英文双引号"包裹字符串内容。我们在hello world代码里就见识过了,只不过那时我们还不知道它的含义。

Console.WriteLine("Hello, world!");

这里面的"Hello, world!"就是一个常规的字符串。我们也可以将它用于赋值:

string a = "Hello, world!";

Console.WriteLine(a);

如上,先将"Hello, world!"字面量赋予string类型的变量a,再在控制台输出变量a,结果依然显示"Hello, world!"

string不但支持Unicode编号转义,而且在上一节中超出char类型范围的那些Unicode字符也能用string轻松表示:

string codePoints = "\u0041\u0042\u0043"; // 等价于"ABC"
string signalMessage = "😀👊🔥";

char不能为空字符,但string可以是空字符串:

string emptyString = "";

另一种表示空字符串的方法是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!"就要加呢?

1
2
3
4
string helloMessage = "Hello, World!";

Console.WriteLine(helloMessage);
Console.WriteLine("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想表达文件路径,但没有用反斜杠转义反斜杠,不能正常工作。

逐字字符串

转义转义,整天转义。转得我都烦了! 💢 难道就不能双引号里面输入什么就是什么,所见即所得吗?

你需要了解逐字字符串!它会使反斜杠\不发挥转义的功能。写法非常简单,只需在字符串前面加一个@符号:

string verbatimString = @"C:\Program Files\dotnet\dotnet.exe";

看见了吗?使用逐字字符串以后,我们再也不需要担心字符串里的反斜杠把什么东西给转义了。所以,我们经常用逐字字符串来写文件路径。非常方便!

Note

它的原理非常简单:就是自动用\\替换字面量中的所有\

逐字字符串的第二个好处是,它支持换行。在常规的字符串里,想要换行只能通过\n实现。我们以信件的结束语为例,看看这是怎么回事。

string endingWord = "    此致\n敬礼";

Console.WriteLine(endingWord);

在此致前面,用了一个制表符。你既可以像上面这样直接用 Tab 输入,也可以使用转义的\t。此致和敬礼之间用了\n来达到换行的效果。OK,输出应该像这样:

    此致
敬礼

换成逐字字符串的话,我们连\n都不需要了:

string endingWord = @"    此致
敬礼";

只需按一下 Enter 就可以让字符串内容也换行了。

不过,如果逐字字符串需要包含双引号的话,为了防止编译器将字符串的引号看作字符串的结束,你得把引号加倍

string someText = @"What do you ""mean""?";

就像上面展示的字符串,它实际上表示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
string someText = """
hello,
world!
""";

开始的引号放在第1行,内容放在2~3行,结束的引号放在第4行。

我们用一张图片说明哪些部分属于字符串内容:

fig

  • 开始的引号以下、结束的引号以上,也就是两条红线之间的部分;
  • 结束的引号左侧的蓝色线往右的部分;

而下图标出的这些部分不是字符串内容:

fig

  • 区域①:开始的引号右侧的部分;
  • 区域②:结束的引号左侧的部分;
  • 区域③:如图所示。

上述区域可以有空格、制表符,但不会计入字符串内容。放置其他任何文本字符都会引发错误,就连注释都不行。

注意对齐

区域②和区域③由结束的引号的位置决定。为了对齐,请在这两个区域要么使用制表符,要么使用空格,不用混用。

原始字符串适合储存复杂的文本结构,比如JSON、XML、正则表达式等等。你可以在开始的引号的上一行使用注释// lang=json// lang=regex启用JSON或正则表达式的语法高亮。但随着现在的IDE越来越智能化,不使用这个注释也能检测到对应的格式并高亮。

// lang=json
string exampleJson = """
    {
        "code": 200,
        "data": {
            "id": 1,
            "title": "原始字符串真好用"
        }
    }
    """;

Console.WriteLine(exampleJson);

语法高亮

你写的代码里面的不同元素被标上了不同的颜色,供你区分。这个功能叫“语法高亮”(Syntax Highlight)。

连接字符串

字符串支持的运算符比较有限(本来也没什么可算的)。使用运算符+可以把两个字符串连接起来:

string combinedText = "hello" + "world";

最终会得到字符串helloworld。变量与字面量、变量之间也可以连接:

string a = "hello";

string b = a + "world"; // "helloworld"
string c = a + b;       // "hellohelloworld"

连加也是可以的:

string message = "hello" + " " + "world" + "!";

还记得复合赋值吗?

string a = "hello";

a += "world";

这相当于a = a + "world";

可以连接别的类型吗?

字符串与其他类型连接,如果那些类型可以被“转换”为字符串,就会被“转换”为字符串后再进行连接。

提前了解

具体来说,就是看那种类型有没有实现ToString()方法。如果实现了,就会通过这个方法“转换”为字符串。

string a = "hello" + 233;

Console.WriteLine(a);

上面这段代码的运算符会把右边的int类型字面量233,通过int类型提供的“转换”为字符串的方法,“转换”为字符串"233"。接着与字符串"hello"相拼接,得到"hello233"

这是类型转换吗?

3 + 2.2"3" + 2.2的原理有什么区别?

3 + 2.2int类型与double类型相加,int类型会被临时转换为double类型后完成相加。这是我们之前所知道的正宗的隐式转换。

"3" + 2.2是通过double类型提供的转为字符串的方法,造出一个字符串"2.2"后完成字符串拼接。这个过程中,并没有发生从double类型到string类型的转换(事实上也不存在这种类型转换)。所以这一切都是通过那个方法(ToString())实现的,不是类型转换。

因此,这种“转换”被加上了引号,提示你区分它与类型转换。

但是呢,我个人不太喜欢字符串与其他类型的连接。在其他类型比较少的情况下,还比较容易理解:

string text = "I am " + 16 + " years old.";
// 得到字符串:"I am 16 years old."

当其他类型变得更多,代码可能会变得相当令人困惑:

string text = "我正在学习第1章的第" + 2 + 3 + "节!";

Console.WriteLine(text);

先别运行代码,猜一下会如何输出。会是“我正在学习第1章的第5节!”吗?

答案是:

fig

好家伙,直接从初学者飞升架构师了。虽然我们可以使用括号来改变计算顺序:

string text = "我正在学习第1章的第" + (2 + 3) + "节!";

但是,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}经验";

也可以在字符串里面插入字符串。下面的案例展示了简单的应用信息:

const string Version = "1.118.0";

string about = $"""
Visual Studio Code
版本:{Version}
""";

三元表达式也是可以的,在需要根据不同条件展示不同信息时很方便:

int volume = 80;

Console.WriteLine($"当前音量:{(volume > 0 ? $"{volume}%" : "静音")}");

知道这段代码在干什么吗?首先判断当前音量volume是否大于0,如果是就显示音量值,否则就显示静音。有趣的是,音量不为0的情况也是一个内插字符串$"{volume}%"。可见,内插字符串使用起来相当的灵活,能带给我们很多便利。

唯有一点需要请你记住:因为三元表达式的结构比较复杂,一定要用括号()把整个表达式包裹住,编译器才会正确理解。如果忘记这么做的话,会出现错误 ❌CS8361:不可在字符串内插中直接使用条件表达式,因为内插已 “:” 结尾。请用括号将条件表达式括起来。 这条信息已经非常直白地说明了修正方式。

总结一下。在插值字符串中,花括号{}是具有特殊功能的符号,在它内部的表达式会被计算。得到的结果如果不是字符串类型,就使用这种类型提供的的ToString方法“转换”为字符串。这个字符串再与花括号外部的内容连接起来,形成整体。

和逐字字符串内部的双引号"写法类似,如果我们的信息内容中要使用普通的花括号,只需加倍即可。

string someText = $"{{商品详情}}";
// 得到字符串:"{商品详情}"

连续的两个花括号{{或者}}不会被识别为插值表达式的边界。

设置宽度格式

不知你是否使用过命令行应用?无论是在Windows的PowerShell,还是Linux系统的各类终端上,它们常常需要利用文字界面显示表格。

fig

不是吧?这么丑陋的“表格”,就连线框都没有。但这已经是早期计算机图形显示技术所能支持的极限了。当初为了用字符来显示线框,人们还制作了各种制表符号。当然,随着表格软件的进化,如今大家更多地把这些符号用来组装昵称和个性签名:︻┳┱─(一把枪)。

在制作这种“古董”表格的时候,每个单元格内的信息有可能会不一致,导致整个表格歪歪扭扭:

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"}");

出来的效果是这样的,杂乱无章:

fig

为了对齐列,设置固定的列宽度是很有用的。只需在内插表达式的后面加上一个逗号,,然后写上宽度就好了。比如{"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"}");

fig

不错,看起来整齐多了!

复合格式

除了宽度以外,你还可以指定其他格式化参数,让你的插值字符串更美观。参见此文档

一个应用场景

插值字符串在多语言应用中特别有用。它可以让翻译工作变得更轻松。在一些西方的语言中,表达第几天的顺序是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()方法,依次填入本地语言字符串、按照顺序排列好的内插参数,就可以组装好完整的字符串了。

int joinTime = 3;
string playerName = "Alice";

// 模拟加载本地语言字符串
string localText = "{0}在{1}分钟前加入游戏";

string Message = string.Format(localText, playerName, joinTime);
Console.WriteLine(Message);

i18n和l10n

i18n即国际化(internationalization,i和n之间有18个字母),就是在程序设计的时候把多语言、多文化显示问题考虑进来。

l10n即本地化(localization,l和n之间有10个字母),就是把程序内容翻译成本地语言、适配本地文化。例如汉化。

休息时间到了,欣赏一下远处的风景吧。🪟