C# 变量与常量:从储物柜到共享地址牌
开篇:为什么需要「容器」?
想象你经营一家快递驿站。每天有大量包裹进出,如果没有编号、没有分类、没有固定位置,找一件快递无异于大海捞针。变量和常量就是程序世界的「包裹管理系统」——变量是「可替换内容的储物格」,常量是「贴了封条的专用柜」。
在 C# 中,每一个数据都有明确的「存放位置」和「使用规则」。理解这些规则,能帮你避免「快递放错柜子」「包裹被误拆」这类低级错误,让代码像高效运转的驿站一样井然有序。
一、变量基础:程序中的「储物格」
1.1 变量的核心定义
变量是内存中一块有名字的存储区域,用于存放程序运行过程中可能变化的数据。在 C# 中,变量必须「先声明,后使用」,且声明时必须确定类型(或让编译器推断)。
// 标准声明语法:类型 变量名 = 初始值;
int studentCount = 25; // 整数变量,存储学生人数
string className = "三年二班"; // 字符串变量,存储班级名称
double averageScore = 87.5; // 双精度浮点变量,存储平均分
bool isActive = true; // 布尔变量,标记是否激活生活化类比:变量就像超市的储物柜。int 是小柜(放手机),string 是大柜(放背包),double 是加厚柜(放精密仪器)。柜子一旦确定大小,就不能硬塞不匹配的物品——这就是类型安全。
1.2 三种存储位置的底层差异
C# 变量按存储位置分为三类,理解它们的差异是写出高效代码的基础。
┌─────────────────────────────────────────┐
│ C# 变量存储位置示意图 │
├─────────────────────────────────────────┤
│ 栈 (Stack) —— 储物柜 │
│ ├─ int age = 25; 直接存值 │
│ ├─ Point p = new(1,2); 直接存坐标 │
│ └─ 特点:自动分配/释放,速度快,容量小 │
│ (方法调用结束,柜子立即清空) │
├─────────────────────────────────────────┤
│ 堆 (Heap) —— 共享仓库 │
│ ├─ string name = "张三"; 存对象内容 │
│ ├─ int[] arr = {1,2,3}; 存数组数据 │
│ └─ 特点:GC 管理,容量大,生命周期灵活 │
│ (多个变量可指向同一仓库位置) │
├─────────────────────────────────────────┤
│ 指针类型 —— 仓库钥匙 │
│ ├─ int* ptr = &age; 存内存地址 │
│ └─ 特点: unsafe 代码中使用,直接操作内存 │
│ (像直接拿钥匙开仓库,跳过管理员) │
└─────────────────────────────────────────┘新手易错案例 1:
// 错误:混淆值类型与引用类型的赋值行为
int[] arr1 = { 1, 2, 3 };
int[] arr2 = arr1; // arr2 不是副本,而是指向同一数组!
arr2[0] = 99;
Console.WriteLine(arr1[0]); // 输出 99,原数组被修改!错误原因:数组是引用类型,arr2 = arr1 复制的是「仓库钥匙」而非「仓库里的货物」。
1.3 变量声明的语法演进
C# 的类型声明经历了从「显式声明」到「智能推断」的演进,最新版本(C# 10)的语法简洁且安全。
// 1. 显式声明(最传统)
int number = 10;
string text = "Hello";
// 2. var 隐式类型(C# 3.0+)—— 编译器推断类型,编译后仍是强类型
var age = 25; // 推断为 int
var name = "张三"; // 推断为 string
var list = new List<int>(); // 推断为 List<int>
// var 的限制:必须初始化,且不能为 null
// var x; // 编译错误!
// var y = null; // 编译错误!
// 3. dynamic 动态类型(C# 4.0+)—— 运行时解析,绕过编译时检查
dynamic dyn = 10;
dyn = "字符串"; // 允许!类型在运行时改变
dyn.NonExistent(); // 编译通过,运行时抛 RuntimeBinderException!
// 4. 目标类型 new(C# 9.0+)—— 已知类型时省略重复
List<string> names = new(); // 等价于 new List<string>()
Dictionary<string, int> dict = new(); // 简洁清晰应用场景:
var:局部变量,类型名冗长或显而易见时(如var result = GetData();)dynamic:与 COM 组件、动态语言(Python/JavaScript)互操作,或反射场景- 目标类型
new():字段初始化、数组元素创建,减少代码重复
新手易错案例 2:
// 错误:误以为 var 是弱类型
var num = 10;
num = "字符串"; // 编译错误!var 推断后类型固定为 int错误原因:var 是「编译时类型推断」,不是「运行时动态类型」。推断结果一旦确定,就不能更改。
二、变量分类:作用域与生命周期
变量的「可见范围」和「存活时间」由声明位置决定。理解作用域规则,能避免「变量未定义」「值意外残留」等常见 Bug。
2.1 块级作用域
由 {} 界定的代码块内,变量仅在块内有效。
if (true)
{
int blockVar = 10; // 块级变量
Console.WriteLine(blockVar); // 有效
}
// Console.WriteLine(blockVar); // 编译错误!超出作用域2.2 方法级作用域
方法体内声明的变量,在整个方法内有效,方法调用结束即销毁。
void Calculate()
{
int localVar = 100; // 方法级变量
for (int i = 0; i < 10; i++)
{
Console.WriteLine(localVar + i); // 有效
}
} // localVar 和 i 都在这里销毁2.3 类级作用域(字段)
类的成员变量(字段)与类的实例同生命周期,或静态字段与应用程序域同生命周期。
public class Student
{
// 实例字段:每个 Student 对象独立拥有
public string Name = "未命名";
private int _score = 0;
// 静态字段:所有 Student 对象共享,类加载时创建
public static int TotalCount = 0;
private static readonly DateTime _startTime = DateTime.Now;
}
// 使用
Student s1 = new Student();
Student s2 = new Student();
s1.Name = "张三";
s2.Name = "李四";
Student.TotalCount = 2; // 静态字段通过类名访问2.4 静态全局作用域
静态字段在首次访问时初始化,直到应用程序域卸载。常用于全局配置、缓存计数器。
public static class AppConfig
{
public static string Version = "1.0.0";
public static int MaxRetry = 3;
private static Dictionary<string, string> _cache = new();
}新手易错案例 3:
// 错误:在循环外意外累积
int total = 0;
for (int i = 0; i < 3; i++)
{
int sum = 0; // 每次循环重新声明,正确
sum += i;
total += sum;
}
Console.WriteLine(total); // 输出 0+1+2 = 3
// 对比错误写法
int sumWrong = 0; // 声明在循环外
for (int i = 0; i < 3; i++)
{
sumWrong += i; // 累积:0+1+2 = 3,但若逻辑要求每次重置则出错
}错误原因:变量声明位置决定其生命周期。需要「每次重置」的变量必须声明在循环体内。
三、常量核心:程序的「封条专用柜」
3.1 const:编译期确定的「死封条」
const 声明的常量在编译时就确定值,直接嵌入到使用它的代码中,像快递单上打印死的条形码。
public class MathConstants
{
public const double PI = 3.14159265359; // 数学常数
public const int MAX_RETRY = 3; // 重试次数上限
public const string APP_NAME = "MyApp"; // 应用名称
// const 限制:必须在声明时初始化
// public const int WRONG; // 编译错误!
// const 限制:仅支持基元类型、枚举、字符串
// public const DateTime NOW = DateTime.Now; // 编译错误!DateTime 不是编译时常量
}底层行为:编译器将 const 值直接替换到使用处。假设 PI 被 100 个文件引用,编译后这 100 处都嵌入了 3.14159265359 这个数值,而非引用关系。
3.2 readonly:运行期确定的「活封条」
readonly 字段在运行时初始化,初始化后不可修改。像快递柜的密码——设置一次,之后只能取件不能改密码。
public class Config
{
// 声明时初始化(推荐)
public readonly string Environment = "Production";
// 构造函数中初始化(常见场景:依赖注入的配置)
public readonly string ConnectionString;
public readonly DateTime StartTime;
public Config(string env, string connStr)
{
Environment = env; // 允许:构造函数中赋值
ConnectionString = connStr; // 允许:构造函数中赋值
StartTime = DateTime.Now; // 允许:运行时计算
}
public void Update()
{
// Environment = "Test"; // 编译错误!readonly 不可在方法中修改
}
}3.3 static readonly:全局只读配置
static readonly 组合用于「全局共享且运行期确定」的常量,是实际项目中最常用的形式。
public static class GlobalSettings
{
// 静态只读:程序启动时计算一次,之后不可变
public static readonly string AppPath = AppDomain.CurrentDomain.BaseDirectory;
public static readonly DateTime LaunchTime = DateTime.Now;
public static readonly int ProcessorCount = Environment.ProcessorCount;
// 静态只读集合:引用不可变,但内容可变(注意!)
public static readonly List<string> SupportedLanguages = new() { "zh-CN", "en-US" };
}
// 使用
Console.WriteLine(GlobalSettings.AppPath);
GlobalSettings.SupportedLanguages.Add("ja-JP"); // 允许!集合内容可修改
// GlobalSettings.SupportedLanguages = new(); // 编译错误!引用不可变3.4 进阶特性
C# 8.0:readonly struct
确保结构体所有成员不可变,避免意外修改。
public readonly struct ImmutablePoint
{
public readonly int X;
public readonly int Y;
public ImmutablePoint(int x, int y) => (X, Y) = (x, y);
// 编译器强制:readonly struct 中不能声明可变字段
// public int Z; // 编译错误!
}C# 9.0:init 访问器
允许对象初始化后「冻结」,实现「构造后不可变」。
public class Product
{
public string Name { get; init; } = "未命名";
public decimal Price { get; init; }
// init 仅允许在对象初始化器中赋值
}
var product = new Product { Name = "手机", Price = 3999m };
// product.Price = 2999m; // 编译错误!init 属性初始化后不可修改3.5 const vs readonly vs static readonly 对比
| 特性 | const | readonly(实例) | static readonly |
|---|---|---|---|
| 确定时机 | 编译时 | 运行时(构造函数) | 运行时(静态构造函数/声明时) |
| 内存位置 | 嵌入到使用处 | 堆(对象实例中) | 静态存储区 |
| 修改限制 | 绝对不可变 | 构造函数中可赋值一次 | 静态构造函数中可赋值一次 |
| 支持类型 | 基元类型、枚举、字符串 | 任意类型 | 任意类型 |
| 跨程序集 | 值被嵌入,更新需重新编译所有引用方 | 通过引用访问,更新只需重新编译定义方 | 同 readonly |
| 典型场景 | 数学常数、固定配置 | 依赖注入的配置、对象标识 | 全局路径、环境信息、启动时间 |
新手易错案例 4:
// 错误:误以为 const 可以延迟赋值
public class BadExample
{
public const string Config;
static BadExample()
{
Config = LoadFromFile(); // 编译错误!const 不能在静态构造函数中赋值
}
}
// 正确:使用 static readonly
public class GoodExample
{
public static readonly string Config;
static GoodExample()
{
Config = LoadFromFile(); // 正确:运行时加载配置
}
}四、实战避坑指南
4.1 循环中的变量作用域陷阱
// 经典陷阱:闭包捕获循环变量
var actions = new List<Action>();
for (int i = 0; i < 3; i++)
{
actions.Add(() => Console.WriteLine(i)); // C# 5.0+ 修复:每次迭代捕获新副本
}
actions.ForEach(a => a()); // 输出 0, 1, 2
// 但在旧版 C# 或某些场景下仍需注意
var tasks = new List<Task>();
for (int i = 0; i < 3; i++)
{
int temp = i; // 显式创建副本,确保捕获正确值
tasks.Add(Task.Run(() => Console.WriteLine(temp)));
}4.2 多线程下的只读变量线程安全
public class ThreadSafeCounter
{
// readonly 引用本身线程安全,但指向的对象未必
private readonly Dictionary<string, int> _cache = new();
private readonly object _lock = new();
public void Increment(string key)
{
lock (_lock)
{
_cache[key] = _cache.GetValueOrDefault(key) + 1;
}
}
}
// 注意:readonly 字段本身不可变引用是线程安全的
// 但 readonly 集合的内容修改仍需同步4.3 ASP.NET Core 实战示例
// Program.cs - .NET 6+ 最小 API 风格
var builder = WebApplication.CreateBuilder(args);
// 常量定义:应用级配置
const string API_VERSION = "v1";
const int DEFAULT_PAGE_SIZE = 20;
// 只读配置:从 appsettings.json 读取,运行期确定
var appSettings = builder.Configuration.GetSection("AppSettings").Get<AppSettings>();
var app = builder.Build();
// 使用常量
app.MapGet($"/api/{API_VERSION}/products", () =>
{
// 使用只读配置
var pageSize = appSettings?.PageSize ?? DEFAULT_PAGE_SIZE;
return Results.Ok(new { Version = API_VERSION, PageSize = pageSize });
});
app.Run();
// 配置类:init 属性确保绑定后不可变
public class AppSettings
{
public int PageSize { get; init; } = 10;
public string Theme { get; init; } = "light";
public IReadOnlyList<string> AllowedOrigins { get; init; } = new List<string>();
}五、基础自测题
题目 1:以下代码输出什么?
int a = 5;
object obj = a;
a = 10;
Console.WriteLine(obj);查看答案
答案:5
解析:a = 10 修改的是栈上的值类型变量;obj 是装箱后的堆上对象,与 a 已无关。
题目 2:const 和 static readonly 在跨程序集引用时的关键差异是什么?
查看答案
答案:const 值在编译时嵌入到引用程序集中,修改 const 定义后必须重新编译所有引用程序集;static readonly 通过引用访问,修改定义后只需重新编译定义程序集。
解析:这是 const 最隐蔽的陷阱。公共 API 中的常量若可能变化,应使用 static readonly 而非 const。
题目 3:以下代码能否编译通过?
readonly int x = 10;
void Test() { x = 20; }查看答案
答案:不能。
解析:readonly 实例字段只能在声明时或构造函数中赋值。普通方法中赋值会触发编译错误 CS0191。
题目 4:var 和 dynamic 的核心区别是什么?
查看答案
答案:var 是编译时类型推断,编译后类型固定,享有完整的编译时检查;dynamic 是运行时类型解析,编译时绕过检查,所有操作在运行时绑定,可能抛出 RuntimeBinderException。
解析:var 是「让编译器帮你写类型」,dynamic 是「告诉编译器别管类型」。前者安全,后者灵活但危险。
题目 5:以下代码输出什么?
var list = new List<int> { 1, 2, 3 };
var copy = list;
copy.Add(4);
Console.WriteLine(list.Count);查看答案
答案:4
解析:List<T> 是引用类型,copy = list 复制的是引用地址。两个变量指向同一个列表对象,修改相互可见。若需真正副本,应使用 new List<int>(list) 或 list.ToList()。
知识点总结思维导图
C# 变量与常量
│
├─ 变量基础
│ ├─ 定义:命名的内存存储区域
│ ├─ 声明语法:类型 变量名 = 值;
│ ├─ 存储位置
│ │ ├─ 栈:值类型、局部变量 → 自动管理,快速
│ │ ├─ 堆:引用类型对象 → GC 管理,灵活
│ │ └─ 指针:unsafe 代码 → 直接内存操作
│ └─ 声明方式演进
│ ├─ 显式声明(传统)
│ ├─ var 隐式类型(C# 3.0)→ 编译时推断
│ ├─ dynamic 动态类型(C# 4.0)→ 运行时解析
│ └─ 目标类型 new()(C# 9.0)→ 简洁初始化
│
├─ 变量分类(作用域)
│ ├─ 块级:{} 内有效,如 if/for 内部
│ ├─ 方法级:方法体内有效,方法结束销毁
│ ├─ 类级(字段):与对象同生命周期
│ │ ├─ 实例字段:每对象独立
│ │ └─ 静态字段:全局共享,类加载时创建
│ └─ 静态全局:应用程序域生命周期
│
├─ 常量核心
│ ├─ const
│ │ ├─ 编译时确定,嵌入使用处
│ │ ├─ 仅支持基元类型、枚举、字符串
│ │ └─ 跨程序集更新需重新编译引用方
│ ├─ readonly(实例)
│ │ ├─ 运行时确定,构造函数中赋值
│ │ ├─ 任意类型,比 const 灵活
│ │ └─ 适合依赖注入的配置
│ ├─ static readonly(推荐)
│ │ ├─ 全局只读,运行时确定
│ │ ├─ 静态构造函数或声明时初始化
│ │ └─ 适合环境信息、启动时间等
│ └─ 进阶特性
│ ├─ readonly struct(C# 8.0)→ 不可变值类型
│ └─ init 访问器(C# 9.0)→ 对象初始化后冻结
│
└─ 实战要点
├─ 循环变量作用域:需要重置的变量声明在循环内
├─ 闭包捕获:C# 5.0+ 修复,但仍建议显式副本
├─ 多线程安全:readonly 引用安全,内容修改需加锁
└─ ASP.NET Core:const 版本号 + static readonly 配置 + init 属性