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++ 的宏替换。

核心规则

  1. 必须在声明时立即初始化,且值必须是编译时可确定的常量表达式
  2. 隐式为静态,不能额外加 static 修饰符
  3. 仅限修饰基元类型:整数、浮点、char、bool、string、枚举、null
  4. 可修饰字段和局部变量,不能修饰属性
  5. 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 引用

如果 A 程序集定义 public const int Max = 100,B 程序集引用并使用了该常量:
  • 编译 B 时,B 代码中所有 Max 都会被直接替换成字面量 100
  • 后续修改 A 中 Max = 200,若不重新编译 B,B 里的值依然是 100
  • 跨程序集共享常量优先使用 static readonly,避免版本不一致问题

2.3 readonly:运行时常量

readonly 修饰的字段称为只读字段,属于运行时常量,值在运行时确定,仅能在「声明时」或「构造函数中」赋值,赋值完成后不可修改。

核心规则

  1. 只能修饰字段,不能修饰局部变量
  2. 支持两种级别:实例只读字段、静态只读字段
  3. 可以用运行时结果赋值(如配置文件、环境变量、方法返回值)
  4. 支持所有类型(包括自定义类、结构体)
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 变量仅限性能优化场景:普通业务代码不要滥用,避免降低代码可读性
posted @ 2026-07-08 10:23  挺秃然的i  阅读(9)  评论(0)    收藏  举报