第10章 简洁代码的特性“盛宴”

第10章 简洁代码的特性“盛宴”

10.1 using static 指令

10.1.1 引入静态成员

using static 指令可以简化 静态 方法的调用。

使用该指令后,以下几种成员可以通过名称直接引用,无需附带类型名:

  • 静态字段和属性。
  • 静态方法。
  • 枚举值。
  • 嵌套类型。

如下两段代码等价,显然第二段更为简洁:

using System;
...
static Point PolarToCartesian(double degrees, double magnitude)
{
    double radians = degrees * Math.PI / 180;
    return new Point(
        Math.Cos(radians) * magnitude,
        Math.Sin(radians) * magnitude);
}
using static System.Math;
...
static Point PolarToCartesian(double degrees, double magnitude)
{
    double radians = degrees * PI / 180;
    return new Point(
        Cos(radians) * magnitude,
        Sin(radians) * magnitude);
}

Notice

通过 using static 引入的成员,在进行成员查找时,它们的优先级要 于其他同名成员。

假设引入了 System.Math,而此时类中还声明了一个 Sin​ 方法,那么 Sin()​ 调用的将是 自己 声明的 Sin​ 方法,而不是 Math ​ 中的方法。

Info

using static 中虽然有 static,但它并不要求引入的类型必须是 态的。以下代码引入了非静态的 string​ 类型,也是合法的:

using static System.String;
...
string[] elements = { "a", "b" };
Console.WriteLine(Join(" ", elements));

10.1.2 using static 与扩展方法

在 C#6 之前的代码中,同一命名空间下的扩展方法,我们只能全部引入或全部舍弃。C#6 通过 using static 解决了这一问题。该用法有两个特别之处:

  • 使用 using static 指令引入的某个类型的扩展方法,不会导致对应命名空间下的其他扩展方法被 引入
  • 通过类型引入扩展方法,不能按照一般的 静态 方法调用(比如 Math.Sin()​),而是像 实例 方法一样调用。

第一点以 LINQ 中的 System.Linq.Queryable​ (支持 IQueryable<T>​ 扩展方法的类)和 System.Linq.Enumerable​ (支持 IEnumerable<T>​ 扩展方法的类)为例,下面这段代码演示了只引入 IQueryable<T>​ 扩展方法、隔离 IEnumerable<T>​ 扩展方法:

using static System.Linq.Queryable;
...
var query = new[] { "a", "bc", "d" }.AsQueryable(); // 创建一个 IQueryable<string>

Expression<Func<string, bool>> expr =           // 创建一个
    x => x.Length > 1;                          // 委托和
Func<string, bool> del = x => x.Length > 1;     // 表达式树

var valid = query.Where(expr);      // 合法,使用 Queryable.Where
var invalid = query.Where(del);     // 非法,查找范围内不存在接受 Func<...> 为参数的 Where 方法

Notice

上述代码如果不小心使用了 using System.Linq​,最后一句代码也是合法的。

第二点同样以 LINQ 进行展示。如下代码对字符串序列调用 Enumerable.Count()​ 方法,其中以扩展方法的方式调用是 法的,以普通静态方法的方式调用是 法的:

using System.Collections.Generic;
using static System.Linq.Enumerable;
...
IEnumerable<string> strings = new[] { "a", "b", "c" };
int valid = strings.Count();    // 合法
int invalid = Count(strings);   // 非法

Warn

将现有的静态方法改造成扩展方法(在第一个参数前添加 this 修饰符),以前是非破坏性的改动;对于 C#6,特性二使得该行为属于破坏性改动。

10.2 对象初始化器和集合初始化器特性增强

我们在3.3 对象和集合的初始化讲解了两个初始化器。C#6 对二者又进行了增强:

  • 对象初始化器增加了对索引器的支持;
  • 集合初始化器增加了对扩展方法的支持。

10.2.1 对象初始化器中的索引器

自 C#6 开始,我们可以在对象初始化器中使用索引器。其语法为 [index] = value​。如下代码演示了索引器的使用:

string text = "This text needs truncating";
StringBuilder builder = new StringBuilder(text)
{
    Length = 10,
    [9] = '\u2026'
};
Console.OutputEncoding = Encoding.UTF8;    // 确保 Console 支持 Unicode 编码
Console.WriteLine(builder);

