C# 语言基础

从表达式、语句、控制流和方法理解 C# 程序怎样执行,并建立处理类型、输入与边界条件的可靠心智模型。

难度 入门 时长 标准深度约 15分钟
版本 C# 14 / .NET 10
what

C# 程序由带静态类型的值、产生值的表达式、执行动作的语句和封装行为的方法组成;编译器会在运行前检查它们能否合法组合。

trap

目标变量的类型不会改变中间表达式的计算方式,var 也不会变成动态类型;整数除法、溢出和空值常因此被忽略。

fix

先写清输入与输出类型,再逐步跟踪表达式、分支和循环,并用边界输入验证每条控制流路径。

是什么,为什么存在

C# 是在 .NET 上运行的静态类型语言。源代码声明数据是什么类型、允许执行哪些操作,以及程序按什么顺序执行这些操作。编译器先检查这些约束,再生成交给 .NET 运行时执行的程序。

语言基础不是一张关键字清单,而是一套组合规则。 表达式(expression) 计算并产生值,例如 quantity * unitPrice 语句(statement) 执行动作,例如声明变量、调用方法、选择分支或重复执行代码。方法把一组语句包装成有名称、有输入和输出的操作。

这些规则解决两个问题。第一,编译器能在程序运行前拒绝类型不匹配、未赋值读取和不可达写法。第二,读代码的人可以从局部声明和控制流推断行为,而不必猜测某个名称在运行时会变成什么。

你会在每个 C# 程序中遇到这些构件。命令行工具、Web API、桌面应用和测试项目的外层框架不同,但方法内部仍由变量、表达式、分支、循环与调用构成。数据类型的复制语义、集合 API 和异常策略分别由关联主题继续展开。

工作原理

程序从入口点开始

C# 应用只有一个入口点。现代控制台项目默认允许把可执行语句直接写在文件顶层,这些叫作 顶级语句(top-level statement) 。编译器会为它们生成入口方法;已有代码库也常显式声明 static Main,两种形式都遵循同一套表达式和语句规则。

一个项目可以有多个 .cs 文件,但只能有一个文件包含顶级语句。该文件中的 using 指令必须位于顶级语句之前,类型与命名空间声明则放在顶级语句之后。小示例使用顶级语句可以减少样板代码,库和大型应用仍会把大部分行为放进类型与方法。

从源代码到输出的关系可以简化为下面这条路径。编译器产生中间语言(IL)和类型元数据,.NET 运行时加载它们,并在执行时生成适合当前平台的机器代码。图中省略了构建配置、程序集加载和优化细节,但保留了理解错误阶段所需的边界。

阶段能发现的问题典型证据
编辑与编译语法、名称绑定、类型与部分控制流问题编译器诊断
程序启动配置、依赖与入口点问题启动错误和日志
处理输入空值、格式、范围与业务规则问题测试结果和异常
持续运行状态、资源与并发问题指标、跟踪和故障记录

同一行代码可能跨越多个阶段。例如,方法调用必须先通过编译器的重载与类型检查,但被调用方法仍可能根据输入抛出异常。准确描述阶段,比笼统地说“代码不工作”更容易找到修复位置。

语法或类型错误发生在编译阶段,因输入、空值或越界访问触发的问题通常发生在运行阶段。区分这两个阶段很重要:增加运行时测试不能让无效源码通过编译,而通过编译也不等于所有输入都安全。

变量有固定的静态类型

变量把名称绑定到某种类型的值。int quantity = 3; 同时声明类型、名称和初始值;之后可以给 quantity 赋另一个 int,却不能直接赋字符串。const 适合编译期已知且之后不能重新赋值的值。

var 使用 类型推断(type inference) 从初始化表达式确定静态类型。var total = 12.5m; 中,total 仍然是固定的 decimal,不是可以随意换类型的容器。var 需要初始化值,因为没有右侧表达式就没有可推断的类型。

