未来已来:C++ modules初探

2024-01-22 15:40:03 浏览数 (2)

你好,我是乐哥,一个从事C/CPP开发十几年的老鸟~~

在C 中,编译器在编译某个源文件时确实需要查看其中所有需要调用的函数的声明。这是因为C 是一种静态类型语言,编译器在编译阶段需要了解函数的签名(返回类型、函数名、参数类型和顺序等信息),以便进行类型检查和生成正确的机器代码。因此,如果我们在一个文件中定义一个函数,并想在另一个文件中调用它,则也必须在该文件中声明它。只有这样编译器才能生成适当的代码来调用该函数。

如下:

hello.cc

代码语言:javascript复制
// hello.cc

#include <iostream>

void fun() {
    std::cout << "Hello!" << std::endl;
}

main.cc:

代码语言:javascript复制
void fun();

int main() {
    fun();  // 调用在 hello.cc 中定义的函数,只需提供声明
    return 0;
}

使用如下命令编译:

代码语言:javascript复制
g   -g main.cc hello.cc -o main

编译成功,也就是说使用声明 使用的方式也是可行的,不过这样有一个很明显的弊端,如果需要依赖的函数很多,那么就需要显示声明每个函数,稍不注意就会出错,如果修改了函数名字,那么所有的涉及到该函数调用和声明的地方都要修改。正是因为这个原因,引入了 #include

include

include对于cpp开发人员来说太熟悉不过了,它是个预处理器指令,用于将一个文件的内容包含到另一个文件中。要使用某个函数或者功能,就需要把对应的头文件包含进来。预处理器在碰见 #include 的时候,就将指定的文件的内容复制到包含该include的文件中。

编译时长

在C 20之前,这种方式没有问题,或者说也唯有这种方式最便捷,但是到了C 20起,这种方式就不是最优的了,因为include会增加整个编译的时间。也正是因为预处理器对于include采用内容复制的方式,因此很容易生成很大的文件,如下:

代码语言:javascript复制
#include <iostream>

int main() {
  std::cout << "hello" << std::endl;
  
  return 0;
}

使用如下命令查看生成的文件大小:

代码语言:javascript复制
clang   -std=c  20  -E hello_include.cc | wc -c
1132796

有的时候,一个头文件会被重复包含,导致的结果就是其内容被重复复制多次,尤其是在存在函数定义的情况下,这种重复包含会导致编译失败,为了解决重复包含导致编译失败的问题,可以在头文件中添加#ifndef #define #endif或者#pragma once

传递包含

且看下面代码:

hello.h

代码语言:javascript复制
#pragma once
#include <iostream>

void Hello() {
  std::cout << "Hello!";
}

main.cc

代码语言:javascript复制
#include "hello.h";

int main() {
  Hello();
  std::cout << "nHello!";
  return 0;
}

输出:

代码语言:javascript复制
Hello!
Hello!

有时候这种传递依赖并不是一件好事,如果某个头文件定义忘记包含#pragma once或者ifndef 等,就会报错,这样的话,我们需要查找所有的代码来定位这个原因,而有了module之后,虽然不能完全避免,但是可以在一定程度上减轻,因为其必须包含当前所需要的module或者头文件。

module

为了解决include引起的文件内容过大和重复包含导致的编译失败问题,自C 20起,引入了modules

hello_module.cc

代码语言:javascript复制
import iostream
int main() {
  std::cout << "hello" << std::endl;

  return 0;
}

clang   -std=c  20  -E hello.cc | wc -c
215

同样的,也可以节省编译时间:

代码语言:javascript复制
> time clang   -std=c  20 -stdlib=libc   hello_include.cc
real 0m0.639s
user 0m0.584s
sys 0m0.058s
> time clang   -std=c  20 -stdlib=libc   -fmodules 
               -fbuiltin-module-map hello_module.cc
real 0m0.087s
user 0m0.052s
sys 0m0.037s

clang本身支持标准头文件到module的映射,如果要使用gcc的话,需要手动编译iostream:

代码语言:javascript复制
g   -std=c  20 -fmodules-ts -xc  -system-header iostream
g   -std=c  20 -fmodules-ts hello_module.cc

使用module

且不说module在使用上在编译阶段造成的遍历,我相信很多人看了上面例子一样,想知道自己的代码如何编译成为module且在项目中使用。

export

export 关键字用于声明模块的导出项,即那些希望在模块的接口中可见的符号(变量、函数、类等)。使用 export 可以将这些符号导出到模块接口,使其他模块可以引入并使用它们。

如果要导出一个module,需要加上如下这个语句:

代码语言:javascript复制
export module mymodule;

接着就可以像平常那也写代码:

代码语言:javascript复制
export module mymodule;

export int plus(int x, int y) {
  return x   y;
}

编译方式:

代码语言:javascript复制
g   -std=c  20 -fmodules-ts -c mymath.cc

import

对于include,我们往往像如下这么使用:

代码语言:javascript复制
#include <iostream>

int main() {
  std::cout << "Hello World!";
  return 0;
}

如果要使用module,则可以使用import,即:

代码语言:javascript复制
import <iostream>;

int main() {
  std::cout << "Hello World!";
  return 0;
}

与include不同的是,import是一个c 语句,因此在import语句最后要加上分号即**;**。

上面这个例子编译语句是:

代码语言:javascript复制
> g   -std=c  20 -fmodules-ts -xc  -system-header iostream
> g   -std=c  20 main.cc -o main

下面是一个完整的例子:

math.cc

代码语言:javascript复制
export module mathematics;
export auto plus(auto x, auto y) -> decltype(x y) {
    return x   y;
}
export namespace mynamespace {
    auto minus(auto x, auto y) -> decltype(x-y) {
        return x - y;
    }
}

