Lesson 02: C# 8 Switch Expressions · Using Declarations · Indices/Ranges

三个轻量但高频的特性,让你的日常代码少写 30% 的废话

前置 / Prerequisite:Lesson 01 的 NRT 基础。
本课目标:三件能马上在项目里用起来的工具——switch 表达式Switch Expressions、using 声明Using Declarations、索引与范围Indices & Ranges

一、Switch 表达式Switch Expressions

老写法的问题

传统的 switch 是语句Statement,不是表达式Expression。你不能把它放在赋值的右侧,于是代码变成:

// C# 7.3:啰嗦五连——break、return、临时变量、重复书写
string dayType;
switch (day)
{
    case DayOfWeek.Saturday:
    case DayOfWeek.Sunday:
        dayType = "周末";
        break;
    case DayOfWeek.Monday:
        dayType = "星期一综合症";
        break;
    default:
        dayType = "工作日";
        break;
}

四个痛点:声明临时变量、重复赋值、每个分支 breakcaseswitch 本身都是纯噪音。

新写法

// C# 8:开关表达式——直接放在 = 右侧
string dayType = day switch
{
    DayOfWeek.Saturday or DayOfWeek.Sunday => "周末",
    DayOfWeek.Monday                          => "星期一综合症",
    _                                         => "工作日",
};

一次只改一样东西,效果很明显:

老写法 (Statement)

string result;
switch (x) {
    case 1: result = "一"; break;
    case 2: result = "二"; break;
    default: result = "其他"; break;
}
return result;

C# 8 (Expression)

return x switch
{
    1 => "一",
    2 => "二",
    _ => "其他",
};

核心语法元素

