C#
语法、类型系统与内存语义
从静态类型、值与引用复制、装箱和可空值理解 C# 类型系统与内存语义。
发布于 2026年7月23日
语法、类型系统与内存语义
C# 是静态、强类型语言,但“静态”不等于所有类型都必须手写,“值类型”也不等于永远位于栈上。真正重要的是赋值和传参时复制了什么、对象是否被共享,以及编译器能否证明一次操作安全。
一、学习目标
- 理解静态类型、类型推断和运行时类型
- 区分值类型与引用类型的复制语义
- 掌握数值、枚举、字符串和可空值类型
- 理解装箱、拆箱与模式匹配
- 避免把
var、dynamic和object混为一谈 - 为任务建立第一版数据表示
二、静态类型与类型推断
string title = "阅读 C# 文档";
var count = 3; // 编译期推断为 int
object boxedTitle = title; // 静态类型是 object
var 不会把变量变成动态类型。编译器在声明位置确定其静态类型,此后 count 仍只能按 int 使用。公开 API 的参数和返回值应显式表达契约,局部变量则可在右侧类型清楚时使用 var。
dynamic 会把成员绑定推迟到运行时:
dynamic value = GetExternalValue();
Console.WriteLine(value.MissingMember); // 编译通过,运行时可能失败
除 COM、动态语言互操作等明确边界外,不要用 dynamic 绕过建模和验证。
三、值类型与引用类型
值类型变量保存值;赋值通常复制整个值。引用类型变量保存对象引用;赋值复制引用,因此两个变量可能指向同一对象。
int first = 10;
int second = first;
second++;
Console.WriteLine(first); // 10
var a = new List<string> { "阅读文档" };
var b = a;
b.Add("编写测试");
Console.WriteLine(a.Count); // 2
struct、数值、bool、char、枚举和可空值类型属于值类型;class、数组、委托、接口和 string 属于引用类型。
不要用“值类型一定在栈、引用类型一定在堆”解释语义。JIT、闭包、字段布局和逃逸情况都会影响实际存储位置;语言层面应先关注复制和共享。
四、数值与转换
int completed = 3;
int total = 5;
decimal ratio = (decimal)completed / total;
Console.WriteLine($"{ratio:P0}"); // 60%
整数相除仍得到整数。金融或十进制定点业务通常使用 decimal;科学计算常用 double。外部文本应通过 TryParse 转换:
if (!int.TryParse(input, out int taskId) || taskId <= 0)
{
Console.Error.WriteLine("任务 ID 必须为正整数");
return 2;
}
显式转换可能截断或溢出。需要检测时使用 checked:
int next = checked(current + delta);
五、枚举不是完整校验
public enum TaskStatus
{
Todo,
Done
}
枚举底层是数值,强制转换可以产生未声明值:
TaskStatus status = (TaskStatus)99;
Console.WriteLine(status); // 99
因此从数据库、JSON 或命令行读取枚举时仍要验证。协议边界若要求稳定文本,显式映射 "todo" 与 "done",不要依赖成员名称永远不变。
六、字符串与字符
string 是引用类型,但不可变。修改字符串会创建新值:
string title = " 阅读 C# 文档 ";
string cleanTitle = title.Trim();
Console.WriteLine(title.Length > cleanTitle.Length); // True
比较面向机器的标识符时明确使用序数规则:
bool same = string.Equals(
actual,
expected,
StringComparison.OrdinalIgnoreCase);
面向用户的排序和显示可能需要指定文化。不要让当前机器区域设置隐式决定配置键、协议值和文件格式。
大量循环拼接使用 StringBuilder;少量插值优先使用清晰的 $"..."。原始字符串适合内嵌 JSON 或 SQL:
string json = """
{
"title": "阅读 C# 文档",
"status": "todo"
}
""";
七、可空值类型
值类型默认不能为 null,可用 T? 表示缺失:
DateTimeOffset? completedAt = null;
if (completedAt is { } timestamp)
{
Console.WriteLine(timestamp.ToString("O"));
}
int? 是 Nullable<int> 的语法糖。GetValueOrDefault 会把缺失值变成默认值,可能丢失“未提供”和“明确为零”的区别,因此只在业务语义允许时使用。
空合并运算符可以提供默认值或抛出异常:
string name = configuredName ?? "StudyTasks";
string token = configuredToken
?? throw new InvalidOperationException("缺少 API Token");
引用类型的可空分析将在后续专篇讨论。
八、装箱、拆箱与模式匹配
把值类型赋给 object 或接口变量时可能装箱:
int id = 42;
object boxed = id;
int restored = (int)boxed;
拆箱要求运行时类型精确匹配,不能把装箱的 int 直接拆成 long。频繁装箱会带来分配和类型不透明,通用容器应优先使用泛型。
处理未知对象时使用模式匹配:
static string Describe(object? value) => value switch
{
null => "缺失",
int n when n > 0 => $"正整数 {n}",
string { Length: > 0 } text => $"文本 {text}",
_ => $"其他类型 {value.GetType().Name}"
};
模式既检查类型又引入正确静态类型,通常比手工 is 后强制转换更清晰。
九、任务的第一版表示
先用元组表达短暂的局部组合:
var task = (
Id: 1,
Title: "阅读 C# 文档",
Status: TaskStatus.Todo);
Console.WriteLine($"{task.Id}: {task.Title} [{task.Status}]");
元组适合局部返回值,不适合作为跨层领域契约。随着校验、更新时间和行为增加,后续会迁移到不可变 record。
十、常见错误
认为 var 会关闭类型检查
var 只是省略重复类型名。真正推迟检查的是 dynamic,其失败会从编译期移动到运行时。
通过 ToString() 序列化业务协议
数字、日期和枚举的 ToString() 可能受文化或重命名影响。协议必须使用指定格式和显式映射。
把引用复制误认为对象复制
var copy = originalList 只复制引用。需要快照时使用 ToArray、集合构造器或定义明确的深复制策略。
用 object 代替泛型
这会丢失静态契约并引入装箱和转换。若容器元素类型一致,应使用 List<T>、Dictionary<TKey,TValue> 等泛型。
十一、练习与自测
练习:
- 解析任务 ID,拒绝空值、非数字、零和负数。
- 定义
TaskStatus,实现稳定的"todo"/"done"双向映射。 - 演示列表引用共享,再创建独立快照。
- 用 switch 表达式描述
null、正整数和非空字符串。
自测:
var、object和dynamic的检查时机有何不同?- 值类型与引用类型最重要的语义差异是什么?
- 为什么不能用“都在栈上”定义值类型?
- 装箱后的
int为什么不能直接拆箱为long?
十二、官方资料
上一篇:环境、SDK 与项目基线 | 下一篇:集合、泛型与常用数据结构