并不是所有的对象都是new出来的

在使用了一年多C#之后,回头看C++时,有很多疑惑.首先是,在C#中,所有的对象都是需要new的.

看下面代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#include <iostream>
#include <string>

// 定义一个简单的类
class Person
{
private:
std::string name;
int age;

public:
Person()
{
std ::cout << "执行无参构造函数" << std::endl;
}

// 构造函数,用于初始化对象
Person(const std::string &n, int a) : name(n), age(a)
{
std::cout << "执行有参构造函数" << std::endl;
}

// 成员函数,用于显示对象信息
void displayInfo()
{
std::cout << "Name: " << name << ", Age: " << age << std::endl;
}

// 析构函数,在对象销毁时自动调用
~Person()
{
std::cout << "Destroying person: " << name << std::endl;
}
};

int main()
{
// 在栈上创建 Person 类的对象
Person person("Alice", 23);

// 调用对象的成员函数
person.displayInfo();

// 在栈上创建 Person 类对象
Person *p = new Person("Hank", 20);
p->displayInfo();
delete p;

return 0;
}

看一下在栈上创建的对象的特点:

  • 在栈上创建的对象不需要new,也不需要delete,其生命周期跟随其所在方法域的生命周期一致.
  • 在栈上创建的对象不需要delete
  • 在栈上创建的对象调用其字段和方法成员时,使用操作符.,在堆上创建的对象使用操作符->访问其成员.

这篇文章介绍了如何在C++中实现只能在堆上或者只能在栈上创建的对象

Warning: treating ‘c-header’ input as ‘c++-header’ when in C++ mode, this behavior is deprecated

比如一个cpp文件中引用了某一个头文件,在编译的时候就没必要把这个头文件列出来.

https://stackoverflow.com/questions/23534362/warning-treating-c-header-input-as-c-header-when-in-c-mode-this-behavi

在C++中可以较为自由地控制某一个对象是创建在堆上还是栈上,而在C#中,值类型只能创建在栈上,堆类型只能创建在堆上,在不考虑装拆箱类型强转的情况下。

C++宏的使用

在 C++ 中,除了系统相关的宏,其他宏的开关控制方式主要有编译器默认定义、构建系统设置、头文件包含等,以下是具体介绍:
编译器默认定义
一些编译器会根据自身特性和编译选项,默认定义某些宏。例如,GCC 编译器会定义GNUC宏,用于标识使用的是 GCC 编译器,其值表示 GCC 的版本号。
对于 C++ 标准库相关的宏,如cplusplus,它用于标识当前编译环境的 C++ 标准版本。在 C++11 环境下,cplusplus的值通常被定义为201103L,在 C++20 环境下则为202002L等,编译器会根据所支持的 C++ 标准自动定义该宏。
构建系统设置
Makefile:可以在 Makefile 中通过CFLAGS(针对 C 语言)或CXXFLAGS(针对 C++ 语言)变量来定义宏。比如要定义一个名为MY_MACRO的宏,可以写成CFLAGS += -DMY_MACRO或CXXFLAGS += -DMY_MACRO。如果要为宏指定值,例如MY_MACRO的值为10,则可以写成CFLAGS += -DMY_MACRO=10或CXXFLAGS += -DMY_MACRO=10。
CMake:使用add_definitions命令来定义宏,如add_definitions(-DMY_MACRO)或add_definitions(-DMY_MACRO=10)。也可以通过set命令结合CMAKE_C_FLAGS或CMAKE_CXX_FLAGS变量来设置,例如set(CMAKE_CXX_FLAGS “${CMAKE_CXX_FLAGS} -DMY_MACRO=10”)。
Ninja:与 Makefile 类似,在生成 Ninja 构建文件时,可以通过相关的命令行参数或配置选项来添加宏定义。具体方式取决于生成 Ninja 文件的工具或脚本,例如使用 CMake 生成 Ninja 文件时,可以通过 CMake 的配置来间接影响 Ninja 构建时的宏定义。
头文件包含
许多库的头文件会根据自身的需求和条件定义一些宏。当包含这些头文件时,相应的宏就会被定义。例如,在包含头文件时,可能会引入一些与标准输入输出相关的宏定义。
一些跨平台的库会在头文件中根据不同的平台来定义特定的宏。以 OpenGL 库为例,在 Windows 平台下包含头文件后,可能会定义一些与 Windows 图形系统相关的宏,然后在 OpenGL 的头文件中会根据这些宏来进一步定义与 OpenGL 在 Windows 平台上相关的特定宏。
命令行参数
在使用编译器命令行进行编译时,可以通过命令行参数来定义宏。例如,使用 GCC 编译时,可以通过-D选项来定义宏,如gcc -D MY_MACRO main.c会在编译main.c文件时定义MY_MACRO宏。如果要为宏赋值,可使用-D MY_MACRO=10的形式。
在使用 CMake 等构建系统时,也可以在命令行中通过-D选项来传递自定义的宏定义。例如cmake -D MY_MACRO=10..,这会在运行 CMake 配置项目时定义MY_MACRO宏,后续的编译过程中就可以使用该宏。

简单的使用可以看一下这个仓库下的Code/CPP/assertAndMacro

C++模板和C#范型(generic)之间的区别

C# 泛型和 C++ 模板均是支持参数化类型的语言功能。 但是,两者之间存在很多不同。 在语法层次,C# 泛型是参数化类型的一个更简单的方法,而不具有 C++ 模板的复杂性。 此外,C# 不试图提供 C++ 模板所具有的所有功能。 在实现层次,主要区别在于 C# 泛型类型的替换在运行时执行,从而为实例化对象保留了泛型类型信息.

以下是 C# 泛型和 C++ 模板之间的主要差异:

C# 泛型的灵活性与 C++ 模板不同。 例如,虽然可以调用 C# 泛型类中的用户定义的运算符,但是无法调用算术运算符。

C# 不允许使用非类型模板参数,如 template C {}。

C# 不支持显式定制化;即特定类型模板的自定义实现。

C# 不支持部分定制化:部分类型参数的自定义实现。

C# 不允许将类型参数用作泛型类型的基类。

C# 不允许类型参数具有默认类型。

在 C# 中,泛型类型参数本身不能是泛型,但是构造类型可以用作泛型。 C++ 允许使用模板参数。

C++ 允许在模板中使用可能并非对所有类型参数有效的代码,随后针对用作类型参数的特定类型检查此代码。 C# 要求类中编写的代码可处理满足约束的任何类型。 例如,在 C++ 中可以编写一个函数,此函数对类型参数的对象使用算术运算符 + 和 -,在实例化具有不支持这些运算符的类型的模板时,此函数将产生错误。 C# 不允许此操作;唯一允许的语言构造是可以从约束中推断出来的构造。

C++ wchar_t介绍以及和string类型的转换

https://blog.csdn.net/sxhlovehmm/article/details/40919607

关于CMake

还是回头来看一下从C++源文件变成可执行文件的过程.

执行CMake命令,实质上是在构建makefile的过程,即 生成构建文件.
执行CMake实在生成make, 执行make才是真正的开始将C++源代码转换成可执行文件.

CMake中常用命令

target_include_directories

在CMake中用于为特定的目标指定包含目录, 这些指定的目录在编译时会被添加到编译器的搜索路径中, 使得编译器可以找到目标的源文件包含的头文件. 当项目包含多个目录,并且源文件在不同目录下分布时,target_include_directories能够帮助编译器找到这些分散的头文件。这对于保持项目的组织结构清晰、解决头文件依赖问题非常有用。
使用方法

1
2
3
4
target_include_directories(<target>
[BEFORE]
<INTERFACE|PUBLIC|PRIVATE> [items1...]
[<INTERFACE|PUBLIC|PRIVATE> [items2...] ...])

  • : 目标的名称,可以是可执行文件或库。
  • BEFORE: 可选关键字,指定包含目录应该在默认目录之前被添加到编译器的搜索路径。
  • : 指定包含目录的范围:
    • PRIVATE: 目录仅对该目标可见,不会影响依赖该目标的其他目标。
    • INTERFACE: 目录不会添加到该目标的编译选项中,但会添加到链接该目标的其他目标中。
    • PUBLIC: 目录既会添加到该目标的编译选项中,也会添加到链接该目标的其他目标中。
  • [items...]: 要添加为包含目录的路径列表。

add_executable

add_executable()命令用于定义一个新的可执行目标,也就是要生成的可执行文件。这个命令告诉CMake要编译一系列的源文件,然后将其链接以产生一个可执行文件