Warn

Dictionary<,>​ 添加元素更推荐使用 集合 初始化器,而非对象初始化器中为索引器赋值。试比较如下两段代码,两段代码都错误的添加了相同 Key,但是第 段代码会报错。

var collectionInitializer = new Dictionary<string, int>
{
    { "A", 20 },
    { "B", 30 },
    { "B", 40 }
};
var objectInitializer = new Dictionary<string, int>
{
    ["A"] = 20,
    ["B"] = 30,
    ["B"] = 40
};

下面几种情况应使用对象初始化器,而非集合初始化器:

  • 当前类型没有实现 IEnumerable ​ 接口或者没有适合的 Add() ​ 方法,导致集合初始化器不能使用时。

  • 当前类型的索引器和 Add()​ 方法对于 重复键值 的处理方式一致。

    Dictionary<,>​ 显然不符合这一条

  • 确实需要 替换 现有元素而不是添加新元素。

    例如可能需要使用现有字典来新建一个字典,然后替换特定键值。

  • 其他情况:在可读性和可靠性之间斟酌

10.2.2 在集合初始化器中使用扩展方法

我们在3.3.3.1 工作流程提到:

自定义集合类型想使用集合初始化器,需要满足以下两个条件:

  • 实现了 IEnumerable ​ 接口;
  • 具有 Add() ​ 方法。

Add()​ 方法也有诸多限制:

  • 任何没有大括号包围的元素,都被视为调用 参数的 Add()​ 方法。如果需要调用 参数的 Add()​ 方法,则需要用大括号包围这些参数。

C#6 拓宽了 Add()​ 方法“合适”的限制:它可以是扩展方法。

以如下代码为例,因 重载决议 失败,它在 C#6 之前是无法通过编译的:

List<string> strings = new List<string>
{
    10,
    "hello",
    { 20, 3 }
};
// 等价于:
List<string> strings = new List<string>();
strings.Add(10);
strings.Add("hello");
strings.Add(20, 3);

在 C#6 中,添加如下扩展方法可以使上述代码通过编译:

public static class StringListExtensions
{
    public static void Add(this List<string> list, int value, int count = 1)
    {
        list.AddRange(Enumerable.Repeat(value.ToString(), count));
    }
}

可以看到:

  • 扩展方法可以用于 集合 初始化器;
  • 扩展方法 可以 是泛型的(上述代码未演示);
  • 扩展方法 可以 使用可选形参。

10.2.2.1 创建其他通用的 Add 方法

通过自定义 Add()​ 扩展方法实现 AddRange()​ 功能可以将集合元素的添加工作挪至集合初始化器中。以如下代码为例,它实现了 List<T> ​ 类型使用集合初始化器时可以接受 集合 元素:

static class ListExtensions
{
    public static void Add<T>(this List<T> list, IEnumerable<T> collection)
    {
        list.AddRange(collection);
    }
}

10.2.2.2 创建专有的 Add 方法

专有的 Add 方法还可以用来简化业务代码。

假设我们有如下 Person​ 类:

class Person
{
    public string Name { get; set; }
}

我们需要创建一个字典,其 Key​ 是 Person​ 的 Name​ 属性,Value​ 是相应的 Person​ 实例。为此我们不得不创建 临时变量 以记录 Person​ 实例:

var person1 = new Person { Name = "Jon" };
var person2 = new Person { Name = "Holly" };

var dictionary = new Dictionary<string, Person>
{
    {person1.Name, person1},
    {person2.Name, person2},
};

此时我们可以添加一个专门服务于 Dictionary<,>​ 和 Preson​ 的 Add​ 方法:

static class PersonDictionaryExtensions
{
    public static void Add(
    this Dictionary<string, Person> dictionary, Person person)
    {
        dictionary.Add(person.Name, person);
    }
}

此时为字典添加元素无需 临时的局部变量

var dictionary = new Dictionary<string, Person>
{
    { new Person { Name = "Jon" } },
    { new Person { Name = "Holly" } },
};

10.2.2.3 重新暴露显式实现的接口方法

ConcurrentDictionary<,>​ 为例,它的 Add()​ 方法以显式方式实现,因此如下代码非法:

