目录
一、什么是modules
二、vs版本要求
三、项目配置
四、标准库模块引入方式
五、兼容旧式写法
六、导出类与变量
七、文件后缀
八、实现与主接口分开
九、模块命名与分区
十、引入模块
十一、模块分区
十一、模块私有部分
十二、结语
大家好,我是略游。今天讲一讲我在vs上操作C++20新标准模块(modules)的经验。此文章具有时效性,只代表当前实验结果,也可作为一个基本写法入门,将来的编译器一定会完善得更好。
modules是C++20标准的源码组织方式,以替代#include等传统的源码组织方式。与它相关的基本知识网上有很多文章,废话不多说直接实际操作。
官方文档说16.10版本以上,更新最新版本即可。使用vs2022也可,不过需要注意的是需要安装以下工具:
虽然在随后的实验中,我没有成功使用上标准库的模块接口,但这个工具是否必须安装,我暂时懒得测试了,在这里获取工具:
首先设置C++语言标准为/std:c++ latest,并且启用标准库模块:
在定义使用最新特性的命令行/await:
在链接器中关闭使用增量链接,同时使用程序数据库,这二者是配套的,如果不关闭增量链接,会与模块编译有冲突,后者不能正常识别修改。
按照微软文档的说法,如下即可导入标准库模块:
import std.core;
import std.filesystem;
//...
但实际操作中很困难,会提示一些编译选项冲突。除了/EHsc和/MD选项,还有一些其他的定义冲突,例如_DEBUG宏。
文档链接如下:
Overview of modules in C++ | Microsoft Docs
要以#include包含旧式头文件,可以如下写:
module;
#include "Head.h"
export module Test;
这里包含了一个头文件Head.h,然后导出了一个模块叫Test。但是此处有一些局限性,Head.h不能是预编译头,预编译头是msvc的一种加快编译速度的方式,但是这里是不能兼容的。
在Head.h里面我包含了一大串头文件,这样我们也能使用上标准库。当前这样的写法,缺点是每次修改都需要重新编译所包含的头文件内容。预编译头可以解决这个问题,等完全使用上模块后,也能解决这个问题。现在只是兼容的写法。
//Head.h
#pragma once
#include //!< 数组
#include //!< 链表
//more...
值得一提的是,我在包含windows头文件时,遇到一些报错无法解决,最后只好不直接包含windows头文件了,暂时使用传统方法隐藏起来。
语法很简明,在“module;”和“export module Test;”之间就是旧式的包含头文件方式。
前面加export即可导出类与全局变量,如下:
module;
#include "Head.h"
export module Test;
export class Test
{
//...
};
export Test g_test;
这里就不用再像以往一样,害怕变量重定义,不能直接放置到头文件。在以前的写法就是这样的:
//--------------Test.h
#include "Head.h"
class Test
{
//...
};
extern Test g_test;
//--------------Test.cpp
Test g_test;
另外constexpr常量并不能直接导出,因为它是不变量,在编译期就会替换为数值,并不会真实存在这个变量,只能通过命名空间间接导出。
//-----------failed
export constexpr size_t DEFINE_NUM_GOODS_00 = 16 * 22;
//-----------ok
export
namespace Define
{
constexpr size_t NUM_GOODS_00 = 16 * 22;
}
C++标准没有规定文件的后缀名必须是什么,但是到目前为止,msvc必须使用.ixx后缀名,否则编译器会报错。
同时还应该设置文件项类型为C/C++编译器 ,如下图所示:
为了区分主接口文件,我们可以让实现文件后缀为.cpp,但仍然注意属性页里的项类型也要是C/C++编译器。
直接右键添加一个模块文件,这样也是正确的:
我们可以直接在主接口文件实现Test::Init成员函数的定义,但也可以分开文件存放。例如以下文件Test.cpp,在兼容旧写法的同时,还需表明自己是Test模块的一部分。
module;
#include "Head.h"
module Test;
void Test::Init()
{
}
在以前的写法,将函数定义在头文件的严重缺点就是,修改此函数就会导致整个文件被修改,然后所有包含此文件的源码都需要重新编译。而模板函数必须放在头文件,不但编译速度极慢,而且无法规避这个问题。而使用模块后便可以改进这个缺点,同时我们还能保持文件分开以保证代码的整洁性。
在一些示例中我们可以看到用点来分隔名字,比如export module Test.A,但实际上Test.A就是一个模块的名字,它是一个整体,并不代表Test模块的A分区(点在这里没有任何意义,只是为了好看)。正规的分区写法如下:
//Test.A是一个独立的模块
export module Test.A;
//A是Test的分区
export module Test:A;
另外微软推荐文件名命名与模块名相似,假设一个模块叫Test.Point:Draw,那么它的文件名应该是Test.Point-Draw.ixx,实现文件可以叫Test.Point-Draw.cpp。即用-来代替:。
很简单,如下写即可:
import Test;
如果想要“转发”模块,在自己引入X的时候,同时引入自己的地方也会引入X,如下所示:
export module Test;
export import Test.Image;
export import Test.Sprite;
export import Test.Tex;
export import Test.Text;
export import Test.UI;
当另一个文件引入Test时,就不需要再引入Test.Image等了,只需import Test就会import Test.Image等。注意这里的.只是名字的一部分,而不是模块分区,只是为了好看。
如果只是Test自己使用,去掉export则不会转发:
export module Test;
import Test.Image;
import Test.Sprite;
import Test.Tex;
import Test.Text;
import Test.UI;
前面用“.”来分隔名字只是一种权宜手段,它们本质上是单独的模块,其余文件不需要通过Test来导入Test.Image,可以直接导入Test.Image。而分区模块如果主接口没有导出,则其他文件是使用不了的。
假设有个Test:Struct分区,在Main.cpp想要导入。那么这么写是无效的:
import Test:Struct; //error
而用.的话,就可以:
import Test.Image; //ok
所以用分区的区别在于此,并且从定义上来说Test:Image是真正属于Test的。
要定义一个模块分区,如下:
export module Test:Struct;
export
struct A
{
//...
};
在上面的代码,在Test:Struct分区导出了一个类A。在Test主接口文件,同样可以选择import或者export import。这决定了Test:Struct是否对外界可见。
//导出模块Test
export module Test;
//导入Define
import Define;
//导入Test.Image并且使导入Test的自动导入Test.Image
export import Test.Image;
//导入Test.Ui供自己使用
import Test.UI;
//导入分区,但是不导出,也可以前面加export导出
import :Struct;
注意import模块分区时,则必须省略冒号前面的模块名, 想必编译器也是由此判断的。因为不属于Test的模块会import Test:Struct,然而这是不允许的。而属于Test的模块,是知道自己在Test的,所以可以省略“:”前的Test。
在声明module :private之后的内容只对自己文件可见,当然这是对于实现文件和模块分区来说的。因为一个类或变量是否导出,取决于前面是否有export关键字。
module :private;
如果你觉得此文章有帮到你,可以点击收藏,然后点击关注,这可以极大的支持我发更多的文章。
你还可以加我的QQ群讨论:游戏编程星云阁 170100866