原文:What's new in C# 7
2016-12-21译者注:原文于 2016 年 12 月发表,当时 Visual Studio 2017 还是 15 Preview 5,不过直到 VS2017 它们仍然没什么变化。
C# 7 中添加了不少语言特性:
-
out
变量- 你可以在向方法传入参数的时候定义
out
变量
- 你可以在向方法传入参数的时候定义
-
元组(Tuples)
- 可用于创建轻量、无名称、包含多个公共字段的类型。编译器和 IDE 工具知道这些类型的语义。
-
模式匹配
- 基于任意类型和它们的值创建逻辑分支。
-
定义为
ref
的局部变量和返回值- 方法参数和局部变量可以在其它位置引用
-
局部函数
- 可以在函数中嵌入函数定义,嵌入函数受外部函数作用域和可见性限制。
-
更多成员可使用表达式语法
- 可使用表达式编写的成员列表已经得到扩充。
-
throw
表达式- 你可以在代码结构中抛出异常,而在之前因为它是语句,做不到这一点。
-
异步返回类型泛型化
- 使用
async
定义的方法可以返回除Task
和Task<T>
之外的类型了。
- 使用
-
改善数字字符量语法
- 使用新的符号提高数字常量的可读性。
本文下面对每个特性都进行了详述。你可以了解到每个特性背后的原因,也可以学习到相关的语法。还有一些使用这些特性的场景示例。所有这些都会让你成为更有高效的开发者。
out
变量
现有的语法已经支持 out
参数,但它在这个版本中得到了改进。
之前,你需要在两个语句中申明和使用输出(out)变量:
int numericResult;
if (int.TryParse(input, out numericResult))
WriteLine(numericResult);
else
WriteLine("Could not parse input");
而现在可以在调用方法的时候直接在参数列表中定义 out
变量,避免单独的申明语句:
if (int.TryParse(input, out int result))
WriteLine(result);
else
WriteLine("Could not parse input");
你可以像上面那样明确的申明 out
变量的类型,不过语言本身支持对局部变量使用隐式类型(自动推断):
if (int.TryParse(input, out var answer))
WriteLine(answer);
else
WriteLine("Could not parse input");
-
这个代码非常易读
- 在使用输出变量的时候申明它,不需要另加一行代码
-
不需要赋予初始值
- 在方法中使用
out
变量的时候才申明它,就不会出现还没有赋值就使用的情况。
- 在方法中使用
这个特性最常用的地方是在使用 try
模式的时候。在这个模式中,方法会返回 bool
值来标标识它是成功还是失败,如果成功,其处理的结果则是通过 out
变量提供的。
元组(Tuples)
C# 提供了丰富语法来支持类和结构,而类和结构主要用于解释你的设计思想。然而有时候,丰富的语法也需要一些额外的工作所带来的小优势。你可能经常会在写到某个方法时发现需要一个简单但又包含多个数据元素的结构。为了支持这类情况,C# 添加了元组。元组是轻量级的数据结构,包含多个字段来表示数据成员。这些字段不经过验证。另外,你也不能在元组中定义自己的方法。
注:在 C# 7 之前,元组已经通过 API 实现,但这个实现有很多限制。最重要的是,元组的成员被命名为
Item1
、Item2
等(译者注:语义不明)。语言(译者注:指 C# 7 支持为元组的字段定义语义化化的名称。
可以通过对元组的每个成员赋值来定义元组:
var letters = ("a", "b");
这个赋值语句通过元组语法创建了一个元组,其成员是 Item1
和 Item2
。可以修改这个赋值语句,使元组具有语义化的成员:
(string Alpha, string Beta) namedLetters = ("a", "b");
注:新的元组特性需要
System.ValueTuple
类型。在 Visual Studio 2017 和之前的预览版本中,你需要添加 NuGet 包System.ValueTuple
。
namedLatters
元组包含 Alpha
和 Beta
两个字段。在元组赋值过语句中,你也可以在右侧指定字段的名称:
var alphabetStart = (Alpha: "a", Beta: "b");
C# 语言允许你在赋值语句的左侧和右侧同时指定字段名称:
(string First, string Second) firstLetters = (Alpha: "a", Beta: "b");
上面这一行会产生一个警告,CS8123
,告诉你赋值语句右侧指定的 Alpha
和 Beta
这两个名称会被忽略,因为它们与左侧指定的 First
和 Second
名称产生了冲突。
上面的例子展示了基本的元组语法。元组通常用于 private
和 internal
方法的返回类型。元组为这些方法提供了简单的语法来返回多个数值:不再需要为返回数据定义 class
或 struct
类型的工作,很省事。
创建元组更有效率。这是用来定义多值数据的一个简单而轻量的语法。下面作为示例的方法找到并返回一组整数的最小值和最大值:
private static (int Max, int Min) Range(IEnumerable<int> numbers)
{
int min = int.MaxValue;
int max = int.MinValue;
foreach(var n in numbers)
{
min = (n < min) ? n : min;
max = (n > max) ? n : max;
}
return (max, min);
}
这里使用元组带来了如下优势:
- 省事。不需要为返回类型定义
class
或struct
- 不需要创建新的类型
- 增强的语言不再需要调用
Create<T1>(T1)
方法
方法申明中为返回元组数据的字段提供了名称。调用这个方法的时候,返回的元组会有 Max
和 Min
两个字段:
var range = Range(numbers);
有时候你可能想将方法返回的元组数据拆解开。没问题,你可以为元组的每个字段单独申明变量。这称为对元组进行解构(deconstructing):
(int max, int min) = Range(numbers);
你也可以为 .NET 中任意类类型提供类似的解构功能,方法是为类提供一个 Deconstruct
成员方法(译者注:称为解构方法,注意与析构方法区分)。Deconstruct
方法提供一组 out
参数,对应于你想解构出来的每一个属性。下面的 Point
类提供了一个解构方法用于提取 X
和 Y
坐标:
public class Point
{
public Point(double x, double y)
{
this.X = x;
this.Y = y;
}
public double X { get; }
public double Y { get; }
public void Deconstruct(out double x, out double y)
{
x = this.X;
y = this.Y;
}
}
现在可以通过将 Point
对象赋值到元组来提取各个字段:
var p = new Point(3.14, 2.71);
(double X, double Y) = p;
定义 Deconstruct
方法的时候并没有绑定名称。你可以在赋值语句中提取变量时对其命名:
(double horizontalDistance, double verticalDistance) = p;
更多相关内容,请参阅元组主题
模式匹配
模式匹配功能让你可以在方法中根据对象类型之外的属性进行处理。你可能已经很熟基于对象类型的方法处理。在面向对象编程中,虚方法和重载方法语法用于实现基于对象类型的方法处理。基类和派生类会提供不同的实现。模式匹配语法扩展了这个概念,让你很容易根据类型和数据元素实现类似的处理模式,而这与继承无关。
模式匹配支持 is
表达式和 switch
表达式。它通过对对象及其属性的检查来确定对象是否满足所要求的模式。使用 when
关键字来为模式指定附加规则。
is
表达式
is
模式表达式扩展了大家熟悉的 is
运算符,用不仅限于类型的方式来查询对象。
让我们从一个简单的问题开始。我们会扩展这个问题来演示模式匹配是如何简便处理问题的。首先,我们来计算一个掷骰结果的数值之和。
public static int DiceSum(IEnumerable<int> values)
{
return values.Sum();
}
很快你发现需要统计的结果掷骰列表中,有些时候并不是只掷了一个骰子。输入的每一项都有可能是多个结果,而不仅仅是一个数:
public static int DiceSum2(IEnumerable<object> values)
{
var sum = 0;
foreach(var item in values)
{
if (item is int val)
sum += val;
else if (item is IEnumerable<object> subList)
sum += DiceSum2(subList);
}
return sum;
}
这里 is
模式表达式很好的发挥了作用。在检查某一项的类型时,可以同时进行变量初始化。这里创建了一个有效的运行时类型变量。
继续扩展这个示例中的问题,你可能会发现需要更多 if
和 else if
语句。这样一来,你会想使用 switch
模式表达式。
升级的 switch
语句
这个匹配表达式与 C# 语言中已经存在的 switch
语句具有相似的语法。在添加新的条件之前,先把上面的代码转换成匹配表达式:
public static int DiceSum3(IEnumerable<object> values)
{
var sum = 0;
foreach (var item in values)
{
switch (item)
{
case int val:
sum += val;
break;
case IEnumerable<object> subList:
sum += DiceSum3(subList);
break;
}
}
return sum;
}
匹配表达式的语法与 is
表达式略有不同,是在 case
表达式的开始位置申明类型和变量。
匹配表达式也支持常量,这在遇到简单的条件判断时会节约不少时间:
public static int DiceSum4(IEnumerable<object> values)
{
var sum = 0;
foreach (var item in values)
{
switch (item)
{
case 0:
break;
case int val:
sum += val;
break;
case IEnumerable<object> subList when subList.Any():
sum += DiceSum4(subList);
break;
case IEnumerable<object> subList:
break;
case null:
break;
default:
throw new InvalidOperationException("unknown item type");
}
}
return sum;
}
上面的代码添加了 0
作为 int
的特殊情况,null
则是另一个特殊情况,代表没有输入。这演示了 switch 模式表达式中一项重要特性:需要注意 case
表达式的顺序。0
这个条件必须出现在其它 int
条件之前。要不然,int
条件会先匹配到,即使值为 0
。如果你搞错了匹配表达式的顺序,一个本应该后匹配到的条件被提前处理了,编译器会标记出来并产生一个错误。
在处理空输入的时候也存在类似的情况。你可以看到,特定 IEnumerable
的分支必须出现在一般 IEnumerable
的分支之前。
这一版本的代码还添加了 default
分支。不管 default
放在源码中什么位置,它总是在最后进行判断。因此,一般约定把 default
分支放在最后。
最后,我们来添加最后一个 case
,用于处理我们在游戏加入的一种新骰子。某些游戏使用百分骰来表示较大范围的数。
注:两个 10 面的百分骰可以表示从 0 到 99 的每一个数。一个骰子各面标记着
00
、10
、20
、...、90
,另一个而标记着0
、1
、2
、...、9
。把两个骰子的数值加起来就能得到一个 0 到 99 之间的数。
为了在集合中添加这种类型的骰子,需要先定义对应的类型:
public struct PercentileDie
{
public int Value { get; }
public int Multiplier { get; }
public PercentileDie(int multiplier, int value)
{
this.Value = value;
this.Multiplier = multiplier;
}
}
然后,添加 case
匹配表达式来处理这种新类型:
public static int DiceSum5(IEnumerable<object> values)
{
var sum = 0;
foreach (var item in values)
{
switch (item)
{
case 0:
break;
case int val:
sum += val;
break;
case PercentileDie die:
sum += die.Multiplier * die.Value;
break;
case IEnumerable<object> subList when subList.Any():
sum += DiceSum5(subList);
break;
case IEnumerable<object> subList:
break;
case null:
break;
default:
throw new InvalidOperationException("unknown item type");
}
}
return sum;
}
在基于对象类型和其它属性来处理算法的时候,新的模式匹配表达式语法更简间明了。模式匹配表达式通过数据类型来组织代码代码,而且与继承无关。
如果想了解更多关于模式匹配的主题,请参阅 C# 中的模式匹配
引用局部变量和返回值
这个语法特性允许使用和返回定义在其它地方的变量引用。一个示例是关于大型矩阵,需要在其中找到某个特定数据的位置。这里定义一个方法返回矩阵中用来表示某个位置的两个索引:
public static (int i, int j) Find(int[,] matrix, Func<int, bool> predicate)
{
for (int i = 0; i < matrix.GetLength(0); i++)
for (int j = 0; j < matrix.GetLength(1); j++)
if (predicate(matrix[i, j]))
return (i, j);
return (-1, -1); // Not found
}
这个代码中有很多问题。首先,它是一返回元组的公共方法,虽然从语法上来说没有问题,但对于公共 API 来说最好是使用用户定义的类型(class 或 struct)。
其次,这个方法返回了矩阵中某项的索引,调用者可以通过这对索引引用矩阵中的元素,并修改其值。
var indices = MatrixSearch.Find(matrix, (val) => val == 42);
Console.WriteLine(indices);
matrix[indices.i, indices.j] = 24;
相比之下你可能更愿意写一个返回矩阵元素引用的方法来改变元素的值。在以前,你只能使用不安全的代码返回整数指针来实现。
让我们来通过一系列的变化演示引用局部变量的特性,并展示如何创建一个方法来返回内部存储的引用。通过这些变化你会学习到返回引用和局部引用特性的规则,避免不小心对这个特性进行滥用。
我们从修改 find
的返回类型为 ref int
开始。然后修改返回语句,使其返回存储于矩阵中一个值而不是它的一对索引:
// 注意这段代码不能通过编译。
// 方法申明为返回引用类型,
// 但返回语句返回的是一个特定值。
public static ref int Find2(int[,] matrix, Func<int, bool> predicate)
{
for (int i = 0; i < matrix.GetLength(0); i++)
for (int j = 0; j < matrix.GetLength(1); j++)
if (predicate(matrix[i, j]))
return matrix[i, j];
throw new InvalidOperationException("Not found");
}
你在申明方法返回 ref
变量的时候,你必须在所有返回语句中添加 ref
关键字,这表示返回的是引用,这有助于开发者在阅读这段代码时能清楚的知道返回的是引用。
public static ref int Find3(int[,] matrix, Func<int, bool> predicate)
{
for (int i = 0; i < matrix.GetLength(0); i++)
for (int j = 0; j < matrix.GetLength(1); j++)
if (predicate(matrix[i, j]))
return ref matrix[i, j];
throw new InvalidOperationException("Not found");
}
现在这个方法返回矩阵某个整数值的引用,你需要在调用的位置对它进行修改。var
申明知道 varItem
现在是 int
而不是元组:
var valItem = MatrixSearch.Find3(matrix, (val) => val == 42);
Console.WriteLine(valItem);
valItem = 24;
Console.WriteLine(matrix[4, 2]);
上例中第二句 WriteLine
语句输出的值是 42
而不是 24
。变量 varItem
是 int
,而不是 ref int
。var
关键字会让编译器搞明白类型,但它并不会隐式地添加 ref
修饰符。因为这个变量不是 ref
变量,所以 ref return
将变量的值拷贝到了赋值语句的左侧。
ref var item = ref MatrixSearch.Find3(matrix, (val) => val == 42);
Console.WriteLine(item);
item = 24;
Console.WriteLine(matrix[4, 2]);
现在第二句 WriteLine
语句会打印出 24
,这表示矩阵中保存的内容已经被修改了。使用 ref
修饰符申明的局部变量可以用来获取 ref
返回。你必须在 ref
变量申明的时候对其进行初始化,不能将申明语句和初始化语句分开。
C# 语言还有两项规则来避免你误用 ref
局部变量和 ref
返回:
-
不能赋值给 ref 变量
- 也就是不允许这样的语句:
ref int i = sequence.Count();
- 也就是不允许这样的语句:
-
不能返回一个生命周期短于方法执行时间的
ref
变量。- 即不能返回局部变量的引用,或者类似作用域中的变量。
这些规则确保您不会意外地混合值变量和引用变量,还确保你不能引用即将被垃圾回收的数据。
此外,局部引用和返回引用避免了在算法中拷贝值,或者进行多次解引用操作,所以有利于提高效率。
局部函数
很多类设计中都存在只在某一个地方调用的方法。这些额外的私有方法使方法变得小而专注。然而,它们同时也使阅读类代码变得困难。这些方法必须在调用上下文之外进行理解。
对于这些设计,局部函数允许你在某个方法的上下文内申明另一个方法。局部方法使读者更容易看到调用它的上下文。
局部方法很有两个很常见的用法:公共迭代方法和公共异步方法。这两种类型的方法生成的代码都会晚于程序员预期的时间报告错误。在迭代方法中,异常只会在枚举调用的时候被发现。而异步方法中,只有其返回的任务完成才能观察到异常发生。
先来看一个迭代方法:
public static IEnumerable<char> AlphabetSubset(char start, char end)
{
if ((start < 'a') || (start > 'z'))
throw new ArgumentOutOfRangeException(paramName: nameof(start), message: "start must be a letter");
if ((end < 'a') || (end > 'z'))
throw new ArgumentOutOfRangeException(paramName: nameof(end), message: "end must be a letter");
if (end <= start)
throw new ArgumentException($"{nameof(end)} must be greater than {nameof(start)}");
for (var c = start; c < end; c++)
yield return c;
}
检查下面的代码对迭代方法地错误调用:
var resultSet = Iterator.AlphabetSubset('f', 'a');
Console.WriteLine("iterator created");
foreach (var thing in resultSet)
Console.Write($"{thing}, ");
异常会在 resultSet
迭代时抛出,而不是在 resultSet
创建出来的时候。在这个示例中,多数开发者能迅速诊断出问题所在。然而,在更大的代码库中,创建迭代器的代码通常并不与枚举其结果的代码放在一起。你可以重构代码让公共方法验证所有参数,再用私有方法来产生枚举项:
public static IEnumerable<char> AlphabetSubset2(char start, char end)
{
if ((start < 'a') || (start > 'z'))
throw new ArgumentOutOfRangeException(paramName: nameof(start), message: "start must be a letter");
if ((end < 'a') || (end > 'z'))
throw new ArgumentOutOfRangeException(paramName: nameof(end), message: "end must be a letter");
if (end <= start)
throw new ArgumentException($"{nameof(end)} must be greater than {nameof(start)}");
return alphabetSubsetImplementation(start, end);
}
private static IEnumerable<char> alphabetSubsetImplementation(char start, char end)
{
for (var c = start; c < end; c++)
yield return c;
}
这个重构版本会即时抛出异常,因为公共方法并非迭代方法;只有私有方法使用了 yield return
语法。然而,这个重构存潜在的问题。私有方法应该只由公共接口方法调用,因为它跳过了所有参数验证。阅读这个类的人必须从整个类中去发现这个事实,并查找在其它地方是否存在对 alphabetSubsetImplementation
方法的引用。
如果把 alphabetSubsetImplementation
定义为公共 API 方法中的一个局部函数,就清楚多了:
public static IEnumerable<char> AlphabetSubset3(char start, char end)
{
if ((start < 'a') || (start > 'z'))
throw new ArgumentOutOfRangeException(paramName: nameof(start), message: "start must be a letter");
if ((end < 'a') || (end > 'z'))
throw new ArgumentOutOfRangeException(paramName: nameof(end), message: "end must be a letter");
if (end <= start)
throw new ArgumentException($"{nameof(end)} must be greater than {nameof(start)}");
return alphabetSubsetImplementation();
IEnumerable<char> alphabetSubsetImplementation()
{
for (var c = start; c < end; c++)
yield return c;
}
}
上面这个版本很清晰的表明了局部方法只在外部方法的作用域内被引用。局部函数也能确保开发者不会意外地从类中其它位置调用局部函数以跳过参数验证。
async
方法中也可以使用同样的技术来保证在实际工作之前进行参数验证,并立即抛出异常:
public Task<string> PerformLongRunningWork(string address, int index, string name)
{
if (string.IsNullOrWhiteSpace(address))
throw new ArgumentException(message: "An address is required", paramName: nameof(address));
if (index < 0)
throw new ArgumentOutOfRangeException(paramName: nameof(index), message: "The index must be non-negative");
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException(message: "You must supply a name", paramName: nameof(name));
return longRunningWorkImplementation();
async Task<string> longRunningWorkImplementation()
{
var interimResult = await FirstWork(address);
var secondResult = await SecondStep(index, name);
return $"The results are {interimResult} and {secondResult}. Enjoy.";
}
}
注:某些设计使用 Lambda 表达式 来作为局部函数。有兴趣的朋友可以去看看它们之间的区别
更多成员支持表达式作为函数体
C# 6 对成员函数和只读属性引入了使用表达式作为函数体的成员。C# 7 扩展了允许使用这一特性的成员。在 C# 7 中,可以对构造方法、析构方法、属性的 get
和 set
访问器以及索引使用这一特性,下面是示例:
// Expression-bodied constructor
public ExpressionMembersExample(string label) => this.Label = label;
// Expression-bodied finalizer
~ExpressionMembersExample() => Console.Error.WriteLine("Finalized!");
private string label;
// Expression-bodied get / set accessors.
public string Label
{
get => label;
set => this.label = value ?? "Default label";
}
注:这个示例不需要析构方法,但它证明了这个语法有效。一般情况下你不应该实现类的析构方法,除非必须在其中释放非托管资源。另外,你应该考虑使用 SafeHandle 类来管理非托管资源,而不是直接对其进行管理。
这些支持表达式函数体的新成员标志着 C# 语言一个重要的里程碑:这些特性由社区成员在开源的 Roslyn 项目实现。
## throw 表达式
C# 中 throw
曾经一直都是一个语句。因为 throw
是语句而不是表达式,有些地方就不能使用它。这些地方包括条件表达式、空值合并表达式以及一些 Lambda 表达式。throw 表达式作为新增的表达式成员无疑是有益的。至此你可以将 C# 7 引入的 throw 表达式写在任意结构中。
它的语法和之前用到的 throw
语句语法相似。唯一不同的是你可以把它用于一些新的位置,比如,条件表达式:
public string Name
{
get => name;
set => name = value ??
throw new ArgumentNullException(paramName: nameof(value), message: "New name must not be null");
}
这个特性也允许在初始化表达式中使用 throw 表达式:
private ConfigResource loadedConfig = LoadConfigResourceOrDefault() ??
throw new InvalidOperationException("Could not load config");
而在以前,这些初始化过程都需要在构造方法中进行,在函数体中使用 throw 语句:
public ApplicationOptions()
{
loadedConfig = LoadConfigResourceOrDefault();
if (loadedConfig == null)
throw new InvalidOperationException("Could not load config");
}
注:上述两种结构都会在构造对象的时候抛出异常,这通常难以恢复。因此,应该尽量不要设计在构造过程中抛出异常。
扩大 async 返回类型
从 async 方法中返回 Task
对象可能引起某些路径的性能瓶颈。Task
是引用类型,所以使用它就意味着会分配对象。申明为 async
的方法可能返回一个缓存的结果,或完成同步,这种情况下,额外的分配会成为重要的时间成本,这段代码对性能至关重要。如果这些分配发生频繁,它会变得非常昂贵。
新的语言特性允许 async 方法返回 Task
、Task<T>
和 void
之外的研。返回类型仍然必须满足 async 模式,即必须要有可访问的 GetAwaiter
方法。ValueTask
作为一个具体的示例已经添加到 .NET 框架中:
public async ValueTask<int> Func()
{
await Task.Delay(100);
return 5;
}
注:你需要添加 NuGet 包
System.Threading.Tasks.Extensions
之后才能在 Visual Studio 2017 中使用ValueTask
。
在之前使用 Task
的地方使用 ValueTask
是种简单地优化。然而,如果你想手工进行更多优化,你可以缓存异步操作返回的结果并以后面的调用使用它。ValueTask
结构体有一个使用 Task
作为参数的构造函数,所以你可以从现有 async 方法的返回结果构造 ValueTask
:
public ValueTask<int> CachedFunc()
{
return (cache) ? new ValueTask<int>(cacheResult) : new ValueTask<int>(loadCache());
}
private bool cache = false;
private int cacheResult;
private async Task<int> loadCache()
{
// simulate async work:
await Task.Delay(100);
cache = true;
cacheResult = 100;
return cacheResult;
}
与所有性能建议一样,你应该在对代码进行大规则更改之前对两个版本进行基线测试。
改进数值字面量语法
误读数值会使用阅读代码变得困难。一个数在作为二进制掩码或其它符号,而不是作为数值的时候,这种情况经常发生。C# 7 引入了两个新的特性来改善这类情况,让代码更时尚也更容易阅读:二进制字面量,数字分隔符。
创建二进制掩码的时候,或需要提供二进制数值的时候,为了代码更易读,可以直接写二进制字面量:
public const int One = 0b0001;
public const int Two = 0b0010;
public const int Four = 0b0100;
public const int Eight = 0b1000;
0b
开始的常量表示它们被写作二进制数。
二进制数可能会很长,所以引入 _
作为数字分隔符:
public const int Sixteen = 0b0001_0000;
public const int ThirtyTwo = 0b0010_0000;
public const int SixtyFour = 0b0100_0000;
public const int OneHundredTwentyEight = 0b1000_0000;
数字分隔符可以出现在常量中任何地方。对于 10 进制数来说,它们常用作千分位分隔符:
public const long BillionsAndBillions = 100_000_000_000;
数字分隔符也可用于 decimal
、float
和 double
:
public const double AvogadroConstant = 6.022_140_857_747_474e23;
public const decimal GoldenRatio = 1.618_033_988_749_894_848_204_586_834_365_638_117_720_309_179M;
使用上述两个新特性,你可以申明更易读的数字常量。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。