var dictionary = new ConcurrentDictionary<string, int>
{
    { "x", 10 },
    { "y", 20 }
};

我们可以实现针对 IDictionary<,> ​ 接口的扩展方法,以支持 ConcurrentDictionary<,>​ 使用集合初始化器:

public static class DictionaryExtensions
{
    public static void Add<TKey, TValue>(this IDictionary<TKey, TValue> dictionary, TKey key, TValue value)
    {
        dictionary.Add(key, value);
    }
}

Tips

上述代码把所有字典类型都扩展了,过于宽泛。我们可以把目标锁定于 ConcurrentDictionary<,>​,防止应用范围扩大化。

10.3 空值条件运算符

空值条件运算符 ?.​ 于 C#6 引入。

10.3.2 关于空值条件运算符的更多细节

?.​ 运算符可用于方法、字段、索引器。其基本工作原理是:

  1. 每遇到一个空值条件运算符,编译器都会为 ?.​ 前面的值插入一条 空值检查 语句;

  2. 如果值为 null,那么整个表达式 的运算终止 ,并返回 null 值;

  3. 如果值不为 null,则表达式继续向右对属性、方法、字段或者索引进行运算,而不需要对左边做重复运算。

    该原理令所有表达式只运算 次,很好的规避了多线程的静态条件造成的影响。

如果整个表达式是非可空值类型,一旦其中出现了空值条件运算符,整个表达式的类型就会变成 对应的可空值 类型。

10.3.3 处理布尔值比较

通过 ?.​ 得到的运算结果,在进行相等比较时需要通过 == ​ 进行,而非 Equals() ​ 方法(可空类型的 Equals()​ 方法返回的结果是 bool? ​,它无法直接进行条件判断)。以如下代码为例,它无法通过编译:

c => c.Profile?.DefaultShippingAddress?.Town?.Equals("Reading")

在条件判断中使用空值条件运算符时,需要考虑以下 3 种可能的执行情况:

  • 表达式中的每一部分都运算过了,结果是 true;
  • 表达式中的每一部分都运算过了,结果是 false;
  • 表达式因为 null 值而中断,结果是 null

我们需要将第三种情况映射到 true 或 false。一般有两种实现方式:与某个 bool ​ 类型的常量做比较,或者使用 空合并 运算符 ?? ​。

下面演示了两种方案的使用:

// 如果 name 为 null,则不执行
if (name?.Equals("X") ?? false)
if (name?.Equals("X") == true) 
// 如果 name 为 null,则执行
if (name?.Equals("X") ?? true)
if (name?.Equals("X") != false)

Suggest

作者建议使用 空合并运算符 ,它可以理解为“尝试执行比较操作,如果操作没有执行完,就采用 ??​ 后的值”。

10.3.4 索引器与空值条件运算符

索引器使用空值条件运算符,其问号要放在方括号之

int[] array = null;
int? firstElement = array?[0];

10.3.5 使用空值条件运算符提升编程效率

空值条件运算符常用场景有二:

1. 安全便捷的事件触发

在空值条件运算符出来之前,触发事件的代码一般编写如下:

EventHandler handler = Click;    // 通过局部变量防止竞态条件
if (handler != null)
{
    handler(this, EventArgs.Empty);
}

使用空值条件运算符后可简化成一行代码:

Click?.Invoke(this, EventArgs.Empty);
2. 最大程度地利用返回 null 的 API

我们在内插字符串中有提到:

  1. 无意义的消耗 内存

    有时我们的格式化字符串并不会用到,但是内插字符串会提前创建 string​/FormattableString​ 实例,导致内存消耗:

    Preconditions.CheckArgument(start.Year < 2000,
        Invariant($"Start date {start:yyyy-MM-dd} should not be earlier than year 2000."));
    

    这在日志系统十分常见。

如果日志系统的 API 支持返回 null,像下面这样:

public interface ILogger
{
    IActiveLogger Debug { get; }    //
    IActiveLogger Info { get; }     // 如果未开启日志
    IActiveLogger Warning { get; }  // 则属性返回 null
    IActiveLogger Error { get; }    //
}

public interface IActiveLogger
{
    void Log(string message);
}

Debug​ 为 null 时,如下这段代码的内插字符串 不会被创建

