第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 为“本地函数”。

本地函数 - C# | Microsoft Learn

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;
}
  1. 编译器会创建一个私有的 可变结构体

    结构体中的 公共字段 表示所有同作用域的捕获变量,在本例中就是变量 i ​。

  2. 编译器会在 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 之前,整型字面量有两种表示方式:

  • 十进制:无前缀
  • 十六进制:使用 0x0X 作为前缀

C#7 增加了二进制字面量:

  • 二进制:使用 0b0B 作为前缀

试比较如下三种表示方式,显然二进制字面量最为直观:

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​)便属于这一范畴。

下面是这些泛型约束的使用示例,其中第 714 行代码非法:

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 之前,因重载决议会忽略类型形参的条件约束,它会命中第 2Method()​ 方法,会在 编译 时报错。自 C#7.3 开始,重载决议也会考虑类型形参的条件约束,会命中第 1Method()​ 方法:

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; }
posted @ 2025-04-17 23:05  hihaojie  阅读(10)  评论(0)    收藏  举报