C++重温笔记(十二): C++多文件编程 |
您所在的位置:网站首页 › cpp文件命名 › C++重温笔记(十二): C++多文件编程 |
1. 写在前面
c++在线编译工具,可快速进行实验: https://www.bejson.com/runcode/cpp920/ 这段时间打算重新把c++捡起来, 实习给我的一个体会就是算法工程师是去解决实际问题的,所以呢,不能被算法或者工程局限住,应时刻提高解决问题的能力,在这个过程中,我发现cpp很重要, 正好这段时间也在接触些c++开发相关的任务,所有想借这个机会把c++重新学习一遍。 在推荐领域, 目前我接触到的算法模型方面主要是基于Python, 而线上的服务全是c++(算法侧, 业务那边基本上用go),我们所谓的模型,也一般是训练好部署上线然后提供接口而已。所以现在也终于知道,为啥只单纯熟悉Python不太行了, cpp,才是yyds。 和python一样, 这个系列是重温,依然不会整理太基础性的东西,更像是查缺补漏, 不过,c++对我来说, 已经5年没有用过了, 这个缺很大, 也差不多相当重学了, 所以接下来的时间, 重温一遍啦 😉 资料参考主要是C语言中文网和光城哥写的C++教程,然后再加自己的理解和编程实验作为辅助,加深印象,当然有些地方我也会通过其他资料(C++ Primer Plus)进行扩充。 关于更多的细节,还是建议看这两个教程。 今天这篇文章学习多文件编程, 对于C++项目, 往往都是很多个.h和.cpp文件组合在一起最后构成程序, 这里面其实有很多细节需要注意, 之前在实习的时候, 参与过C++项目开发, 经常在cpp里面找不到某个类的声明或者某个函数的定义等等报错问题, 当时还觉得奇怪,明明都写好了? 怎么还是报找不到呢? 其实像多文件编程里面, 头文件的引入,命名空间的使用, const常量的使用,以及过个文件如何在一起编译等,是有很多细节东西的, 这篇文章就把这几个地方整理下,这样就能从一个项目的角度再审视C++啦。 主要内容: C++多文件编程初步C++如何防止头文件被重复引入C++命名空间如何应用在多文件编程中C++ const常量如何在多文件编程中用C++多文件项目如何用g++命令执行C++多文件编程更深层原理g++, MakeFile和CMake小补小总Ok, let’s go! 2. C++多文件编程初步为啥要用多文件编程呢? 我们不妨先拿个单文件编程看看,比如建立一个student.cpp, 然后写下面代码: // Stuent.cpp #include using namespace std; class Stuent{ public: char *name; int age; float score; void say(){ cout public: const char *name; int age; float score; void say(); }; // student.cpp #include #include "student.h" void Student::say(){ std::cout //...... }; //school.h #include "student.h" class School { //...... private: Student stu[50]; }; //main.cpp #include "student.h" #include "school.h" int main() { //...... return 0; }运行此项目会发现,编译器报“Student 类型重定义”错误。这是因为在 school.h 文件中已经 #include 了一次 "student.h",而在 main.cpp 主程序又同时 #include 了 "school.h" 和 "student.h",即 Student 类的定义被引入了 2 次,C++不允许同一个类被重复定义。 那么,我们既然知道了在school.h文件中已经引入了Student类,那如果去掉main.cpp中的引入student.h文件呢? 这个确实能解决这里的问题,但并不适合所有"重复引入"的场景。 比如,a.h中定义了个类型TYPE1, b.h中定义了个类型TYPE2, 而这俩里面都包含了stdio.h。 如果我main.cpp要同时使用TYPE1和TYPE2,就必须将a.h和b.h包含进来。 这时候stdio.h被重复包含, 而这个情况,我们无法用上面方法解决。 下面整理, C++多文件编程中, 处理"多次#include导致重复引入"问题的方式, 有下面3种: 使用宏定义避免重复引入 实际多文件开发中,往往使用下面的宏定义来避免重复引入: #ifndef _NAME_H #define _NAME_H // 头文件内容 #endif其中,_NAME_H是宏的名称。需要注意的是,这里设置的宏名必须是独一无二的,不要和项目中其他宏的名称相同。 当程序第一次#include该文件时, 由于_NAME_H尚未定义,所以会定义_NAME_H并执行"头文件内容代码", 当发生多次#include时, 因为前面定义好了_NAME_H, 所以不会重复执行"头文件内容"部分的代码了。 所以, 将上面项目中student.h文件做如下修改: #ifndef _STUDENT_H #define _STUDENT_H class Student{ //.... }; #endif虽然该项目 main.cpp 文件中仍 #include 了 2 次 "student.h",但鉴于 _STUDENT_H 宏只能定义一次,所以 Student 类也仅会定义一次。再次执行该项目会发现,其可以正常执行。 使用#pragma once避免重复引入 除了第一种最常用方式之外,还可以使用#pragma one指令,将其附加到指定文件的最开头位置。则该文件只会被#include一次。 #prama once class Student{ //..... };#ifndef是通过定义独一无二的宏来避免重复引入的, 这意味每次引入头文件都要进行识别,效率不高。 但考虑C和C++都支持宏定义,所以项目使用#ifndef规避可能出现的"头文件重复引入"问题,不会影响项目的可移植性。#pragma once不涉及宏定义,当编译器遇到它时就会立刻知道当前文件只引入一次,所以效率很高。 但值得一提的是,并不是每个版本编译器都能识别#pragma one指令,所以兼容性可能不是很好。 另外, #pragma once只能作用于某个具体文件, 无法像#ifndef那样仅作用于指定的一段代码。 使用_Pragma操作符 C99标准中新增加了一个和#pragma指令类似的_Pragma操作符,可以看做是#pragma的plus版,不仅实现了#prama所有功能,更重要的, _Pragma还能和宏搭配使用。 当处理头文件重复引入问题, 可以把下面的具体加到相应文件开头: _Pragma("once") class Student { //...... };事实上,无论是 C 语言还是 C++,为防止用户重复引入系统库文件,几乎所有库文件中都采用了以上 3 种结构中的一种,这也是为什么重复引入系统库文件编译器也不会报错的原因。 这三种方法的后两种其实是一类, 特点是编译效率高,但可移植性差(编译器不支持,会发出警告等), 而#ifndef特点是可移植性高,编译效率差。 一般常用的是#ifndef / #define / #endif 组合解决头文件被重复引入的问题。 4. C++命名空间如何应用在多文件编程C++引入命名空间是为了避免合作开发项目时产生的命名冲突, 当进行多文件编程的时候, 通常是将声明部分(变量、函数和类等)划分到.h文件中, 将实现部分划分到.cpp文件中。 这时候,如果是要给变量、函数或类指定命名空间, 则该命名空间至少包含它们的声明部分, 所以多文件编程时, 命名空间在.h头文件中。 // student_li.h #ifndef _STUDENT_LI_H #define _STUDENT_LI_H namespace Li{ class Student{ public: void display(); }; } #endif // student_li.cpp #include "student_li.h" #include void Li::Student::display(){ std::cout public: void display(); }; } #endif // student_han.cpp #include "student_han.h" #include void Han::Student::display(){ std::cout void display() { std::cout std::cout std::cout std::cout 这一步,主要是做了宏替换, 注释删除工作等 g++ -E main.cpp -o main.i g++ -E student.cpp -o student.i这里的-E选项用于限定g++编译器只进行预处理,而不进行后续的3个阶段, -o选项用于指定生成文件的名称, 通常".i"作为C++程序预处理后的文件后缀名。 我们这里看一看下.i文件cat main.i。 #include 是一个来自 C 语言的宏命令,作用于程序执行的预处理阶段,其功能是将它后面所写文件中的内容,完完整整、一字不差地拷贝到当前文件中。 经历编译阶段,即对预处理阶段得到的 -i 文件做进一步的语法分析,生成相应的汇编代码文件。 g++ -S main.i -o main.s g++ -S student.i -o student.s其中, -S选项用于限定g++编译器对指定文件编译,得到的汇编代码通常以".s"作为后缀名。我们这里依然是看下"cat main.s"。 经历汇编阶段,即将汇编代码文件转换成可以执行的机器指令, 也就是二进制机器码 -- > 生成目标文件,二进制机器码 g++ -c mian.s -o main.o g++ -c student.s -o student.o-c指令限定g++编译器只进行汇编操作, 最终生成目标文件(本质是二进制文件)以".o"作为后缀名。 这个依然是看一眼: 经历链接阶段,即将所有的目标文件组织成一个可以执行的二进制文件 g++ main.o student.o -o student.exe注意,如果不用 -o 指定可执行文件的名称,默认情况下会生成 a.out 可执行文件。Linux 系统并不以文件的扩展名开分区文件类型,所以 a.out 和 student.exe 都是可执行文件,只是文件名称有区别罢了。此时大功告成: 这个编译过程非常重要。当然,上面的这四步过程也可以直接一步到位: g++ main.cpp student.cpp -o student.exe 7. C++多文件编程更深层原理 7.1 更深一层C++多文件编程中,一个完整C++项目可以包含2类文件, 头文件(.h)和源文件(.cpp)。 同属一个C++项目中所有代码是分别进行编译的,只需要在编译成目标文件后再与其他目标文件做一次链接即可。 例如,在 a.cpp 源文件中定义有一个全局函数 a(),而在文件 b.cpp 中需要调用这个函数。即便如此,处于编译阶段的 a.cpp 和 b.cpp 并不需要知道对方的存在,它们各自是独立编译的是,只要最后将编译得到的目标文件进行链接,整个程序就可以运行。 那么,这个过程是怎么实现的呢? b.cpp怎么就能找到a.cpp里面的a()函数了? 怎么知道a()函数就在a.cpp中了? 万一有多个a()函数咋办? 下面一一来捋捋。 首先, 写程序的角度, 当文件b.cpp需要调用a()函数时, 只需要先声明一下该函数即可, 这是因为, 编译器在编译b.cpp的时候,会生成一个符号表, 类似a()这样看不到定义的符号就存到这个表中。 在链接阶段, 编译器就会在别的目标文件中寻找这个符号的定义, 一旦找到, 程序就可以顺利生成,否则,链接错误。 这里的两个概念: 声明和定义 定义: 某个符号完整的描述清楚, 是变量还是函数, 变量类型以及变量值是多少, 函数的参数有哪些以及返回值是什么等声明: 仅仅告诉编译器该符号存在,至于该符号具体含义,等链接的时候才能知道也就是说, 定义的时候需要遵循C++语法规则完整描述一个符号, 而声明时只需要给出该符号原型即可。 一个符号允许被声明多次,但只能被定义一次,否则编译器不知道应该用哪个? 基于声明和定义的不同,才有了 C++ 多文件编程的出现。试想如果有一个很常用的函数 f(),其会被程序中的很多 .cpp 文件调用,那么我们只需要在一个文件中定义此函数,然后在需要调用的这些文件中声明这个函数就可以了。 那么, 如果有几百函数, 声明该怎么办呢? 一种简单的方法是将它们的声明全部放入一个文件中, 当需要时直接从文件中拷贝。 这种方法可行,但太麻烦, 于是乎头文件便可以发挥作用。 所谓头文件, 其实它内容和.cpp文件内容是一样的,都是C++源代码, 唯一区别是在于头文件不用被编译。 我们把所有的函数声明全部放进一个头文件, 当某个.cpp源文件需要,就#include只接将头文件内容引入到.cpp中。 这样, 当.cpp在预处理的时候, #include引入的.h文件就会替换该文件中的所有声明。 所以上面的三个问题, 其实就清楚了。 b.cpp首先会通过#include操作, 把a()函数声明内容复制过去,这样, 编译b.cpp的时候就知道有这么个符号了。 然后a.cpp和b.cpp都进行编译, 生成.o文件之后 进行链接, b.o就去a.o中找a()函数的定义。 而有多个a()函数的时候, 会进行命名空间的限制。 7.2 C++头文件内应该写啥.h头文件作用是被其他.cpp包含进去,其本身并不参与编译, 但它的内容会在多个.cpp中得到编译。 根据"符号的定义只有一次"的规则,就很容易理解,** 头文件中只能放变量和函数的声明而不能放他们定义**, 因为一个头文件内容实际上是会被其他多个不同.cpp文件引入且编译。 如果把函数和变量定义放在里面,这时候会发生重定义错误。 比如: // 声明 ok extern int a; void f(); // 定义 不OK int a; void f() {}但, 有三种情况是例外, 他们虽然属于定义的范畴,但应该放在.h文件中。 头文件中可以定义const对象 全局的 const 对象默认是没有 extern 声明的,所以它只在当前文件中有效。把这样的对象写进头文件中,即使它被包含到其他多个 .cpp 文件中,这个对象也都只在包含它的那个文件中有效,对其他文件来说是不可见的,所以便不会导致多重定义。与此同时,由于这些 .cpp 文件中的 const 对象都是从一个头文件中包含进去的,也就保证了这些 .cpp 文件中的 const 对象的值是相同的,可谓一举两得。 同理, static对象的定义也可以放进头文件头文件中可以定义内联函数 内联函数(inline 修饰)是需要编译器在编译阶段根据其定义将它内联展开的(类似宏展开),而并非像普通函数那样先声明再链接。这就意味着,编译器必须在编译时就找到内联函数的完整定义。头文件中可以定义类 在程序中创建一个类的对象时, 编译器只有在这个类的定义完全可见情况下,才能知道这个类对象应该如何布局。所以关于类的定义要求,和内联函数是一样的,即把类定义放进头文件, 然后再使用这个类的.cpp文件中#include。类的内部通常包含成员变量和成员函数, 成员变量要等到具体对象被创建时才会被定义(分配空间), 但成员函数需要一开始就被定义。 通常做法是类定义放到头文件, 成员函数实现放在.cpp中。 当然,还有一种方法是在类定义的时候,直接把成员函数实现写好,此时编译器会将其看为内联函数。但尽量不这么做。 7.3 再看头文件重复引入错误C++多文件编程中, .h头文件中只包含声明语句的话, 即便被同一个.cpp文件引入多次也没有问题, 因为声明语句是可以重复的, 但上面有3种特例, 是可以把定义写入头文件, 并且这3种特例都是很常见的操作。 那么这个时候, 如果包含上面3种特例里面某一种的头文件,被多个.cpp文件引入, 就会出现重定义错误了。 C++提供了3种处理机制, 最常用的就是#ifndef/#define/#endif。 详细的看上面吧,到这里,就串起来了,也更加清晰啦。 8. g++,CMake和MakeFile小补这里补充点知识,就是MakeFile和CMake,这两个是这两天学习CV项目时偶然接触到的,所以就产生了好奇, 想看看这俩东西是啥,既然有了g++了, 为啥还要有这俩东西? 8.1 MakeFile上面已经梳理清楚了一个完成C++项目的执行过程, 主要是分为四步: 预处理、编译、汇编、链接。g++命令确实可以对一个C++项目通过上面四步转成可执行文件,但在中大型项目里面,这样玩还是太复杂。 于是乎就有了MakeFile。 Makefile 文件描述了 Linux 系统下 C/C++ 工程的编译规则,它用来自动化编译 C/C++ 项目。一旦写编写好 Makefile 文件,只需要一个 make 命令,整个工程就开始自动编译,不再需要手动执行 GCC 命令。 一个中大型 C/C++ 工程的源文件有成百上千个,它们按照功能、模块、类型分别放在不同的目录中,Makefile 文件定义了一系列规则,指明了源文件的编译顺序、依赖关系、是否需要重新编译等。 MakeFile文件是g++代码的整理, 有了MakeFile文件, 执行程序会更加快速方便, 而CMake是简化MakeFile编写,可以自动生成MakeFile文件 当然,这里不会整理非常详细,因为我现在也只学到了点皮毛,只是通过一个例子看看这玩意咋用,现在学习首先是从应用角度开始, 关于原理和理论可以先参考上面的C语言中文网相关内容,后面如果学习到更多,会继续补充。 首先, MakeFile文件的基本格式长下面这样: target ... : prerequisites ... command ...这里参数说明: target: 目标文件, 可以是object file,也可以是可执行文件prerequisites - 生成target所需要的文件或目标command - make需要执行的命令(任意shell命令), Makefile命令必须tab开头不用把这玩意想成多么高大上, 这其实就是个文件,更直接的说, 这玩意里面其实就是我们上面生成student.exe文件的全部指令,只不过只要有了这个文件, 我们就可以直接用make命令,直接生成可执行文件。 那你说, 那不还得把上面指令敲到MakeFile文件吗? 其实有了CMake, 就不需要了。 Makefile五个重要的东西: 显示规则, 隐晦规则,变量定义, 文件指示和注释 显示规则: 通常在写makefile时使用的都是显式规则,这需要指明target和prerequisite文件。 一条规则可以包含多个target,这意味着其中每个target的prerequisite都是相同的。 当其中的一个target被修改后,整个规则中的其他target文件都会被重新编译或执行。 隐晦规则: make的自动推导功能所执行的规则 变量的定义: Makefile中定义的变量,一般是字符串 文件指示: Makefile中引用其他Makefile;指定Makefile中有效部分;定义一个多行命令 注释: Makefile只有行注释 “#”, 如果要使用或者输出"#"字符, 需要进行转义, "# 下面就拿上面那个student.cpp和main.cpp编译的例子来编写MakeFile文件。 LearningC目录下把其他的文件删除,只保留两个.cpp文件和一个.h文件。 编写clean, 作用是删除所有的.o文件和可执行文件, 以防带来杂乱干扰 clean: rm *o student这里要是tab 编写目标文件1: 依赖文件1 目标文件就是想得到的文件, 依赖文件就是目前所拥有的东西。 目前只有student.cpp和main.cpp文件, 而目标文件就是得到对应的.o文件 student.o:student.cpp g++ -c student.cpp -o student.o main.o:main.cpp g++ -c main.cpp -o main.o和上面格式要对应上 编写目标文件2: 依赖文件2 这里和上面同理, 我们现在有了student.o和main.o文件了,接下来我们就是基于这两个,得到我们的可执行文件 student:student.o main.o g++ main.o student.o -o student这样就编写完了, 我们组织文件的时候, 是从下往上组织上面代码。 当然,上面这个代码是一个最简单的版本了,下面我们尝试修改一下,让其稍微复杂一点, 主要是为了编译更高效。参考这篇文章 说完了makefile文件,接下来, 整理下Cmake了 CMake是一个跨平台的编译(Build)工具,可以用简单的语句来描述所有平台的编译过程,其是在make基础上发展而来的,早期的make需要程序员写Makefile文件,进行编译,而现在CMake能够通过对cmakelists.txt的编辑,轻松实现对复杂工程的组织 首先, 需要先安装cmake sudo apt-get install cmake cmake --version编译流程如下: 1. 编写Cmake配置文件CMakeLists.txt, 理解成Cmake所要处理的代码 2. 执行命令 cmake path生成MakeFile, path是CmakeList.txt所在目录 3. 使用make命令进行编译这么说太抽象,还是拿上面例子实操下。 把之前编写的makefile以及生成的可执行文件删除掉, 然后建立一个build目录,以及建立CMakeLists.txt文件, 文件组织如下: 接下来,我们编写CMakeLists.txt文件, vim下 cmake_minimum_required(VERSION 3.10) # set the project name project(student) # add the executable add_executable(student student.cpp main.cpp)这个是不是比上面编写MakeFile文件简单多了,并且这里面的大部分命令都是固定语法,相当于我们只需要指定一些参数即可。 首先, 先整理上面几个命令: 命令命令语法命令简述使用范例cmake_minimum_requiredcmake_minimum_required(VERSION major[.minor[.patch[.tweak]]][FATAL_ERROR])用于指定需要的CMake 的最低版本cmake_minimum_required(VERSION 3.10)projectproject( [languageName1 languageName2 … ] )用于指定项目的名称,一般和项目的文件名称对应project(student)add_executableadd_executable( [WIN32] [MACOSX_BUNDLE][EXCLUDE_FROM_ALL] source1 source2 … sourceN)用于指定从一组源文件 source1 source2 … sourceN 编译出一个可执行文件且命名为nameadd_executable(student student.cpp main.cpp)include_directoriesinclude_directories([AFTER|BEFORE] [SYSTEM] dir1 dir2 …)用于设定目录,这些设定的目录将被编译器用来查找 include 文件include_directories(${PROJECT_SOURCE_DIR}/lib)这种命令太多了,需要平时多加积累, 由于我这里就用到了这几个, 所以先整理这几个,如果有其他命令需要用到,可以去官网查具体使用https://cmake.org/cmake/help/v2.8.8/cmake.html#section_Commands 把上面CMakeLists.txt写好之后, 我们去build目录, 在这里面输入cmake ../.
首先,还是先来一张导图把这一篇文章内容拎起来: 这里不是打广告,跟了一遍C++教程,确实觉得站长内容写的非常详细且内容之间非常连贯,非常适合构建知识体系, 所以也非常感谢站长的教程, 当然我整理的这几篇博客算是笔记,更是对我之前C++知识的查缺补漏, 如果想学习更多细节, 还是建议去读站长的原教程。 C++语言一直给我的感觉就是非常有魅力,当然这种魅力我也说不出在哪,就是比较喜欢。当然, 离熟悉还差很远,离精通更是遥不可及,不过这一遍学习,我算是把知识框架搭建了起来, 我觉得这个东西对我学习来说非常重要, 不管学习什么知识,我觉得只要我有框架, 我就能持续学习,不断扩充, 这是一个非常有意思的事情。 所以,这十二篇博客,后面会根据所学持续扩充, 我也知道, 每一篇都非常长,但每次遇到问题,回来查阅的时候,我会发现非常的舒服,几乎相关内容都连在一块,查起来非常高效。也希望能帮助到更多的伙伴啦。 趁着在学校的这半年时间, 我博客会补充各种基础知识,形成框架体系,因为我觉得这个时候是建立知识框架最好的时间,通过实习,我发现一旦工作,我会变得非常浮躁, 那时候不适合静心学习,搭建框架, 只适合根据实际应用把新知识进行补充,来使得框架变丰满,只可惜,我是最后半年才悟到的,得抓紧了哈哈。 语言方面: python和C++继续重温,依然是参考站长编写的教程, C++目前基础过完,后面会补几篇C语言之前不知道的关键知识,然后就是C++11新特性,然后就是STL库了,这个才是实际中常用的东西,感觉得系统走一遍python数据分析的库后面得走一遍, 为后面工作打基础Linux以及几个数据库教程走一遍,同样为后面工作打基础学校的时间是打基础最好的时间,所以继续Rush 😉 |
今日新闻 |
推荐新闻 |
CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3 |