1
add_executable(<name> [WIN32] [MACOSX_BUNDLE] [EXCLUDE_FROM_ALL] source1 [source2 ...])

其中

  • <name> 是要生成的可执行文件的名称。
  • [WIN32] 在Windows上生成一个应用程序,而不是控制台应用程序。
  • [MACOSX_BUNDLE] 在macOS上生成一个bundle。
  • [EXCLUDE_FROM_ALL] 表示此目标不会被默认构建(例如,当你运行 “make” 或 “ninja” 时)。
  • source1, source2, … 是要编译的源文件。

pkg_check_modules

include

find_package

set

add_subdirectory

add_subdirectory() 是 CMake 中的一个命令,用于添加一个子目录到构建中。当此命令被执行时,CMake 会进入指定的子目录,并查找并处理那个子目录下的 CMakeLists.txt 文件。

1
add_subdirectory(source_dir [binary_dir] [EXCLUDE_FROM_ALL])

参数解释:

  • source_dir:要加入构建的子目录的路径。这是必需的。
  • binary_dir:为子目录生成的构建文件(如 Makefiles 或项目文件)应放置的目录。通常,如果您没有指定,CMake 将在当前二进制目录下为子目录创建一个同名目录。这不是必需的。
  • EXCLUDE_FROM_ALL:当设置此选项时,这个子目录下的目标(例如库或可执行文件)不会被默认构建目标(例如 “make” 或 “ninja” 的默认目标)包括进来。
    假设有以下的目录结构:
    1
    2
    3
    4
    /my_project
    ├── CMakeLists.txt
    └── /sub_project
    └── CMakeLists.txt
    在 /my_project/CMakeLists.txt (根目录下面的CMakeLists.txt)中,你可以这样写:
    1
    add_subdirectory(sub_project)
    这样,当CMake处理 /my_project/CMakeLists.txt 时,它也会处理 /sub_project/CMakeLists.txt。

CMAKE_MODULE_PATH

CMAKE_MODULE_PATH

CMAKE_PREFIX_PATH

FindXXX.cmake

_INCLUDE_DIRS

_LIBRARIES

_EXECUTABLE

_ROOT_DIR

_FOUND

关于C++中的*和&符号

&符号多用于

在C++中有符合类型的概念,引用和指针就是符合类型中的两种,*和&号是这两种符合类型中经常使用到的符号.

引用为对象起了另外一个名字,引用必须初始化.

C++中函数参数的传递方式有:

  • 按值传递
  • 按指针传递
  • 按引用传递

函数传参的过程,发生了什么?
如何理解函数参数中的&符号,比如下面这个例子

1
2
3
4
5
6
7
8
9
10
11
12
void func(int a, int &b){
a++;
b++;
}

int main(){

int a = 1;
int b = 2;
func(a, b);
}

在调用函数时,我们需要向函数中传入一系列的参数,如果我们不给参数添加&时,你可以理解为,调用函数一方,也就是原始参数持有一方所持有的函数,和真正参与函数执行的参数,是完全没关系的. &的作用就是将这两个参数关联起来,或者说,让这两个参数就是一个参数.因此,函数内对参数的操作会在函数执行完毕,回到调用函数侧时,对参数做的修改会得以保留.

那如果我想要把外层和函数内层的字段关联起来,该怎么做呢?通过指针啊,指针之间的传值是复制,但是只要指针指向的内存地址一样,就可以使用不同的指针对同一片内存地址操作啊.

引用&符号的作用更多的是用在函数传参中,避免不必要的参数复制造成额外开销.
迷糊了,直接用指针就可以
比如一个站

模版template的使用

在 C++ 里,模板函数和模板类的定义和声明通常需要放在同一个头文件中。这是因为模板在编译时才会被实例化,编译器需要看到模板的完整定义才能生成具体的代码。在你的代码里,Stack 类的定义放在了 stack.h 头文件中,而成员函数的实现放在了 stack.cpp 文件中,这就导致编译器在编译 main.cpp 时,无法找到 Stack 类成员函数的具体实现,从而在链接阶段出现未定义符号的错误。

如何在C++中实现C#中的反射 HybridCLR可能会用到

C++ volatile 关键字

在 C++ 中,volatile 是一个类型修饰符(关键字),用于告诉编译器:被修饰的变量可能会被程序之外的因素(如硬件、中断、多线程等)修改,因此编译器不应对该变量的读写操作进行优化(如缓存到寄存器、重排指令等),必须每次都直接从内存中读取或写入。

核心作用:阻止编译器优化

编译器在编译时会对代码进行优化,例如:

  • 将频繁访问的变量缓存到寄存器(减少内存读写开销)
  • 重排指令执行顺序(提高执行效率)
  • 省略重复的读写操作(如连续多次读取同一个变量时,可能只读取一次)

但对于 volatile 修饰的变量,这些优化可能导致错误,因为变量的值可能在编译器不知情的情况下被修改。volatile 强制编译器每次都直接与内存交互,确保操作的真实性。

典型使用场景

  1. 硬件寄存器访问
    硬件设备的寄存器(如传感器数据、定时器计数)的值可能会被硬件直接修改,必须用 volatile 修饰,避免编译器缓存旧值。

    1
    2
    3
    4
    5
    // 假设 0x1234 是硬件寄存器地址
    volatile int* sensor_data = (volatile int*)0x1234;

    // 每次都从内存(寄存器)读取最新值
    int current_value = *sensor_data;
  2. 中断服务程序(ISR)与主程序共享变量
    中断程序可能随时修改变量,主程序必须每次都读取内存中的最新值。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    volatile bool flag = false;  // 被中断程序和主程序共享

    // 中断服务程序(ISR)
    void interrupt_handler() {
    flag = true; // 修改标志
    }

    // 主程序
    int main() {
    while (!flag) { // 每次都从内存读取 flag 的最新值
    // 等待中断
    }
    return 0;
    }
  3. 多线程共享变量(有限场景)
    在没有同步机制(如互斥锁)的情况下,volatile 可确保线程读取到变量的最新值(但不能替代线程同步,因为它不保证原子性)。

注意事项

  1. volatile 不保证原子性
    它仅确保变量的读写不被优化,但复杂操作(如 i++)仍可能被拆分为多个指令,在多线程环境中存在竞态条件。需配合原子操作(如 std::atomic)或锁机制使用。

  2. volatileconst 可同时使用
    表示变量“可以被外部修改,但程序内部不能修改”:

    1
    2
    3
    volatile const int* config = /* 硬件配置寄存器地址 */;
    // *config = 10; // 错误:const 不允许修改
    // 但硬件可以修改 *config 的值
  3. 函数参数和返回值也可被修饰
    表示参数/返回值可能被外部修改:

    1
    2
    3
    volatile int read_data(volatile int* ptr) {
    return *ptr; // 读取 volatile 变量
    }
  4. 不要过度使用
    volatile 会禁用编译器优化,可能降低程序性能,仅在确实需要时使用。

总结

volatile 的核心意义是告诉编译器:“这个变量的变化不受程序控制,请勿优化其读写操作”,主要用于硬件交互、中断处理等场景,确保程序能获取变量的最新值。但它不能解决线程同步问题,需与其他机制配合使用。

C++ inline 关键字

在 C++ 中,inline 关键字用于向编译器建议将函数内联展开(inline expansion),即把函数调用处直接替换为函数体代码,而不是通过传统的函数调用机制(如压栈、跳转、返回等)执行。

主要作用:

  1. 减少函数调用开销
    传统函数调用需要执行一系列操作(如保存寄存器、参数入栈、程序跳转等),这些操作会产生额外开销。对于简短的函数(如只包含几行代码),调用开销可能甚至超过函数本身的执行时间。
    inline 建议编译器在调用处直接插入函数体,消除这些额外开销,提高程序运行效率。

  2. 不影响函数的封装性
    #define 宏定义相比,inline 函数既保留了函数的类型检查、作用域规则等封装特性,又能实现类似宏的”代码替换”效果,是更安全的选择。

使用注意事项:

  1. 只是编译器的”建议”
    inline 是给编译器的提示,而非强制命令。编译器可能会忽略该建议(例如,对于递归函数、包含循环/switch的复杂函数,编译器通常不会将其 inline)。

  2. 通常用于简短函数
    如果函数体过长,inline 会导致代码膨胀(重复插入函数体),反而增加内存占用和指令缓存压力,降低性能。

  3. 定义需放在头文件中
    与普通函数不同,inline 函数的定义通常需要放在头文件里(而非仅声明),因为编译器在编译调用处代码时,需要知道函数体内容才能进行 inline 展开。

  4. 避免副作用
    与宏不同,inline 函数的参数只计算一次,不会产生类似宏的副作用。例如:

    1
    2
    3
    4
    5
    6
    7
    8
    // 宏可能产生副作用
    #define ADD(a, b) (a + b)
    int x = 1;
    int y = ADD(x++, x++); // 结果不确定(x可能被递增2次或1次)

    // inline函数无此问题
    inline int add(int a, int b) { return a + b; }
    int y = add(x++, x++); // 明确:x先递增两次,再计算和

