C#基础(二):变量与常量
变量与常量是 C# 程序的基础存储单元,二者核心区别在于:变量运行时可修改,常量在声明 / 初始化后不可修改。本文从底层原理、分类规则、高级特性、常见坑点四个维度展开,配合代码示例讲透所有细节。
一、变量(Variables)
1.1 核心本质
变量是绑定了特定类型的命名内存存储单元,C# 作为强类型语言,每个变量在编译时就确定了类型,存储的值必须与类型匹配。
- 值类型变量:直接存储数据本身
- 引用类型变量:存储堆上对象的内存地址(引用)
1.2 声明、初始化与明确赋值规则
基础语法
// 标准格式:类型 变量名 = 初始值; int age; // 仅声明,未分配初始值 age = 25; // 后续赋值 string name = "Tom"; // 声明同时初始化(推荐写法)
明确赋值原则(Definite Assignment)
C# 编译器强制保障:变量在被读取前必须已经明确赋值,从根源避免脏数据,不同变量规则不同:
- 局部变量:无默认值,使用前必须手动赋值,否则编译报错
- 类 / 结构体的字段:自动初始化为类型默认零值,声明后可直接读取
- 数组元素:数组创建后自动初始化为对应类型的默认值
void TestMethod() { int a; // Console.WriteLine(a); // 编译报错:使用了未赋值的局部变量 a = 10; Console.WriteLine(a); // 正常,已赋值 } public class Person { public int Age; // 实例字段,默认值 0 public string Name; // 引用类型字段,默认值 null } // 外部使用 Person p = new Person(); Console.WriteLine(p.Age); // 输出 0,字段有默认值,可直接读取
类型默认值与 default 表达式
所有类型都有固定的默认零值,可通过 default(T) 获取:
| 类型分类 | 默认值 |
|---|---|
| 数值类型 | 0(浮点为 0.0) |
| bool | false |
| char | '\0'(空字符) |
| 枚举 | 对应 0 的成员 |
| 结构体 | 所有字段均为默认值 |
| 引用类型 | null |
int defaultInt = default; // 0 string? defaultStr = default; // null DateTime defaultDt = default; // 0001/1/1 0:00:00
1.3 类型推断:var 关键字
var 是 C# 3.0 引入的编译时类型推断语法糖,编译器会根据右侧初始化表达式自动推导出变量类型,本质仍是强类型,和 dynamic 运行时动态类型完全不同。
核心规则
- 必须在声明时同时初始化,且初始化表达式类型必须明确
- 编译后和显式写类型完全等效,无任何性能差异
- 仅能用于局部变量,不能用于字段、方法参数、返回值
- 不能直接赋值
null,因为无法从 null 推断具体类型
// 编译器自动推断为 int var num = 100; // 编译后完全等效于:int num = 100; var str = "hello"; // 推断为 string var list = new List<int>(); // 推断为 List<int> // 错误用法 // var a; // 编译报错,无初始化无法推断 // var b = null; // 编译报错,无法从null推断类型 var c = (string?)null; // 正确,显式指定类型后可赋值null
使用规范
- 推荐场景:右侧类型名称很长(如复杂泛型)、类型显而易见时使用,提升代码简洁度
- 不推荐场景:基础类型(int/string/bool)刻意用 var,导致可读性下降
扩展:目标类型 new(C# 9.0+)
进一步简化构造调用,编译器从左侧变量类型推断右侧构造类型:
// 传统写法 List<int> list1 = new List<int>(); // 目标类型 new 简化写法 List<int> list2 = new(); // 二者编译后完全等效
1.4 变量的完整分类(按存储与生命周期)
C# 变量并非只有 “局部变量” 一种,不同分类的内存位置、生命周期、默认值规则差异极大:
| 变量分类 | 定义位置 | 内存位置 | 生命周期 | 默认值 |
|---|---|---|---|---|
| 静态字段 | 类内,static 修饰 |
托管高频堆 | 程序启动到进程退出 | 有默认零值 |
| 实例字段 | 类内,无 static | 所属对象的堆内存 | 随对象创建到 GC 回收 | 有默认零值 |
| 局部变量 | 方法 / 代码块内 | 栈(值类型)/ 栈存引用(引用类型) | 代码块执行结束自动释放 | 无默认值,需手动赋值 |
| 值参数 | 方法参数列表 | 栈 | 方法执行周期 | 调用时赋值 |
| 引用参数(ref/out) | 方法参数列表 | 指向原变量的内存地址 | 方法执行周期 | 调用时赋值 |
| 数组元素 | 数组内部 | 托管堆 | 随数组对象生命周期 | 有默认零值 |
代码演示:静态字段 vs 实例字段
public class Counter { // 静态字段:全类共享,所有实例共用同一份内存 public static int TotalCount; // 实例字段:每个实例独立一份,互不影响 public int InstanceCount; public void Add() { TotalCount++; InstanceCount++; } } // 测试 Counter c1 = new Counter(); Counter c2 = new Counter(); c1.Add(); c2.Add(); Console.WriteLine(Counter.TotalCount); // 输出 2,全局累加 Console.WriteLine(c1.InstanceCount); // 输出 1,仅 c1 自身计数 Console.WriteLine(c2.InstanceCount); // 输出 1,仅 c2 自身计数
1.5 作用域与变量遮蔽
作用域
变量的代码可见范围,由声明它的代码块决定,遵循 “内层可访问外层,外层不可访问内层” 原则:
- 类级作用域:字段在整个类的所有方法内可见
- 方法级作用域:局部变量在整个方法内可见
- 块级作用域:
if/for/while/ 独立{}内声明的变量,仅在块内可见
void TestScope() { int outer = 10; if (true) { int inner = 20; Console.WriteLine(outer); // 块内可访问外层变量 } // Console.WriteLine(inner); // 编译报错,inner 仅在 if 块内可见 }
变量遮蔽(同名覆盖)
内层代码块可以声明与外层同名的变量,内层变量会临时遮蔽外层变量:
void TestShadow() { int num = 10; if (true) { int num = 20; // 合法,遮蔽外层 num Console.WriteLine(num); // 输出 20,优先访问内层变量 } Console.WriteLine(num); // 输出 10,外层变量不受影响 }
注意:C# 不允许同级作用域声明同名变量;字段与局部变量同名时,可通过 this.字段名 显式访问字段。
1.6 高级特性:ref 局部变量与返回值(C# 7.0+)
ref 变量本质是变量的别名,直接指向原变量的内存地址,修改 ref 变量就是修改原变量,主要用于高性能场景,避免大结构体拷贝开销。
ref 局部变量
int[] arr = { 1, 2, 3 }; // 定义 ref 变量,直接指向数组第一个元素的内存地址 ref int firstElement = ref arr[0]; firstElement = 999; Console.WriteLine(arr[0]); // 输出 999,原数组元素被直接修改
ref 只读变量(C# 7.2+)
ref readonly 只能读取原变量,不能修改,兼顾性能与安全性:
ref readonly int val = ref arr[0]; // val = 100; // 编译报错,只读引用不可修改
ref 返回值
方法可以返回变量的引用,调用方可直接修改原位置的值:
public ref int FindElement(int[] array, int target) { for (int i = 0; i < array.Length; i++) { if (array[i] == target) return ref array[i]; // 返回元素的内存引用 } throw new ArgumentException("未找到目标元素"); } // 使用 int[] nums = { 10, 20, 30 }; ref int found = ref FindElement(nums, 20); found = 200; Console.WriteLine(nums[1]); // 输出 200,原数组元素被修改
二、常量(Constants)
常量是程序运行期间值不可变更的存储单元,C# 提供多层级常量化方案,核心分为「编译时常量」和「运行时常量」两大类。
2.1 字面量常量
直接写在代码中的固定值,是最基础的常量形式:
int a = 100; // 整数字面量 string s = "hello"; // 字符串字面量 bool b = true; // 布尔字面量 char c = 'A'; // 字符字面量 string? n = null; // null 字面量
2.2 const:编译时常量
const 修饰的常量是编译时常量,编译器会在编译阶段把所有用到该常量的地方直接替换为字面量值,类似 C++ 的宏替换。
核心规则
- 必须在声明时立即初始化,且值必须是编译时可确定的常量表达式
- 隐式为静态,不能额外加
static修饰符 - 仅限修饰基元类型:整数、浮点、char、bool、string、枚举、null
- 可修饰字段和局部变量,不能修饰属性
- C# 10+ 支持常量字符串插值(插值内容必须全为常量)
public class MathUtils { // 正确:编译时可确定值 public const double PI = 3.1415926; public const string AppName = "MyApp"; public const int DefaultPort = 8080; // C# 10+ 常量字符串插值 public const string Version = "1.0.0"; public const string FullAppName = $"{AppName} v{Version}"; // 错误:运行时才能确定,无法作为编译时常量 // public const DateTime CreateTime = DateTime.Now; // public const int Value = GetConfigValue(); } // 使用 double area = MathUtils.PI * radius * radius; // 编译后等效于:double area = 3.1415926 * radius * radius;
经典坑点:跨程序集 const 引用
public const int Max = 100,B 程序集引用并使用了该常量:- 编译 B 时,B 代码中所有
Max都会被直接替换成字面量100 - 后续修改 A 中
Max = 200,若不重新编译 B,B 里的值依然是 100 - 跨程序集共享常量优先使用
static readonly,避免版本不一致问题
2.3 readonly:运行时常量
readonly 修饰的字段称为只读字段,属于运行时常量,值在运行时确定,仅能在「声明时」或「构造函数中」赋值,赋值完成后不可修改。核心规则
- 只能修饰字段,不能修饰局部变量
- 支持两种级别:实例只读字段、静态只读字段
- 可以用运行时结果赋值(如配置文件、环境变量、方法返回值)
- 支持所有类型(包括自定义类、结构体)
public class AppConfig { // 实例只读字段:每个实例独立,实例构造函数中赋值 public readonly string ConfigId; // 静态只读字段:全局一份,静态构造函数中赋值 public static readonly string AppVersion; // 实例构造函数赋值 public AppConfig(string id) { ConfigId = id; } // 静态构造函数赋值 static AppConfig() { // 运行时从文件读取,编译时无法确定 AppVersion = File.ReadAllText("version.txt").Trim(); } public void TestModify() { // ConfigId = "new-id"; // 编译报错,构造函数外不可修改 } }
2.4 const vs static readonly 核心对比
二者都表示 “不可变”,但底层机制与适用场景完全不同:
| 对比维度 | const 编译时常量 | static readonly 运行时常量 |
|---|---|---|
| 赋值时机 | 编译时确定,字面量直接替换 | 运行时确定,类型加载时赋值 |
| 支持类型 | 仅限基元类型(int/string/bool 等) | 支持所有 C# 类型 |
| 内存占用 | 无额外字段存储,直接嵌入代码 | 占用静态字段内存空间 |
| 跨程序集影响 | 修改后需重新编译所有引用方 | 修改后无需重编译引用程序集 |
| 性能 | 略高(无字段读取开销) | 略低(需要读取静态字段) |
| 适用场景 | 永恒不变的数学常量、固定魔法值 | 可能变更的配置、复杂类型常量 |
2.5 扩展:init-only 属性(C# 9.0+)
针对对象初始化场景,init 访问器允许对象初始化器中赋值,初始化完成后只读,比 readonly 更灵活,非常适合不可变数据模型。
public class User { public int Id { get; init; } public string Name { get; init; } } // 对象初始化时可赋值 var user = new User { Id = 1, Name = "Alice" }; // user.Name = "Bob"; // 编译报错,初始化完成后不可修改
2.6 其他常量化方案
- 枚举常量:枚举成员本质是编译时常量,底层为 const 整型
- nameof 运算符:编译时获取变量 / 类型 / 成员的字符串名称,重构时自动同步,避免硬编码字符串错误
string propertyName = nameof(User.Name); // 编译时得到 "Name"
三、最佳实践与常见坑点
- 命名规范:字段与常量采用 PascalCase,局部变量与参数采用 camelCase;常量名建议清晰表意,避免魔法数字
- 缩小作用域:局部变量尽量在靠近使用的位置声明,提升代码可读性与可维护性
- 合理使用 var:类型不明确时显式声明类型,不要为了 “简洁” 牺牲可读性
- 跨程序集共享常量用 static readonly:避免 const 编译替换导致的版本不一致问题
- 场景选型:永恒不变的数学 / 物理常量用 const,运行时加载的配置用 readonly
- 避免变量遮蔽:内层代码块尽量不声明与外层同名的变量,防止逻辑错误
- 慎用静态字段:静态字段生命周期与进程一致,滥用易导致内存泄漏与线程安全问题
- ref 变量仅限性能优化场景:普通业务代码不要滥用,避免降低代码可读性

浙公网安备 33010602011771号