Clangd中的C++20模块支持
作者:许传奇
这篇博客的目的是鼓励更多的人来贡献Clangd中的C++20模块支持.我发现,抱怨C++20模块的智能提示的人数,与Clangd中C++20模块支持的贡献者人数,及Clangd中C++20模块支持的复杂度,这三者并不成比例.
所以我推测大多数人可能没有理解Clangd中C++20模块不难.所以写了这篇博客,希望说明,与编译器中模块支持的复杂度不同,在clangd(及类似的工具,如clangtidy或其他语言服务器)中不难支持C++20模块.
此外也在此推荐下Clice,虽然当前还没有支持模块,但看上去前景不错,这里.
C++20模块前置背景
在C++20模块前,可直接编译所有的编译单元.几乎所有C++工具(构建系统,静态分析工具等等)都依赖这一前提.
然而在引入C++20模块后,该前提就错误了.这是C++20模块会对工具链造成巨大影响的原因,也是当前C++20模块应用进度相对较慢的原因.
以一个最简单的C++20模块示例:
cpp
//`a.cppm`
export module a;
//`b.cppm`
export module b;
import a;
为了编译b.cppm,现在"需要"使用两条编译命令:
cpp
$ clang++ -std=c++20 a.cppm --precompile -o a.pcm
$ clang++ -std=c++20 b.cppm -fmodule-file=a=a.pcm -fsyntax-only
对该极简单的情况,编译器可能用一条语句处理,但这不具备可扩展性,除非把构建系统塞到编译器里,但这里不讲它.
这里的a.pcm叫BMI*,是编译器对a.cppm需要对外暴露信息序化后的结果.所有直接或间接``import a;的编译单元,都需要a.cppm的BMI才可编译它.
BMI指二进制模块接口.
工具链支持模块要求步骤
除了编译器外的工具链,为了支持模块需要完成:
1,对任意包含导入的编译单元,需要计算出其直接和间接导入的模块单元,这里称这些模块单元为要求模块单元.
并根据此信息计算出编译单元的有向无环图依赖图.
2,取这些要求模块单元对应的BMI,当不存在此BMI时,工具需要根据有向无环依赖图调用编译器以生成要求的BMI.
3,对一个编译单元,当其存在导入时,需根据以上两条信息,拼接出编译此编译单元要求的编译命令.
特别地,当构建了一个项目,并生成了编译数据库后,对其中的每一个编译单元,实际上既有编译此单元要求的编译命令,又有编译此单元所需的全部BMI,所以此时其实是可以用很多工具的.
编辑数据库
因此可在不显式支持clangtidy时,集成clangtidy到使用模块的项目中的原因.此外,因此,建议构建系统搞个特别的叫"buildBMIsonly"的模式.
这样可让用户在只需要clangtidy此场景里只构建项目里所有的BMI而不需要编译整个项目.
Clangd支持模块要求步骤
同上,当构建一个项目并生成编译数据库后,将该编译数据库导入Clangd,可能会发现此时的Clangd突然可在使用模块的项目中工作了.
但这并不理想,因为:
1,这要求Clangd的版本和构建时使用的Clang必须是完全一致的.暗示了不能使用GCC或MSVC为构建工具.这和Clangd的设计理念不符.
2,Clangd按语言服务器需要及时反馈.但clangd如果不显式支持模块的话,当修改了模块接口后,对应的BMI是不会自动更新的,也无法看到对应的修改的,除非重构一遍项目.此用户体验不好.
这里的第二点是clangd显式支持C++20模块更迫切的主要原因.其他工具如clangtidy是可接受先构建后分析的工作流的,但对clangd而言,不能即时看到更改的用户体验差异会非常大.
Clangd``工作原理
高级来说,Clangd的核心逻辑在clangtoolsextra/clangd/TUScheduler.cpp中,其中有详细的注释,大家感兴趣的可阅读.
其中Clangd会为打开的每一个选项卡页创建一个工作者,该工作者在一个单独的线程中.每个工作者会调用Clang的API来编译其打开的页,取AST.
当后续事件来临时(如鼠标悬浮,要求跳转等),工作者会先检查之前的AST是否依然可用,如果已过时了,工作者会重新编译,然后根据AST响应各种事件的请求.
因此,Clangd支持C++20模块的工作主要集中在编译部分和检测编译结果是否过时两部分.
对利用AST的部分,在不要求新功能,只要求clangd可继续工作时,暂时可不用关心.新功能的示例之一即编写import A时,Clangd可自动提示后面可能的模块名,还没有支持这部分工作.
编译
如前,在引入C++20模块前,每个编译单元都是独立的,只需要对应的编译命令(当然,及对应的文件)就可编译.
Clangd用编译数据库取每个编译单元的编译命令,然后编译当前页以取AST.这部分逻辑也主要在TUScheduler.cpp中.
特别地,为了加速编译,Clangd的工作者为其打开的选项卡页使用PCH(预编译头技术)提前编译头文件,并将编译结果保存起来,叫(Preamble)预制件.
好处是当打开一个选项卡页,并修改该页的内容时,(如果没有修改头文件区域),重新编译当前制表符内容时可避免重新编译头文件区域的内容.
这是很好的优化手段,大大加快了Clangd的处理速度.
当然也有坏处,因为每个预制件属于不同ASTWorker,所以打开新的选项卡页时,Clangd依旧会花费额外的时间,编译该选项卡页的预制件,这会浪费时间.
此外因为每个选项卡页都有自己的预制件,可以预见当打开大量选项卡页时Clangd会花费更多内存.
从该角度,可知,对充分使用C++20模块的项目,在完成热身后,将大大减少Clangd消耗的内存.
检测编译结果是否过时
预制件设计的另一个意义是,它按两部分划分选项卡页的内容,一个是当前页的依赖,另一个是该页的内容自身.
此时检测编译结果是否过时,则自然变成了检测当前页内容是否过时,及依赖的内容是否过时两部分.检测当前页是否过时很简单.
只需要检测依赖的内容,及预制件是否过时即可.
这部分在isPreambleCompatible中实现,这里.
Clangd中支持C++20模块
高级现状
当前Clangd中已基本支持C++20模块,只需要传入--experimental-modules-support选项即可打开.
本地功能符合期望,但社区里还是有很多抱怨,很多问题也不好复现.这也是写这篇博客的主要动机,大家可在自己的环境里简单检查下,发现clangd的漏洞或顺手开发的新特征也可贡献到社区.
如前,使用C++20模块需要传入编译数据库.编译数据库中需要包含当前项目中所有TU的编译选项.
社区常见的几个错误报告或为没有传入编译数据库,或为编译数据库没有包含必须的编译单元及其编译命令.
如,如果你的项目使用了import std;则需要在传入的编译数据库中看到提供std module的编译单元.
测试与调试
因为Clangd可编译数据库复用构建系统构建的BMI文件,大家测试Clangd的C++20模块能力时,可在生成编译数据库后删除所有BMI,避免假阳性现象的出现.
当大家当前使用clangd的C++20模块特征时,如果有问题,可先使用--log=verbose观察Clangd的输出,可能会有发现.
若发现Clangd编译模块单元报错时,需要检查是否可用同版本的Clang``编译器构建你的项目.
如果可以,但是Clangd编译模块单元时依然报错甚至崩溃,可试用--debug-modules-builder选项.
否则,Clangd会在退出时,删除所有其构建的BMI文件,这不利于复现Clangd的报错或崩溃.
在打开该选项后,Clangd不会删除其构建的BMI文件.结合--log=verbose中显示的clangd构建编译数据库所使用的编译命令,就可在终端中复现Clangd的报错了.
另根据推测,在#include与导入大量混用的项目中,Clangd和Clang的编译结果不一致的原因,可能是Clangd自带的PCH和C++20模块间的兼容问题.
PCH,Clang头模块和C++20模块间理论上是兼容的,但缺乏足够的测试和实践,在早期开发Clangd中C++20模块支持时,遇见的不少问题就是Clangd自带的PCH和C++20模块二者交互时产生的问题.
当前在Clangd中,没有看到可关闭ClangdPCH功能的选项,之后可能可以增加一个(避免在使用导入的TU中构建PCH).
支持思路
如前,在clangd中支持C++20模块,需要打开任意一个选项卡页时,找到其直接和间接导入的所有模块单元的RequiredModuleUnits,然后找到或构建这些要求模块单元所对应的BMI,然后拼接编译选项即可.
第一个问题是,在源码当中,只能看到import a;import b;import c;此语句,并不能确定当前TU中import a;所指的a模块是由哪一个模块单元提供的.
为此,对每个选项卡页,会在看到导入时扫描整个项目,以取从模块名到模块单元的映射.会按缓存全局维护一个模块名到模块单元的映射.
然后可递归地得到当前TU,直接依赖和间接依赖的模块单元的RequiredModuleUnits.然后可自下而上地对要求模块单元中的模块单元执行GetOrBuildBMI逻辑.
并按缓存维护一个全局的模块名到BMI文件的映射.
同时因为导入和#include都可以说,是为当前TU引入依赖,导入也一般,会放在TU的最前面,所以当前TU要求的BMI自然变成了当前TU的预制件.
与PCH不同,每个选项卡页只存一份到全局BMI文件的引用,避免了相同BMI文件的反复构建和冗余存储.
所以也需要在isPreambleCompatible中管理当前TU所导入的模块是否依然可复用.
可用编译器提供的能力来检测BMI是否过时.
以上,即是Clangd中支持C++20模块的全部高级逻辑了.其中真正最复杂的两个逻辑:"如何构建BMI"和"如何检测BMI是否过时"实际上都是Clang完成的,Clangd中只需要管理,如何调用BMI及如何管理BMI的生命期了.
实现
本节说明Clangd对C++20模块的实现细节.
可用如下三个抽象来理解Clangd中C++20模块支持的入口
1,项目模块,查询项目中的模块信息
2,PrerequisiteModules,一个文件要求的模块信息,也是预制件中要求的模块信息.
3,ModulesBuilder,构建模块文件(.pcm)
项目模块
cpp
class ProjectModules {
public:
using CommandMangler =
llvm::unique_function<void(tooling::CompileCommand &, PathRef) const>;
virtual std::vector<std::string> getRequiredModules(PathRef File) = 0;
virtual std::string getModuleNameForSource(PathRef File) = 0;
virtual std::string getSourceForModuleName(llvm::StringRef ModuleName, PathRef RequiredSrcFile) = 0;
virtual void setCommandMangler(CommandMangler Mangler) {}
virtual ~ProjectModules() = default;
};
项目模块用来查询项目中模块的信息.其中setCommandMangler(CommandMangler Mangler)用来处理clang处理不了的命令,一般来使用GCC编译的场景下.
其他接口来查看文件直接导入的模块,查看一个文件的模块名及查看一个模块名对应的文件.
因为其生态位类似编译数据库,即为clangd提供项目的信息,所以接口上设计需要从GlobalCompilationDatabase取项目模块.
ScanningAllProjectModules
cpp
class ScanningAllProjectModules : public ProjectModules {
public:
ScanningAllProjectModules(
std::shared_ptr<const clang::tooling::CompilationDatabase> CDB,
const ThreadsafeFS &TFS)
: Scanner(CDB, TFS) {}
~ScanningAllProjectModules() override = default;
std::vector<std::string> getRequiredModules(PathRef File) override;
void setCommandMangler(CommandMangler Mangler) override;
//并非故意使用`RequiredSourceFile`.
//细节见`ModuleDependencyScanner`的注释.
std::string getSourceForModuleName(llvm::StringRef ModuleName, PathRef RequiredSourceFile) override;
std::string getModuleNameForSource(PathRef File) override;
private:
ModuleDependencyScanner Scanner;
CommandMangler Mangler;
};
std::unique_ptr<ProjectModules> scanningProjectModules(
std::shared_ptr<const clang::tooling::CompilationDatabase> CDB,
const ThreadsafeFS &TFS) {
return std::make_unique<ScanningAllProjectModules>(CDB, TFS);
}
ScanningAllProjectModules则实现了前述的扫描编译数据库中所有文件的逻辑.实现底层调用了与clang-scan-deps中扫描模块相同的API.
CachingProjectModules
cpp
class CachingProjectModules : public ProjectModules {
public:
CachingProjectModules(std::unique_ptr<ProjectModules> MDB, ModuleNameToSourceCache &Cache);
std::vector<std::string> getRequiredModules(PathRef File) override;
std::string getModuleNameForSource(PathRef File) override;
std::string getSourceForModuleName(llvm::StringRef ModuleName, PathRef RequiredSrcFile) override;
private:
std::unique_ptr<ProjectModules> MDB;
ModuleNameToSourceCache &Cache;
};
CachingProjectModules用来全局缓存模块名到模块单元的映射.避免反复扫描所有文件造成浪费.
PrerequisiteModules
cpp
class PrerequisiteModules {
public:
//在本`PrerequisiteModules`中,
//先更改命令,来加载记录的模块文件.
virtual void
adjustHeaderSearchOptions(HeaderSearchOptions &Options) const = 0;
//无论`构建模块文件`是否是最新的.
//注意,这只能在`构建模块文件`之后使用.
virtual bool
canReuse(const CompilerInvocation &CI,
llvm::IntrusiveRefCntPtr<llvm::vfs::FileSystem>) const = 0;
virtual ~PrerequisiteModules() = default;
};
PrerequisiteModules用来表示一个TU需要的模块信息.这里只输出了两个接口,canReuse()用来查询这些BMI是否已过时,void adjustHeaderSearchOptions(HeaderSearchOptions &Options)则用来将BMI信息直接拼接到编译命令中.这里专门避免了泄露BMI信息.
如前,PrerequisiteModules的语义天然地属于一个TU的前置信息,所以在预制数据中放置它.
cpp
struct PreambleData {
PreambleData(PrecompiledPreamble Preamble) : Preamble(std::move(Preamble)) {}
//本预制件所基于的解析输入版本.
std::string Version;
tooling::CompileCommand CompileCommand;
//构建预制件时使用的目标选项.
//变更目标可能导致,反序化预制件时崩溃,
//这使得消费者(而无需重新解析`CompileCommand`)可用相同目标.
std::unique_ptr<TargetOptions> TargetOpts = nullptr;
PrecompiledPreamble Preamble;
std::vector<Diag> Diags;
//像补全代码和转向定义此流程需要`#include`信息,
//而它们的编译操作跳过预制件范围.
IncludeStructure Includes;
//在`#included``头文件`中捕捉`#include`映射信息.
std::shared_ptr<const include_cleaner::PragmaIncludes> Pragmas;
+
//本预制件要求的模块文件的信息.
+ std::unique_ptr<PrerequisiteModules> RequiredModules;
//宏定义在主文件的预制件节.
//用户关心的是`头文件`与主文件,而不是预制件与非预制件.
//应按主文件实例对待这些,如补全代码.
MainFileMacros Macros;
//在主文件的预制件节定义`Pragma`标记.
std::vector<PragmaMark> Marks;
//构建预制件时执行的`FS`操作缓存.
//在重用预制件时,可消耗该缓存以节省`IO`.
std::shared_ptr<PreambleFileStatusCache> StatCache;
//主文件是否存在(可能不完整的)`include`护卫.
//需要"手动"将这些信息传播到`后续的解析`中.
bool MainIsIncludeGuarded = false;
};
ModulesBuilder
cpp
class ModulesBuilder {
public:
ModulesBuilder(const GlobalCompilationDatabase &CDB);
~ModulesBuilder();
ModulesBuilder(const ModulesBuilder &) = delete;
ModulesBuilder(ModulesBuilder &&) = delete;
ModulesBuilder &operator=(const ModulesBuilder &) = delete;
ModulesBuilder &operator=(ModulesBuilder &&) = delete;
std::unique_ptr<PrerequisiteModules>
buildPrerequisiteModulesFor(PathRef File, const ThreadsafeFS &TFS);
private:
class ModulesBuilderImpl;
std::unique_ptr<ModulesBuilderImpl> Impl;
};
ModulesBuilder是clangd实现C++20模块的核心逻辑.主要逻辑都在ModulesBuilder.cpp中.
cpp
std::unique_ptr<PrerequisiteModules>
ModulesBuilder::buildPrerequisiteModulesFor(PathRef File, const ThreadsafeFS &TFS) {
std::unique_ptr<ProjectModules> MDB = Impl->getCDB().getProjectModules(File);
if (!MDB) {
elog("Failed to get Project Modules information for {0}", File);
return std::make_unique<FailedPrerequisiteModules>();
}
CachingProjectModules CachedMDB(std::move(MDB), Impl->getProjectModulesCache());
std::vector<std::string> RequiredModuleNames =
CachedMDB.getRequiredModules(File);
if (RequiredModuleNames.empty())
return std::make_unique<ReusablePrerequisiteModules>();
auto RequiredModules = std::make_unique<ReusablePrerequisiteModules>();
for (llvm::StringRef RequiredModuleName : RequiredModuleNames) {
//如果出错,提前返回.
if (llvm::Error Err = Impl->getOrBuildModuleFile( File, RequiredModuleName, TFS, CachedMDB, *RequiredModules.get())) {
elog("Failed to build module {0}; due to {1}", RequiredModuleName, toString(std::move(Err)));
return std::make_unique<FailedPrerequisiteModules>();
}
}
return std::move(RequiredModules);
}
ModulesBuilder::buildPrerequisiteModulesFor会去调用ModulesBuilderImpl::getOrBuildModuleFile.
cpp
llvm::Error ModulesBuilder::ModulesBuilderImpl::getOrBuildModuleFile(
PathRef RequiredSource, StringRef ModuleName, const ThreadsafeFS &TFS,
CachingProjectModules &MDB, ReusablePrerequisiteModules &BuiltModuleFiles) {
if (BuiltModuleFiles.isModuleUnitBuilt(ModuleName))
return llvm::Error::success();
std::string ModuleUnitFileName =
MDB.getSourceForModuleName(ModuleName, RequiredSource);
//有可能遇见的是第三方模块(源码不在项目中,
//如`大多数`项目的`标准模块`可能是`第三方模块`),
//或是`项目模块`的实现有问题.
//`FIXME`:应该如何处理第三方模块?如果想忽略第三方模块,这里应该返回`真`而不是`假`.
//当前只是选出.
if (ModuleUnitFileName.empty())
return llvm::createStringError(
llvm::formatv("Don't get the module unit for module {0}", ModuleName));
//尽量先从编译`数据库`里取`预构建的模块文件`.
//这帮助避免构建`编译器`已构建的模块文件.
getPrebuiltModuleFile(ModuleName, ModuleUnitFileName, TFS, BuiltModuleFiles);
//按拓扑顺序取要求的模块.
auto ReqModuleNames = getAllRequiredModules(RequiredSource, MDB, ModuleName);
for (llvm::StringRef ReqModuleName : ReqModuleNames) {
if (BuiltModuleFiles.isModuleUnitBuilt(ReqModuleName))
continue;
if (auto Cached = Cache.getModule(ReqModuleName)) {
if (IsModuleFileUpToDate(Cached->getModuleFilePath(), BuiltModuleFiles, TFS.view(std::nullopt))) {
log("Reusing module {0} from {1}", ReqModuleName, Cached->getModuleFilePath());
BuiltModuleFiles.addModuleFile(std::move(Cached));
continue;
}
Cache.remove(ReqModuleName);
}
std::string ReqFileName =
MDB.getSourceForModuleName(ReqModuleName, RequiredSource);
llvm::Expected<std::shared_ptr<BuiltModuleFile>> MF = buildModuleFile(
ReqModuleName, ReqFileName, getCDB(), TFS, BuiltModuleFiles);
if (llvm::Error Err = MF.takeError())
return Err;
log("Built module {0} to {1}", ReqModuleName, (*MF)->getModuleFilePath());
Cache.add(ReqModuleName, *MF);
BuiltModuleFiles.addModuleFile(std::move(*MF));
}
return llvm::Error::success();
}
删除已构建模块的情况,构建模块的逻辑一方面是先从构建命令中,取之前编译好的BMI(即如前的由构建系统构建的BMI),然后递归的扫描所有依赖的文件以取所有直接或间接导入的模块单元.
之后若无法在缓存中,找到对应的BMI,则用buildModuleFile接口构建.
cpp
//为`"模块名"`的模块构建一个模块文件.
//在`\param BuiltModuleFiles`中保存`构建模块文件`的信息.
llvm::Expected<std::shared_ptr<BuiltModuleFile>>
buildModuleFile(llvm::StringRef ModuleName, PathRef ModuleUnitFileName,
const GlobalCompilationDatabase &CDB, const ThreadsafeFS &TFS,
const ReusablePrerequisiteModules &BuiltModuleFiles) {
//如果有问题,尽早试便宜的操作,这样可便宜地解决.
auto Cmd = CDB.getCompileCommand(ModuleUnitFileName);
if (!Cmd)
return llvm::createStringError(
llvm::formatv("No compile command for {0}", ModuleUnitFileName));
llvm::SmallString<256> ModuleFilesPrefix =
getUniqueModuleFilesPath(ModuleUnitFileName);
Cmd->Output = getModuleFilePath(ModuleName, ModuleFilesPrefix);
ParseInputs Inputs;
Inputs.TFS = &TFS;
Inputs.CompileCommand = std::move(*Cmd);
IgnoreDiagnostics IgnoreDiags;
auto CI = buildCompilerInvocation(Inputs, IgnoreDiags);
if (!CI)
return llvm::createStringError("Failed to build compiler invocation");
auto FS = Inputs.TFS->view(Inputs.CompileCommand.Directory);
auto Buf = FS->getBufferForFile(Inputs.CompileCommand.Filename);
if (!Buf)
return llvm::createStringError("Failed to create buffer");
//在`Clang`的驱动中,将抑制`GMF`中`ODR`违规的检查.
//详见`Clang.cpp`中的`RenderModulesOptions`实现.
CI->getLangOpts().SkipODRCheckInGMF = true;
//哈希`输入文件`内容,并在`BMI`文件中存储哈希值.
//这样当想重用`BMI`文件时,
//可检查这些文件是否仍有效.
CI->getHeaderSearchOpts().ValidateASTInputFilesContent = true;
BuiltModuleFiles.adjustHeaderSearchOptions(CI->getHeaderSearchOpts());
CI->getFrontendOpts().OutputFile = Inputs.CompileCommand.Output;
auto Clang =
prepareCompilerInstance(std::move(CI), /*预制件=*/nullptr, std::move(*Buf), std::move(FS), IgnoreDiags);
if (!Clang)
return llvm::createStringError("Failed to prepare compiler instance");
GenerateReducedModuleInterfaceAction Action;
Clang->ExecuteAction(Action);
if (Clang->getDiagnostics().hasErrorOccurred()) {
std::string Cmds;
for (const auto &Arg : Inputs.CompileCommand.CommandLine) {
if (!Cmds.empty())
Cmds += " ";
Cmds += Arg;
}
clangd::vlog("Failed to compile {0} with command: {1}", ModuleUnitFileName, Cmds);
std::string BuiltModuleFilesStr = BuiltModuleFiles.getAsString();
if (!BuiltModuleFilesStr.empty())
clangd::vlog("The actual used module files built by clangd is {0}", BuiltModuleFilesStr);
return llvm::createStringError(
llvm::formatv("Failed to compile {0}. Use '-log=verbose' to view "
"detailed failure reasons. It is helpful to use "
"'-debugmodulesbuilder' flag to keep the clangd's "
"built module files to reproduce the failure for "
"debugging. Remember to remove them after debugging.", ModuleUnitFileName));
}
return BuiltModuleFile::make(ModuleName, Inputs.CompileCommand.Output);
}
这段代码的核心逻辑即是从编译数据库中取编译选项,然后调用Clang,API编译.
其中
cpp
BuiltModuleFiles.adjustHeaderSearchOptions(CI->getHeaderSearchOpts());
使用clangd构建的BMI替换编译数据库中提供的BMI.用户可理解为替换/增加编译命令中的-fmodule=<module-name>=<BMI-path>选项.
cpp
CI->getHeaderSearchOpts().ValidateASTInputFilesContent = true;
这是允许编译器中的一致性检查功能.编译器在写BMI时会记录所有的输入文件(模块单元,即包含的文件).
然后当ValidateASTInputFilesContent选项为真时,当导入此BMI时,编译器会检查此前纪录的所有文件的哈希值是否依然相等.
重用
接上文,用编译器的能力,可试导入的方式测试是否依然可复用BMI.
cpp
bool ReusablePrerequisiteModules::canReuse(
const CompilerInvocation &CI,
llvm::IntrusiveRefCntPtr<llvm::vfs::FileSystem> VFS) const {
if (RequiredModules.empty())
return true;
llvm::SmallVector<llvm::StringRef> BMIPaths;
for (auto &MF : RequiredModules)
BMIPaths.push_back(MF->getModuleFilePath());
return IsModuleFilesUpToDate(BMIPaths, *this, VFS);
}
bool IsModuleFileUpToDate(PathRef ModuleFilePath,
const PrerequisiteModules &RequisiteModules,
llvm::IntrusiveRefCntPtr<llvm::vfs::FileSystem> VFS) {
HeaderSearchOptions HSOpts;
RequisiteModules.adjustHeaderSearchOptions(HSOpts);
HSOpts.ForceCheckCXX20ModulesInputFiles = true;
HSOpts.ValidateASTInputFilesContent = true;
clang::clangd::IgnoreDiagnostics IgnoreDiags;
DiagnosticOptions DiagOpts;
IntrusiveRefCntPtr<DiagnosticsEngine> Diags =
CompilerInstance::createDiagnostics(*VFS, DiagOpts, &IgnoreDiags, /*`ShouldOwnClient=`*/false);
LangOptions LangOpts;
LangOpts.SkipODRCheckInGMF = true;
FileManager FileMgr(FileSystemOptions(), VFS);
SourceManager SourceMgr(*Diags, FileMgr);
HeaderSearch HeaderInfo(HSOpts, SourceMgr, *Diags, LangOpts, /*目标=*/nullptr);
PreprocessorOptions PPOpts;
TrivialModuleLoader ModuleLoader;
Preprocessor PP(PPOpts, *Diags, LangOpts, SourceMgr, HeaderInfo, ModuleLoader);
IntrusiveRefCntPtr<ModuleCache> ModCache = createCrossProcessModuleCache();
PCHContainerOperations PCHOperations;
CodeGenOptions CodeGenOpts;
ASTReader Reader(PP, *ModCache, /*`ASTContext=`*/nullptr, PCHOperations.getRawReader(), CodeGenOpts, {});
//不需要任何监听器.
//默认,它会使用验证`监听器`.
Reader.setListener(nullptr);
if (Reader.ReadAST(ModuleFilePath, serialization::MK_MainFile, SourceLocation(), ASTReader::ARR_None) != ASTReader::Success)
return false;
bool UpToDate = true;
Reader.getModuleManager().visit([&](serialization::ModuleFile &MF) -> bool {
Reader.visitInputFiles(
MF, /*`IncludeSystem=`*/false, /*抱怨=*/false,
[&](const serialization::InputFile &IF, bool isSystem) {
if (!IF.getFile() || IF.isOutOfDate())
UpToDate = false;
});
return !UpToDate;
});
return UpToDate;
}
bool IsModuleFilesUpToDate(
llvm::SmallVector<PathRef> ModuleFilePaths,
const PrerequisiteModules &RequisiteModules,
llvm::IntrusiveRefCntPtr<llvm::vfs::FileSystem> VFS) {
return llvm::all_of(
ModuleFilePaths, [&RequisiteModules, VFS](auto ModuleFilePath) {
return IsModuleFileUpToDate(ModuleFilePath, RequisiteModules, VFS);
});
}
该做法最大的缺点是加载BMI的成本过高.这里应该有充足的优化空间.
未来待办
至此,已介绍完了当前Clangd中C++20模块的支持原理和情况.大家在遇见问题时可先根据自己的情况试修复.除了修复漏洞外,其他能加入的优化和特征:
重用优化
如上,当前的ModulesBMIReuse的实现依赖编译器加载BMI后再给出结果.太慢了.可能可在Clangd中,记录每个BMI涉及到的文件,然后重用时直接比较这些文件.
扫描优化
现在虽然有扫描缓存,但对超大规模项目可能会因为长时扫描有比较长的热身时间.可试一些方式优化.
后缀优化
一个简单可行的优化是,在找模块名到模块单元的映射时,可只扫描带特殊后缀(.cppm,.ixx)的文件.
LazyScanning
另一个优化是避免全局扫描,而是根据需要查询模块名,来按需扫描,如当需要a模块时,当发现一个模块单元声明了a模块,就可停止扫描返回该结果了.
该优化可和后缀优化一起使用.
BMI持久化
现在clangd为了避免资源泄漏,会在关闭制表符时减少此制表符要求的BMI的引用.当一个BMI的引用归0时,就会删掉该BMI.
同时直接关闭clangd时,也会删除clangd构建的所有BMI.
这导致关闭clangd再打开clangd后,可能会等待较长的时间构建许多BMI.可设计一些策略,在避免资源泄漏时,将BMI持久地存储下来.
模块的恰当编译数据库
在前文,我有意地避免提及当前clangd对C++20模块支持的两个缺陷,这两个缺陷需要更好的编译数据库来支持.相关工作有p2977r2.这里
重复的模块名
考虑如下项目:
cpp
//`a.cppm`
export module a;
//`a.v1.cppm`
export module a;
//`main.cc`
import a;
int main() { return 0; }
//==
add_executable(exe main.cc a.cppm)
add_library(a a.v1.cppm)
//(`假cmake`)
这是个正确的项目吗?该项目在不同TU中包含了重复的模块.该项目是正确的.因为ISOCPP的规定是,在链接的程序中,禁止有重名的模块.
但未链接在一起,则重名并不影响正确性.
在clangd中,错误的假设了整个项目中不包含重名的模块.在当前的设计中,clangd也没法处理不同模块单元声明相同模块.
因为clangd没有足够的信息来判断,对特定的TU,哪一个模块单元才是正确的.
clangd需要构建系统提供更细粒度的编译信息才可做出正确的判断.
相同模块单元的不同编译选项
如
cpp
//`a.cc``编译时`用`std=c++23`
import std;
...
//`b.cc``编译时`用`std=c++26`
import std;
...
本例中,最好,需要将为std模块提供-std=c++23和-std=c++26两个版本的BMI.但当前缺乏足够的信息.
除了依赖构建系统提供更好的编译数据库外,对它clangd可能可做一些更有效率的猜测.
BMI构建库
如上,clangd支持模块的逻辑,其实和很多其他工具支持模块要求的逻辑是高度类似的.此时,按一个单独的库抽象这些逻辑会好很多.
不然clangtidy等工具可能每个都得类似处理.
总结
本文介绍了Clangd中C++20模块支持的方法和现状,希望可鼓励更多人参与到Clangd对C++20模块的支持中,也希望对大家使用clangd的C++20模块有帮助