典型用法示例:

1
2
3
4
5
6
7
// 头文件中定义inline函数
inline int max(int a, int b) {
return (a > b) ? a : b;
}

// 调用处会被编译器建议展开为:(a > b) ? a : b
int result = max(3, 5);

总结:

inline 的核心价值是在不牺牲代码可读性和安全性的前提下,优化简短函数的调用效率。它是 C++ 对宏定义的一种改进,合理使用能提升程序性能,但过度使用(如对复杂函数加 inline)可能适得其反。

C++ 友元

在 C++ 中,友元(friend) 是一种打破类封装性的机制,允许特定的外部函数或类访问当前类的私有(private)和保护(protected)成员。

友元的定义方式:

  1. 友元函数:在类内部声明一个外部函数为友元,使其能访问类的私有成员。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    class MyClass {
    private:
    int value;
    public:
    MyClass(int v) : value(v) {}
    // 声明外部函数为友元
    friend void printValue(MyClass obj);
    };

    // 友元函数可以直接访问MyClass的私有成员
    void printValue(MyClass obj) {
    cout << obj.value << endl; // 合法:访问私有成员value
    }
  2. 友元类:一个类可以声明另一个类为友元,友元类的所有成员函数都能访问当前类的私有成员。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    class A {
    private:
    int secret;
    // 声明B为友元类
    friend class B;
    };

    class B {
    public:
    void accessA(A& a) {
    a.secret = 100; // 合法:B是A的友元,可访问其私有成员
    }
    };

为什么需要友元?

C++ 的类封装性要求私有成员只能被类内部的成员函数访问,但在某些场景下,这种严格的封装会带来不便,友元机制正是为了解决这些特殊需求:

  1. 方便操作类的私有数据
    例如在实现运算符重载(如 << 输出运算符)时,需要访问类的私有成员,但运算符重载函数通常是全局函数而非成员函数,此时可将其声明为友元。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class Point {
    private:
    int x, y;
    // 声明输出运算符重载为友元
    friend ostream& operator<<(ostream& os, const Point& p);
    };

    ostream& operator<<(ostream& os, const Point& p) {
    os << "(" << p.x << "," << p.y << ")"; // 访问私有成员x、y
    return os;
    }
  2. 实现类之间的协作
    当两个类关系紧密(如容器类与迭代器类),迭代器需要访问容器的内部数据结构时,可将迭代器类声明为容器类的友元,简化协作逻辑。

  3. 避免过度暴露接口
    如果不使用友元,为了让外部访问私有成员,可能需要增加大量的 getter/setter 方法,反而破坏封装的简洁性。友元可以在不暴露接口的前提下,有选择地开放访问权限。

注意事项:

  1. 友元关系是单向的:若 A 是 B 的友元,B 不一定是 A 的友元。
  2. 友元关系不可传递:若 A 是 B 的友元,B 是 C 的友元,A 不一定是 C 的友元。
  3. 破坏封装性:友元本质上是对封装的突破,过度使用会降低代码的安全性和可维护性,应谨慎使用。

友元的核心价值是在保证封装性的前提下,为特殊场景提供灵活的访问机制,是对严格封装的一种补充,而非替代。

C++ 继承多个类

在 C++ 中,支持多继承,即一个派生类可以同时继承多个基类。这种机制增强了代码的灵活性,但也带来了一些复杂问题,需要特别注意。

一、多继承的基本语法

派生类声明时,在基类列表中用逗号分隔多个基类:

1
2
3
4
5
6
7
class Base1 { /* ... */ };
class Base2 { /* ... */ };

// 派生类同时继承 Base1 和 Base2
class Derived : public Base1, public Base2 {
// 派生类成员
};

二、多继承需要注意的问题

  1. 菱形继承(钻石问题)导致的二义性
    最典型的问题是“菱形继承”:两个派生类(B、C)继承自同一个基类(A),而另一个派生类(D)同时继承 B 和 C。此时,D 会间接包含两份 A 的成员,导致访问 A 的成员时出现二义性。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class A { public: int x; };
    class B : public A { }; // B 继承 A
    class C : public A { }; // C 继承 A
    class D : public B, public C { }; // D 继承 B 和 C

    int main() {
    D d;
    // d.x = 10; // 错误:二义性(B::x 和 C::x 冲突)
    return 0;
    }

    解决方法:使用虚继承(virtual inheritance),让 B 和 C 虚继承自 A,确保 A 在 D 中只存在一份实例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class A { public: int x; };
    class B : virtual public A { }; // 虚继承
    class C : virtual public A { }; // 虚继承
    class D : public B, public C { };

    int main() {
    D d;
    d.x = 10; // 合法:A 只存在一份,无歧义
    return 0;
    }
  1. 成员名冲突
    若多个基类有同名成员(变量或函数),派生类访问时必须通过作用域限定符明确指定来自哪个基类,否则会产生二义性:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    class Base1 {
    public:
    void func() { cout << "Base1::func" << endl; }
    };
    class Base2 {
    public:
    void func() { cout << "Base2::func" << endl; }
    };
    class Derived : public Base1, public Base2 { };

    int main() {
    Derived d;
    // d.func(); // 错误:二义性(Base1::func 和 Base2::func 冲突)
    d.Base1::func(); // 正确:指定访问 Base1 的 func
    d.Base2::func(); // 正确:指定访问 Base2 的 func
    return 0;
    }
  1. 构造函数与析构函数的调用顺序

    • 构造函数:先调用所有基类的构造函数(按继承列表中的声明顺序),再调用派生类的构造函数。
    • 析构函数:与构造函数顺序相反(先派生类,再基类,按继承列表的逆序)。
    1
    2
    3
    4
    5
    6
    7
    class Base1 { public: Base1() { cout << "Base1 构造" << endl; } };
    class Base2 { public: Base2() { cout << "Base2 构造" << endl; } };
    class Derived : public Base1, public Base2 {
    public:
    Derived() { cout << "Derived 构造" << endl; }
    };
    // 输出顺序:Base1 构造 → Base2 构造 → Derived 构造
  1. 代码复杂度与维护性
    多继承会增加类层次的复杂性,过度使用可能导致代码难以理解和维护。在很多场景下,可通过“组合”或“接口继承+实现分离”替代多继承(如 Java 的单继承+接口)。

三、多继承中的类型转换

多继承中的类型转换与单继承类似,但需注意基类之间的独立性。

  1. 向上转型(派生类 → 基类)
    派生类对象可以隐式转换为任意基类的指针或引用(安全转换):
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class Base1 { };
    class Base2 { };
    class Derived : public Base1, public Base2 { };

    int main() {
    Derived d;
    Base1* b1 = &d; // 隐式转换:Derived* → Base1*
    Base2* b2 = &d; // 隐式转换:Derived* → Base2*
    return 0;
    }
  1. 向下转型(基类 → 派生类)
    基类指针/引用转换为派生类指针/引用时,必须显式转换,且需确保安全性:

    • 若基类有虚函数,推荐使用 dynamic_cast(运行时检查,转换失败返回 nullptr 或抛出异常)。
    • 若基类无虚函数,可使用 static_cast(编译时转换,无运行时检查,风险较高)。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    class Base1 { public: virtual void f() {} };  // 有虚函数
    class Base2 { public: virtual void g() {} };
    class Derived : public Base1, public Base2 { };

    int main() {
    Base1* b1 = new Derived(); // 向上转型

    // 向下转型:Base1* → Derived*
    Derived* d = dynamic_cast<Derived*>(b1);
    if (d) { // 转换成功
    cout << "转换成功" << endl;
    }

    delete b1;
    return 0;
    }
  1. 交叉转型(基类 → 另一个基类)
    两个基类(如 Base1 和 Base2)之间无继承关系,不能直接转换。需先转换为派生类,再转换为另一个基类:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    class Base1 { public: virtual void f() {} };
    class Base2 { public: virtual void g() {} };
    class Derived : public Base1, public Base2 { };

    int main() {
    Base1* b1 = new Derived();

    // 先转为 Derived*,再转为 Base2*
    Derived* d = dynamic_cast<Derived*>(b1);
    if (d) {
    Base2* b2 = d; // 隐式转换:Derived* → Base2*
    }

    delete b1;
    return 0;
    }

