前言
- 在前几节中,我们讲述了如何借助
CMake
和CMakeLists
去创建自定义C++库,如何链接到.so
并包含hpp
去运行自定义库- # 【动态库.so | 头文件.hpp】基于CMake与CMakeList编写C++自定义库
- 在
C++
类编程中,我们常常会遇到多个类相互包含的工程文件,如果没有正确处理好这些文件之间的关系,就会导致程序报错。 - 今天这一期分享我们就来分享多个类互相包含的问题,并尝试分析其解决方案。
1 前向声明
1-1 前向声明介绍
- 前向声明是指在代码中声明一个类、函数、结构体等的名字,但不提供其完整的定义。前向声明告诉编译器某个实体的存在,以便可以在之后的代码中引用该实体。这对于避免循环依赖、减少编译时间以及解决一些设计上的问题非常有用。
- 前向声明的作用
- 解决循环依赖问题:当两个或多个类相互引用时,使用前向声明可以避免头文件的互相包含。
- 减少编译时间:前向声明通常比包含完整的头文件要高效,可以减少不必要的编译工作。
- 改善设计结构:前向声明有助于减少类之间的强依赖关系,从而使代码更加模块化。
- 语法:
class MyClass; // 这是对 MyClass 的前向声明
int add(int, int); // 前向声明,声明一个返回 int 类型的 add 函数
1-2 类的循环依赖
- 我们来看一个例子:我们有如下文件结构
├── CMakeLists.txt
├── include
│ ├── ClassA.hpp
│ └── ClassB.hpp
└── src└── main.cpp
- 我们让
classA
和classB
相互包含
ClassB.hpp
#ifndef __CLASSB_HPP__
#define __CLASSB_HPP__
#include "ClassA.hpp"class ClassB {
public:ClassB(ClassA* classA_);
private: ClassA* classA; // 引用了 ClassA};#endif
ClassA.hpp
#ifndef __CLASSA_HPP__
#define __CLASSA_HPP__
// ClassA.hpp
#include "./ClassB.hpp"class ClassA {
public:ClassA();~ClassA();
private:ClassB* classB; // 引用了 ClassB};#endif
-
如何编译:
- # 【动态库.so | 头文件.hpp】基于CMake与CMakeList编写C++自定义库
-
这时候我们编译代码,就会出现如下报错
-
这样做会导致编译器陷入一个死循环,因为它需要
ClassA
的完整定义来处理ClassB
,而同时它又需要ClassB
的定义来处理ClassA
。这时,我们就可以通过前向声明来避免循环依赖。
1-3 (使用前向声明)
- 我们分别在代码中添加前向声明
ClassA.hpp
#ifndef __CLASSA_HPP__
#define __CLASSA_HPP__
// ClassA.hpp
#include "./ClassB.hpp"class ClassB;
class ClassA {
public:ClassA();~ClassA();
private:ClassB* classB; // 引用了 ClassB};#endif
ClassB.hpp
#ifndef __CLASSB_HPP__
#define __CLASSB_HPP__
#include "ClassA.hpp"class ClassA;class ClassB {
public:ClassB(ClassA* classA_);
private: ClassA* classA; // 引用了 ClassA};#endif
- 再次编译,代码没有报错。
2 类定义和实现分离
- 在C++中,类的定义和实现通常是分开的,这种做法是为了提高代码的可维护性、可扩展性,并且减少编译依赖和编译时间。常见的做法是将类的声明放在头文件 (
.hpp
或.h
),而将类的实现放在源文件 (.cpp
) 中。
2-1 类定义与实现分离的意义
- 提高可维护性:
- 头文件包含了类的接口(即成员函数的声明、成员变量的声明),源文件包含了类的实现(即成员函数的具体实现)。这种分离方式使得程序的结构更加清晰,易于管理和维护。
- 减少编译依赖:
- 头文件中的接口通常不需要修改,而源文件中的实现可能会频繁更改。通过分离定义和实现,当实现部分更改时,编译器只需要重新编译源文件,而不需要重新编译所有依赖该类的文件。这样可以显著减少编译时间。
- 隐藏实现细节:
- 通过将实现放在源文件中,类的实现细节对外部代码是不可见的,外部代码只能看到接口。这是面向对象编程中的封装概念,有助于保护类的内部实现不被滥用。
- 提高代码可读性:
- 头文件通常只包含接口(声明),对于类的使用者来说,只需要关注接口即可,不必关心具体的实现。这使得代码更简洁,易于理解。
2-2 扩展实现
- 我们同样使用上面的代码,尝试不分离类定义和实现分离,看看他们在重复包含这里会出现什么问题
ClassA.hpp
#ifndef __CLASSA_HPP__
#define __CLASSA_HPP__
// ClassA.hpp
#include<iostream>
#include "./ClassB.hpp"class ClassB;
class ClassA {
public:ClassA():classB(new ClassB(this)){}~ClassA(){if (classB)delete classB;}
private:ClassB* classB; // 引用了 ClassB};#endif
ClassB.hpp
#ifndef __CLASSB_HPP__
#define __CLASSB_HPP__
#include "ClassA.hpp"class ClassA;class ClassB {
public:ClassB(ClassA* classA_):classA(classA_){}private: ClassA* classA; // 引用了 ClassA};#endif
- 我们编译代码,发现代码没有报错
2-3 问题抛出
- 我们进一步扩展,我们为两个类分别编写两个函数,并让B调用A的函数
ClassA.hpp
#ifndef __CLASSA_HPP__
#define __CLASSA_HPP__
// ClassA.hpp
#include<iostream>
#include "./ClassB.hpp"class ClassB;
class ClassA {
public:ClassA():classB(new ClassB(this)){}~ClassA(){if (classB)delete classB;}void funcA(){std::cout<<"funcA()"<<std::endl;}
private:ClassB* classB; // 引用了 ClassB};#endif
ClassB.hpp
#ifndef __CLASSB_HPP__
#define __CLASSB_HPP__
#include "ClassA.hpp"class ClassA;class ClassB {
public:ClassB(ClassA* classA_):classA(classA_){}void funcB(){classA->funcA();std::cout<<"funcB()"<<std::endl;}
private: ClassA* classA; // 引用了 ClassA};#endif
-
我们编译代码,发现代码报错
-
从错误信息和代码结构来看,问题的根本原因是
ClassB
尝试使用ClassA
的成员函数funcA()
时,ClassA
只是被前向声明(即class ClassA;
),并没有完整的定义。 -
在
ClassB.hpp
中,classA->funcA();
这一行需要ClassA
类的完整定义,因为funcA
是ClassA
的成员函数,编译器需要知道ClassA
类的结构以及它的成员函数定义才能正确解析这行代码。由于ClassA
仅仅是前向声明了,编译器不知道ClassA
的具体实现,因此会报错。 -
那你可能会问,我不是已经
#include "ClassA.hpp"
了吗,但是在ClassA.hpp
右包含了ClassB
,也就是再一次陷入了循环的问题。
2-4 分离类定义和实现
- 我们将类的实现和定义分开,变成下属文件结构:
├── CMakeLists.txt
├── include
│ ├── ClassA.hpp
│ └── ClassB.hpp
└── src├── classA.cpp├── classB.cpp└── main.cpp
ClassA.hpp
#ifndef __CLASSA_HPP__
#define __CLASSA_HPP__
// ClassA.hpp#include<iostream>class ClassB;class ClassA {
public:ClassA();~ClassA();void funcA();
private:ClassB* classB; // 引用了 ClassB};#endif
classA.cpp
#include "../include/ClassA.hpp"
#include "../include/ClassB.hpp"ClassA::ClassA():classB(new ClassB(this)){}
ClassA::~ClassA(){if (classB)delete classB;
}
void ClassA::funcA()
{std::cout<<"funcA()"<<std::endl;
}
ClassB.hpp
#ifndef __CLASSB_HPP__
#define __CLASSB_HPP__class ClassA;class ClassB {
public:ClassB(ClassA* classA_);void funcB();
private: ClassA* classA; // 引用了 ClassA};#endif
classB.cpp
#include "../include/ClassB.hpp"
#include "../include/ClassA.hpp"ClassB::ClassB(ClassA* classA_):classA(classA_){}
void ClassB::funcB()
{classA->funcA();std::cout<<"funcB()"<<std::endl;
}
CMakeLists.txt
cmake_minimum_required(VERSION 3.10)# 设置项目名称
project(MyProject)# 设置 C++ 标准为 C++17
set(CMAKE_CXX_STANDARD 17)# 设置可执行文件输出目录
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)# 包含头文件目录
include_directories(${CURSES_INCLUDE_DIR})# 添加源文件
set(SOURCESsrc/main.cppsrc/classA.cppsrc/classB.cpp
)# 生成可执行文件
add_executable(MyProject ${SOURCES})
- 如此以来我们进行编译
2-5 问题分析
ClassA.hpp
:只声明了ClassA
类和它的成员函数,且没有包含ClassB.hpp
。在这里,ClassB
仅通过前向声明告知编译器ClassB
的存在,编译器并不需要知道ClassB
的具体实现细节。这样,ClassA
的定义可以独立存在。ClassB.hpp
:同理,ClassB.hpp
只声明了ClassB
类和它的成员函数,并且通过前向声明告知编译器ClassA
的存在。此时,ClassB
仅依赖ClassA
的指针或引用,不需要了解ClassA
的完整定义。classA.cpp
和classB.cpp
:这两个源文件包含了完整的类定义,==因此在源文件中,编译器可以访问到类的完整实现,包括成员函数和构造函数等。==在这里,ClassA
和ClassB
都被完全实现,且它们的互相依赖在源文件中不会导致任何问题。
- 通过这种分离方式,避免了循环依赖问题:
- 头文件中,只声明类,而不实现类的细节。
- 在源文件中,类的实现和类之间的互相调用可以正常工作。
2-6 总结
- 完整流程总结
- 头文件只包含类声明和前向声明,避免了互相依赖的循环。
- 源文件包含了具体的实现部分,其中包括完整的类定义和方法实现。
- 在编译过程中,编译器在处理源文件时,会看到类的完整实现,因此能够正确地解析类的成员函数和指针引用。
3 进阶三个类互相包含问题
- 你可以先根据问题描述自己敲一遍看看搞清楚没有~~~
3-1 问题描述
- 我们来看一个测试一下刚刚学到的东西,假设我们现在有三个类,每个类中都有一个函数:
classA
:funcA()
,classB
:funcB()
classC
:funcC()
- 其中
classA
包含ClassB
和ClassC
,且ClassB
和ClassC
均持有classA
的一个指针。 funcB()
和funcC()
将访问classA
中的变量value
并输出- 而
funcA()
将调用funcB()
和funcC()
- 我们可以绘制
UML
类土如下:
- 我们分别创建三个头文件:
classA.hpp
,classB.hpp
,classC.hpp
- 以及三个实现文件
3-2 代码实现
- 我们定义如下文件架构:
├── CMakeLists.txt
├── include
│ ├── ClassA.hpp
│ ├── ClassB.hpp
│ └── ClassC.hpp
└── src├── classA.cpp├── classB.cpp├── classC.cpp└── main.cpp
ClassA.hpp
#ifndef __CLASSA_HPP__
#define __CLASSA_HPP__
// ClassA.hpp#include<iostream>class ClassB;
class ClassC;class ClassA {
public:int value;
public:ClassA();~ClassA();void funcA();
private:ClassB* classB; ClassC* classC; };#endif
classA.cpp
#include "../include/ClassA.hpp"
#include "../include/ClassB.hpp"
#include "../include/ClassC.hpp"ClassA::ClassA():classB(new ClassB(this)),classC(new ClassC(this)),value(42){}
ClassA::~ClassA(){if (classB)delete classB;if(classC)delete classC;
}
void ClassA::funcA()
{std::cout<<"funcA()"<<std::endl;classC->funcC();classB->funcB();
}
ClassB.hpp
#ifndef __CLASSB_HPP__
#define __CLASSB_HPP__class ClassA;class ClassB {
public:ClassB(ClassA* classA_);void funcB();
private: ClassA* classA; // 引用了 ClassA};#endif
classB.cpp
#include "../include/ClassB.hpp"#include "../include/ClassA.hpp"ClassB::ClassB(ClassA* classA_):classA(classA_){}void ClassB::funcB(){std::cout<<"funcB():"<<classA->value<<std::endl;}
ClassC.hpp
#ifndef __CLASSC_HPP__
#define __CLASSC_HPP__class ClassA;class ClassC {
public:ClassC(ClassA* classA_);void funcC();
private: ClassA* classA; };#endif
class.cpp
#include "../include/ClassC.hpp"
#include "../include/ClassA.hpp"ClassC::ClassC(ClassA* classA_):classA(classA_){}
void ClassC::funcC()
{std::cout<<"funcC():"<<classA->value<<std::endl;
}
main.cpp
#include "../include/ClassA.hpp"int main()
{ClassA* classA=new ClassA();classA->funcA();if(classA)delete classA;return 0;
}
CMakeLists.txt
cmake_minimum_required(VERSION 3.10)# 设置项目名称
project(MyProject)# 设置 C++ 标准为 C++17
set(CMAKE_CXX_STANDARD 17)# 设置可执行文件输出目录
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)# 包含头文件目录
include_directories(${CURSES_INCLUDE_DIR})# 添加源文件
set(SOURCESsrc/main.cppsrc/classA.cppsrc/classB.cppsrc/classC.cpp
)# 生成可执行文件
add_executable(MyProject ${SOURCES})
- 编译执行
4 总结
- 本节讲述了如何使用前向声明和头文件和源文件分离解决类的重复依赖问题
- 如有错误!欢迎指出!
- 感谢大家的支持!