局部变量在读取前必须沿每条可达路径完成明确赋值。编译器会分析分支:如果某个变量只在 if 的一侧赋值,分支之后直接读取它可能无法编译。字段和数组元素有默认值,但这并不代表默认值一定符合业务含义。

C# 的值类型与引用类型具有不同的复制语义,可空值类型与可空引用注解也有不同机制。这里把类型看作编译器检查操作的契约;需要判断赋值后是否共享对象、何时装箱或怎样安全转换时,应继续阅读 csharp/data-types

表达式先按操作数类型计算

字面量、变量读取、方法调用和运算符组合都可以形成表达式。表达式本身有类型,外层表达式根据内层结果继续计算。赋值语句右侧会先完整求值,最后才把结果转换或存入左侧变量。

这个顺序解释了常见的中间值错误。double ratio = completed / total; 中,如果两个操作数都是 int,除法先执行整数除法,之后才把结果转换成 double。要保留小数,必须在除法前把至少一个操作数转换成浮点类型。

运算符优先级决定表达式怎样分组,求值顺序决定这些部分何时运行。括号可以明确分组,却不会自动改变操作数类型。遇到混合算术时,先标出每个子表达式的类型,再讨论最后的目标类型。

&&|| 使用 短路求值(short-circuit evaluation) left && right 在左侧为 false 时不求值右侧,left || right 在左侧为 true 时不求值右侧。这能安全地把空值检查放在成员访问之前,但把必要副作用藏在右侧会让执行路径难以判断。

语句塑造控制流

声明语句创建局部变量,表达式语句执行赋值、调用或增减操作,选择语句决定走哪条路径,迭代语句重复一个代码块。花括号形成块,块内声明的局部名称通常不能在块外使用。缩进帮助人阅读,真正决定范围的是语法结构。

if 适合按布尔条件决定是否执行代码,if / else 从互斥路径中选择一条。switch 语句适合让不同情况执行动作,switch 表达式则直接从匹配分支产生值。模式匹配能进一步拆解类型和数据形状,但基础代码仍应先保证分支覆盖清楚。

for 适合初始化、条件和步进都清楚的计数循环,foreach 适合逐个读取序列元素,while 在每轮开始前检查条件,do / while 至少执行一次。break 结束最近一层循环,continue 跳到下一轮,return 则结束整个方法。

选择循环时要表达终止条件,而不是追求最短写法。审查时沿零次、一次和多次迭代分别推演,并检查索引的起点、严格或非严格比较以及每条路径是否推进状态。

方法建立小型契约

方法声明返回类型、名称和参数列表。调用方按参数类型提供实参,方法完成工作后返回声明类型的值;void 表示没有返回值。清楚的签名让编译器和调用方都知道一次调用需要什么、承诺产生什么。

参数与方法内部声明的名称属于该次调用的局部范围。默认参数传递会复制实参的值;如果实参是引用类型,复制的是引用,而不是对象。refoutin 会改变传递规则,除非 API 明确需要别名语义,否则基础代码不必用它们。

同名方法可以通过不同的参数列表重载。编译器根据调用点可用的实参选择适用成员,因此过多相近重载可能产生歧义或意外转换。对初学代码而言,一个方法只做一件可命名的事、参数含义明确,比压缩成复杂表达式更重要。

方法也是控制流边界。参数验证通常靠近入口,无法满足契约时可以返回显式失败结果或抛出适合的异常;正常结果通过 return 离开。选择哪种失败方式属于 API 契约,异常细节由 csharp/exceptions 继续说明。

示例

从带类型的值计算订单金额

第一个程序使用顶级语句,把数量、单价和税率组合成一个结果。每个字面量都有类型:3int,带 m 后缀的十进制字面量是 decimal

OrderTotal.cs
// # not executed here: .NET SDK is not installed in the local environment.
using System;
using System.Globalization;

const decimal TaxRate = 0.20m;
int quantity = 3;
decimal unitPrice = 19.95m;

