在C++基础上理解Csharp-2

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# 结构体特性:

  1. 是值类型,默认在栈上分配
  2. 不能继承其他类或结构体
  3. 可以实现接口
  4. 不能有显式的无参数构造函数(编译器会自动生成)
  5. 不能有析构函数
  6. 结构体变量赋值时会复制整个对象
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方法

所有基本值类型都提供了ParseTryParse方法,用于将字符串转换为对应的值类型。

  • 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("转换失败");
}
相关推荐
桀人1 小时前
类和对象——上篇
开发语言·c++
智者知已应修善业1 小时前
【51单片机独立按键和定时器中断的疑惑验证】2023-11-2
c++·经验分享·笔记·算法·51单片机
zzzsde1 小时前
【Linux】线程概念与控制(3):线程ID&&C++封装线程
linux·运维·服务器·开发语言·算法
消失的旧时光-19431 小时前
C 语言如何实现“面向对象”?—— 从 struct + 函数指针,到 Linux 内核设计思想
linux·c语言·开发语言
handler011 小时前
滑动窗口(同向双指针)算法:模板与例题解析
c语言·c++·笔记·算法·蓝桥杯·双指针·滑动窗口
Brilliantwxx1 小时前
【算法题】基础计算器的不同实现方式
c++·算法
Sunsets_Red1 小时前
P12375 「LAOI-12」MST? 题解
c++·算法·洛谷·信息学·oier·洛谷题解
小短腿的代码世界1 小时前
Qt时间日期处理与QTimer高级应用:从毫秒级精度到跨平台定时器的完整架构解析
开发语言·qt·架构
TAN-90°-2 小时前
Java 6——成员变量初始值 object equals和== toString instanceof 参数传递问题
java·开发语言