Cpp类的讲解

c++ 与 c#在类上的不同

兼容性

有时候我们编写头文件的时候,我们准备让代码有更好的兼容性,比如说库文件,我们可能会遇到一些宏,类似于如下:

#ifdef __cplusplus
extern "C" {
#endif

它们的作用就是确保cpp的编译器在处理戴拿的时候按照c语言的方式来处理函数名,因为C语言是不支持函数重载的,C编译器眼中,函数 void print(int) 的符号名就是 print。C++ 支持函数重载。为了区分 print(int) 和 print(double),C++ 编译器会偷偷修改函数名(这被称为 Name Mangling)。

print(int)` 可能会变成 `_Z5printi
print(double)` 可能会变成 `_Z5printd

如果我们在一个c++中包含了一个使用C编译的库,c++的链接器会去寻找_Z5printi,但 C 库里只有 print。结果就是:链接错误(LNK2019),提示找不到符号。

如果我们用了__cplusplus这个编译器预定义的宏,那么c++编译器编译它的时候,它就存在,如果是用的C编译器,那么它就不存在。

上面的exern "C":{} 就是告诉c++的编译器不使用name mangling,直接使用它们原本的名称。最经典的使用场景会包在整个头文件的内容外面:

#pragma once

#ifdef __cplusplus
extern "C" {
#endif

// 这里写你的 C 风格函数定义
void Logger_Log(const char* message);
int Calculate_Sum(int a, int b);

#ifdef __cplusplus
}
#endif

拷贝构造和移动构造

拷贝是对象被当作值传递或者A a = b的时候触发,移动构造是c++11引入的,用于转移资源所有权,就比如说一个从临时对象拿走内存,来避免昂贵的内存拷贝。

在CPP中,是否发生了拷贝是由参数传递方式决定的,比如说究竟是值传递还是引用传递。

void Func(const std::string s) { ... }

这种就属于按值传递,编译器回去拷贝一份字符串,const作用就是函数内部,不能修改整个拷贝出来的副本,这种方式少见而且不推荐,因为这种方式限制了修改副本的同时还花费了拷贝开销,很烂。

我们可以按常量传递,使用类似 const T& x这种方式:

void Func(const std::string& s) { ... }

这种不会发生拷贝,函数会直接指向数据的内存地址,这里是左值引用,const的作用是确保函数内部智能读取原数据而不能修改,这个是最常用的,避免了拷贝,还可以保证原数据的安全,是只读的。类似于c#的in关键字。

还有一种是普通引用传递T& x{}:

void Func(std::string& s) { ... }

这种不会发生拷贝,并且可以随意的修改原数据,修改会直接反应在调用者那里,类似于ref关键字,这个就是我们常说的这个函数会产生副作用

静态类的处理

C#中,static class意味着类不能被实例化,且所有成员必须是静态的。 在 C++ 中:

类本身不能标记为 static。static 关键字在 C++ 中修饰变量或函数时,表示“内部链接”或“类共有成员”,但不能直接修饰 class 定义。

如果你想要实现类似 C# 静态类的效果,C++ 通常有两种做法:使用 namespace 或 类内全静态成员。

就比如:

// Logger.h
#pragma once
#include <iostream>
#include <string>

enum class LoggerLevel { // 建议使用 enum class 避免命名污染
    Info,
    Debug,
    Warning,
    Error
};

namespace Logger {
    // 声明函数
    void Log(LoggerLevel level, const std::string& message);
}

顺带一提, enum class是c++11引入的强类型枚举,也叫有作用域枚举,C++ 的 enum class 在行为上几乎等同于 C# 的 enum。而 C++ 传统的 enum(不带 class 的)反而更像是一个“披着名字的整数”。

普通的enum中,枚举值是之际额暴露在外部,在enum class中,必须通过类名来引用,因此我们才叫做它可以避免命名污染。

C++的构造函数

在 C++ 中,构造函数是用于初始化类对象的特殊成员函数。它的名称与类名完全相同,没有返回类型(连 void 都没有)。

class Logger {
private:
    int _id;
    std::string _name;

public:
    // 1. 默认构造函数 (Default Constructor)
    Logger() {
        _id = 0;
        _name = "Default";
    }

    // 2. 有参构造函数 (Parameterized Constructor)
    Logger(int id, std::string name) {
        _id = id;
        _name = name;
    }
};

委托构造

如果你有多个构造函数,且它们有重复的逻辑,可以让一个构造函数调用另一个。

class Device {
public:
    Device(int id, std::string type) {
        // 复杂的初始化逻辑
    }

    // 调用上面的构造函数
    Device(int id) : Device(id, "Unknown") { } 
};

default和delete

如果你定义了有参构造函数,编译器就不会自动生成默认构造函数。你可以手动强制它生成,我们需要使用deafult关键字。如果你不希望对象被实例化(比如写单例或静态工具类),可以禁用构造函数,我们使用delete。

class Utils {
public:
    Utils() = delete; // 禁止创建实例
};

class Data {
public:
    Data() = default; // 显式要求编译器生成默认构造函数
    Data(int x) { }
};

防止隐式转换

在 C++ 中,如果构造函数只有一个参数,编译器会尝试进行隐式类型转换,这有时会导致诡异的 Bug,我们可以使用explicit关键字:

class Buffer {
public:
    explicit Buffer(int size) { ... }
};

// 使用:
Buffer b1(10);    // OK
// Buffer b2 = 10; // 报错!因为加了 explicit,禁止将 int 直接“变成” Buffer

初始化

在 C++ 中,上面的赋值写法(在 {} 内部赋值)实际上是“先创建变量再赋值”。更高效、更推荐的做法是使用“初始化列表”。

语法: Constructor() : member1(val1), member2(val2) { ... }

class Camera {
private:
    const int _camId; // 常量必须在初始化列表中初始化
    int _width;
    int _height;

public:
    // 推荐写法:在进入构造函数体之前完成初始化
    Camera(int id, int w, int h) 
        : _camId(id), _width(w), _height(h) 
    {
        // 函数体通常为空,或者只写一些逻辑校验
    }
};
class MyVector {
public:
    // 拷贝构造:参数必须是常量引用
    MyVector(const MyVector& other) {
        // 执行深拷贝
    }

    // 移动构造:参数是右值引用
    MyVector(MyVector&& other) noexcept {
        // 转移指针的所有权
    }
};

noexcept关键字是 c++11引入的,用来告诉编译器和开发者,这个函数保证是不会抛出异常的,c#的方法默认也是不声明异常的,不强制使用try-catch语法,但是cpp的noexcept不仅仅是一个文档说明,对性能和稳定性也有很重要的影响。先来说说性能优化的地方:

当 C++ 的标准库容器(比如 std::vector)需要扩容时,它会将旧内存中的对象搬到新内存中,如果说对象是的移动构造函数是noexcept的话,std::vector就会使用高效的移动操作,如果不是noexcept的话,编译器就会保证移动过程中报错了原数据就能不丢失,会保守选择拷贝方式。

可能我们比较奇怪的一点,为什么拷贝构造的形参是一个只读左值引用,但是却叫做拷贝。拷贝构造描述的其实是函数的功能,只读的左值引用是实现的最高效、安全的手段:

递归死循环

这个是技术上最根本的原因,假设不使用引用的话,而是真的使用值传递的话:

class MyClass {
public:
    // 假设我们不写引用 &
    MyClass(MyClass other) { 
        // ... 实现拷贝逻辑
    }
};

当你执行 MyClass a; MyClass b = a; 时:编译器发现我们需要使用b的拷贝构造函数,为了调用这个函数我们必须先把实参a传递给形参other,那么这个时候按值传递就意味着要把a拷贝一份给other,然后为了把a拷贝给other,编译器就要调用MyClass的拷贝构造函数,为了调用这个拷贝构造函数,又需要再次拷贝实参。

编译器就会报错!

或者我们可以从结果来说,拷贝构造定义的是函数执行之后的结果,内存里多了个和原对象一模一样的新对象。

接下来说说移动构造,为什么必须是右值引用:

移动构造本质是资源所有权的转移,我们可以脱离拷贝,直接将buffer指针地址拿来赋值给自己,然后将原来的指针设置为nullptr,为什么必须是右值,因为左值是一个持久的对象,它是有名字有地址的对象,如果你把一个正在使用的变量之际额给拿走,原程序就会崩溃,右值由于是临时对象,没有名字会马上销毁,那么它占用的内存资源就可以拿来利用,这个对原程序没有任何影响。

如果说没有右值引用,那么编译器就没法在语法层面上面区分是想要拷贝还是移动:

class Logger {
public:
    // 拷贝构造:我承诺不改你,所以我用 const &
    Logger(const Logger& other); 

    // 移动构造:我需要改你(把你置空),所以我不能用 const
    // 我需要专门处理临时变量,所以用 &&
    Logger(Logger&& other) noexcept; 
};

访问符号的区别

C++区别于C#,他把权限分的很细,我们使用C#的时候一直都是使用.来访问成员,但是CPP不一样。 . 这样的点运算符是对象成员访问,用于直接操作对象实例,左侧必须是一个具体的对象,也就是在栈上定义的变量或者解引用后的对象,对应C#的是访问普通类属性或方法的逻辑:

Logger myLogger;         // 对象在栈上
myLogger.Log(level, ".."); // 使用点访问

->箭头运算符用于指针访问成员,这是C++处理指针时的专属符号,左侧必须是一个指针,比如说unique_ptr,或者是shared_ptr或者原始指针*。

它是(*ptr)的缩写,他回去解引用找到对应的对象后,再去访问成员。C#的引用类型变量比如class在底层都是指针,知识c#在帮你自动转换,在cpp中必须手动区分,如果手里是地址,就得用->。

auto loggerPtr = std::make_unique<Logger>(); // loggerPtr 是个智能指针
loggerPtr->Log(level, "..");                  // 使用箭头访问

::是作用域解析运算符,用于静态/全局访问,左侧可以是命名空间,也可以是类名或者枚举名称。我们经常用它来访问静态成员变量/方法,访问命名空间里的类或者函数,访问enum class里的成员。对应的是c#里面中的话,访问静态方法比如说Console.WriteLine或者命名空间也用的是. ,但是c++里面必须用::。

Loagger::Logger myLogger;        // 命名空间 :: 类名
LoggerLevel level = LoggerLevel::info; // 枚举 :: 成员
std::cout << "Hi";               // 命名空间 :: 对象

禁止构造函数隐式转换

explicit 用于禁止构造函数或转换运算符的隐式转换。它强制开发者必须显式地调用构造函数,从而避免编译器自动进行不期望的类型转换。

    explicit __CLR_OR_THIS_CALL basic_ostream(basic_streambuf<_Elem, _Traits>* _Strbuf, bool _Isstd = false) {
        _Myios::init(_Strbuf, _Isstd);
    }

这里是std::basic_ostream的构造函数,作用是流缓冲区初始化输出流,我们平常这样使用:

// 你平时这样用:
std::stringstream ss;           // 内部有自己的 streambuf
std::ostream os(&ss);           // ← 这里调用了这个构造函数

_Strbuf 是指向流缓冲区的指针,实际数据存储和传输的地方。_Isstd默认false,标记是否为标准流,标准流有cout、cerr、clog等,用于特殊处理。

这里用explicit防止隐式转换:

std::stringbuf buf;
std::ostream os = &buf;   // ❌ 如果没有 explicit,这居然能编译!
std::stringbuf buf;
std::ostream os(&buf);    // ✅ 加了explicit需要显式构造,清晰明确