decimal subtotal = quantity * unitPrice;
decimal total = subtotal * (1 + TaxRate);

Console.WriteLine($"items: {quantity}");
Console.WriteLine($"subtotal: {subtotal.ToString("F2", CultureInfo.InvariantCulture)}");
Console.WriteLine($"total: {total.ToString("F2", CultureInfo.InvariantCulture)}");
items: 3
subtotal: 59.85
total: 71.82

乘法把 int 数量转换到可参与 decimal 运算的表示,结果是 decimal。括号让加税步骤的分组一目了然;1 也能隐式转换后与 TaxRate 相加。

格式化时显式使用 InvariantCulture,因此小数点不会随运行机器的区域设置变化。实际金额代码还需要规定币种、小数位和舍入策略;示例只展示表达式如何组合,不替业务制定这些规则。

在循环中验证每个输入

第二个程序把外部文本当作可能失败的输入。TryParse 返回布尔值,并通过 out 参数提供成功解析的整数;失败和非正数都走拒绝分支。

QuantityValidation.cs
// # not executed here: .NET SDK is not installed in the local environment.
using System;

string[] requestedQuantities = ["2", "0", "many", "5"];
int acceptedUnits = 0;

foreach (string text in requestedQuantities)
{
    if (!int.TryParse(text, out int quantity) || quantity <= 0)
    {
        Console.WriteLine($"rejected: {text}");
        continue;
    }

    acceptedUnits += quantity;
    Console.WriteLine($"accepted: {quantity}");
}

Console.WriteLine($"accepted units: {acceptedUnits}");
accepted: 2
rejected: 0
rejected: many
accepted: 5
accepted units: 7

foreach 为数组中的每个字符串执行一次块。失败分支使用 continue,所以本轮不会执行累加;只有成功且为正的数量能到达 acceptedUnits += quantity

条件的左侧先运行 TryParse。它失败时,|| 已经确定整体为 true,右侧的范围检查会被跳过。编译器知道 quantity 在后续可达路径上已经由 TryParse 赋值。

用方法和 switch 表达式封装规则

第三个程序把配送费规则移进方法。调用点只需要区域与小计,方法签名保证返回 decimalswitch 表达式(switch expression)让每个分支直接产生费用。

ShippingRules.cs
// # not executed here: .NET SDK is not installed in the local environment.
using System;
using System.Globalization;

string[] zones = ["local", "regional", "remote"];
decimal subtotal = 42m;

foreach (string zone in zones)
{
    decimal fee = CalculateShipping(zone, subtotal);
    Console.WriteLine($"{zone}: {fee.ToString("F2", CultureInfo.InvariantCulture)}");
}

static decimal CalculateShipping(string zone, decimal subtotal)
{
    return zone switch
    {
        "local" => 0m,
        "regional" => subtotal >= 50m ? 0m : 5m,
        "remote" => 12.50m,
        _ => throw new ArgumentOutOfRangeException(nameof(zone))
    };
}
local: 0.00
regional: 5.00
remote: 12.50

三个已知区域分别匹配一个分支。regional 分支内部再用条件表达式决定是否免运费;示例小计低于 50m,因此返回 5m

最后的弃元模式 _ 覆盖其他输入,并明确拒绝未知区域。没有这个分支时,编译器不能证明该 switch 表达式一定产生值。生产代码还应决定区域文本是否区分大小写,以及错误应在边界处怎样呈现。

把解析、分支和方法组合起来

最后一个程序处理三条订单行。它沿用前面的输入验证,再调用小方法计算有效行金额,并在循环结束后报告合计。

OrderSummary.cs
// # not executed here: .NET SDK is not installed in the local environment.
using System;
using System.Globalization;

(string Sku, string Quantity, decimal UnitPrice)[] lines =
[
    ("A-10", "2", 9.50m),
    ("B-20", "oops", 4m),
    ("C-30", "3", 2.25m)
];

decimal grandTotal = 0m;