总结

  • C++ 支持多继承,允许一个类继承多个基类。
  • 需重点注意菱形继承的二义性(通过虚继承解决)、成员名冲突(通过作用域限定符解决)。
  • 类型转换中,向上转型隐式安全,向下转型需用 dynamic_cast(带虚函数时),交叉转型需通过派生类中转。

多继承是一把“双刃剑”,合理使用可提升灵活性,但过度使用会增加复杂度,实践中需谨慎设计类层次。

在 C++ 中,虚继承(virtual inheritance) 是一种特殊的继承方式,用于解决多继承中可能出现的菱形继承(钻石问题) 导致的成员冗余和二义性问题。

什么是菱形继承问题?

当类的继承关系形成“菱形”结构时,会出现基类成员被多次继承的问题:

  • 类 A 是顶层基类,包含成员 x
  • 类 B 和类 C 分别继承自 A,因此都包含 A 的成员 x
  • 类 D 同时继承 B 和 C,此时 D 中会间接包含两份 A 的成员 x(一份来自 B,一份来自 C)。

这种情况下,访问 D 中的 x 会产生二义性(编译器无法确定访问的是 B 继承的 x 还是 C 继承的 x)。

虚继承的作用

虚继承通过让中间基类(如 B 和 C)虚继承顶层基类(如 A),确保顶层基类在最终派生类(如 D)中只存在一份实例,从而消除冗余和二义性。

虚继承的语法

在继承时使用 virtual 关键字声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 顶层基类
class A {
public:
int x;
A() : x(0) {}
};

// 中间基类 B 虚继承 A
class B : virtual public A { };

// 中间基类 C 虚继承 A
class C : virtual public A { };

// 最终派生类 D 继承 B 和 C
class D : public B, public C { };

此时,D 中只会包含一份 A 的成员 x,访问 d.x 时不再有二义性:

1
2
3
4
5
int main() {
D d;
d.x = 10; // 合法:A 只存在一份,无歧义
return 0;
}

虚继承的实现原理

编译器通过虚基类表(virtual base table)虚基类指针(virtual base pointer) 实现虚继承:

  • 虚继承的中间类(B、C)会生成一个虚基类表,存储自身与顶层虚基类(A)的偏移量。
  • 中间类的对象中会增加一个虚基类指针(指向虚基类表),最终派生类(D)通过该指针找到唯一的顶层基类实例。

这种机制确保无论中间类被继承多少次,顶层虚基类在最终派生类中只存在一份。

注意事项

  1. 构造函数的调用顺序
    虚基类的构造函数由最终派生类直接调用,而非中间基类。即使中间基类在构造函数中显式初始化虚基类,最终派生类也会覆盖这种初始化:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    class A {
    public:
    A(int val) : x(val) {}
    int x;
    };

    class B : virtual public A {
    public:
    B() : A(10) {} // 中间类对虚基类的初始化会被覆盖
    };

    class D : public B {
    public:
    D() : A(20) {} // 最终派生类直接初始化虚基类
    };

    int main() {
    D d;
    cout << d.x; // 输出 20(而非 10)
    return 0;
    }
  2. 只在必要时使用
    虚继承会增加内存开销(额外的虚基类指针)和运行时开销(通过指针访问成员),仅在需要解决菱形继承问题时使用。

  3. 避免复杂的虚继承层次
    多层虚继承可能导致代码逻辑复杂,降低可读性和维护性,设计类层次时应尽量简化。

总结

虚继承是 C++ 为解决多继承中菱形继承问题而设计的机制,通过让中间基类虚继承顶层基类,确保顶层基类在最终派生类中只存在一份实例,从而消除成员冗余和访问二义性。其核心价值是在支持多继承的同时,弥补了菱形继承带来的缺陷。

C++中const的用法

是的,const 在 C++ 中是一个非常丰富和多用途的关键字,除了你提到的三种用法,还有其他几种重要用法。以下是 const 关键字的全面总结:


1. Const 变量(命名常量)

这是最基础的用法,用于声明一个其值不可被修改的变量。

1
2
3
4
5
const int MAX_BUFFER_SIZE = 1024; // 值不可修改
const float PI = 3.14159f;

// 尝试修改会导致编译错误
// MAX_BUFFER_SIZE = 2048; // ❌ 错误!

#define 的区别const 变量有类型信息和作用域,更安全,便于调试。


2. Const 引用

声明一个对常量的引用,不能通过这个引用来修改所引用的对象。

1
2
3
4
5
6
7
int originalValue = 42;

const int& constRef = originalValue; // 常量引用
// constRef = 100; // ❌ 错误!不能通过const引用修改值

int& nonConstRef = originalValue; // 非常量引用
nonConstRef = 100; // ✅ 可以修改

主要用途

  • 作为函数参数,避免不必要的拷贝同时保证不修改原对象(比传值更高效)。
  • ```cpp
    void ProcessData(const std::string& data) {
    // 可以读取data,但不能修改它 
    size_t len = data.length(); // ✅
    // data.clear(); // ❌
    
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28

    ---

    ### 3. Const 指针 vs 指向 Const 的指针

    这是一个容易混淆但很重要的区别:

    ```cpp
    int value = 10;
    int anotherValue = 20;

    // 1. 指向常量的指针(Pointer to const)
    // - 指针本身可以改变指向,但不能通过它修改所指的值
    const int* ptrToConst = &value;
    // *ptrToConst = 15; // ❌ 错误:不能通过ptrToConst修改value
    ptrToConst = &anotherValue; // ✅ 正确:可以改变指针指向

    // 2. 常量指针(Const pointer)
    // - 指针本身不能改变指向,但可以通过它修改所指的值
    int* const constPtr = &value;
    *constPtr = 15; // ✅ 正确:可以通过constPtr修改value
    // constPtr = &anotherValue; // ❌ 错误:不能改变指针指向

    // 3. 指向常量的常量指针(Const pointer to const)
    // - 既不能改变指向,也不能通过它修改值
    const int* const constPtrToConst = &value;
    // *constPtrToConst = 15; // ❌
    // constPtrToConst = &anotherValue; // ❌

记忆口诀const* 左边表示指向常量,在 * 右边表示指针本身是常量。


4. Const 成员变量

在类中声明不可修改的成员变量。必须在构造函数的初始化列表中初始化,不能在函数体内赋值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Circle {
private:
const double m_PI; // const成员变量
double m_Radius;

public:
Circle(double radius)
: m_PI(3.14159) // 必须在初始化列表中初始化
, m_Radius(radius)
{
// m_PI = 3.14; // ❌ 错误!不能在构造函数体内赋值
}

double GetArea() const {
return m_PI * m_Radius * m_Radius;
}
};

5. Constexpr (C++11 引入)

constexprconst 更严格,表示编译期常量,值必须在编译时就能确定。

1
2
3
4
5
6
7
8
9
10
11
12
13
constexpr int ArraySize = 100; // 编译期常量
int myArray[ArraySize]; // ✅ 可以用作数组大小

const int size = SomeFunction(); // 运行时才能确定
// int anotherArray[size]; // ❌ 错误:size不是编译期常量

// constexpr函数:如果参数是编译期常量,则结果也是编译期常量
constexpr int Square(int x) {
return x * x;
}

constexpr int squaredValue = Square(5); // 编译时计算
int array[Square(5)]; // ✅ 正确

6. Mutable 成员变量

这是一个与 const 相关的特殊关键字。被声明为 mutable 的成员变量,即使在 const 成员函数中也可以被修改

1
2
3
4
5
6
7
8
9
10
11
12
13
class DataLogger {
private:
mutable int m_AccessCount; // 可变成员变量
std::string m_Data;

public:
const std::string& GetData() const {
m_AccessCount++; // ✅ 即使这是const函数,也可以修改mutable成员
return m_Data;
}

int GetAccessCount() const { return m_AccessCount; }
};

典型用途:用于缓存、引用计数、调试统计等需要在逻辑const操作中更新的辅助数据。


7. Const 与函数返回值

让函数返回常量值或常量引用,可以防止返回值被意外修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class BigData {
std::vector<int> m_HugeData;
public:
// 返回const引用,防止调用者修改内部数据
const std::vector<int>& GetData() const {
return m_HugeData;
}

// 不好的设计:返回非const引用,暴露了内部实现
std::vector<int>& GetData() {
return m_HugeData;
}
};

BigData data;
// 好的:只能读取,不能修改
const std::vector<int>& safeRef = data.GetData();
// safeRef.clear(); // ❌ 编译错误