void this_function_will_not_be_exported() {}

以及main.cc

代码语言:javascript复制
import <iostream>;
import mathematics;
int main() {
  std::cout << "1 2 = " <<  plus(1,2) << "n";
  std::cout << "3-2 = " << mynamespace::minus(3,2)
              << "n";
  return 0;
}

编译方式如下:

代码语言:javascript复制
> g   -std=c  20 -fmodules-ts -xc  -system-header iostream
> g   -std=c  20 -fmodules-ts -c math.cc
> g   -std=c  20 -fmodules-ts -c main.cc
> g   -std=c  20 math.o main.o -o main
  
> ./main
1 2 = 3
3-2 = 1

也可以使用下面的命令进行编译:

代码语言:javascript复制
g   -std=c  20 -fmodules-ts math.cc main.cc -o main

声明与实现分离

当然了,一个module可以分布在多个文件中,即多个module unit,需要注意的是,这些文件中只能有一个文件import module,如果多个文件中存在相同的module接口,那么就会在链接阶段失败,因此需要确保每个module unit中导出的接口不会产生冲突。在实际应用中,可以将不同的实现细节放在不同的模块中,以避免这样的冲突。

mymath.cc

代码语言:javascript复制
export module mymath;
export {
    int plus(int x, int y);
    int minus(int x, int y);
}

plus.cc

代码语言:javascript复制
module mymath;
int plus(int x, int y) {
    return x   y;
}

minus.cc

代码语言:javascript复制
module mymath;
int minus(int x, int y) {
    return x - y;
}

main.cc

代码语言:javascript复制
import <iostream>;
import mymath;
int main() {
  std::cout << "1 2 = " << plus(1,2) << "n";
  std::cout << "3-2 = " << minus(3,2) << "n";
}

由于是刚开始接触这块,引入了不少问题,也幸亏群里有高手可以指教~~

plus.cc 和 minus.cc 中需要是module mymath; 而不是import mymath;

上述代码编译:

代码语言:javascript复制
> g   -std=c  20 -fmodules-ts -xc  -system-header iostream
> g   -std=c  20 -fmodules-ts -c mymath.cc
> g   -std=c  20 -fmodules-ts plus.cc
> g   -std=c  20 -fmodules-ts minus.cc
> g   -std=c  20 -fmodules-ts main.cc
> g   -std=c  20 mymath.o plus.o minus.o main.o -o main

也可以像下面这样编译:

代码语言:javascript复制
> g   -std=c  20 mymath.cc plus.cc minus.cc main.cc -o main

module partition

将module定义分不到不同文件中的另一种方式是module partition,是指一个模块的接口和实现被分割到不同的文件中。一个模块可以包含多个编译单元,每个编译单元都可能包含模块接口单元或实现单元。这种分割使得一个模块的接口可以与实现分开编写,提高了代码的组织性和可维护性。

module partition由以下几个部分组成:

•module名称•:号•partition名称

即形如mymath:plus。

仍然以前面的为例:

plus.cc

代码语言:javascript复制
export module mymath:plus;
export int plus(int x, int y) {
  return x   y;
}

minus.cc

代码语言:javascript复制
export module mymath:minus;
export int minus(int x, int y) {
  return x - y;
}

mymath.cc

代码语言:javascript复制
export module mymath;

export import :plus;
export import :minus;

需要注意的是,在后面两个partition中,module名称可以忽略

main.cc

代码语言:javascript复制
import <iostream>;
import mymath;
int main() {
  std::cout << "1 2 = " << plus(1,2) << "n";
  std::cout << "3-2 = " << minus(3,2) << "n";
}

编译方式与前面的一样,在此不再赘述~

submodule

查阅了相关资料,submodule并没有在标准中,但是很多编译器也支持了,所以本节也略加以介绍,其在使用方式上与partition很像,区别是partition使用**:,而submodule使用.**。

plus.cc

代码语言:javascript复制
export module mymath.plus;
export int plus(int x, int y) {
  return x   y;
}

minus.cc

代码语言:javascript复制
export module mymath.minus;
export int minus(int x, int y) {
  return x - y;
}

mymath.cc

代码语言:javascript复制
export module mymath;

export import mymath.plus;
export import mymath.minus;

需要注意的是,在后面两个partition中,module名称可以忽略,而对于submodule,则必须携带module名,即mymath.plus

main.cc

代码语言:javascript复制
import <iostream>;
import mymath;
int main() {
  std::cout << "1 2 = " << plus(1,2) << "n";
  std::cout << "3-2 = " << minus(3,2) << "n";
}

与module partition的另一个区别是,可以仅导入子module,即如果main.cc中只使用了plus(),那么我们就可以仅import mymath.plus即可。

与include混合使用

在开发过程中,难免与兄弟部门配合或者使用第三方库,如果这个时候第三方库还不支持module,这就必须使用include。

plus.cc

代码语言:javascript复制
export module mymodule;
#include <string>
export int plus(int x, int y) {
  return x   y;
}

如果编译的话,会遇到如下错误:

代码语言:javascript复制
error: cannot define ‘enum class std::align_val_t’ in different module

这是因为模块的设计是为了替代传统的头文件包含方式,它引入了新的语法来定义模块接口和实现。如果包含了include,则违反了模块系统的设计原则。确保遵循模块系统的语法规则,不要将传统的头文件引入方式混用在模块中。如果确定需要使用include话,那么建议使用global module,如下:

plus.cc

代码语言:javascript复制
module; // global module
#include <string>

export module mymodule;

export int plus(int x, int y) {
  return x   y;
}

以上

0 人点赞