C# 类型系统

C# 的类型系统到底是怎么回事?值类型和引用类型有什么区别?什么时候该用 class、什么时候该用 struct?这篇就来聊聊 C# 的类型系统,从最基础的概念讲起,帮你建立起完整的类型认知框架。

  1. 强类型与类型安全:C# 的类型系统如何在编译期帮你捕获错误
  2. 值类型 vs 引用类型:两种类型的本质区别和内存行为
  3. 内置类型与自定义类型:class、struct、record、interface 等各自的使用场景
  4. 编译时类型与运行时类型:两者不一致时会发生什么

一、C# 是强类型语言

C# 是一门强类型语言 。这意味着每个变量、常量、表达式在编译时就有一个明确的类型。编译器会检查你写的每一个操作,确保涉及的类型是兼容的------这叫做类型安全

举个简单的例子:你可以把两个 int​ 相加,但不能把 int​ 和 bool 相加。

csharp 复制代码
 int a = 5;
 int b = a + 2; // OK
 ​
 bool test = true;
 ​
 // 编译错误:运算符 '+' 不能应用于 'int' 和 'bool'
 // int c = a + test;

划重点: 和 C / C++ 不一样,C# 里 bool​ 不能隐式转换为 int。这避免了大量因隐式类型转换引发的逻辑 bug。

类型安全检查不仅发生在编译时------编译器还会把类型信息作为元数据嵌入可执行文件中,供公共语言运行时(CLR)在运行时进行额外的类型校验,双重保障。

二、使用类型声明变量

2.1 显式声明与类型推断

声明变量时你可以显式指定类型 ,也可以使用 var​ 让编译器根据赋值推断类型

csharp 复制代码
 // 显式指定类型:
 int count = 10;
 double temperature = 36.6;
 ​
 // 编译器推断类型(var):
 var name = "C#";
 var items = new List<string> { "one", "two", "three" };

关键知识点:var 只是语法糖,编译器在编译时就已经确定了变量的实际类型,不会带来任何运行时开销。

2.2 方法和参数的类型

方法的参数和返回值同样需要声明类型。下面的方法接受 string​ 和 int​,返回 string

csharp 复制代码
 static string GetGreeting(string name, int visitCount)
 {
     return visitCount switch
     {
         1 => $"Welcome, {name}!",
         _ => $"Welcome back, {name}! Visit #{visitCount}."
     };
 }

代码解析:

  1. static string :方法返回类型为 stringstatic 表示属于类型而非实例。
  2. switch 表达式 :C# 8.0 引入的模式匹配语法,根据 visitCount 的值返回不同内容。
  3. 字符串插值 $"" :在字符串中直接嵌入变量,比 string.Format() 更直观。

2.3 类型不可变性

变量一旦声明,其类型就不能再更改 ,也不能将不兼容的值赋给它。但你可以通过类型转换来应对:

转换方式 特点 示例
隐式转换 自动完成、不丢失数据 int​→long​、float​→double
显式转换(强制转换) 需在代码中明确指定(类型),可能丢失数据 double​→int​、long​→int
csharp 复制代码
 int x = 42;
 long y = x;              // 隐式转换,安全
 ​
 double pi = 3.14159;
 int truncated = (int)pi; // 显式转换,小数部分丢失,结果是 3

常见坑: 显式转换可能导致数据丢失或溢出异常,使用前建议用 checked​ 关键字或 is 模式匹配做安全检查。

三、内置类型和自定义类型

3.1 内置类型

C# 提供了丰富的内置类型,无需额外引用就能直接使用:

类别 类型 说明
整数 int​、long​、short​、byte 各种范围的整数
浮点数 float​、double​、decimal 精度和范围递增
布尔 bool true​/false
字符/字符串 char​、string Unicode 字符和不可变字符串

3.2 自定义类型

除了内置类型,你可以用以下构造创建自己的类型:

构造 类型类别 典型使用场景
class(类) 引用类型 需要复杂行为、多态、可变状态;大多数自定义类型都是类
struct(结构) 值类型 小型轻量数据(约 64 字节以下),每个变量拥有独立副本
record(记录) 值/引用类型 需要值相等性比较、ToString​输出、with表达式进行非破坏性修改
interface(接口) 引用类型 定义可由任何类或结构实现的成员协定,关注"能做什么"而非"是什么"
enum(枚举) 值类型 固定的命名整数常量集,如星期几、状态码、访问模式
tuple(元组) 值类型 临时分组相关值,无需定义命名类型
泛型 引用/值类型 List<T>​、Dictionary<TKey,TValue>,用类型参数实现类型安全的代码复用

划重点: record​ 可能是很多人忽略的利器------它自动帮你生成 Equals​、GetHashCode​、ToString​,还支持 with 表达式做非破坏性修改,非常适合 DTO 和不可变数据建模。

四、值类型和引用类型

这是 C# 类型系统中最核心的两个概念。每个类型要么属于值类型 ,要么属于引用类型,这个区别直接决定了变量如何存储数据和赋值时的行为。

4.1 值类型

  • 存储方式:直接保存数据本身
  • 赋值行为:复制数据,两个变量独立,互不影响
  • 典型代表structenum、所有内置数值类型(intdouble 等)