// 不好的:可以修改内部数据(破坏了封装性)
std::vector<int>& unsafeRef = data.GetData();
unsafeRef.clear(); // ✅ 编译通过,但可能破坏对象状态

总结对比表

用法 语法示例 含义
Const变量 const int size = 10; 值不可变的命名常量
Const引用 const std::string& str; 不能通过引用修改目标对象
指向Const的指针 const int* ptr; 可通过指针改指向,不能改值
Const指针 int* const ptr; 不可改指向,可通过指针改值
Const成员函数 void func() const; 不修改对象状态的成员函数
Const成员变量 const int m_Value; 必须在构造函数初始化列表中初始化的常量成员
Constexpr constexpr int size = 10; 编译期常量,值必须在编译时确定
Mutable mutable int m_Counter; 即使在const函数中也可修改的成员变量
Const返回值 const Type& GetValue() const; 返回不可修改的值或引用,保护内部数据

C++ 中 const 的正确使用是编写健壮、安全和易于维护代码的关键技能,它可以在编译期捕获许多潜在的错误。

关于define的用法

在C++中,#define 是预处理指令,用于创建宏定义,主要作用是在编译前对代码进行文本替换。它的使用方式灵活,常见用法如下:

1. 定义常量

最基本的用法是定义常量,提高代码可读性和维护性:

1
2
3
4
5
6
#define PI 3.1415926  // 定义圆周率常量
#define MAX_SIZE 100 // 定义数组最大长度

// 使用示例
double area = PI * r * r;
int arr[MAX_SIZE];

2. 定义带参数的宏

可以像函数一样定义带参数的宏,实现简单的代码替换:

1
2
3
4
5
6
7
8
9
// 计算两数之和
#define ADD(a, b) (a + b)

// 计算平方
#define SQUARE(x) (x) * (x)

// 使用示例
int sum = ADD(3, 5); // 替换为 (3 + 5)
int square = SQUARE(4); // 替换为 (4) * (4)

注意:带参数的宏需要注意括号的使用,避免运算优先级问题:

1
2
3
4
5
6
7
// 错误示例(缺少括号)
#define MUL(a, b) a * b
int result = MUL(2 + 3, 4); // 会被替换为 2 + 3 * 4,结果为14(非预期的20)

// 正确示例
#define MUL(a, b) ((a) * (b))
int result = MUL(2 + 3, 4); // 替换为 ((2 + 3) * 4),结果为20

3. 定义代码片段

可以定义一段代码块,用于简化重复操作:

1
2
3
4
5
6
7
8
9
10
// 交换两个变量的值
#define SWAP(a, b) do { \
int temp = a; \
a = b; \
b = temp; \
} while(0)

// 使用示例
int x = 5, y = 10;
SWAP(x, y); // 替换为上述代码块

这里的 do-while(0) 用于确保宏在任何场景下都能正确执行(如在 if 语句后无需加括号)。

4. 条件编译

结合其他预处理指令(如 #ifdef#ifndef#else#endif)实现条件编译:

1
2
3
4
5
6
7
8
9
10
11
12
#define DEBUG  // 定义DEBUG宏

#ifdef DEBUG
// 调试模式下执行的代码
#define LOG(message) cout << "Debug: " << message << endl
#else
// 发布模式下不执行任何操作
#define LOG(message)
#endif

// 使用示例
LOG("程序开始执行"); // 调试模式下会输出,发布模式下无操作

5. 取消宏定义

使用 #undef 可以取消已定义的宏:

1
2
3
4
5
#define MAX 100
cout << MAX << endl; // 输出100

#undef MAX // 取消MAX的定义
// cout << MAX << endl; // 错误:MAX未定义

注意事项

  1. 宏与函数的区别:宏是编译前的文本替换,没有类型检查,而函数是运行时的代码调用,有类型检查。
  2. 避免副作用:带参数的宏可能产生副作用,例如 ADD(a++, b++) 会导致变量被多次递增。
  3. 命名规范:通常宏名使用全大写字母,以区分普通变量和函数。
  4. C++中的替代方案:在很多场景下,const 定义的常量或 inline 函数可以替代宏,提供更好的类型安全性。

合理使用 #define 可以简化代码,但过度使用可能导致代码可读性和可维护性下降,需谨慎使用。

C++中可以把字段和方法定义在class外面

在 C++ 中,允许在 class 外部定义字段(变量)和方法(函数),这些被称为全局变量全局函数。这种特性源于 C++ 的设计历史和语言哲学,而 C# 不支持类似机制则是由于其纯面向对象的设计理念。

一、C++ 为什么允许类外定义字段和方法?

C++ 是从 C 语言发展而来的,而 C 是一种面向过程(procedural)语言,其核心语法支持全局变量和全局函数(即不依赖于任何结构体/类的变量和函数)。C++ 为了保持对 C 的兼容性,保留了这一特性,同时增加了面向对象(OOP)的特性(如类、继承等)。

因此,C++ 本质上是面向过程和面向对象的混合语言,既支持类内的成员变量/方法(OOP 特性),也支持类外的全局变量/函数(面向过程特性)。这种设计允许开发者根据场景灵活选择编程范式:

  • 对于简单逻辑(如工具函数、全局配置),可以用全局函数/变量快速实现;
  • 对于复杂模块,用类封装数据和行为,保证封装性和复用性。

二、C++ 如何实现类外的字段和方法?

C++ 通过全局作用域编译-链接模型支持类外的字段和方法:

  1. 全局作用域
    C++ 存在一个全局命名空间(global namespace),所有未被包裹在 namespaceclass 中的变量和函数,默认属于这个全局作用域。例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // 全局变量(类外字段)
    int global_count = 0;

    // 全局函数(类外方法)
    void print_count() {
    cout << global_count << endl;
    }

    class MyClass {
    // 类内成员(属于类作用域)
    };
  2. 编译与链接机制
    全局变量和函数在编译时会被编译器标记为全局符号(global symbol),存储在目标文件(.o/.obj)的符号表中。链接阶段, linker 会将多个目标文件中的全局符号合并,确保整个程序中全局变量/函数的地址唯一(除非用 static 限制为文件内可见)。

    例如,全局变量会被分配在数据段(.data 或 .bss),全局函数会被分配在代码段(.text),其地址在程序加载时确定,可被整个程序访问(只要声明正确)。

  3. 命名空间(namespace)的补充
    为了避免全局作用域的命名冲突(多个全局实体重名),C++ 引入了 namespace 机制,将全局实体分组管理,但本质上仍属于类外的全局作用域:

    1
    2
    3
    4
    namespace GlobalUtils {
    int count = 0; // 仍为类外字段,属于 GlobalUtils 命名空间
    void print() { ... } // 类外方法
    }

三、为什么 C# 不支持类外的字段和方法?

C# 是一种纯面向对象(pure OOP)语言,其设计哲学要求“一切皆为对象”,所有变量和方法必须属于某个类型(classstructinterface 等),不允许存在独立于类型的全局实体。这一设计主要出于以下原因:

  1. 语言设计理念的差异
    C# 诞生于 OOP 已经成熟的时代,强调封装性和类型化(typed)。将所有成员绑定到类型中,可以:

    • 避免全局作用域的命名污染和冲突;
    • 强制开发者通过类型组织代码,提升可读性和可维护性;
    • 更好地支持模块化和面向对象的核心特性(如继承、多态)。
  2. 运行时(CLR)的限制
    C# 代码编译为中间语言(IL),运行在 .NET 公共语言运行时(CLR)上。CLR 的类型系统要求所有变量和方法必须关联到具体类型,IL 指令中不存在“全局符号”的概念,自然无法支持类外的字段和方法。

  3. 替代方案的存在
    C# 虽然没有全局函数/变量,但可以通过静态类(static class) 实现类似功能。静态类中的静态成员(static 字段/方法)可以在类外通过类名访问,兼具全局访问性和类型封装性:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public static class GlobalUtils {
    public static int Count = 0; // 类似全局变量
    public static void PrintCount() { // 类似全局函数
    Console.WriteLine(Count);
    }
    }

    // 使用时通过类名访问,仍属于类的成员
    GlobalUtils.Count = 10;
    GlobalUtils.PrintCount();

总结

  • C++ 允许类外定义字段和方法:源于对 C 语言的兼容,支持面向过程与面向对象混合编程,通过全局作用域和编译-链接机制实现。
  • C# 不支持:因纯面向对象设计理念,要求所有成员属于类型,且运行时(CLR)不支持全局符号,可通过静态类替代全局实体的功能。

