第14章 C#7 的代码简洁之道
第14章 C#7 的代码简洁之道
14.1 局部方法
在任何有若干语句出现的位置,都可以使用局部方法:方法、构造器、属性、索引器、事件访问器、终结器、匿名函数中甚至另一个嵌套的局部方法中。
局部方法和普通方法的声明方法基本一致,但有如下限制条件:
- 不能有访问修饰符(public、private 等);
- 不能使用 extern、virtual、new、override、static 或者 abstract 修饰符;
- 不能应用 attribute(例如
MethodImpl
); - 不能与同级的其他局部方法重名,局部方法没有方法重载。
除此以外,局部方法和普通方法的行为一致:
- 可以有或者没有返回值;
- 可以有 async 修饰符;
- 可以有 unsafe 修饰符;
- 可以通过迭代器块实现;
- 可以有形参,包括可选形参;
- 可以是泛型方法;
- 可以指向任何闭合的类型形参;
- 可以是某个方法组转换的目标。
Warn
本章讲解的“局部方法(local functions)”和2.5.1.1 局部方法(C#3)中的局部方法(partial method)不是同一个概念。
微软官方称 local functions 为“本地函数”。
14.1.1 局部方法中的变量访问
局部方法可以捕获变量,这些局部变量有:
14.1.1.1 局部方法只能捕获 作用域内的 变量
这里的作用域指 方法声明所在的 代码块。
以如下代码为例,第 1 段代码非法:
static void Invalid()
{
for (int i = 0; i < 10; i++)
{
PrintI();
}
void PrintI() => Console.WriteLine(i);
}
static void Valid()
{
for (int i = 0; i < 10; i++)
{
PrintI();
void PrintI() => Console.WriteLine(i);
}
}
14.1.1.2. 局部方法必须在其捕获的变量声明之 后 声明
不能在变量声明前使用该变量。同理,也不能在变量声明前捕获该变量。
如下代码 非 法:
static void Invalid()
{
void PrintI() => Console.WriteLine(i);
int i = 10;
PrintI();
}
14.1.1.3. 局部方法 不能 捕获 ref 参数
如下代码 非 法:
static void Invalid(ref int p)
{
PrintAndIncrementP();
void PrintAndIncrementP() => Console.WriteLine(p++);
}
不过我们可以通过 传参的 方式将 ref 参数再传递一次:
static void Valid(ref int p)
{
PrintAndIncrement(ref p);
void PrintAndIncrement(ref int x) => Console.WriteLine(x++);
}
如果在局部方法中不需要修改参数值,也可以使用值参数传递。
Warn
这一限制必然导致(还是参考匿名函数):在结构体中声明的局部方法不能访问 this。可以把 this 视作每个实例方法参数列表中的一个隐含参数。对于类方法,它是一个 值 参数;但是对于结构体方法,它就是 引用 参数。因此,类中的局部方法可以捕获 this,但是结构体不可以。这一点对于其他引用参数也适用。
14.1.1.4 局部方法与“确定赋值”
C# 中“确定赋值”的规则很复杂,有了局部方法之后就更复杂了。最简单的思考模型是,把所有局部方法调用都看作 内联 调用。
以如下代码为例,第 5 行的调用使用了未赋值的变量 i
,该代码 非 法:
static void AttemptToReadNotDefinitelyAssignedVariable()
{
int i;
void PrintI() => Console.WriteLine(i);
PrintI();
i = 10;
PrintI();
}
上述代码编译器提示的是第 5 行出现了编译错误!而非第 3 行声明处,也不是第 4 行变量使用处。
然而,如下代码却是 合 法的,他通过另一个局部方法对变量完成了赋值:
static void DefinitelyAssignInMethod()
{
int i;
void PrintI() => Console.WriteLine(i);
AssignI();
PrintI();
void AssignI() => i = 10;
}
14.1.1.5 局部方法 不能 给只读字段赋值
只读字段只能在字段初始化器或者构造器中进行赋值。有了局部方法之后,该规则依然成立,不过增加了一项小的补充条款:即便在构造器中声明的局部方法,对变量的赋值 也不能算作字段初始化 。例如以下代码 非 法:
class Demo
{
private readonly int value;
public Demo()
{
AssignValue();
void AssignValue()
{
value = 10;
}
}
}
14.1.2 局部方法的实现
实现细节:不确保任何东西
这部分只讨论 Roslyn 编译器对于 C#7.0 局部方法的实现。在未来的 Roslyn 版本中,实现方式可能会发生变化,而其他 C# 编译器也可能有不同的实现方式,这就意味着读者可能无须关注这部分的某些细节。
实现方式确实会影响程序性能,因此在性能敏感的代码中使用局部方法需要慎重再三。不过,对于性能问题也不能一概而论,还是要坚持通过认真测试和衡量来做决定,而不是根据理论来决策。
在CLR层面不存在局部方法的概念 。C# 编译器负责把局部方法转换成 普通方法 ,转换原则是 让最终代码的行为不违反语言规则 。
(相较匿名方法)多数情况下,局部方法只能在宿主方法内部进行调用,因此无须理会调用结束后所捕获的变量。局部方法实现起来会更高效,因为只需要操作 栈 内存而不牵涉 堆 内存分配。
以如下代码为例,我们分析 Roslyn 编译器做了什么工作:
static void Main()
{
int i = 0;
AddToI(5);
AddToI(10);
Console.WriteLine(i);
void AddToI(int amount) => i += amount;
}
-
编译器会创建一个私有的 可变结构体
结构体中的 公共字段 表示所有同作用域的捕获变量,在本例中就是变量
i
。 -
编译器会在
Main()
方法中创建一个该结构体类型的变量,然后该结构体变量按 引用 传递给AddToI()
方法
编译器转换后的方法如下:
private struct MainLocals
{
public int i;
}
static void Main()
{
MainLocals locals = new MainLocals();
locals.i = 0;
AddToI(5, ref locals);
AddToI(10, ref locals);
Console.WriteLine(locals.i);
}
static void AddToI(int amount, ref MainLocals locals)
{
locals.i += amount;
}
可以观察到:
-
AddToI()
方法被转换为静态方法这是因为该方法未捕获 this ,因此编译器将其生成为静态方法。
-
结构体参数通过 引用 传递
好处有:
- 局部方法可以修改局部变量。
- 无论有多少局部变量被捕获,对局部方法调用的性能影响都很小。
14.1.2.1 在多个作用域捕获变量
在之前的匿名方法中,如果捕获的变量来自多个作用域(这些作用域互相嵌套),就会生成多个类,每个类表示一个作用域,每个类都有一个字段指向外部作用域的实例(参考3.5.2.3 多个作用域下的变量捕获)。
局部方法的处理完全不同:编译器会为每个作用域生成一个 结构体 ,这些 结构体 都包含各自的捕获变量,且 不会 指向外部作用域的实例。
如下代码演示了编译器的处理方式:
static void Main()
{
DateTime now = DateTime.UtcNow;
int hour = now.Hour;
if (hour > 5)
{
int minute = now.Minute;
PrintValues();
void PrintValues() =>
Console.WriteLine($"hour = {hour}; minute = {minute}");
}
}
struct OuterScope //
{ // 为外层作用域生成的结构体
public int hour; //
} //
struct InnerScope //
{ // 为内层作用域
public int minute; // 生成的结构体
} //
static void Main()
{
DateTime now = DateTime.UtcNow; // 未被捕获的局部变量
OuterScope outer = new OuterScope(); // 为外层作用域变量 hour
outer.hour = now.Hour; // 创建并使用的结构体
if (outer.hour > 5)
{
InnerScope inner = new InnerScope(); // 为内层作用域变量 minute
inner.minute = now.Minute; // 创建并使用的结构体
PrintValues(ref outer, ref inner); // 按引用结构体传递给生成的方法
}
}
static void PrintValues(ref OuterScope outer, ref InnerScope inner)
{
Console.WriteLine($"hour = {outer.hour}; minute = {inner.minute}");
}
14.1.2.2 局部方法摆脱宿主代码的几种情况
对于普通的局部方法,编译器会执行“把所有操作都控制在 栈 内存之上”的优化。以下 4 种局部方法则不同:
- 异步 方法
- 迭代器 方法
- 被 匿名 函数调用
- 方法组转换 的目标
下面这段代码演示了对局部方法进行方法组转换,编译器对它的处理方式。可以看到,这里和 lambda 表达式的处理方式无异,都是通过 私有类 对变量进行捕获:
static void Main()
{
Action counter = CreateCounter();
counter(); // CreateCounter 方法
counter(); // 完成后调用委托
}
static Action CreateCounter()
{
int count = 0; // Count 方法可以捕获的局部变量
return Count; // Count 方法到 Action 委托的方法组转换
void Count() => Console.WriteLine(count++); // 局部方法
}
static void Main()
{
Action counter = CreateCounter();
counter();
counter();
}
static Action CreateCounter()
{
CountHolder holder = new CountHolder(); // 保存捕获
holder.count = 0; // 的变量
return holder.Count; // 方法组转换
}
private class CountHolder // 私有类,用于保存局部变量和局部私有方法
{
public int count;
public void Count() => Console.WriteLine(count++);
}
14.1.3 使用指南
局部方法的适用场景主要有以下两种模式:
- 在某个方法中存在 多处重复的 逻辑;
- 存在只用于 单一方法 的私有方法。
使用局部方法的一个重点在于:是在表达这部分代码属于 某个方法 的实现细节,而不是 类型 的实现细节。
14.1.3.1 迭代器/async 方法参数校验以及局部方法优化
当迭代器或者 async 方法需要做积极的 参数校验 时,可以考虑使用局部方法进行优化。
试比较如下两段代码,第二段代码通过局部方法实现:
public static IEnumerable<TResult> Select<TSource, TResult>(
this IEnumerable<TSource> source,
Func<TSource, TResult> selector)
{
Preconditions.CheckNotNull(source, nameof(source));
Preconditions.CheckNotNull(
selector, nameof(selector));
return SelectImpl(source, selector);
}
private static IEnumerable<TResult> SelectImpl<TSource, TResult>(
IEnumerable<TSource> source,
Func<TSource, TResult> selector)
{
foreach (TSource item in source)
{
yield return selector(item);
}
}
public static IEnumerable<TResult> Select<TSource, TResult>(
this IEnumerable<TSource> source,
Func<TSource, TResult> selector)
{
Preconditions.CheckNotNull(source, nameof(source));
Preconditions.CheckNotNull(selector, nameof(selector));
return SelectImpl(source, selector);
IEnumerable<TResult> SelectImpl(
IEnumerable<TSource> validatedSource,
Func<TSource, TResult> validatedSelector)
{
foreach (TSource item in validatedSource)
{
yield return validatedSelector(item);
}
}
}
出于性能提升的目的,第二段代码中的局部变量采用 传参 的方式传入变量值,而非变量捕获。
14.2 out 变量
14.2.1 out 参数的内联变量声明
自 C#7 开始,out 参数允许内联声明。试比较如下两段代码,内联声明的方式显然更加简洁:
static int? ParseInt32(string text)
{
int value;
return int.TryParse(text, out value) ? value : (int?) null;
}
static int? ParseInt32(string text) =>
int.TryParse(text, out int value) ? value : (int?) null;
out 变量实参还有如下特点(这些特点和模式匹配中引入的新变量时的情况相似):
-
如果不关心变量值,可以在变量名前加 下划线 表示这是一个抛弃变量。
-
可以使用 var 声明一个隐式类型的变量(通过形参的类型推断其类型)。
-
在表达式树中不能使用 out 变量实参。
-
变量作用域局限于 周围的代码块 。
-
在字段、属性、构造器初始化器或者 C#7.3 之前的查询表达式中不能使用 out 变量。
-
当且仅当方法确定会被调起,该 out 变量才会是确定赋值的。
以如下代码为例,变量
value2
的值是不确定赋值的,因此无法通过编译:static int? ParseAndSum(string text1, string text2) => int.TryParse(text1, out int value1) || int.TryParse(text2, out int value2) ? value1 + value2 : (int?)null;
14.2.2 C#7.3 关于 out 变量和模式变量解除的限制
C#7.3 之前 out 变量和模式变量在如下几种情况不能使用:
- 初始化字段或属性时
- 构造器
- 查询表达式
C#7.3 解除了这一限制。下面是一个简单的示例:
class ParsedText
{
public string Text { get; }
public bool Valid { get; }
protected ParsedText(string text, bool valid)
{
Text = text;
Valid = valid;
}
}
class ParsedInt32 : ParsedText
{
public int? Value { get; }
public ParsedInt32(string text)
: base(text, int.TryParse(text, out int parseResult))
{
Value = Valid ? parseResult : (int?)null;
}
}
14.3 数字字面量的改进
14.3.1 二进制整型字面量
在 C#7 之前,整型字面量有两种表示方式:
- 十进制:无前缀
- 十六进制:使用 0x 或 0X 作为前缀
C#7 增加了二进制字面量:
- 二进制:使用 0b 或 0B 作为前缀
试比较如下三种表示方式,显然二进制字面量最为直观:
byte b1 = 135;
byte b2 = 0x83;
byte b3 = 0b10000111;
14.3.2 下划线分隔符
下划线分隔符可以能用于数字字面量(整型、浮点型皆可)。
下面是一些示例:
int maxInt32 = 2_147_483_647;
decimal largeSalary = 123_456_789.12m;
ulong alternatingBytes = 0xff_00_ff_00_ff_00_ff_00;
ulong alternatingWords = 0xffff_0000_ffff_0000;
ulong alternatingDwords = 0xffffffff_00000000;
它也有一些限制:
-
下划线不能出现在字面量的 开始 ;
-
下划线不能出现在字面量的 末尾 (包括后缀前面);
-
浮点型字面量 小数点 前后不能有下划线;
-
在 C#7.0 和 C#7.1 中,整型字面量的数基(0x 或 0b)后不能出现下划线。
C#7.2 解除了这一限制
14.4 throw 表达式
自 C#7 开始可以使用 throw 表达式,不过仅限于如下几种场景:
- 作为 lambda 表达式的主体;
- 用作表达式主体成员;
-
??
运算符的第 2 个操作数; - 条件运算符
?:
的第 2 个或者第 3 个操作数(这两个操作数不能同时是 throw 表达式)。
如下是上述场景的示例:
public void UnimplementedMethod() => // 表达式
throw new NotImplementedException(); // 主体方法
public void TestPredicateNeverCalledOnEmptySequence()
{
int count = new string[0]
.Count(x => throw new Exception("Bang!")); // lambda 表达式
Assert.AreEqual(0, count);
}
public static T CheckNotNull<T>(T value, string paramName) where T : class
=> value ?? // ?? 运算符
throw new ArgumentNullException(paramName); // (在表达式主体方法内部)
public static Name =>
initialized
? data["name"] // ?: 运算符
: throw new Exception("..."); // (在表达式属性内部)
14.5 default 字面量(C#7.1)
default(T)
是 C# 2.0 引入的特性,它主要为泛型服务。
default 运算符的结果分为两种:
-
引用类型: null 引用
-
数值类型: 相应的 0 值
如
char
类型的 U+0000,bool
类型的 false
下面是一个简单的示例:
public async Task<string> FetchValueAsync(
string key,
CancellationToken cancellationToken = default(CancellationToken))
可以看到一长串的赋值让代码显得冗长。为此 C#7.1 支持通过上下文自动推断 default 对应的类型,上述代码可以简化为:
public async Task<string> FetchValueAsync(
string key, CancellationToken cancellationToken = default)
它可以在任何有上下文推断的地方使用,例如作为数组元素:
var intArray = new[] { default, 5 };
var stringArray = new[] { default, "text" };
Don't
我们在使用 default 的过程中要尽量避免如下意义不明的代码:
static void PrintValue(int value = 10) { Console.WriteLine(value); } static void Main() { PrintValue(default); }
14.6 非尾部命名实参
可选形参和命名实参是 C#4 的两个特性。这两个特性都对参数顺序有要求:
- 可选形参必须出现在 所有必要形参 之后(形参数组除外),
- 命名实参必须出现在所有 定位实参 之后。
这种限制也带来一些不便。以如下两段代码为例,第一段代码 null 参数的含义不够明晰,第二段代码又额外引入了一个变量:
client.UploadCsv(table, null, csvData, options)
TableSchema schema = null;
client.UploadCsv(table, schema, csvData, options);
为此 c#7.2 取消了命名实参的位置限制,上述代码可以改为:
client.UploadCsv(table, schema: null, csvData, options);
为了避免而一些,C# 设计团队也添加了一些限制:如果命名实参后面出现了未命名实参,那么应把命名实参当作 普通定位实参 。以如下代码为例,第 5 行代码非法:
void M(int x, int y, int z){}
M(5, z: 15, y: 10);
M(5, y: 10, 15);
M(y: 10, 5, 15);
14.7 私有受保护的访问权限(C#7.2)
CLR 中一直有 private protected 访问修饰符,它限制“只有 在同一程序集中 并且 继承该类的 代码,才能访问 private protected 的成员。”。
该特性自 C#7.2 才在语言层面对外提供。该特性了解即可。
14.8 C# 7.3 的一些小改进
14.8.1 泛型类型约束
在2.1.5 类型约束中我们列举了若干泛型约束,C#7.3 又增加了三种:
- 枚举约束(Enum)
- 委托约束(Delegate)
- 非托管类型约束(unmanaged)
关于枚举约束,最常使用的形式是 where T : struct, Enum
,这样可以限制类型 T 只能是 Enum
的子类 ,不能是 Enum
类型本身 。
unmanaged 类型指“非可空、非泛型的值类型,且字段也不能是引用类型”,.NET 中的大部分值类型(Int32
、Double
、Decimal
、Guid
)便属于这一范畴。
下面是这些泛型约束的使用示例,其中第 7 、 14 行代码非法:
enum SampleEnum { }
static void EnumMethod<T>() where T : struct, Enum { }
static void DelegateMethod<T>() where T : Delegate { }
static void UnmanagedMethod<T>() where T : unmanaged { }
...
EnumMethod<SampleEnum>();
EnumMethod<Enum>();
DelegateMethod<Action>();
DelegateMethod<Delegate>();
DelegateMethod<MulticastDelegate>();
UnmanagedMethod<int>();
UnmanagedMethod<string>();
14.8.2 重载决议改进
Info
这部分内容用不到,仅做了解
C#7.3 改进了几项编译时检查:
- 泛型类型实参必须符合类型形参的全部类型约束;
- 静态方法不能按照实例方法的方式调用;
- 实例方法不能按照静态方法的方式调用。
关于第一种情况,以如下代码为例,在 C#7.3 之前,因重载决议会忽略类型形参的条件约束,它会命中第 2 个 Method()
方法,会在 编译 时报错。自 C#7.3 开始,重载决议也会考虑类型形参的条件约束,会命中第 1 个 Method()
方法:
Method<int>("text");
static void Method<T>(object x) where T : struct =>
Console.WriteLine($"{typeof(T)} is a struct");
static void Method<T>(string x) where T : class =>
Console.WriteLine($"{typeof(T)} is a reference type");
Question
后两个检查我没看懂什么意思,在之前的 C# 版本中实例可以调用静态方法吗?可以通过类名直接调用实例方法?我也没查到相应的资料。
14.8.3 字段的 attribute 支持自动实现的属性
在 C#7.3 之前,要想对属性背后的字段添加属性,将不能使用 自动 属性。下面是一个简单的示例:
[Demo]
private string name;
public string Name
{
get { return name; }
set { name = value; }
}
自 C#7.3 开始,通过 field 关键字,上述代码可以简化成如下形式:
[field: Demo]
public string Name { get; set; }