1:学习目标
- 彻底理解 C# 类型系统的二元结构:值类型与引用类型
- 掌握值类型和引用类型在内存分配上的本质差异
- 理解赋值操作和参数传递在两种类型上的不同行为
- 掌握装箱与拆箱的原理和性能影响
- 理解 C# 字符串的不可变性和特殊行为
- 熟练使用 C# 的类型转换机制
- 所有知识点均与 C++ 对应概念进行深度对比,消除认知误区
2:C#类型系统概述
C#是一种强类型,类型安全的语言,所有类型最终都直接或间接继承自System.Object类。这是C#与C++最根本的区别之一,C++没有统一的根类型。
C#的类型系统分为两大类:
- 值类型 (Value Types):变量本身包含其数据
- 引用类型 (Reference Types):变量包含指向其数据的引用(地址)
| 分类 | 包含类型 | 默认值 | 内存分配位置 |
|---|---|---|---|
| 值类型 | 基本数值类型 (int、double、float、char、bool 等)、结构体 (struct)、枚举 (enum) | 0、false、'\0' 等 | 栈 (Stack) |
| 引用类型 | 类 (class)、接口 (interface)、数组、字符串 (string)、委托 (delegate) | null | 堆 (Heap) |
3:值类型详解
值类型的变量直接存储数据本身。当你创建一个值类型变量时,系统会在栈上分配一块内存来存储改值。
1:基本值类型
C#提供了一组预定义的基本类型,每个类型都有对应的.net框架类型别名
| C# 关键字 | .NET 框架类型 | 大小 | 取值范围 | 对应 C++ 类型 |
|---|---|---|---|---|
| sbyte | System.SByte | 1 字节 | -128 到 127 | signed char |
| byte | System.Byte | 1 字节 | 0 到 255 | unsigned char |
| short | System.Int16 | 2 字节 | -32768 到 32767 | short |
| ushort | System.UInt16 | 2 字节 | 0 到 65535 | unsigned short |
| int | System.Int32 | 4 字节 | -2147483648 到 2147483647 | int |
| uint | System.UInt32 | 4 字节 | 0 到 4294967295 | unsigned int |
| long | System.Int64 | 8 字节 | -9223372036854775808 到 9223372036854775807 | long long |
| ulong | System.UInt64 | 8 字节 | 0 到 18446744073709551615 | unsigned long long |
| float | System.Single | 4 字节 | ±1.5e-45 到 ±3.4e38 | float |
| double | System.Double | 8 字节 | ±5.0e-324 到 ±1.7e308 | double |
| decimal | System.Decimal | 16 字节 | ±1.0e-28 到 ±7.9e28 | 无对应类型(高精度十进制) |
| char | System.Char | 2 字节 | U+0000 到 U+FFFF | wchar_t |
| bool | System.Boolean | 1 字节 | true 或 false | bool |
注意:
- C# 的
char类型是 16 位的 Unicode 字符,而 C++ 的char是 8 位的 ASCII 字符 - C# 的
decimal类型是专门用于财务和货币计算的高精度十进制类型,C++ 没有内置的对应类型 - 所有基本值类型都是结构体 (struct)
2:结构体struct
结构体是用户自定义的值类型,用于封装小型相关数据组。C#的结构体与C++的结构体有本质区别。
C# 结构体特性:
- 是值类型,默认在栈上分配
- 不能继承其他类或结构体
- 可以实现接口
- 不能有显式的无参数构造函数(编译器会自动生成)
- 不能有析构函数
- 结构体变量赋值时会复制整个对象
cs
namespace CsharpDataTypes
{
public struct Point
{
public int x;
public int y;
public Point(int X, int Y)
{
x = X;
y = Y;
}
public void Print()
{
Console.WriteLine($"Point({x},{y})");
}
}
internal class Program
{
static void Main(string[] args)
{
Point p = new Point(10, 20);
p.Print();
Point p2 = p;//复制整个结构体
p2.x = 100;
p.Print();
p2.Print();
}
}
}
与C++代码的对比
cpp
struct Point
{
int X;
int Y;
Point(int x, int y) : X(x), Y(y) {}
void Print()
{
std::cout << "Point(" << X << ", " << Y << ")" << std::endl;
}
};
int main()
{
Point p1(10, 20);
p1.Print();
Point p2 = p1; // 复制整个对象
p2.X = 100;
p1.Print(); // 输出 Point(10, 20)
p2.Print(); // 输出 Point(100, 20)
return 0;
}
在这个简单的赋值场景下,C# 和 C++ 的结构体行为是相同的。但区别在于:C# 的结构体不能在堆上分配(除非装箱),而 C++ 的结构体可以用new在堆上分配。
另外C++的初始化有很多种情况,我们C#的初始化情况我似乎只实验出两种。
3:枚举
枚举是一组命名的整数常量,用于表示一组相关的值。C#的枚举与C++的枚举类()(enum class)行为类似。
cs
namespace CsharpDataTypes
{
public enum WeekDay
{
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday,
Sunday
}
internal class Program
{
static void Main(string[] args)
{
WeekDay today = WeekDay.Friday;
Console.WriteLine(today);
Console.WriteLine((int)today);//输出4
}
}
}
4:引用类型详解
1:类Class
引用类型的变量不直接存储数据本身,而是存储指向堆中数据的引用(地址)。当你创建一个引用类型变量时,系统会在栈上分配一块内存来存储引用,而实际的数据存储在堆上。
cs
// 定义一个类
public class Person
{
public string Name;
public int Age;
public Person(string name, int age)
{
Name = name;
Age = age;
}
public void Print()
{
Console.WriteLine($"Name: {Name}, Age: {Age}");
}
}
// 使用类
Person person1 = new Person("张三", 25);
person1.Print(); // 输出 Name: 张三, Age: 25
Person person2 = person1; // 复制引用,指向同一个对象
person2.Name = "李四";
person1.Print(); // 输出 Name: 李四, Age: 25 - person1被改变了!
person2.Print(); // 输出 Name: 李四, Age: 25
C++代码
cs
class Person
{
public:
std::string Name;
int Age;
Person(std::string name, int age) : Name(name), Age(age) {}
void Print()
{
std::cout << "Name: " << Name << ", Age: " << Age << std::endl;
}
};
int main()
{
Person* person1 = new Person("张三", 25);
person1->Print();
Person* person2 = person1; // 复制指针,指向同一个对象
person2->Name = "李四";
person1->Print(); // 输出 Name: 李四, Age: 25
person2->Print(); // 输出 Name: 李四, Age: 25
delete person1; // 需要手动释放内存
return 0;
}
关键结论:
- C# 的类对象赋值行为与 C++ 的指针赋值行为完全相同
- C# 的引用类型变量本质上就是一个自动管理的指针
- C# 不需要手动释放内存,垃圾回收器会自动回收不再被引用的堆对象
2:数组
C# 的数组是引用类型,即使数组元素是值类型。这与 C++ 的数组有很大不同。
cs
// 创建一个int数组
int[] numbers1 = new int[3] { 1, 2, 3 };
Console.WriteLine(numbers1[0]); // 输出 1
int[] numbers2 = numbers1; // 复制引用,指向同一个数组
numbers2[0] = 100;
Console.WriteLine(numbers1[0]); // 输出 100 - numbers1被改变了!
cpp
int* numbers1 = new int[3] { 1, 2, 3 };
std::cout << numbers1[0] << std::endl; // 输出 1
int* numbers2 = numbers1; // 复制指针
numbers2[0] = 100;
std::cout << numbers1[0] << std::endl; // 输出 100
delete[] numbers1;
5:核心差异,内存分配与赋值行为
这是 C# 与 C++ 最本质的区别,也是 C++ 开发者最容易犯错的地方。
1:内存分配示意图
cpp
值类型变量:
栈内存
+-------+
| 10 | <-- int a = 10;
+-------+
引用类型变量:
栈内存 堆内存
+-------+ +-------+
| 地址 | ------> | 10 | <-- int* p = new int(10);
+-------+ +-------+
在C#中
- 值类型变量直接在栈上存储值
- 引用类型变量在栈上存储地址,值存储在堆上
在c++中
- 你可以选择将值类型存储在栈上或堆上(使用 new)
- 指针和引用是显式的
2:赋值操作的本质区别
| 操作 | 值类型 | 引用类型 |
|---|---|---|
Type b = a; |
复制 a 的值到 b,a 和 b 是两个独立的对象 | 复制 a 的引用到 b,a 和 b 指向同一个对象 |
| 修改 b | 不会影响 a | 会影响 a(如果修改的是对象的内容) |
cs
// 值类型赋值
int a = 10;
int b = a;
b = 20;
Console.WriteLine(a); // 输出 10
Console.WriteLine(b); // 输出 20
// 引用类型赋值
Person p1 = new Person("张三", 25);
Person p2 = p1;
p2.Name = "李四";
Console.WriteLine(p1.Name); // 输出 李四
Console.WriteLine(p2.Name); // 输出 李四
6:参数传递机制
C#中所有方法参数默认都是值传递。这一点与C++完全相同,但很多C++开发者会误解C#的引用类型是引用传递。
1:默认值传递
- 对于值类型:传递值的副本,方法内对参数的修改不会影响原始变量
- 对于引用类型:传递引用的副本,方法内对参数指向的对象内容的修改会影响原始对象,但对引用本身的修改不会影响原始引用
cs
// 值类型参数传递
void ChangeValue(int x)
{
x = 100;
Console.WriteLine($"方法内x: {x}"); // 输出 100
}
int num = 10;
ChangeValue(num);
Console.WriteLine($"方法外num: {num}"); // 输出 10 - 没有改变
// 引用类型参数传递
void ChangePerson(Person p)
{
p.Name = "李四"; // 修改对象的内容
Console.WriteLine($"方法内Name: {p.Name}"); // 输出 李四
}
Person person = new Person("张三", 25);
ChangePerson(person);
Console.WriteLine($"方法外Name: {person.Name}"); // 输出 李四 - 被改变了
// 引用本身的修改不会影响原始引用
void ChangeReference(Person p)
{
p = new Person("王五", 30); // 修改引用本身,指向新对象
Console.WriteLine($"方法内Name: {p.Name}"); // 输出 王五
}
Person person2 = new Person("张三", 25);
ChangeReference(person2);
Console.WriteLine($"方法外Name: {person2.Name}"); // 输出 张三 - 没有改变
2:ref参数(引用传递)
使用ref关键字可以实现引用传递,方法内对参数的任何修改都会影响原始变量。ref参数对应 C++ 的T&引用。
cs
// 值类型ref参数
void ChangeValueRef(ref int x)
{
x = 100;
}
int num = 10;
ChangeValueRef(ref num);
Console.WriteLine(num); // 输出 100 - 被改变了
// 引用类型ref参数
void ChangeReferenceRef(ref Person p)
{
p = new Person("王五", 30); // 修改引用本身
}
Person person = new Person("张三", 25);
ChangeReferenceRef(ref person);
Console.WriteLine(person.Name); // 输出 王五 - 被改变了
3:out参数(输出参数)
out关键字用于方法返回多个值,参数在传入方法时不需要初始化,方法必须为其赋值。out参数对应C++中用于输出的指针或者引用。
cs
// 使用out参数返回多个值
bool TryDivide(int dividend, int divisor, out int result)
{
if (divisor == 0)
{
result = 0;
return false;
}
result = dividend / divisor;
return true;
}
// 调用方法
int res;
bool success = TryDivide(10, 3, out res);
if (success)
{
Console.WriteLine($"结果: {res}"); // 输出 3
}
else
{
Console.WriteLine("除数不能为零");
}
// C# 7.0+ 可以在调用时声明out变量
TryDivide(10, 3, out int res2);
Console.WriteLine(res2); // 输出 3
4:in参数(只读引用)
in关键字用于传递只读引用,方法内不能修改参数的值。in参数对应 C++ 的const T&,用于避免大值类型的复制开销。
cs
// 定义一个大结构体
public struct LargeStruct
{
public long Value1;
public long Value2;
public long Value3;
public long Value4;
}
// 使用in参数传递,避免复制整个结构体
void ProcessLargeStruct(in LargeStruct ls)
{
// ls.Value1 = 100; // 编译错误:不能修改in参数
Console.WriteLine(ls.Value1);
}
LargeStruct largeStruct = new LargeStruct();
ProcessLargeStruct(in largeStruct);
7:装箱与拆箱
装箱和拆箱是 C# 特有的概念,因为所有类型都继承自object,所以值类型可以转换为object类型,反之亦然。
- 装箱 (Boxing):将值类型转换为引用类型(object)
- 拆箱 (Unboxing):将引用类型(object)转换回值类型
1:装箱的原理
当你将一个值类型赋值给一个object变量时,系统会在堆上分配一块内存,将值类型的值复制到堆上,然后返回指向这块内存的引用。
cs
int i = 10;
object obj = i; // 装箱操作
2:拆箱的原理
拆箱是装箱的逆操作,将堆上的值类型值复制回栈上的值类型变量。拆箱时必须确保类型匹配,否则会抛出InvalidCastException异常。
cs
object obj = 10;
int i = (int)obj; // 拆箱操作
// 错误的拆箱
object obj2 = 10;
long l = (long)obj2; // 运行时异常:InvalidCastException
3:性能影响
装箱和拆箱操作会带来显著的性能开销,因为它们涉及堆内存分配和复制操作。在性能敏感的代码中应尽量避免不必要的装箱和拆箱。
常见的装箱场景:
- 将值类型赋值给
object变量 - 将值类型作为参数传递给接受
object类型的方法 - 将值类型添加到非泛型集合中(如
ArrayList)
8:可空值类型
C# 的值类型不能为null,因为它们直接存储值。但在很多场景下,我们需要表示一个值 "不存在" 的状态。C# 2.0 引入了可空值类型来解决这个问题。
可空值类型使用Nullable<T>表示,其中T必须是值类型。C# 提供了简化语法T?。
cs
// 声明可空int变量
int? age = null;
Console.WriteLine(age.HasValue); // 输出 False
age = 25;
Console.WriteLine(age.HasValue); // 输出 True
Console.WriteLine(age.Value); // 输出 25
// 使用null合并运算符
int realAge = age ?? 0; // 如果age有值则使用age.Value,否则使用0
Console.WriteLine(realAge); // 输出 25
// 可空值类型的转换
int? a = 10;
int b = (int)a; // 显式转换
可空值类型对应 C++17 引入的std::optional<T>。
9:字符串的特殊性
string是 C# 中最常用的引用类型,但它具有值类型的语义,因为它是不可变的。
1:字符串的不可变性
一旦创建了一个字符串对象,就不能修改它的内容。所有看似修改字符串的操作实际上都是创建了一个新的字符串对象。
cs
string s1 = "Hello";
string s2 = s1;
s1 += " World";
Console.WriteLine(s1); // 输出 Hello World
Console.WriteLine(s2); // 输出 Hello - s2没有改变
这与 C++ 的std::string完全不同:
cpp
std::string s1 = "Hello";
std::string s2 = s1;
s1 += " World";
std::cout << s1 << std::endl; // 输出 Hello World
std::cout << s2 << std::endl; // 输出 Hello - 这里看起来相同,但原理不同
在 C++ 中,s1 += " World"修改了s1对象本身;而在 C# 中,s1 += " World"创建了一个新的字符串对象,并将s1的引用指向这个新对象。
2:字符串池
为了提高性能,CLR 维护了一个字符串池,包含所有在程序中使用的字符串字面量。当你创建一个字符串字面量时,CLR 会先检查字符串池中是否已经存在相同的字符串,如果存在则直接返回引用,否则创建一个新的字符串并添加到池中。
cs
string s1 = "Hello";
string s2 = "Hello";
Console.WriteLine(s1 == s2); // 输出 True
Console.WriteLine(object.ReferenceEquals(s1, s2)); // 输出 True - 指向同一个对象
10:类型转换
C# 提供了多种类型转换方式:
1:隐式转换
当转换是安全的,不会丢失数据时,编译器会自动进行隐式转换。
cs
int i = 10;
long l = i; // 隐式转换,int可以安全转换为long
double d = i; // 隐式转换,int可以安全转换为double
2:显示转换
当转换可能丢失数据时,必须使用显式转换。
cs
double d = 10.5;
int i = (int)d; // 显式转换,丢失小数部分
Console.WriteLine(i); // 输出 10
3:Convert类
Convert类提供了一组静态方法,用于在不同的基本类型之间进行转换。
cs
string s = "123";
int i = Convert.ToInt32(s);
double d = Convert.ToDouble(s);
bool b = Convert.ToBoolean(1);
Console.WriteLine(i); // 输出 123
Console.WriteLine(d); // 输出 123.0
Console.WriteLine(b); // 输出 True
4:Parse和TryParse方法
所有基本值类型都提供了Parse和TryParse方法,用于将字符串转换为对应的值类型。
Parse:如果转换失败会抛出异常TryParse:如果转换失败返回false,不会抛出异常
cs
string s = "123";
int i = int.Parse(s); // 转换成功
string s2 = "abc";
// int j = int.Parse(s2); // 抛出FormatException异常
// 使用TryParse
if (int.TryParse(s2, out int j))
{
Console.WriteLine(j);
}
else
{
Console.WriteLine("转换失败");
}