两种设计各有侧重:C++ 更灵活,兼顾历史兼容性;C# 更强调类型化和封装性,简化代码组织。

C++在 #define中使用 ## 和

在 C++ 的宏定义中,# 是一个字符串化操作符(stringizing operator),它的作用是将宏参数转换为字符串常量

在你给出的宏定义中:

1
2
#define SHADERTEXTUREPROP(a) \
FastTexturePropertyName kSLProp ## a("_" #a, "_" #a "_ST", "_" #a "_TexelSize", "_" #a "_HDR")

#a 的含义是将宏参数 a 转换为以该参数名为内容的字符串。例如,当使用 SHADERTEXTUREPROP(MainTex) 调用这个宏时:

  • #a 会被替换为 "MainTex"(字符串)
  • 结合前后的 "_",会生成 "_MainTex""_MainTex_ST" 等字符串

展开后的完整代码会是:

1
FastTexturePropertyName kSLPropMainTex("_MainTex", "_MainTex_ST", "_MainTex_TexelSize", "_MainTex_HDR")

补充说明:

宏中还用到了 ## 操作符(连接操作符),它的作用是将两个标识符拼接成一个新的标识符
在上面的例子中,kSLProp ## a 会将 kSLProp 与参数 aMainTex)拼接,生成新的变量名 kSLPropMainTex

总结:

  • #:将宏参数转换为字符串(字符串化)
  • ##:将两个标识符拼接成一个新标识符(连接)

这两个操作符是 C++ 宏定义中常用的“元编程”工具,用于动态生成代码元素(如变量名、字符串),减少重复代码。

关于C++头文件中出现的前置声明

在 C++ 头文件中,类声明之前出现的大量 struct 声明(如 CoreCameraValuesSharedRendererScene 等),主要作用是提前声明类中会用到的类型,为后续的类定义提供类型支持,具体原因如下:

1. 解决“类型依赖”问题

C++ 要求“使用一个类型前必须先声明它”。在 Camera 类的定义中,很可能会用到这些 struct 作为成员变量类型、函数参数类型或返回值类型。例如:

1
2
3
4
5
6
class Camera : public Behaviour {
private:
CoreCameraValues* m_CoreValues; // 使用CoreCameraValues作为成员类型
void SetCullingParams(const CullingParameters& params); // 使用CullingParameters作为参数
// ...
};

如果不在 Camera 类之前声明这些 struct,编译器会因为“未识别的类型”而报错。因此,提前声明这些结构体是为了让编译器知道“这些类型存在”,确保 Camera 类的定义能够正常编译。

2. 避免“循环依赖”

在大型项目(如游戏引擎中的相机模块)中,类与类之间往往存在复杂的依赖关系。例如:

  • Camera 类可能需要引用 CullingParameters 结构体;
  • CullingParameters 结构体可能又需要引用 Camera 类的某些成员或枚举。

这种情况下,如果将 struct 的完整定义写在 Camera 类之后,会导致“循环依赖”(编译器处理 Camera 时不认识 CullingParameters,处理 CullingParameters 时又不认识 Camera)。

通过在 Camera 类之前前置声明这些 struct(只声明“存在这个类型”,不定义具体成员),可以暂时打破循环依赖,让编译器先接受 Camera 类的定义,后续再通过其他头文件提供这些 struct 的完整定义。

3. 减少“头文件冗余”

这些 struct 的完整定义可能非常复杂,且可能被多个类(不仅是 Camera)使用。如果在 Camera 类的头文件中直接包含它们的完整定义,会导致:

  • 头文件体积膨胀,编译速度变慢;
  • 一旦这些 struct 的定义修改,所有包含该头文件的代码都需要重新编译。

因此,通常的做法是:

  1. Camera.h只前置声明这些 struct(告诉编译器“有这个类型”);
  2. 在专门的头文件(如 CullingParameters.hCoreCameraValues.h)中提供它们的完整定义;
  3. Camera 类的实现文件(Camera.cpp)中再 #include 这些头文件,获取完整定义。

这样既保证了 Camera.h 的简洁性,又避免了不必要的编译依赖。

总结

Camera 类声明前的 struct 声明是 “前置声明(forward declaration)” 技术的应用,主要目的是:

  • 让编译器识别 Camera 类中使用的各种类型,避免“未声明类型”错误;
  • 解决复杂项目中的类型循环依赖问题;
  • 减少头文件冗余,提高编译效率。

这是 C++ 大型项目中管理类型依赖的常见手段,尤其在模块化程度高的代码(如游戏引擎)中广泛使用。

在 C++ 中,编译器找到这些类型的完整定义,依赖于“前置声明 + 头文件包含”的配合机制,以及编译和链接的分工。具体过程可以分为两个阶段:编译阶段(找到类型的完整定义)和链接阶段(找到类型相关的实体地址)。

一、编译阶段:如何找到类型的完整定义?

当编译器在 Camera 类中遇到前置声明的类型(如 struct CoreCameraValues)时,它只知道“这个类型存在”,但不知道其内部成员(大小、方法等)。要完成编译,必须在使用该类型的具体代码处找到其完整定义。

这个过程通过以下方式实现:

  1. 前置声明让编译器“暂时接受”类型
    前置声明(如 struct CoreCameraValues;)告诉编译器:“存在一个名为 CoreCameraValues 的结构体,后续会提供完整定义”。此时,编译器允许在 Camera 类中用该类型声明指针、引用或函数参数/返回值(这些场景不需要知道类型的具体大小和成员):

    1
    2
    3
    4
    5
    6
    7
    8
    // 前置声明
    struct CoreCameraValues;

    class Camera {
    private:
    CoreCameraValues* m_values; // 允许:指针不需要知道类型大小
    void SetValues(const CoreCameraValues& values); // 允许:引用作为参数
    };
  2. 完整定义通过头文件引入
    当代码需要访问该类型的成员(如 m_values->field)或创建其实例(如 CoreCameraValues values;)时,编译器必须知道类型的完整定义。这些完整定义通常放在专门的头文件中(如 CoreCameraValues.h),并在使用该类型的 .cpp 文件中通过 #include 引入:

    1
    2
    3
    4
    5
    6
    7
    // Camera.cpp
    #include "Camera.h"
    #include "CoreCameraValues.h" // 包含CoreCameraValues的完整定义

    void Camera::SomeMethod() {
    m_values->width = 1024; // 此时编译器已知晓CoreCameraValues的成员,合法
    }

    对于跨文件的依赖,只要在编译每个 .cpp(编译单元)时,确保所有被使用的类型在首次需要完整定义的地方之前已经通过头文件引入,编译器就能找到它们。

  1. 头文件的“包含链”传递定义
    有时,类型的完整定义可能通过“间接包含”传递。例如:

    • CoreCameraValues.h 可能包含 MathTypes.h(定义了 Vector2f);
    • Camera.cpp 包含 CoreCameraValues.h 后,也就间接获得了 Vector2f 的定义。

    编译器会沿着 #include 形成的“包含链”查找所有需要的类型定义。

二、链接阶段:如何找到类型相关的实体?

如果这些 struct 包含非内联函数(即函数体定义在 .cpp 文件中),链接器需要将函数调用与实际的函数实现关联起来。这个过程依赖于:

  1. 符号表记录类型相关的实体
    每个 .cpp 编译生成的目标文件(.obj/.o)会包含一个符号表,记录该文件中定义的函数、变量等(如 CoreCameraValues::Calculate())。

  2. 链接器合并符号表
    链接器会扫描所有目标文件的符号表,将“声明”与“定义”匹配。例如,Camera.cpp 中调用 CoreCameraValues::Calculate() 时,链接器会在 CoreCameraValues.cpp 生成的目标文件中找到该函数的实现地址,完成关联。

三、如果找不到类型会发生什么?

  1. 编译错误:如果代码中使用了类型的成员(如 values.width),但未包含其完整定义的头文件,编译器会报“不完全类型”错误(incomplete type)。

    例:

    1
    2
    3
    4
    5
    6
    struct CoreCameraValues;  // 仅前置声明,无完整定义

    void func() {
    CoreCameraValues values; // 错误:需要知道类型大小,但无完整定义
    values.width = 100; // 错误:不知道width成员
    }
  2. 链接错误:如果类型的函数只有声明而无定义(如忘记实现 CoreCameraValues::Calculate()),编译器能通过,但链接器会报“未定义引用”错误(undefined reference)。

总结

C++ 找到这些类型的完整定义,本质是“编译阶段通过头文件包含传递完整定义,链接阶段通过符号表匹配实体实现”的过程:

  1. 前置声明让编译器暂时接受类型的存在;
  2. 头文件包含提供类型的完整定义,确保编译通过;
  3. 链接器通过符号表关联类型的声明与实现。