logger.Debug?.Log($"Received request for URL {request.Url}");

Tips

Element("xyz").Value​ 调用在 xyz 元素不存在时将 抛出 NullReferenceExceptionXElement​ 为 string​ 类型定义了显式转换,可以通过强制类型避免此异常。即:

string xyz = (string)settings.Element ("xyz");

当然,我们也可以使用 ?.

10.3.6 空值条件运算符的局限性

空值条件运算符表达式的结果是一个 ,而不是 变量 。该限制导致我们不能把空值条件运算符用在赋值号(=)的左边,例如以下几种写法均 法:

person?.Name = "";
stats?.RequestCount++;
array?[index] = 10;

此时需要使用 if 语句进行判断。

10.4 异常过滤器

10.4.1 异常过滤器的语法和语义

异常过滤器的语法为:

  • 关键字 when ​ 之后跟一对小括号,括号中是表达式。
  • 表达式可以使用 catch 块中声明的异常变量,且执行结果必须是布尔类型。

它的简单应用如下,该代码会输出:“ Caught 'You can catch this '”和“ Caught 'You can catch this too '”:

string[] messages =
{
    "You can catch this",
    "You can catch this too",
    "This won't be caught"
};
foreach (string message in messages)
{
    try
    {
        throw new Exception(message);
    }
    catch (Exception e)
        when (e.Message.Contains("catch"))  // 只有当 message 中包含 catch 时才捕获
    {
        Console.WriteLine($"Caught '{e.Message}'");
    }
}

10.4.1.1 双通路异常模型

CLR 处理异常采用双通路模型,方式如下:

flowchart LR id1([异常抛出]) id2[CLR 遍历 catch 块] id3{catch 块是否 兼容该异常?} id4{catch 块的异常过滤器 是否返回 true?} id5[释放调用栈 (自异常抛出位置开始至当前位置)] id6[执行释放栈过程 的 finally 块] id7[处理 catch 块执行] id8[处理 catch 块的 finally 块执行] id1-->id2-->id3--是-->id4--是-->id5-->id6-->id7-->id8 id3--否-->id2 id4--否-->id2
  • 异常抛出,第 1 条通路开始。

  • CLR 自上而下检查栈空间,寻找可以处理当前异常的 catch 块。

    这里我们将该块称为“处理 catch 块”,这不是官方术语。

    • 只有具备 兼容异常类型 的 catch 块才会被考虑。
    • 如果该 catch 块中有异常过滤器,那么先执行异常过滤器。如果过滤器返回 false ,则 catch 块不处理异常。
    • 不包含异常过滤器的 catch 块等价于包含异常过滤器返回 true 的 catch 块。
  • 处理 catch 块确定了之后,第 2 条通路开始。

  • CLR 把从异常抛出的位置开始到 catch 块确定了的位置的调用栈释放。

    在释放栈的过程中,所有 finally 块将被执行。(不包括处理 catch 块所对应的 finally块。)

  • 处理 catch 块执行。

  • 如果处理 catch 块有对应的 finally 块,那么该部分代码也会被执行。

如下代码演示了上述流程:

Bottom();

static bool LogAndReturn(string message, bool result)
{
    Console.WriteLine(message);
    return result;
}

static void Top()
{
    try
    {
        throw new Exception();
    }
    finally                                 //
    {                                       // 在第2条通路中执行
        Console.WriteLine("Top finally");   // 的 finally 块
    }                                       //
}

static void Middle()
{
    try
    {
        Top();
    }
    catch (Exception e) when (LogAndReturn("Middle filter", false))
    {
        Console.WriteLine("Caught in middle");
    }
    finally                                     //
    {                                           // 在第2条通路中执行
        Console.WriteLine("Middle finally");    // 的 finally 块
    }                                           //
}

static void Bottom()
{
    try
    {
        Middle();
    }
    catch (IOException e) when (LogAndReturn("Never called", true))
    {
    }
    catch (Exception e) when (LogAndReturn("Bottom filter", true))
    {
        Console.WriteLine("Caught in Bottom");
    }
}

image

Warn

上述流程我们可以发现:when 块的内容是 于 finally 块执行的!这意味着恶意开发者可以通过在自己的 when 块中执行恶意代码!