foreach (var line in lines)
{
    if (!int.TryParse(line.Quantity, out int quantity) || quantity <= 0)
    {
        Console.WriteLine($"{line.Sku}: invalid quantity");
        continue;
    }

    decimal lineTotal = CalculateLineTotal(quantity, line.UnitPrice);
    grandTotal += lineTotal;
    Console.WriteLine($"{line.Sku}: {Money(lineTotal)}");
}

Console.WriteLine($"grand total: {Money(grandTotal)}");

static decimal CalculateLineTotal(int quantity, decimal unitPrice) => quantity * unitPrice;
static string Money(decimal value) => value.ToString("F2", CultureInfo.InvariantCulture);
A-10: 19.00
B-20: invalid quantity
C-30: 6.75
grand total: 25.75

元组数组为每个位置提供了名称,var line 的静态类型仍是该元组类型。无效的第二行不会调用计算方法,也不会改变合计;第一行与第三行分别贡献 19.006.75

两个小方法分开表达计算与显示规则。这个边界便于单独测试,但真实订单还应验证负价格、数量上限、币种与溢出策略。基础语法能表达规则,规则是否完整仍由领域契约决定。

陷阱

只看目标类型,忽略中间计算

修复方法: 从最内层开始标注每个子表达式的类型,并在运算前转换操作数,例如 (double)total / countchecked((long)quantity * unitPrice)。不要把更宽的左侧变量当作对早期运算的保护。

var 当成动态类型

修复方法: 查看初始化表达式决定的准确类型。类型不明显或领域含义比构造语法更重要时直接写出类型;确实需要多种状态时,用明确的领域类型表达,而不是退回 object

在布尔表达式里隐藏必要动作

修复方法: 让条件负责判断,让必要动作成为独立语句。只有右侧本来就应按条件执行时才依赖短路,例如先检查 customer is not null,再访问其成员。

循环边界多走或少走一次

修复方法: 写出循环访问的第一项、最后一项和空输入行为。遍历全部元素时优先使用 foreach;确实需要索引时通常使用 index < items.Length,并用长度为 01 和多项的输入测试。

用空值抑制代替边界验证

修复方法: 在值进入系统时验证必填条件,为合法缺失状态使用可空类型,并让分支覆盖缺失路径。只有程序已经建立了编译器无法推断的不变量时才使用 !,同时用测试证明该不变量。

把方法签名写得比契约更宽

修复方法: 参数使用最具体的所需类型,返回类型保留调用方必须区分的状态。行为模式有名称时使用枚举或独立方法;公共边界还要验证静态类型无法表达的范围和格式约束。

深入 求值顺序不会替你消除副作用

求值顺序不会替你消除副作用

C# 通常按从左到右的顺序求值表达式操作数。方法实参也按源码顺序计算,然后才进入被调用方法。依赖这条规则可以解释行为,但把多个状态变化压进一个表达式仍会增加审查成本。

自增运算最容易暴露这个问题。current++ 产生递增前的值,再更新变量;++current 先更新,再产生新值。单独作为语句时,两者最终状态相同;嵌入更大的表达式后,产生值的时机不同。

方法调用可能读取时间、推进枚举器、修改对象或执行 I/O,因此不能假定重复调用等价。生成代码常写出 LoadOrder() is not null ? LoadOrder().Total : 0m,这会调用两次。先把结果保存到有明确类型的局部变量,再验证和使用,能把一次性动作与纯计算分开。

短路运算符是明确的例外路径,不是优化提示。右侧是否运行属于语言语义,所以右侧的异常与副作用也可能完全不发生。审查复杂条件时,把每个操作数当作带前置条件的步骤,确认跳过它是否符合契约。

编译器证明的是语言规则

明确赋值分析会拒绝读取可能尚未赋值的局部变量。它根据可达控制流工作,不会理解任意业务事实。即使人知道某个配置总让分支成立,编译器也可能要求另一条路径完成赋值或提前离开。