这一机制依赖于开发者正确组织头文件的包含关系,是 C++ 模块化管理的核心基础。

是的,如果代码中使用了某个类型(需要其完整定义),但所有 #include 的头文件里都没有该类型的完整定义,编译器一定会报错。具体报错场景和原因,需要结合“类型的使用方式”和“头文件是否提供定义”进一步拆解:

一、先明确两个关键前提

在判断是否报错前,要先区分两种对类型的使用场景,它们对“是否需要完整定义”的要求不同:

  1. 仅“声明存在”即可的场景:用类型声明指针、引用、函数参数/返回值(不需要知道类型的大小、成员)。
    此时只需前置声明(如 struct CoreCameraValues;),即使头文件没提供完整定义,编译器也不会报错。
  2. 必须“完整定义”的场景:需要访问类型成员(如 obj->field)、创建类型实例(如 CoreCameraValues obj;)、计算类型大小(如 sizeof(CoreCameraValues))。
    此时必须通过 #include 头文件获取该类型的完整定义,否则必然报错。

二、具体报错场景与原因

如果 #include 的头文件里没有前置声明过的类型的完整定义,会触发以下两类错误,核心原因都是“编译器无法获取类型的详细信息”:

1. 编译错误:“不完全类型”(incomplete type)

这是最常见的错误,发生在“必须完整定义”但未提供定义的场景。
编译器知道“有这个类型”(因为有前置声明),但不知道它的内部结构(成员、大小),无法完成编译。

示例代码(错误场景):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Camera.h
#pragma once
#include "SomeHeader.h" // 假设这个头文件里没有 CoreCameraValues 的完整定义

// 前置声明:告诉编译器“CoreCameraValues 存在”
struct CoreCameraValues;

class Camera {
public:
void Init() {
// 错误:创建实例需要知道 CoreCameraValues 的大小, but 头文件没给完整定义
CoreCameraValues values;
// 错误:访问成员需要知道类型的内部结构, but 头文件没给完整定义
values.width = 1024;
}
private:
CoreCameraValues* m_ptr; // 没问题:指针不需要完整定义
};

编译器报错信息(不同编译器措辞略有差异):

  • GCC/Clang:error: variable has incomplete type 'CoreCameraValues'(变量类型不完整)
  • MSVC:error C2079: 'values' uses undefined struct 'CoreCameraValues'(使用了未定义的结构体)

2. 编译错误:“未定义类型”(undefined type)

如果连“前置声明”都没有,且头文件也没提供定义,编译器会直接认为“这个类型不存在”,报错比“不完全类型”更直接。

示例代码(错误场景):

1
2
3
4
5
6
7
8
9
10
11
// Camera.h
#pragma once
#include "SomeHeader.h" // 既没有 CoreCameraValues 的前置声明,也没有完整定义

class Camera {
public:
void Init() {
// 错误:编译器根本不知道 CoreCameraValues 是什么
CoreCameraValues* ptr = nullptr;
}
};

编译器报错信息

  • GCC/Clang:error: unknown type name 'CoreCameraValues'(未知类型名)
  • MSVC:error C2065: 'CoreCameraValues': undeclared identifier(未声明的标识符)

三、为什么会出现“头文件没有定义”的情况?

本质是头文件包含关系遗漏,常见原因有两种:

  1. 直接遗漏:忘记 #include 该类型对应的“定义头文件”。
    比如 CoreCameraValues 的完整定义在 CoreCameraValues.h 里,但 Camera.hCamera.cpp 没写 #include "CoreCameraValues.h"
  2. 间接包含断裂:原本依赖“间接包含”(A 头文件包含 B 头文件,B 里有定义),但后续代码修改导致间接包含失效。
    比如原本 SomeHeader.h 包含 CoreCameraValues.h,但后来有人修改 SomeHeader.h 时删掉了这个 #include,导致依赖它的 Camera.h 失去了定义。

四、如何解决?

核心思路是“让编译器在需要完整定义的地方,能通过 #include 找到定义”:

  1. 直接包含对应头文件:如果知道类型的定义在哪个头文件里,直接在“使用该类型”的 .h.cpp#include
    比如 CoreCameraValues 定义在 CoreCameraValues.h,就在 Camera.cpp 里加 #include "CoreCameraValues.h"(如果 Camera.h 里需要完整定义,也可以在 Camera.h 里加)。
  2. 检查间接包含是否有效:如果依赖间接包含,可打开中间头文件(如 SomeHeader.h)确认是否还包含目标类型的定义,若没有则补充。
  3. 避免“过度依赖间接包含”:最佳实践是“谁使用,谁包含”——即使间接包含能拿到定义,也建议直接包含目标头文件,避免后续代码修改导致间接包含断裂。

总结

  • 若仅用类型声明指针/引用:只需前置声明,头文件没定义也不报错;
  • 若需访问成员/创建实例:必须通过 #include 头文件获取完整定义,否则必报“不完全类型”或“未定义类型”错误;
  • 报错的本质是“编译器无法获取类型的详细信息(大小、成员)”,解决方式是补全对应的头文件包含。
    是的,如果 SomeHeader.h 中通过 #include 包含了 CoreCameraValues 的完整定义(或者 SomeHeader.h 自身就定义了 CoreCameraValues),那么即使 Camera.h 中没有直接包含 CoreCameraValues 的定义,也不会报错。这是因为 #include 指令会将被包含文件的内容“复制粘贴”到当前文件中,形成一个完整的预处理结果(编译单元),编译器能在这个合并后的内容中找到所需的类型定义。

具体示例说明

假设项目文件关系如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// CoreCameraValues.h
struct CoreCameraValues {
int width; // 完整定义:包含成员
int height;
};

// SomeHeader.h
#include "CoreCameraValues.h" // 包含CoreCameraValues的完整定义

// Camera.h
#pragma once
#include "SomeHeader.h" // 包含SomeHeader.h,间接获得CoreCameraValues的定义

class Camera {
public:
void Init() {
CoreCameraValues values; // 合法:编译器通过SomeHeader.h间接找到完整定义
values.width = 1024; // 合法:已知width成员
}
};

在这个例子中:

  1. Camera.h 包含 SomeHeader.h
  2. SomeHeader.h 包含 CoreCameraValues.h,而 CoreCameraValues.h 提供了 CoreCameraValues 的完整定义;
  3. 预处理后,Camera.h 的内容会间接包含 CoreCameraValues 的定义,因此编译器能识别该类型的成员和大小,不会报错。

注意事项

虽然“间接包含”能解决类型定义的问题,但实际开发中不推荐过度依赖这种方式,原因如下:

  1. 可读性差:其他开发者阅读 Camera.h 时,无法直接知道 CoreCameraValues 的定义来自哪里,需要逐层追溯 SomeHeader.h,增加理解成本。

  2. 脆弱性高:如果未来有人修改 SomeHeader.h 并移除 #include "CoreCameraValues.h"Camera.h 会突然出现“类型未定义”错误,且排查原因较困难。

  3. 编译效率低SomeHeader.h 可能包含很多无关的定义(如其他结构体、函数),间接包含会导致 Camera.h 的预处理结果变大,增加编译时间。

最佳实践

