跳至主要內容

02. 模块系统

LincZero大约 11 分钟

02. 模块系统

简概

概念、底层原理(C++)

  • C++库函数

    • C++库函数存储在库文件中,编译程序时会自动在库文件搜索你使用的函数(但好像还是要写头文件?)
    • 即库文件包括很多头文件咯。库文件是工具仓库,头文件(库)是工具箱,头文件函数是工具
  • 理解 C++ 中的头文件和源文件的作用open in new window

    • 模块系统的作用:

    • 提供函数原型的作用:简单来讲,头文件包括了库的函数原型的声明

    • 为什么C/C++中有标准库头文件

      • 这也是由于C/C++相较于其他一些语言的一个不同所导致的,C/C++使用函数前需要先声明函数原型

        而头文件除了提供模块以外,另一个作用是提高声明函数原型

      • 而像Python这种不需要写函数原型声明的语言,其直接使用解释器内嵌的内置函数,就不需要标准库的存在了

        只有使用非内置函数、使用其他模块时,才需要声明

      • C/C++没有编译时自动加入标准库的操作,说是“标准库”,但其实不存在真正意义上的标准库

        stdio/iostream里最重要的其实还是输入输出流,但并不必须。有的文件并不需要引入

    • C++中不使用头文件cmath,也能运行sqrt函数?

      • MSVC STL在实现iostream的数值输入/输出时用了<cmath>中的一些函数(比如ldexp),所以<iostream>间接包含了<cmath>
      • 不过这是实现细节,随时可能会改

头文件

头文件声明

有两种

  • 使用<>包裹,如<coo din.h>
    • 编译器会在存储标准头文件的主机系统的文件系统中查找
  • 使用""包裹,如"<coordin.h"
    • 编译器首先查找当前的工作目录或源代码目录(或编译器指定其他目录) 如果没有找到头文件,则编译器再在标准位置查找
    • 在包含自己的头文件时,应使用引号而不是尖括号

头文件内容

头文件可以放的东西

  • 可放
    • 结构声明
    • 函数模板声明
    • 内联函数定义:原因:会被预处理掉
    • 类声明中的函数定义:原因:就算不加inline也会被视作内联函数
    • const常量:原因详见内存管理一章的底层原理,其第一次使用时才分配内存
    • enum枚举:原因:未知
    • priveta中函数定义(不用加inline为内联函数):原因:这种函数并不会在客户程序中定义,当多个文件引用一个头文件时当然也并不会起冲突
  • 不可放
    • 普通函数定义
    • 变量声明(定义)

应该放的东西

  • 类声明头文件
    • 函数/类模板声明:不能放实现文件,其不能被独立编译
    • 内联函数定义:不能放实现文件,其不能被独立编译
  • 类实现文件
  • 客户代码文件

不可放的原因

  • 头文件的引用#include使用的是预处理机制
  • 在头文件中定义会导致多个文件定义同一样东西,违反C++的单一定义原则

类与头文件

  • 常见问题:

    • 可以在实现代码中定义变量而不在头文件中声明吗?

    • 可以,但这样定义出来的变量的作用域是局部的,即无法被其他类函数使用,只能使用函数传参(使用起来不方便,放了相当于有全局变量方便点)

      一般临时变量可以这样做。即使是new出来的变量,指向他的指针也会在头文件中声明

头文件 —— 宏

常用宏

其他

  • 宏定义, #define
  • 文件包含, #include
  • 条件编译, #if

QT的宏

  • Q_OBJECT,Qt元对象系统

  • QT_BEGIN_NAMESPACE,命名空间

    QT_BEGIN_NAMESPACE
    namespace Ui { class MainWindow; }
    QT_END_NAMESPACE
    
  • QT_FORWARD_DECLARE_CLASS,和头文件#include作用类似

C++常用宏

  • #undef XXX为解除定义
  • #error XXXX

条件编译宏