双通路模型的安全影响

finally 块执行的时序会影响 using 和 lock 语句,这一点对于 try/finally 或者 using中的内容有重要影响,因为代码的执行环境可能包含恶意代码。如果有非受信代码调用我们的方法,而我们的方法可以抛出异常,调用方就可以在 finally 块执行之前通过异常过滤器来执行代码了。

以上内容表明:对安全敏感的代码不要出现在 finally 块中。例如在 try 块中进入一个较高权限的状态,然后 finally 块中的代码回到一个较低权限的状态,此时其他代码就可能在仍处于较高权限时被执行。不过很少需要考虑此类安全问题,因为代码多数时候是在比较友好的环境中运行的,但是我们必须意识到存在这样的风险。若想提升安全度,可以添加一个带有异常过滤器的空 catch 块,该过滤器负责移除相关权限并返回 false(这样异常就不会被捕获了),不过我一般不会这么做。

10.4.1.2 多次捕获同一类型的异常

同一个 try 块在不同的 catch 块中捕获同一个异常类型,且未使用 异常过滤器 ,代码将无法通过编译。如下代码演示了不同 catch 块捕获相同异常,并使用异常过滤块:

try
{
    // 尝试 Web 操作
}
catch (WebException e) when (e.Status == WebExceptionStatus.ConnectFailure)
{
    // 处理连接失败
}
catch (WebException e) when (e.Status == WebExceptionStatus.NameResolutionFailure)
{
    // 处理名称决议失败
}

10.4.2 重试操作

异常过滤器常用于特定条件下的重试操作。如下代码演示了一个通用方法,它可以做到:

  • 执行外部传入的操作
  • 执行失败则尝试操作若干次
static T Retry<T>(Func<T> operation, int attempts)
{
    while (true)
    {
        try
        {
            attempts--;
            return operation();
        }
        catch (Exception e) when (attempts > 0)
        {
            Console.WriteLine($"Failed: {e}");
            Console.WriteLine($"Attempts left: {attempts}");
            Thread.Sleep(5000);
        }
    }
}

10.4.3 记录日志的“副作用”

我们可以利用异常过滤器记录全部异常。实现方式很简单:

  • 异常过滤器 中调用记录日志的方法
  • 返回 false 表明不需要捕获当前异常

类似的代码如下:

static void Main()
{
    try
    {
        UnreliableMethod();
    }
    catch (Exception e) when (Log(e))
    {
    }
}
static void UnreliableMethod()
{
    throw new Exception("Bang!");
}

static bool Log(Exception e)
{
    Console.WriteLine($"{DateTime.UtcNow}: {e.GetType()} {e.Message}");
    return false;
}

10.4.4 单个、有针对性的日志过滤器

10.4.5 为何不直接抛出异常

以如下两段代码为例:

catch (Exception e) when (condition)
{
    ...
}
catch (Exception e)
{
    if (!condition)
    {
        throw;
    }
    ...
}

它们有这些区别:

  • 调用栈 不同

代码一会保持原异常抛出的调用栈,代码二则从当前位置重新计算。

这也意味着二者的 finally 块的调用是有细微差别的。试比较10.4.1.1 双通路异常模型中的示例代码和如下代码输出内容的区别:

Bottom();

static bool LogAndReturn(string message, bool result)
{
    Console.WriteLine(message);
    return result;
}

static void Top()
{
    try
    {
        throw new Exception();
    }
    finally
    {
        Console.WriteLine("Top finally");
    }
}

static void Middle()
{
    try
    {
        Top();
    }
    catch (Exception e)
    {
        if (!LogAndReturn("Middle filter", false))
        {
            throw;
        }
        Console.WriteLine("Caught in middle");
    }
    finally
    {   
        Console.WriteLine("Middle finally");
    }   
}

static void Bottom()
{
    try
    {
        Middle();
    }
    catch (Exception e)
    {
        if (!LogAndReturn("Bottom filter", true))
        {
            throw;
        }
        Console.WriteLine("Caught in Bottom");
    }
}
  • throw 语句可以保留大部分原始调用栈,但仍存在细微差别
posted @ 2025-04-09 22:34  hihaojie  阅读(24)  评论(0)    收藏  举报