“谁使用,谁直接包含”:如果 Camera 类需要 CoreCameraValues 的完整定义,最好在 Camera.hCamera.cpp直接包含其定义所在的头文件(如 #include "CoreCameraValues.h"),而非依赖间接包含。这样既清晰又可靠,避免隐藏依赖带来的问题。

总结:间接包含确实能让编译器找到类型定义,避免报错,但出于代码可读性和维护性,建议优先使用直接包含。

.cpp 文件中同样会有前置声明的场景,但使用目的和头文件(.h)中有明显区别——核心仍是遵循“编译器仅需最小信息”的原则,不过 .cpp 中的前置声明更多是为了简化局部代码依赖,而非优化头文件的“依赖传递”(头文件的前置声明更侧重减少其他文件的编译连锁反应)。

一、.cpp 文件中使用前置声明的典型场景

1. 局部使用某类型(无需完整定义),且不想包含头文件

.cpp 中仅用某类型的指针、引用、函数声明(无需创建实例、访问成员)时,可通过前置声明避免 #include 头文件——尤其适合“临时用一次”或“避免引入复杂头文件”的场景,能轻微提升编译效率(减少头文件展开的开销)。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Camera.cpp
#include "Camera.h" // 仅包含当前类的头文件

// 前置声明:仅在当前.cpp中用一次CoreCameraValues的指针,无需#include其头文件
struct CoreCameraValues;

// 局部函数:参数是CoreCameraValues的指针(仅需声明)
void PrintCameraInfo(CoreCameraValues* values) {
if (values != nullptr) {
// 注意:这里不能访问values->width(需要完整定义),只能做指针判空等操作
printf("Camera values exist\n");
}
}

// 若后续需要访问成员,再在需要的地方#include头文件
#include "CoreCameraValues.h"
void InitCamera() {
CoreCameraValues values;
values.width = 1024; // 访问成员:必须包含头文件
PrintCameraInfo(&values);
}

2. 解决“局部循环依赖”

.cpp 中某局部代码需要同时用到两个互相包含头文件的类型(循环依赖),且仅需其中一个类型的“存在性”(指针/引用),前置声明可打破循环。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// A.cpp
#include "A.h" // A的头文件中可能包含了B的前向声明,但B的头文件又包含A的头文件

// 前置声明B:避免直接#include "B.h"(否则触发A和B的头文件循环包含)
class B;

void A::DoSomething(B* b) {
if (b != nullptr) {
b->CallMethod(); // 若CallMethod()的声明在B的前置声明后可见,此处可调用(需B的头文件在后续包含)
}
}

// 后续包含B的头文件,获取完整定义(若需要)
#include "B.h"

3. 声明局部函数(较少见,但合法)

虽然不推荐,但 .cpp 中也可通过前置声明声明后续定义的局部函数(无需在头文件中暴露),避免“函数未声明就调用”的错误。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Tool.cpp
#include <iostream>

// 前置声明局部函数:告诉编译器“后面会定义这个函数”
void PrintHelper(int value);

void ProcessData(int data) {
PrintHelper(data); // 调用前必须声明(前置声明或提前定义)
}

// 定义局部函数
void PrintHelper(int value) {
std::cout << "Value: " << value << std::endl;
}

二、.cpp 与 .h 中前置声明的核心区别

对比维度 头文件(.h)中的前置声明 .cpp 文件中的前置声明
核心目的 减少头文件依赖,避免“依赖传递”(降低其他文件的编译开销) 简化局部代码依赖,避免引入不必要的头文件(提升当前.cpp的编译效率)
影响范围 所有包含该头文件的文件(全局影响) 仅当前 .cpp 文件(局部影响)
使用限制 必须严格控制(避免其他文件因“缺少定义”报错) 更灵活(仅影响自身,无需考虑其他文件)

三、注意:.cpp 中前置声明的“局限性”

和头文件一样,若 .cpp 中需要创建类型实例、访问成员变量/函数、计算类型大小(sizeof,仅靠前置声明无法满足——必须通过 #include 头文件获取完整定义,否则编译器会报错(“不完整类型”错误)。

示例(错误情况):

1
2
3
4
5
6
7
// Camera.cpp
struct CoreCameraValues; // 仅前置声明

void InitCamera() {
CoreCameraValues values; // 错误:需要完整定义才能创建实例
values.width = 1024; // 错误:需要完整定义才能访问成员
}

总结

.cpp 文件中会用到前置声明,但其本质是“头文件前置声明逻辑的延伸”——核心仍是根据代码对类型的“使用程度”(仅需存在性 vs 需完整定义),提供编译器所需的最小信息。区别在于:头文件的前置声明更关注“全局依赖优化”,而 .cpp 的前置声明更关注“局部代码简洁性”。

C++中的static_cast

static_cast<T> 是 C++ 中显式类型转换的一种安全写法,用于将表达式转换为指定类型T
它是 C++ 所引入的多种类型转换操作符之一(还有 dynamic_castconst_castreinterpret_cast),比 C 语言的标准类型强制转换(如 (int)x)更安全、语法更清楚。


1. 基本用法

1
T value = static_cast<T>(expression);

例如:

1
2
3
4
5
double d = 3.14;
int a = static_cast<int>(d); // a 变成 3

Base* pb = new Derived();
Derived* pd = static_cast<Derived*>(pb); // ⬅️ 向下转换指针


2. 适用场景

  • 内置类型转换
    如:doubleintfloatint 等。
  • 类层次结构下的指针/引用转换
    如:父类指针转子类指针(向下转型),子类指针转父类指针(向上转型)(但不安全时编译也不会报错,需确保实际对象确实是该类型)。
  • void* 与 具体指针类型的转换
    如:void*SomeType*
  • 相关类型的 user-defined conversion
    即,如果有operator T(),也会被用到。
  • 枚举/整型互转
    如:enumint

3. 它的优势

  • 显式、清晰
    一看 static_cast<T> 就知道这里发生了类型转换,易于查错和维护。
  • 比 C 风格强转更安全
    C风格转型如 (T)x,容易不小心用错,且不易区分到底是 const_cast、static_cast 还是 reinterpret_cast。static_cast 只能用于“静态已知、语法明确”的类型转换,很多非法转换编译器直接报错。

4. 注意事项

  • 不会做运行时安全检查,如指针类型向下转换时,如果类型不是实际的派生类,对象用错类型会引发未定义行为。所以转换前要确保类型关系正确。如果你要做运行时检查(比如多态类型非安全转换),应使用 dynamic_cast
  • 严格限制转换范围,只允许已知可行的类型之间转换。
  • 不可用于去除对象的 const、volatile 等属性(那要用 const_cast)。

5. 小结一句话

static_cast<T> 是 C++ 中最常用的类型安全转换操作符,适用于绝大多数已知可行的类型转换,比C风格强制类型转换安全、易读、易查错。


如果你有具体类型转换的实际场景或者出现编译报错等特殊用法,可以贴出来详细问!

关于左值和右值的定义

这个问题很棒,也是现代C++语法的重点之一!
下面详细讲讲 *& 在变量定义、函数参数等场景下的含义,尤其关注“左值”“右值”相关的写法和区别。


1. *& 的基本作用

  • * : 声明一个指针类型,表示该变量是一个指针,可以存地址。
  • & : 声明一个引用类型,表示该变量是对另一个变量的一个“别名”,可以直接操作原对象。

2. 左值、右值的基本概念

  • 左值(lvalue):有名字,可以被取地址的内存对象。简单理解为“你能在等号左边写的东西”。
  • 右值(rvalue):通常是临时对象、字面量等,没有具体名字,也不能取地址(比如 5, a+b, 函数返回的匿名临时等)。

3. &&& 在声明中的区别

a) 单独的 & —— 左值引用

1
2
3
4
5
6
7
8
9
10
int a = 10;
int &b = a; // b 是 a 的引用,b和a指的是同一块内存
b = 15; // 现在a也变成15了

void f(int &x) // 只能传给f一个左值
{
x = 99;
}
f(a); // OK
f(20); // 错误!字面量不是左值

结论:& 定义的是“左值引用”,只能引用有名字的对象(左值)。


b) 两个 && —— 右值引用(C++11新引入)

1
2
3
4
5
6
7
8
9
10
11
int &&r = 20;    // r 绑定到字面量20(临时对象)
r = 55;

void g(int &&y) // 只能传给g一个右值(如匿名临时、move出来的变量等)
{
y = 42;
}
g(100); // OK
int c = 5;
g(c); // 错误!c是左值
g(std::move(c)); // OK! 被move后c变成右值

结论:&& 定义的是“右值引用”,只能引用无名的对象(右值)。


4. 常用场景小结

  • int* p; // 指针类型(*没涉及左值右值),p是指向int的指针
  • int &a = b; // 左值引用,只能绑定左值
  • int &&c = 123; // 右值引用,只能绑定右值
  • int foo(int& x); // 参数接受左值
  • int foo(int&& x); // 参数接受右值

5. 总结一句话

  • * :和左值右值没有直接关系,是指针符号
  • & :在类型名后面作为引用,单独一个是”左值引用”,两个&&就是”右值引用”
  • 左值引用 只能绑定左值;右值引用 只能绑定右值

附赠:什么时候用右值引用 &&

  • 普通参数/引用/返回值/成员变量,绝大多数用 & 即可(左值引用)
  • 想要接收临时对象、实现移动语义完美转发,用 &&(右值引用)

拓展

如果你还想了解“引用折叠”、“右值引用用于移动构造/移动赋值”、“forward和move的区别”等相关高级话题,欢迎继续提问!


结论

  • * 是指针声明
  • & 一个是左值引用,绑定左值
  • && 是右值引用,绑定右值
  • 左值引用和右值引用的最大区别在于所能绑定的对象类型不同。

参考资料

【CMake】 简单的CMakeLists命令