C++ 20 Module
头文件包含一直是C/C++的传统,它使代码声明与实现分离,但它有一个非常大的问题就是会被重复编译,拖累编译速度。
通常一个标准头文件iostream
展开后可能达几十万甚至上百万行。笔者使用下面的示例进行测试,新建一个main.cc
,内容如下:
1#include <iostream>
2
3int main(int argc, char* argv[])
4{
5 return 0;
6}
然后分别使用g++和clang++加wc来统计大小:
1g++ -E main.cc | wc -c
21003912
1clang++ -E main.cc | wc -c
2999191
随着C++ 20 Module的出现以及各编译器对其的逐步实现,C++也能进行模块化编程,提高编译速度了。
由于历史原因,现有以头文件形式组织的C++代码,不会在短时间内消失,这种情况将持续相当长的时间,也许是几年,十几年,甚至几十年或者更长,所以目前的C++依旧可以在Module中包含头文件,让之与模块(Module)共存,方便使用现有代码。
目前主流的C++编译器有GCC、Clang和MSVC,各个编译器实现的进展不一,使用的命令行参数也不一样,为了简单起见,笔者先以GCC编译器的命令行和Makefile为例来介绍模块的基本写法及编译,再介绍Clang和MVVC使用CMake来编译项目。
本文使用的各编译器版本为: GCC 13.2.0 Clang 17.0.6 MSVC 19.38.33134.0,即VS2022 17.8.4
CMake版本为:3.28.1
一、模块基础
1. 定义模块
libA.cpp:
1export module libA;
2
3export int plus(int x, int y)
4{
5 return x + y;
6}
2. 使用模块
main.cpp
1import libA;
2
3int main(int argc, char *argv[])
4{
5 plus(1, 2);
6 return 0;
7}
3. 编译:
1g++ -std=c++20 -fmodules-ts -c libA.cpp
2g++ -std=c++20 main.cpp -c main
3g++ -std=c++20 libA.o main.o -o main
需要先编译libA,再编译main
二、模块进阶
1. 接口与实现分离
前面的libA
模块代码全部在一个文件中,当代码比较多的时候,清晰度就会下降,可以使用接口与实现分离的形式:
api.cpp:
1export module libA;
2export
3{
4int plus(int x, int y);
5}
plus.cpp
1module libA;
2
3int plus(int x, int y)
4{
5 return x + y;
6}
编译:
1g++ -std=c++20 -fmodules-ts -c api.cpp
2g++ -std=c++20 -fmodules-ts -c plus.cpp
3g++ -std=c++20 main.cpp -c main
4g++ -std=c++20 api.o plus.o main.o -o main
注意编译顺序,一定是要先编译模块接口(声明)文件api.cc,再编译模块定义(实现)文件plus.cc,否则会报错:
1libA: error: failed to read compiled module: No such file or directory
2libA: note: compiled module file is 'gcm.cache/libA.gcm'
3libA: note: imports must be built before being imported
4libA: fatal error: returning to the gate for a mechanical issue
5compilation terminated.
2. Module Partition(模块分区)
当一个模块功能比较多时,C++支持将模块进行分区,每个文件只是模块中的一部分,这样可以将模块的接口与实现进一步分离。
libA/minus.cpp
1export module libA:minus;
2
3export int minus(int x, int y)
4{
5 return x - y;
6}
libA/plus.cpp
1export module libA:plus;
2
3export int plus(int x, int y)
4{
5 return x + y;
6}
libA/test.cpp
1export module libA:test;
2import <iostream>;
3#include <string.h>
4
5export class CTest
6{
7 public:
8 CTest()
9 {
10 printf("CTest()\n");
11 }
12 void foo()
13 {
14 printf("foo\n");
15 }
16};
libA/z.cpp
1export module libA;
2
3export import :plus;
4export import :minus;
5export import :test;
在接口文件中引用模块分区时不写主模块名,只写分区名。在GCC中支持写上主模块名,但Clang与MSVC都不支持,所以通用写法是不写主模块名。
编译:
1g++ -std=c++20 -fmodules-ts -xc++-system-header iostream
2g++ -std=c++20 -fmodules-ts -c libA/minus.cpp
3g++ -std=c++20 -fmodules-ts -c libA/plus.cpp
4g++ -std=c++20 -fmodules-ts -c libA/test.cpp
5g++ -std=c++20 -fmodules-ts -c libA/z.cpp
6g++ -std=c++20 main.cpp -c main
7g++ -std=c++20 libA/minus.o libA/plus.o libA/test.o libA/z.o main.o -o main
这里同样需要注意编译顺序,先编译系统级头文件,再编译自定义模块。
GCC可以使用import <iostream>;
来引用标准库头文件,但是需要先手动编译,第一句即是,引用得的标准库头文件越多,自己手动编译得也越多。注意:有些标准库头文件目前还不能使用import来引用,将头文件编译为模块时会报错。为了最大程度上与其它编译器兼容,目前不建议使用import来引用标准库头文件
自定义模块必须先编译各分区的实现(minus.cpp、plus.cpp、test.cpp),最后编译模块的接口(z.cpp)。
当文件比较多的时候,使用命令行直接一个个文件编译比较慢,也容易出错,所以可以改用Makefile来编译,编写Makefile如下:
1CXX := g++
2CXXFLAGS := -std=c++20 -fmodules-ts -gdwarf-4
3Target := main
4
5SOURCE := $(wildcard libA/*.cpp)
6SOURCE += $(wildcard *.cpp)
7
8OBJS := $(addsuffix .o, $(SOURCE))
9
10all: $(Target)
11
12$(Target): std $(OBJS)
13 $(CXX) $(CXXFLAGS) $(OBJS) -o $(Target)
14
15std:
16 $(CXX) $(CXXFLAGS) -xc++-system-header iostream
17 $(CXX) $(CXXFLAGS) -xc++-system-header cmath
18
19%.cpp.o: %.cpp
20 $(CXX) $(CXXFLAGS) -c $< -o $@
21
22clean:
23 rm $(OBJS) gcm.cache $(Target) -rf
此时只需要执行make
即可编译项目,make clean
清除生成的文件。为了让Makefile遵循前面的编译顺序,笔者在文件命名时即按字母顺序进行了相应的排序,所以$(wildcard libA/*.cpp)
得到的文件顺序是符合要求的。
为了让lldb
也可以调试生成的程序,添加了-gdwarf-4
选项。
当源文件有修改,编译时可能会出现CRC不匹配的错误,如下:
需要执行
make clean
再make
即可。
3. 子模块
子模块与模块分区非常像,只是模块分区使用冒号:
分隔,而子模块使用点号.
分隔,同时在引入子模块时,必须带有主模块名,即主模块名.子模块名
。
libA/test.cpp
1export module libA.test;
2import <iostream>;
3#include <string.h>
4
5export class CTest
6{
7 public:
8 CTest()
9 {
10 printf("CTest()\n");
11 }
12 void foo()
13 {
14 printf("foo\n");
15 }
16};
libA/z.cpp
1export module libA;
2
3export import :plus;
4export import :minus;
5export import libA.test;
4. 私有模块
私有模块必须在主模块或者子模块中写,不能写在模块分区中,格式为:
1module :private;
其后的所有内容都将作为模块的私有部分。
注意:目前GCC编译器还不支持私有模块:
1sorry, unimplemented: private module fragment
5. 引用头文件
文章开头有说过,头文件与模块方式将长期共存,模块也可以引用头文件,可以使用全局模块的方式,也可以在模块内引用。一般在全局模块中引用,模块内引用可能会报一些意想不到的错误。
全局模块引用:
1module;
2#include <iostream>
3export module libA:plus;
模块内引用:
1export module libA:plus;
2#include <stdio.h>
三、Clang与MSVC使用CMake来编译项目
前面的示例中,使用了GCC的命令行方式或者Makefile来编译C++模块代码,但是Clang与MSVC使用的参数与GCC不一样,也比GCC复杂,Clang与MSVC在默认情况下都要求模块文件使用新加的扩展名,比如Clang为.cppm
、.ccm
、.cxxm
, .c++m
,MSVC为.ixx
。同时,针对模块分区编译的输出文件也有要求,Clang与MSVC都要求为主模块名-分区名.扩展名
的格式,Clang扩展名为pcm
,MSVC为ifc
。具体信息:
GCC可以参考:
https://gcc.gnu.org/onlinedocs/gcc/C_002b_002b-Modules.html
Clang可以参考:
https://clang.llvm.org/docs/StandardCPlusPlusModules.html
MSVC可以参考:
https://learn.microsoft.com/zh-cn/cpp/cpp/modules-cpp?view=msvc-170
三大编译器不统一,对开发人员来说确实是一件痛苦的事。
好消息是CMake已经支持Clang和MSVC编译器的C++ Module了,而GCC由于还没有实现扫描模块依赖的功能,所以CMake还无法提供支持。
下面还是以前面的示例来介绍使用CMake来编译项目。
在顶层目录创建CMakeLists.txt
1cmake_minimum_required(VERSION 3.28)
2project(main)
3
4set(CMAKE_CXX_STANDARD 20)
5add_subdirectory(libA)
6
7add_executable(${PROJECT_NAME} main.cpp)
8target_link_libraries(${PROJECT_NAME} PRIVATE libA)
在libA目录创建CMakeLists.txt
1cmake_minimum_required(VERSION 3.28)
2project(libA)
3
4set(CMAKE_CXX_STANDARD 20)
5
6aux_source_directory(. SOURCE)
7#这里需要去掉路径中的./
8string(REPLACE "./" "" SOURCE "${SOURCE}")
9
10add_library(${PROJECT_NAME})
11target_sources(${PROJECT_NAME}
12 PUBLIC
13 FILE_SET cxx_modules TYPE CXX_MODULES FILES
14 ${SOURCE}
15)
注意:cmake_minimum_required(VERSION 3.28)
一定要是3.28及以上,3.28需要去掉模块路径中的./
,后面的新版本可以不用。
四、模块的分发(发布)
在实际开发中,很可能需要使用别人开发的库,或者开发库给别人使用,那如果使用C++ Module开发的库,如何给别人使用而不必给实现的源码呢?
其实前面也有说到C++ Module可以做到声明与实现分离,在开发库的过程中,将模块的声明放到一个声明文件中,比如api.cc
中,声明文件中可以再include
本模块的头文件。
示例:
api.cc
:
1// api.cc
2export module libC;
3
4#define EXPORT
5#include "test.h"
6
7export
8{
9 void moduleCFoo();
10}
test.h
:
1// test.h
2#pragma once
3
4EXPORT class CTest
5{
6public:
7 CTest();
8 void Foo();
9};
impl.cc
:
1module;
2#include <stdio.h>
3module libC;
4
5CTest::CTest()
6{
7}
8
9void CTest::Foo()
10{
11}
12
13void moduleCFoo()
14{
15 printf("moduleCFoo called\n");
16}
CMakeLists.txt
:
1project(libC)
2
3aux_source_directory(. SOURCE)
4
5add_library(${PROJECT_NAME} ${SOURCE})
6target_sources(${PROJECT_NAME}
7 PUBLIC
8 FILE_SET cxx_modules TYPE CXX_MODULES FILES
9 api.cc
10)
在给别人库的时候,只需要再把api.cc
及test.h
给出,别人就知道这个模块导出了哪些内容,原型是什么了,但别人又不会直接include,也不会知道实现的源码!
如果本文对你有所帮助,欢迎点赞收藏!
- 原文作者:Witton
- 原文链接:https://wittonbell.github.io/posts/2024/2024-01-23-C++-20-Module/
- 版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议. 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。