可空引用分析同样是静态证明。它跟踪编译器可见路径中的空状态,却不能验证数据库、反射、反序列化或禁用可空上下文的程序集传来的数据。警告消失表示代码满足分析规则,不代表边界数据已经清洗。

范围约束通常也超出静态类型。int quantity 可以表达负数、零和很大的正数,而订单数量可能只允许 1 到某个业务上限。方法入口需要运行时验证,测试则要覆盖界限两侧。

编译器错误、警告与测试各自回答不同问题。错误说明程序不满足语言或类型规则,警告指出值得检查的风险,测试证明给定输入下的可观察行为。可靠代码需要三者协作,不能用其中一个替代另外两个。

控制流决定名称何时有效

花括号不仅组织版式,还创建局部范围。分支块中声明的名称在块结束后不可见,循环变量通常也只在循环结构内有效。这能防止临时状态泄漏到无关代码,但也要求共享结果在外层声明并沿每条路径赋值。

模式变量的范围受表达式与控制流影响。例如,在 value is string text && text.Length > 0 中,右侧只有在模式成功时才运行,所以 text 在那里可用。把 && 改成不合适的运算形式会破坏这项保证,编译器会拒绝可能未赋值的读取。

returnthrowbreakcontinue 会改变后续语句的可达性。守卫子句通过尽早结束无效路径,常能减少嵌套,并让剩余代码拥有更强的不变量。过多提前出口也会分散清理逻辑,因此资源生命周期应交给 using 或明确的 finally

阅读生成代码时,不要只沿着示例输入走一次。先标出所有出口,再从每个入口条件追踪到出口,记录变量在哪条边上获得有效值。这个方法能发现遗漏分支,也能发现形式上存在但永远无法到达的处理代码。

重载选择发生在编译阶段

调用重载方法时,编译器先收集名称匹配且在当前位置可见的候选成员,再判断哪些候选能接收给定实参。最后从适用成员中选择更好的一个;如果没有唯一结果,调用会产生编译错误,而不是留到运行时随机决定。

变量的静态类型会参与这个过程。即使某个引用在运行时指向更具体的对象,普通重载选择仍以调用点可见的静态类型和转换规则为基础。虚方法重写解决的是选中签名之后调用哪个实现,不能与重载选择混为一谈。

可选参数与隐式数值转换会增加候选数量。生成代码若添加一个看似方便的新重载,已有调用可能变得歧义,或者绑定到不同签名。公共 API 的重载变化因此需要编译调用方和运行针对性测试,而不能只检查新方法本身。

初学阶段最稳妥的做法是让方法名和参数类型表达清楚意图。调用结果不符合预期时,先让编辑器显示实际绑定的签名,并列出发生的隐式转换。这样能把“运行时选错方法”的模糊判断还原为可验证的编译期决策。

从源码到运行时错误

编译器解析语法,绑定名称与方法,检查类型和控制流,再生成 IL 与元数据。方法重载选择和大部分隐式转换在这里确定。源码没有通过这一步,就不存在可供运行时“尝试”的程序。

.NET 运行时加载程序集并执行 IL。常见实现会把需要执行的方法编译成本机代码,但具体策略属于运行时实现。语言层面的整数除法、分支选择和异常行为不能靠 JIT 猜测改变。

运行时错误来自合法程序遇到不满足前置条件的状态,例如把无效文本交给 Parse、访问越界索引或解引用 null。这些情况通常无法仅靠静态类型完全排除,因为值来自文件、网络、用户或其他进程。

因此,定位错误时先判断它属于哪个阶段。编译错误从最早的语法或类型诊断开始处理;运行错误保留异常类型、消息与堆栈,再构造触发输入。把阶段说清,能避免要求 AI 用 try / catch 修复编译错误,或用强制转换压掉真实的输入问题。

延伸阅读

检查点

4个问题 · 2 道输出预测题 · 1 道找错题

复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?