
入行十几年C# 里有个语法点我几乎逢人就问const 和 readonly 到底差在哪很多候选人能流利背出“const 是编译期常量readonly 是运行期常量”但再追问一句“那为什么我把 const 值改了程序跑的还是老逻辑”大半人就开始支支吾吾。这说明大家平时写 C# 常量基本靠 IDE 补全和习惯真正理解底层行为的并不多。这篇文章的主角就是 C# 常量。我会从 const 的语法规则、编译行为讲起结合上位机通信、Web API 路由、Dapper 数据库操作、加密算法这些真实场景里常量的用法把我踩过的坑和面试常考的问题一并整理出来。不管你是刚学 C# 的新手还是在 WinForm、上位机、WebAPI 里摸爬滚打的老手应该都能从中找到点有价值的东西。1. 先搞清楚 const 到底是什么1.1 const 的三条硬性规则直接说结论。C# 里用 const 声明的字段编译器会在编译阶段把它替换成字面量。它有三条硬性规则几乎所有坑都源于这三条必须在声明时初始化不能等到构造函数或后续方法里赋值。初始化的值必须是编译期就能确定下来的常量表达式不能是方法返回值、属性值、运行期计算的任何东西。const 字段默认就是 static 的但你不能显式加 static 关键字一起写。具体能用的类型也有限sbyte、byte、short、ushort、int、uint、long、ulong、char、float、double、decimal、bool、string 以及枚举类型。引用类型里只有 string 是特例其他引用类型用 const 声明时只能赋 null。比如public const int MaxRetry 3; public const string AppName OrderSystem; public const double Version 1.2; public const bool EnableDebug true; public const MyStatus CurrentStatus MyStatus.Running; public const object NullObj null; // 可以只能是 null public const StringBuilder Builder null; // 可以但只能 null为什么有这种限制因为编译器必须在编译期把常量值直接塞进生成的 IL 里这个值必须是确定且能在元数据里表达的。你没法把一个运行期 new 出来的对象编码进元数据自然就不能用 const 声明。1.2 编译期常量与运行期常量的本质差异这里要引入一个关键对比就是 static readonly。很多人误以为 readonly 是“运行期常量”我建议你别这么叫因为 readonly 本质是字段叫“只读字段”才准确。更清晰的说法是const 是编译期常量static readonly 是静态只读字段运行期才确定值。先看一个最经典的面试片段public class Constants { public const double Pi 3.14; public static readonly DateTime StartTime DateTime.Now; }用 const 声明的 Pi编译后任何使用 Pi 的地方都会变成字面量 3.14用 static readonly 声明的 StartTime编译后使用方代码里保留的是对 Constants.StartTime 字段的引用运行到那一行才去拿值。这就是本质差异一个编译期替换一个运行期引用。也正因为这层差异两者在跨程序集、类型限制、内存占用、调试体验上表现完全不同。1.3 const 的值会被“复制粘贴”到每个使用方这是我最想让大家记住的一点。const 的值在编译时是直接嵌入引用方的 IL 代码的不是通过字段访问。换句话说你在 A 程序集里声明了一个 const int Timeout 1000B 程序集里用了 Timeout编译后 B 的 IL 里写死的其实是 1000而不是“取 A.Timeout”这样的引用。这意味着一件很坑的事如果某天你把 A 里的 Timeout 改成 2000重新编译了 A 并发布但 B 程序集没有重新编译那么 B 运行起来用的还是旧的 1000。尤其是 DLL 升级场景这种问题极难排查因为代码里明明写的是 Constants.Timeout单步调试看源码也看不出问题只有反编译或者加日志才能发现值没更新。这也是我在实际项目中更倾向于用 static readonly 做配置项的原因之一稍后我会在选型章节详细展开。2. 声明与使用写常量的正确姿势2.1 常量的类型限制与初始化细节再深入一点说说初始化。const 的初始化表达式必须是常量表达式所谓“常量表达式”指的是编译器能在编译期完整求值的表达式。允许的操作包括字面量、其他 const 字段、以及涉及这些的算术、逻辑、字符串拼接操作。比如public const int FrameLength HeaderLength 8; // HeaderLength 也是 const public const string Prefix ORDER_ 2025; // 字符串常量拼接OK public const int BitMask (1 4) | (1 6); // 位运算OK public const long BigValue 1000000; // int 字面量隐式转 longOK但下面这些全部编译失败报错时你大概率会看到那段经典的“表达式必须含有常量值”public const int X GetConfig(); // 方法调用 public const int Y DateTime.Now.Year; // 运行期属性 public const int Z Environment.ProcessorCount; // 运行期属性 public const string S SomeClass.StaticValue; // 非 const 字段编译器报这个错并不是因为你写错了语法而是它确实没法在编译期把这个表达式的值“算”出来。记住这点后面排查问题会顺很多。2.2 字符串常量与字符串驻留热词里出现了“字符串常量”和“转换为字符串常量”这块确实值得单独聊聊。C# 中的字符串常量会在编译期被放入模块的元数据中并且在运行时被“驻留intern”。所谓驻留就是运行时维护一张字符串哈希表相同内容的字符串常量只会存一份后续引用直接用同一个对象。string a hello; string b hello; Console.WriteLine(ReferenceEquals(a, b)); // True上面的 a 和 b 虽然写了两个变量但因为内容相同运行时指向同一个字符串对象。这是字符串常量驻留的效果。它带来的好处是省内存坏处是如果你在循环里大量使用动态拼接的字符串这些新串不会被自动驻留每次都是新对象这也是为什么大量字符串拼接场景推荐用 StringBuilder。还有一点const 字符串拼接在编译期就能完成比如 const string Foo A B编译后就是 AB。但如果是变量和字符串拼接比如 string x GetStr() B那就是运行期的事跟常量无关了。2.3 常量的命名与组织方式命名这块微软官方推荐 const 字段用 PascalCase 而不是全大写下划线比如 MaxRetryCount 而不是 MAX_RETRY_COUNT。但在很多实际项目里尤其是上位机、通信协议相关的代码大家普遍还是用全大写加下划线的风格因为看着像协议里定义的宏比如 FRAME_HEADER、CMD_READ_TEMP。我的建议是在纯产品代码里尽量遵循 C# 官方命名规范用 PascalCase在通信协议、位标记、硬件寄存器地址这些“类 C 风格”的场景里全大写加下划线反而可读性更好团队统一就好。组织方式上不要把所有常量全堆到一个叫 GlobalConst 的巨大类里那样会变成到处引用的“垃圾场”。我见过太多项目一个大静态类挂了上百个常量谁也不知道哪些还在用。更好的做法是按职责拆分public static class ErrorCodes { public const string NotFound 404001; public const string Timeout 408001; } public static class CacheKeys { public const string UserProfile user:profile:{0}; public const string TokenPrefix token:; }每个常量类只负责一个业务板块常量名自解释使用处也清晰。如果怕维护困难还可以把常量放到对应业务的静态类里减少散落。3. 真实项目里常量都用在哪3.1 上位机通信协议帧头和命令字热词里有一堆“c#上位机”相关搜索。上位机开发里TCP、串口通信协议是最典型的常量应用场景。一台设备发过来的报文帧头、帧尾、命令字、寄存器地址这些在协议文档里就是写死的数值用 const 定义再合适不过。public static class TcpProtocol { public const byte FRAME_HEADER 0xAA; public const byte FRAME_TAIL 0x55; public const byte CMD_READ_TEMP 0x01; public const byte CMD_SET_MOTOR_SPEED 0x02; public const byte CMD_START_MACHINE 0x10; public const int HEADER_LENGTH 3; // 帧头命令字长度 public const int MAX_PACKET_SIZE 1024; }解析报文时你直接拿这些常量去比对if (buffer[0] TcpProtocol.FRAME_HEADER buffer[1] TcpProtocol.CMD_READ_TEMP) { int payloadLength buffer[2]; // 继续解析 }为什么这里要用 const 而不是配置因为协议字段尤其是帧头、命令字这类一旦定了基本不会变而且解析逻辑散落在很多函数里如果写成魔法数字 0xAA一旦协议升级要全局替换又容易漏。用 const 之后编译器能在编译期帮你检查类型和常量表达式运行时又零开销非常适合这种场景。我甚至会把协议文档里的版本号也定义成 const string方便在日志里输出。3.2 Web API路由和配置项Web API 里常量最常见的用法是路由前缀。尤其在 ASP.NET Core 里你可以在 Route 特性中使用常量字符串拼接让路由定义和控制器命名保持单一来源public static class ApiRoutes { public const string Version v1; public const string Devices devices; public const string Health health; public const string Orders orders; } [ApiController] [Route(api/ ApiRoutes.Version / ApiRoutes.Devices)] public class DeviceController : ControllerBase { [HttpGet(ApiRoutes.Health)] public IActionResult Health() Ok(); }注意Attribute 的参数必须是编译期常量所以这里的 ApiRoutes.Version 必须是 const 而不能是 static readonly。这是 const 在某些场景下不可替代的原因之一稍后细说。配置项方面我要单独提醒不要把数据库连接字符串、Redis 地址、密钥这类“环境相关”的值写成 const。这些值在不同环境不同部署里不一样应该放配置文件用 IConfiguration 或者 Options 模式读取。如果把连接字符串写成 const 并编译进程序集每次换环境都要重新发布基本等于自掘坟墓。3.3 Dapper 与数据库操作Dapper 是轻量级 ORM很多人写 SQL 都是直接裸字符串。SQL 本身不是常量但表名、字段名、固定 SQL 片段确实适合用 const 管理。我的习惯是把一张表的增删改查 SQL 收敛到一个静态类里public static class OrderSql { public const string TableName orders; public const string SelectById SELECT * FROM orders WHERE id Id;; public const string Insert INSERT INTO orders (order_no, amount, status) VALUES (OrderNo, Amount, Status);; public const string UpdateStatus UPDATE orders SET status Status WHERE id Id;; }然后用 Dapper 执行var order await conn.QueryFirstOrDefaultAsyncOrder(OrderSql.SelectById, new { Id id }); var affected await conn.ExecuteAsync(OrderSql.UpdateStatus, new { Id id, Status Shipped });这样做的收益很直接SQL 有集中管理的地方代码里不会到处飘着 SELECT * 字符串改动字段名时只需要动常量类不用全局搜索替换。顺便一提像 DataSet 这种较重的方式在 Dapper 场景下往往没必要轻量查询直接泛型方法就够了SQL 常量类依旧适用。3.4 AES-CTR 与密码学常量热词里还出现了“c# aes-ctr解析”。密码学代码是另一个极端依赖常量的领域。AES-CTR 的分组大小 16 字节、AES-256 密钥长度 32 字节、计数器块的特殊位这些算法参数必须精确且不可配置否则影响安全性。定义成 const 是最佳实践之一public static class AesCtrConstants { public const int BlockSize 16; // AES 分组大小单位字节 public const int KeySize 32; // AES-256 密钥长度 public const int IvSize 16; // 初始计数器长度 public const uint Poly 0x87; // GCM 模式用常数 }密码学代码最怕魔法数字因为每个数字背后都是一段算法规范。把 BlockSize、KeySize 提炼成 const除了可读性更重要的是提供单一修改入口。如果哪天要从 AES-256 换成 AES-128只动一个 KeySize别处不用翻。不过要注意算法参数通常跟对端、服务器端保持严格一致修改一定要走评审流程别在线上顺手改。4. const 与 static readonly什么时候用哪个4.1 两者的全景对比这个对比基本是 C# 面试题里的常客。我给一张表大家可以直接存下来维度conststatic readonly赋值时机声明时必须初始化声明时或静态构造函数中求值方式编译期替换为字面量运行期字段引用可用类型基本类型、string、枚举、null任意类型隐式 static是否需显式 static数组、集合不行可以用于特性参数可以不行用于 switch 的 case 标签可以不行跨程序集值更新需重新编译所有引用方引用方无需重新编译运行时开销无一次字段读取这里特别提醒static readonly 不是“运行期常量”它本质还是字段只是 readonly 约束了赋值时机。从编译结果看它和 const 是两种完全不同的东西。面试时你要是能把这层说透面试官基本会满意。4.2 必须用 const 的场景先说什么情况“非 const 不可”。第一Attribute 参数。C# 的特性参数要求必须是常量表达式或 typeof 表达式所以路由、标记、描述里的参数只能选 const。[Authorize(Roles Roles.Admin)] // Roles.Admin 必须是 const public class AdminController : ControllerBase { } public static class Roles { public const string Admin admin; public const string User user; }第二switch 的 case 标签。case 后面的值必须是编译期常量这决定了如果你有一组可分支的固定状态用 const 定义它们switch 可以直接用如果用 static readonlycase 会直接编译报错“case 标签必须是常量表达式”。第三需要极致性能且值绝对不变的地方。比如死循环跳出的迭代上限、固定数组长度、位运算掩码这些用 const 让编译器在编译期内联运行时连字段访问都没有性能上是零成本。4.3 必须用 static readonly 的场景反过来static readonly 适合的是“值在运行期确定但初始化后不希望被修改”的东西。常见的典型就是路径、环境变量、随配置变化的值public static class Paths { public static readonly string TempDir Path.GetTempPath(); public static readonly string LogDir Path.Combine(AppContext.BaseDirectory, logs); public static readonly string DataDir Path.Combine(AppContext.BaseDirectory, data); }热词里的“win10 临时目录常量”就在这个场景里。临时目录是运行期确定的跟系统用户、环境变量都有关你不可能用 const 把它写死——写死成 C:\Windows\Temp 在别人的机器上大概率就是错的。正确做法是在程序启动时读取 Path.GetTempPath()然后放进 static readonly 缓存。类似的还有 appsettings.json 里读出来的配置值、启动参数、数据库连接字符串等。还有一个关键点如果你的常量定义在公共类库里又经常要改强烈建议用 static readonly 而不是 const。原因前面说过const 会被嵌入引用方程序集改值后所有引用方都要重新编译static readonly 则不会引用方编译后保留的是字段引用你在类库里把值改了重新发布类库引用方不用改代码运行期拿到的新值就是新值。这在大团队、多项目协作时能省下很多不必要的重新编译和部署。4.4 实例 readonly 又是什么时候用除了 const 和 static readonly还有非 static 的 readonly 实例字段。它的特点是每个对象在自己的构造函数里可以赋值一次之后不可变。常用在描述一个实体对象的不可变属性比如订单创建时间public class Order { public readonly DateTime CreatedAt; public readonly string OrderNo; public Order(string orderNo) { OrderNo orderNo; CreatedAt DateTime.Now; } }注意这和 const 完全不是一回事const 是编译期常量static readonly 是类级只读字段实例 readonly 是对象级只读字段。三者放在一起对比面试答起来会很出彩。5. IL 与元数据常量在编译背后的真相5.1 元数据里的 Constant 表想真正理解 const最好看一眼它的底层。C# 编译器在生成程序集时会把 const 字段记录到元数据中一个叫 Constant 的表里。这个表里存储了字段名、类型和实际值。使用该 const 的 IL 代码则直接把这个值作为立即数压入栈。举个例子你写int x Settings.Timeout;如果 Timeout 是 const int 3000那生成的 IL 大致等价于ldc.i4 3000 stloc.0注意编译器根本不会去解析 Settings 类的类型引用直接一个 ldc.i4 把 3000 写死。但如果你用的是 static readonlyIL 会变成ldsfld int32 Settings::Timeout stloc.0这行 IL 的含义是“在运行时加载 Settings.Timeout 这个静态字段的值”。差别有多大一眼就能看出来。这也是为什么我说 const 本质上是“复制粘贴”行为。5.2 反射读出真实常量值如果你在调试或写工具时需要读取 const 值可以用反射。普通的 GetValue 对 const 也有效但更精准的是 GetRawConstantValue它直接读元数据里 Constant 表的值不会触发字段访问逻辑var field typeof(TcpProtocol).GetField(nameof(TcpProtocol.FRAME_HEADER)); if (field ! null field.IsLiteral) { byte header (byte)field.GetRawConstantValue(); Console.WriteLine(header); // 0xAA }判断一个字段是不是 const用 IsLiteral 属性。这个方法在你写代码生成工具、协议分析器、或者做单元测试断言时会用得上。5.3 const 与 switch、特性、模式匹配的硬性约束前面提过 case 标签必须是常量表达式。底层原因是 switch 的跳转表需要在编译期构造如果标签值是变量编译器没法确定每个分支的跳转目标。同样特性的参数也是编译期写进程序集元数据的所以必须是常量表达式。这两个约束决定了你没法用 static readonly 去替代 const。模式匹配这块稍微宽松一点。使用 when 条件时右边可以是运行期表达式但 case 的模式本身比如 case int n when n MaxRetry这里的 MaxRetry 如果是 const 会被正常编译如果改成 static readonly 也能编译——因为 n MaxRetry 是 when 子句属于运行期判定不属于 case 标签常量。很多人在这个细节上记混实际操作时容易踩错方向。6. 实战避坑常见问题与排查记录6.1 “表达式必须含有常量值”究竟在报什么这个编译错误是 C# 新手和中级开发者最常见的拦路虎之一也是热词里专门搜过的问题。报错场景集中在 const 的初始化表达式里出现了非编译期可求值的内容典型的有这几种public const int A Guid.NewGuid().GetHashCode(); // 方法调用 public const int B DateTime.Now.Year; // 运行期属性 public const decimal D GetAmount(); // 方法调用 public const string S Environment.UserName; // 非 const 字段解决思路很简单要么改用 static readonly把赋值推迟到运行期要么把表达式改写成真正的常量表达式比如用字面量、其他 const、以及允许的运算符组合。注意检查隐式转换比如 const long L 100; 是没问题的int 字面量会隐式转 long但 const int I 1.5; 就不行浮点数不能隐式转 int。6.2 改了 const 却不生效这个问题我在 1.3 里提过但值得再单独说一下症状。表现是你改了公共类库里的一个 const 值重新编译并部署了类库 DLL但主程序运行的输出还是旧值。排查步骤先确认主程序是否真的重新编译过如果没有直接改。因为有 const 引用主程序必须重新编译。检查是否被其他项目以 DLL 引用方式拷贝到了输出目录注意清理 bin、obj 下的旧 DLL。如果确实重新编译过但仍不生效反编译主程序的 IL看调用处是不是直接嵌入的旧字面量。记住const 一旦发布出去改动成本就不只是改一个文件那么简单。这也是为什么公共库、框架级的“配置型常量”我几乎是清一色用 static readonly。6.3 跨程序集常量引用的版本坑接着上面的问题版本坑还涉及语义化版本号管理。如果你的类库对外发布了 NuGet 包里面声明了 const 字段那么当你升级主版本并修改了这个 const 值所有引用了旧包但不会立刻升级的应用行为和预期就会不一致。这类问题很难靠测试发现因为它编译时绝不会报警告。最佳实践是对外 API 中可能变化的值一律用 static readonly。只在程序集内部使用、且语义绝不会变的协议常量、内部状态码才用 const。在 NuGet 包发布说明里明确标注这类改动为 breaking change。这个原则放在真实业务里同样成立凡是可能跟着外部系统变化的值都不要用 const 写死在代码里。6.4 一个面试常考对比顺手把面试题也整理一下。考官如果问“const 和 readonly 的区别”答到下面这些点基本满分const 是编译期常量readonly 是运行期只