条件编译宏

  • #ifdef XXX - #endif,如果定义
  • #ifndef XXX - #endif,如果没定义,if not defined的缩写。用途:避免重复包含头文件
  • #if !defined XXX - #endif,如果没定义
  • #if defined XXX - #endif ,如果定义。用途:是根据你是否定义了XXX这个宏,而使用不同的代码
  • #elif
  • #else
  • 区别
    • #ifdef只能判断单一的宏是否定义,而#if defined()可以组成复杂的判别条件
    • 对于单一的宏AAA来说,#ifdef AAA和#if defined(AAA)是完全相同
    • 而要组成复杂的判别条件,用#if defined()就灵活方便了,比如:#if defined(AAA) && (BBB >= 10)

#ifndef为例

  • 简概

    • 同一个文件中只能将同一个头文件包含一次
    • 但对于嵌套包含头文件或者其他情况,可能会包含多次
    • 标准C/C++的一种技术可以避免多次包含同一头文件,其基于预处理编译指令#ifndefif not defined
  • 使用

    • 举例:(写在头文件中,而不是引用头文件的调用函数中)

      #ifndef COORDIN_H_ // 如果没有定义
      #define COORDIN_H_ // 那么就定义(预编译定义常量)
      // ...
      #endif // 否则跳到此处、编译器不读取中间的内容(已经定义的话)
      
      /* ...
       * ...
       * ... 
       */
      
      #ifndef COORDIN_H_ // 当定义两次时,这里因为前面已经定义了,所以跳过这一块
      #define COORDIN_H_
      // ...
      #endif
      
  • 原理

    • 编译器过程

      • 编译器首次遇到该文件时,名称COORDIN_H_没有定义 (根据include文件名来选择名称,并加上一些下划线,以创建一个其他地方不会被定义的名称)

        在这种情况下,编译器将查看#ifndef#endif之间的内容,并读取定义COORDIN_H_的一行

      • 如果在同一个文件遇到其他包含coordin.h的代码,编译器将知道COORDIN_H_已经被定义了,从而跳到#endif后面的一行上

    • 原理总结

      • 这种方法不防止编译器将文件包含两次,只是让它忽略除第一次包含之外的所有内容 (大多数标准C和C++头文件使用这种防护方案,在同一个文件中定义同一个结构两次不会导致编译错误)

两种防止头文件重复包含的方式

为了避免同一个头文件被包含(include)多次,C/C++中有两种宏实现方式:一种是#ifndef方式;另一种是#pragma once方式

#ifndef 方式

#ifndef MAINWINDOW_H
#define MAINWINDOW_H
// ...
#endif // MAINWINDOW_H

#pragma once 方式

#pragma once
// ...

区别:在能够支持这两种方式的编译器上,二者并没有太大的区别。但两者仍然有一些细微的区别

  • #ifndef
    • #ifndef 的方式受C/C++语言标准支持。它不仅可以保证同一个文件不会被包含多次,也能保证内容完全相同的两个文件(或者代码片段)不会被不小心同时包含
    • 缺点:如果不同头文件中的宏名不小心“撞车”,可能就会导致你看到头文件明明存在,但编译器却硬说找不到声明的状况——这种情况有时非常让人郁闷
  • #pragma once
    • 一般由编译器提供保证:同一个文件不会被包含多次。注意这里所说的“同一个文件”是指物理上的一个文件,而不是指内容相同的两个文件
    • 你无法对一个头文件中的一段代码作pragma once声明,而只能针对文件
    • 好处:你不必再担心宏名冲突了,当然也就不会出现宏名冲突引发的奇怪问题。大型项目的编译速度也因此提高了一些
    • 缺点:如果某个头文件有多份拷贝,本方法不能保证他们不被重复包含。当然,相比宏名冲突引发的“找不到声明”的问题,这种重复包含很容易被发现并修正
    • 缺点2:这种方式不支持跨平台

模块设计

编译和链接

  • C++孤立将组建单独编译,然后在链接成可执行的程序(C++编译器既编译程序,也管理链接器)
  • 这种做法的优点:如果只修改了一个文件,只需要重新编译该文件再链接。而无需所有文件一起重新编译

程序结构:可以分成三部分(原型、定义、调用,三者分离),这种组织方法模块设计与OOP设计一致

  • 头文件:包含结构声明和使用这些结构的函数的原型
  • 源代码文件1:包含与结构有关的函数的定义代码
  • 源代码文件2:包含调用与结构相关的函数的代码