元素写法含义
丢弃模式Discard Pattern_匹配所有剩余情况(代替 default
或模式Or Pattern1 or 2匹配任一值
与模式And Pattern> 0 and < 10两个条件同时成立
属性模式Property Pattern{ Name: "张三" }在 case 里直接解构对象属性
元组模式Tuple Pattern("a", 1)同时匹配多个值
when 子句When Clausen when n > 10附加条件
🤔 为什么是 or/and/not,不是 |/&/!
|& 在 C# 里已经是按位运算——对两个值做运算、产出一个新值。模式匹配不是在算值,是在描述形状

举个例子:x is 1 | 2 & 3 怎么解析?是先算出 1 | 2 = 3,再 3 & 3 = 3,最后匹配 3?还是 1 or (2 and 3)?用 or/and 就没有歧义——模式有自己的优先级,跟表达式优先级彻底隔离。加上 not 没有好的运算符(! 是逻辑非,x is !null 读起来很奇怪),三个一起用关键字更一致。读起来也更像"条件描述"而非"位运算"。

属性模式Property Pattern 深入

属性模式的本质:不拆解对象,直接在 { } 里描述你要匹配的属性形状。

基础:匹配一个或多个属性

public decimal CalcDiscount(Order order) => order switch
{
    { IsVip: true, Total: >= 1000 }          => 0.2m,
    { IsVip: true }                           => 0.1m,
    { Total: >= 500 }                            => 0.05m,
    _                                            => 0m,
};
关键规则:{ } 里的多个属性之间是 AND 关系——必须全部满足才命中该分支。但 未列出的属性会被忽略
比如第三行 { Total: >= 500 } 不管 IsVip 是什么值都进来。

嵌套属性Nested / Recursive Pattern

属性的值本身也可以是一个模式——属性模式可以无限嵌套:

public string DescribeAddress(Person p) => p switch
{
    { Address: { City: "北京", District: "海淀" } } => "海淀区居民",
    { Address: { City: "北京" } }                      => "北京居民",
    { Address: { Province: not null } }                => "有地址但缺城市",
    _                                                   => "无地址信息",
};

组合类型模式——同时做类型检查、属性解构、null 防御

属性模式可以和类型模式写在一起:TypeName { Prop: subpattern }。先检查类型,再检查属性形状。这是日常开发里最常用的模式组合。

public string ProcessShape(object shape) => shape switch
{
    Circle { Radius: > 100 }                     => "大圆",
    Circle { Radius: > 0 }                       => "小圆",
    Rectangle { Width: var w, Height: var h } when w == h => "正方形",
    Rectangle                                     => "矩形",
    null                                          => "空",
    _                                               => "未知形状",
};

注意 Rectangle 这行没带 { }——它是纯类型模式,只检查"是不是 Rectangle"。但如果 shapenull,这一行不会命中,因为类型模式自动排除 null。null 由下面单独的分支兜底。

TypeName { } 又是什么意思?Circle { } 就等于说"是 Circle 类型,且满足属性模式 { }"。而 { } 是属性模式的退化形式——不指定任何属性,匹配所有非 null 实例。效果上等价于纯类型模式 Circle

同理,单独一个 { } 也能用——obj is { } 等价于 obj is not null。如果再加 var 模式,就能一边判 null 一边取出 NotNull 变量:obj is { } notNull

上面第三行还藏着两个新东西:var wvar 模式(匹配任意值并绑定变量),when w == h 是附加条件。这就引出了下面两个话题。

元组模式Tuple Pattern

当你要根据多个独立值的组合来做决策时,传统的做法是一层层的 if-else。元组模式让你把这些组合写成一个二维(或多维)的匹配表。

基础语法

// C# 7.3:层层 if —— 逻辑分散、可读性差
string DescribeGame(string p1, string p2)
{
    if (p1 == "rock" && p2 == "scissors") return "Player1 胜";
    if (p1 == "rock" && p2 == "paper")    return "Player2 胜";
    if (p1 == "rock" && p2 == "rock")     return "平局";
    // ... 还有 6 种组合
    return "未知";
}

// C# 8:元组模式——所有组合一目了然
string DescribeGame(string p1, string p2) => (p1, p2) switch
{
    ("rock", "scissors")   => "Player1 胜",
    ("rock", "paper")      => "Player2 胜",
    ("rock", "rock")       => "平局",
    ("paper", "rock")      => "Player1 胜",
    ("paper", "scissors")  => "Player2 胜",
    ("paper", "paper")     => "平局",
    ("scissors", "paper")  => "Player1 胜",
    ("scissors", "rock")   => "Player2 胜",
    ("scissors", "scissors")=> "平局",
    _                    => "非法手势",
};

元组模式里每个位置都可以是子模式

不限于常量——你可以在元组的每个位置用任何模式

string ClassifyPoint(int x, int y) => (x, y) switch
{
    (0, 0)              => "原点",
    (_, 0)              => "在 X 轴上",
    (0, _)              => "在 Y 轴上",
    (> 0, > 0)          => "第一象限",
    (< 0, > 0)          => "第二象限",
    (< 0, < 0)          => "第三象限",
    (> 0, < 0)          => "第四象限",
};
多用 _ 丢弃你不关心的位置。比如上面 (_, 0) 的含义是"不管 x 是多少,只要 y 是 0 就命中"。

元组模式 + 属性模式 可以混用

// 同时用元组和属性模式做复杂决策
string CanOrder(User user, Product product) => (user, product) switch
{
    ({ IsVip: true }, _)                              => "可以购买(VIP 不限购)",
    (_, { Stock: 0 })                                    => "库存不足",
    ({ Age: < 18 }, { Category: "成人用品" })             => "年龄不符",
    _                                                    => "可以购买",
};

这里 (user, product) 构建了一个元组,元组里每个位置再用属性模式去解构。两种模式完全正交、可以任意嵌套。

when 子句When Clause

when 给任何模式附加一个运行时条件——先匹配模式,再检查条件。模式本身是编译时确定的形状when运行时求值的布尔表达式

什么时候必须用 when

模式匹配能做的事有限:

场景模式能做吗?怎么办
比较两个变量 ❌ 模式只能匹配固定常量 { Width: var w, Height: var h } when w == h
调用方法 ❌ 模式里不能调用方法 s when s.StartsWith("ERR")
访问集合元素 ❌ 模式不支持索引器 list when list.Count > 0 && list[0] > 10
复杂布尔逻辑 ⚠️ 部分支持(or/and/not 简单逻辑用 and/or,复杂逻辑用 when
匹配常量 ✅ 直接用 42"hello"
范围比较 ✅ 关系模式(C# 9+) > 0 and < 100
null 检查 nullnot null
类型检查 ✅ 类型模式 Circle c

实战对比:when 必要 vs 不必要

// ❌ 不需要 when——模式本身就能搞定
string Bad(int n) => n switch
{
    int x when x > 0  => "正数",   // when 多余:直接用 > 0 模式
    int x when x < 0  => "负数",   // 同上
    _               => "零",
};

// ✅ 模式优先——关系模式干的事别用 when
string Good(int n) => n switch
{
    > 0  => "正数",
    < 0  => "负数",
    _    => "零",
};

// ✅ 必须用 when:条件涉及方法调用或变量间关系
string ValidateString(string s) => s switch
{
    null                                          => "空引用",
    ""                                            => "空字符串",
    string x when x.Trim().Length == 0           => "全是空白",        // 调了 Trim()
    string x when x.Length > 100                  => "超长字符串",        // 调了 Length 属性
    string x when x.StartsWith("ERR")           => "错误消息",          // 调了方法
    _                                             => "普通字符串",
};

when 的执行顺序陷阱

关键:switch 表达式里的分支是从上到下依次匹配的,不是选"最匹配"的。when 条件失败不会报错,而是继续尝试下一个分支
string Grade(int score) => score switch
{
    >= 90 when score <= 100  => "A",
    >= 80                   => "B",
    >= 90                   => "这句永远执行不到",   // 如果 score >= 90,早就在第一行被匹配了
    _                       => "C",
};

第一行:score = 95 时,模式 >= 90 命中,然后 when score <= 100 也为 true → 返回 "A"。
score = 110 时,模式 >= 90 命中,但 when score <= 100 为 false → 跳过,继续试下一行>= 80 命中 → 返回 "B"。

所以 when 的本质是:模式先匹配,when 再过滤。模式匹配成功但 when 失败,等于这个分支没命中。

综合实战:三种模式一起用

public string HandleRequest(HttpRequest req, User? user)
{
    return (req, user) switch
    {
        // 元组模式:两个值一起匹配
        // 属性模式:解构 req 的属性
        // when:运行时条件——调用方法判断
        ({ Method: "GET", Path: "/admin" or "/admin/" }, { Role: "Admin" })
            => "管理员页面",

        ({ Method: "GET", Path: "/admin" or "/admin/" }, _)
            => "403 无权限",

        ({ Method: "POST" }, { } user) when user.CanPost()
            => "发布成功",

        ({ Method: "POST" }, _)
            => "请先登录",

        _   => "未知请求",
    };
}

一行拆解:(req, user) 构建元组 → 第一个位置用属性模式解构 MethodPathPath 的值本身又用了 or 模式 → 第二个位置用属性模式检查 Role四种模式装饰在一行里,各自独立、语义清晰。

核心心智模型:模式 = 形状描述。你可以想象自己是在声明 "我要的形状长这样",而不是在写 "如果满足条件 A 且 B 且 C 则……"。代码即文档。
注意:Switch 表达式是穷尽的——你必须提供 _(或穷举所有可能值),否则编译器报错。这和传统 switch 语句不一样(传统 default 是可选的)。

二、Using 声明Using Declarations

传统 using 语句自带一层花括号和缩进:

// C# 7.3:每多一个 using,就多一层缩进
public void DoWork()
{
    using (var conn = new SqlConnection(...))
    using (var cmd = conn.CreateCommand())
    {
        cmd.CommandText = "SELECT ...";
        using (var reader = cmd.ExecuteReader())
        {
            // 三层的缩进只是为了释放资源
        }
    }
}

C# 8 的 using 声明Using Declaration 不需要花括号:

// C# 8:变量在所在作用域结束时自动释放
public void DoWork()
{
    using var conn = new SqlConnection(...);
    using var cmd = conn.CreateCommand();
    cmd.CommandText = "SELECT ...";
    using var reader = cmd.ExecuteReader();
    // 离开方法时,reader → cmd → conn 依次 Dispose
}

区别

using 语句Statementusing 声明Declaration (C# 8)
写法using (var x = ...) { }using var x = ...;
释放时机花括号 } 结束变量离开作用域Scope
有自己的作用域否——变量属于父作用域
陷阱:using 声明在变量离开作用域时才释放。如果你在方法中间声明它,它会在方法结束时才 Dispose,而不是"当前块"结束。如果资源需要尽早释放,仍然用老式 using 语句。

什么时候用哪种

// ✅ 用 using 声明:资源使用到方法结束
void Process()
{
    using var conn = OpenConnection();
    DoQuery(conn);       // 用到方法最后
    DoOtherQuery(conn);  // 方法结束时释放——没问题
}

// ✅ 用老 using 语句:资源需要尽早释放
void Process()
{
    using (var conn = OpenConnection())
    {
        DoQuery(conn);   // } 时立即释放
    }
    // 这里 conn 已经没了,做其他耗时操作不影响连接
    RunHeavyComputation();
}

三、索引与范围Indices & Ranges

末尾索引Index from End^

// C# 7.3:从末尾访问
var last = arr[arr.Length - 1];
var secondLast = arr[arr.Length - 2];

// C# 8:^ 操作符——"从末尾数"
var last = arr[^1];         // 倒数第一个
var secondLast = arr[^2];   // 倒数第二个

Index 是新类型,^n 是"从末尾数第 n 个":

Index i1 = ^1;    // 倒数第一个
Index i2 = 0;     // 从前往后第 0 个(正常索引)
Index i3 = ^0;    // 等于 Length——不是最后一个!

为什么 ^1 是倒数第一,不是 ^0

答案藏在公式里:^n = Length - n。这不是随意定的——是为了让 范围(Ranges)的数学性质干净

先看正向索引和范围的关系:

// 正向索引:0 是第一个,Length 是"末尾之后的位置"
// 范围是左闭右开 [start, end),end 取"末尾之后"刚好表达"到结尾"
int[] arr = [10, 20, 30, 40, 50];  // Length = 5

arr[0..5];    // [10,20,30,40,50]——5 是 Length,"到末尾"
arr[2..5];    // [30,40,50]——"从索引 2 到末尾"

关键点:范围的右端是不包含的。所以"取到数组最后一个元素"的正确写法是 arr[0..Length],而不是 arr[0..Length-1]

倒着数的时候,同样的数学必须成立。设计团队定下的规则是:

// ^n 映射到正向索引:^n = Length - n
^0 = 5 - 0 = 5   // 等于 Length——"末尾之后"
^1 = 5 - 1 = 4   // 最后一个元素
^2 = 5 - 2 = 3   // 倒数第二个
^5 = 5 - 5 = 0   // 第一个元素
这套公式的好处全在范围上。想取"最后三个元素"?写成 arr[^3..^0]——右端是 ^0(等于 Length),完美符合左闭右开的语义:
arr[^3..^0] = arr[2..5] = [30, 40, 50]

如果 ^0 是最后一个元素,那 arr[^3..^0] 的右端到底包含还是不包含?要么破坏左闭右开,要么用 ^3.. 这种省略形式绕过去——两种都是特殊 case,不如让公式统一。

换个角度想:正向索引的 0 是"开头",负向索引的 ^0 是"结尾之后"——对称。正向的 arr.Length 是"最后一个元素之后",负向的 ^0 也是。这套映射是双射(bijection),数学上干净。

日常记住两条就够了:
^1 是最后一个。取单个元素用 ^1^2
② 范围右端用 ^0 表示"直到末尾"。取后缀用 arr[^3..^0],或者更简短的 arr[^3..](省略右端等价于省略 ^0)。

范围Range..

int[] arr = [0, 1, 2, 3, 4, 5];  // 长度 6

var a = arr[0..3];    // [0, 1, 2]——左闭右开
var b = arr[^3..^1];  // [3, 4]——从倒数第3到倒数第1(不含)
var c = arr[3..];     // [3, 4, 5]——从索引3到末尾
var d = arr[..3];     // [0, 1, 2]——从开头到索引3(不含)
var e = arr[..];      // [0, 1, 2, 3, 4, 5]——全部(浅拷贝)

关键规则:

内部实现

// 切片会走 Slice 方法——Span/Memory 也同样支持
ReadOnlySpan<char> slice = "Hello World".AsSpan()[0..5];  // "Hello"

// 字符串切片返回 string(不需要 .AsSpan())
string sub = str[1..^1];  // 去掉首尾字符

任何有 Count/Length 属性和带 int 索引器的类型(加上 Slice(int, int) 方法)都自动支持。常见的:T[]stringSpan<T>List<T>

📝 小测验

Q1. 以下哪个 switch 表达式会编译失败?

// A
var a = n switch { 1 => "一", _ => "其他" };

// B
var b = n switch { 1 => "一", 2 => "二" };

// C
string GetName(DayOfWeek d) => d switch
{
    DayOfWeek.Monday => "周一",
    DayOfWeek.Tuesday => "周二",
    _ => "其他"
};
选择正确答案:

Q2. 关于 using 声明Using Declaration,以下哪句话正确?

选择正确答案:

Q3. arr[^2..^0] 返回什么?(arr = [10, 20, 30, 40, 50],5 个元素)

选择正确答案:

Q4. 以下代码,当 point = (0, -5) 时输出什么?

string Describe(int x, int y) => (x, y) switch
{
    (0, 0)          => "原点",
    (_, 0)          => "在 X 轴上",
    (0, _)          => "在 Y 轴上",
    (> 0, > 0)      => "第一象限",
    (< 0, > 0)      => "第二象限",
    (< 0, < 0)      => "第三象限",
    (> 0, < 0)      => "第四象限",
};
选择正确答案:

Q5. 关于 when 子句,以下哪句话错误

选择正确答案:

Lesson 02 · C# 8 Switch Expressions / Using Declarations / Indices & Ranges · 下一课预告:Lesson 03 — yield return 同步迭代器(异步流的前置基础)