4.2 引用类型

  • 存储方式:保存指向托管堆上对象的引用(地址)
  • 赋值行为 :两个变量指向同一个对象,通过一个变量修改,另一个也会看到变化
  • 典型代表class、数组、委托、string

来看一个对比示例。先定义一个记录结构 Coords 作为值类型:

csharp 复制代码
 public readonly record struct Coords(int X, int Y);
csharp 复制代码
 // 值类型:每个变量持有自己的副本
 var point1 = new Coords(3, 4);
 var point2 = point1;
 ​
 Console.WriteLine($"point1: ({point1.X}, {point1.Y})");
 Console.WriteLine($"point2: ({point2.X}, {point2.Y})");
 // point1 和 point2 是独立的副本,修改互不影响
 ​
 // 引用类型:两个变量指向同一个对象
 var list1 = new List<int> { 1, 2, 3 };
 var list2 = list1;
 list2.Add(4);
 ​
 Console.WriteLine($"list1 count: {list1.Count}"); // 4 ------ 同一个对象

代码解析:

  1. readonly record struct:不可变的值类型记录,初始化后属性不可修改。
  2. 值类型赋值 var point2 = point1 :触发逐字段复制point2 拥有完全独立的副本。
  3. 引用类型赋值 var list2 = list1 :仅复制引用(地址),list1list2 指向堆上同一个 List<int> 实例。

常见坑: 定义结构体时如果包含引用类型字段,仍然是浅拷贝------两个副本会共享同一个引用类型对象。这也是为什么很多团队推荐尽量用 readonly struct 来避免意外。

4.3 统一类型层次

所有类型最终都派生自 System.Object​。值类型比较特殊,它派生自 System.ValueType​(后者又继承 System.Object​)。这个统一的继承体系被称为通用类型系统(CTS)

五、编译时类型和运行时类型

一个变量在编译时运行时可能拥有不同的类型:

  • 编译时类型:源代码中声明或推断出来的类型
  • 运行时类型:变量实际引用的实例类型,必须与编译时类型相同、或是其子类/实现类
csharp 复制代码
 // 编译时类型和运行时类型一致:
 string message = "Hello, world!";
 ​
 // 编译时类型不同于运行时类型:
 object boxed = "This is a string at run time";
 IEnumerable<char> characters = "abcdefghijklmnopqrstuvwxyz";
  • boxed:编译时类型是 object,运行时类型是 stringstring 派生自 object
  • characters:编译时类型是 IEnumerable<char>,运行时类型是 stringstring 实现了该接口)

划重点: 两者的分工很明确。编译时类型决定 了重载解析、可用成员和类型转换;运行时类型决定 了虚方法分发、is​ 表达式、switch 模式匹配的结果。

csharp 复制代码
object obj = "hello";

// 编译时类型是 object,无法直接调用 string 成员
// obj.Length; // 编译错误

// 运行时类型检查:
if (obj is string s)
{
    Console.WriteLine(s.Length); // OK,通过模式匹配获取运行时类型
}

六、选择哪种类型

当你定义新类型时,选什么构造决定了后续代码的行为和性能。下面给出一个决策参考:

场景 推荐选择 原因
值的临时分组,不需要命名类型或行为 tuple(元组) 最轻量,定义零成本
小型数据(约 64 字节以下),需要值语义 structrecord struct 避免堆分配,记录结构还附带值相等性
基于值的相等性比较 + 继承 record class 自动生成Equals​/ToString​,支持with表达式
需要复杂行为、多态、可变状态 class 引用语义,支持继承和多态
定义"能做什么"的契约 interface 多个不相关类型可以共享同一套能力约定
固定的命名常量集 enum 语义清晰,替代魔法数字

划重点: 大多数情况下没有唯一的"正确答案"。通常 class​ 和 record class​ 是默认选择,需要值语义时再考虑 struct​。不要为了"节省内存"而过早使用 struct------先写对,再优化。

七、另见

最后

C# 的类型系统看起来概念很多,但抓住两条主线就够了:值类型 vs 引用类型 (内存行为)、编译时类型 vs 运行时类型(多态行为)。其他所有语法特性------class、struct、record、interface、泛型------都是围绕这两条主线铺开的工具。真正理解了这两点,写 C# 代码时心里就有底了。

相关推荐
爱勇宝2 小时前
第 1 章:别把“需求文档”当成真正的需求
前端·后端·程序员
IT_陈寒7 小时前
闭包陷阱让我加了两天班,JavaScript你真行
前端·人工智能·后端
易协同低代码8 小时前
通达OA核心类库TD类深度解析
后端
Gopher_HBo8 小时前
Go语言学习笔记(十八)Gin处理Session
后端
谭光志9 小时前
工具塞满上下文窗口怎么办?深度拆解 AI Agent Tool Search 按需加载实现原理
前端·后端·ai编程
她说..9 小时前
Java 默认值设置方式
java·开发语言·后端·springboot
foggyprojects9 小时前
从0开始,一句话启动AI DataAgent
后端·数据分析·ai编程
郡杰9 小时前
一些基础和问题解决
后端
陈随易9 小时前
前端项目部署只要30秒
前端·后端·程序员
YIAN9 小时前
从零手写文件读取 MCP 服务:一文吃透 Model Context Protocol 全链路通信原理
前端·后端·mcp