名称空间

名称空间

简概

  • 名称空间原理:通过定义一种新的声明区域(声明区域详见内存管理思想一章)来创建命名的名称空间
  • 名称空间与文件的关系
    • 文件中可有不同的名称空间
    • 不同文件可以有相同的名称空间
    • 即两者没有定性的包含关系
  • 叫法
    • 未被装饰的名称称为未限定的名称unqualified name),如cout
    • 包含名称空间名的名称称为限定的名称qualigied name),如std::cout

使用

  • 声明

    namespace Jack{
        int n;
        // ...
    }
    namespace Jill {
        // ...
    }
    
    /* 与类结合使用 */
    namespace VECTOR
    {
        class Vector
        {
            // 与类的使用
        }
    }
    /* 在QT中的使用 */
    QT_BEGIN_NAMESPACE
    namespace Ui { class MainWindow; }
    QT_END_NAMESPACE
    
  • 使用(三种方法)

    • 作用域解析运算符名称空间名+作用域解析运算符::+里面的成员,举例:Jack::n;std::cout <<
    • using编译指令using namespace 名称空间名;,举例:usign namespace std;
    • using声明方法using namespace

其他特性

  • 注意项

    • 导入名称是,首选作用域解析运算符和using声明方法
    • 而对于using声明,首选使用将其作用域设置为局部而不是全局
    • 不要在头文件中使用using编译指令,否则可能会掩盖了要让那些名称可用,而且包含头文件的顺序可能会影响程序行为
  • 名称空间可以嵌套:例如namespace nna{namespace nnb{int n;}}nna::nnb::n;

  • 可以使用匿名名称空间:例如namespace {int spn;}

    • 这种用法等价于后面跟着using指令一样
    • 原理:其潜在作用域为:从声明点到声明区域结尾
    • 使用场景:提供了链接性为内部的静态变量的代替品(可以批量定义静态变量),其他文件无法连接访问

使用场景

  • 取代静态全局变量、以及外部全局变量的用法
  • 如果开发了一个函数库或者类库,应该放在一个名称空间中
  • 作为将旧代码转换为使用名称空间代码的权宜之计

单独编译

先单独编译,后链接

  • C++提倡这种做法,能节省编译的效率。但这会引发一些问题(多个库的链接)

多个库的链接

多个库链接 x 名称修饰(可能存在的问题)

  • 名称修饰对于二进制模块链接的影响
    • C++标准运行每个编译器设计人员以他认为的何时方式实现名称修饰,两个编译器将为同一个函数生成不同的修饰名
    • 所以它们各自编译的二进制模块可能无法正确链接
  • 解决方案
    • 确保所有对象文件或库都是同一个编译器生成的
    • 如果有源代码,则可以用自己的编译器重新编译源代码来消除链接错误

特殊操作

“跨工程”调用头文件

参考:【CSDN】error LNK2019: 无法解析的外部符号....该符号在函数 ...中被引用 open in new window

问题描述

在Qt中实验时发现了一个问题 两个项目:A.h、A.cpp、B.h、B.cpp,其中项目B要使用项目A中的类 _ B.h:#include A.h 报错:error: LNK2019: 无法解析的外部符号......中引用了该符号 _ B.h:#include A.h、#include A.cpp 报错 _ B.h:#include A.h B.cpp:#include A.cpp 正确运行 _ 然后我懵了......这是种什么操作?我都不知道怎么在搜索引擎描述这个问题了

原理

首先,#include是“预处理机制”机制对吧,除去条件编译等操作,大概相当于把被include的头文件拷贝了一份到当前头文件 那么,#include cpp他也是同理的

如果是在同一个工程内,不需要include cpp这一步。是否可以理解为,如果是分别在两个工程内,两个工程已经被分别编译了,你B工程没办法知道A工程的源代码,所以include头文件无效

而include cpp的工程相当于是将A工程的源代码拷贝到B工程里面

然后看上去B工程使用了A工程里面的类,但事实上只是一份拷贝

总结

这种操作也就实验一下,耦合很高