优秀的编程知识分享平台

网站首页 > 技术文章 正文

C++ 避免使用模块重新编译模板库(调用c++模块,不忽略异常)

nanyue 2024-10-18 07:36:09 技术文章 9 ℃


头文件自从 C 语言诞生之初就存在了。最初,它们主要用于文本替换宏和链接翻译单元之间的外部符号。随着模板的引入,C++ 利用头文件来承载实际的代码。由于模板需要针对专业化的更改进行重新编译,我们多年来一直将它们放在头文件中。随着 STL 多年来的不断增长,这些头文件也随之增长。这种情况已经变得难以处理,并且对未来来说不再可扩展。

头文件通常包含的不仅仅是模板。它们经常包含配置宏和其他系统所需的符号,但对应用程序并不有用。随着头文件数量的增加,符号冲突的机会也随之增加。当你考虑到宏的丰富性时,这个问题更加严重,因为宏不受命名空间限制,也没有类型安全性。

C++20 通过模块解决了这个问题。

如何做到这一点…

你可能习惯于创建像这样的头文件:

#ifndef BW_MATH

#define BW_MATH

namespace bw {

  template<typename T>

  T add(T lhs, T rhs) {

  	return lhs + rhs;

  }

}

#endif // BW_MATH


这个极简的例子说明了模块解决的一些问题。BW_MATH 符号被用作包含守卫。它的唯一目的是防止头文件被包含多次,然而它在整个翻译单元中被携带。当你在源文件中包含这个头文件时,它可能看起来像这样:

#include "bw-math.h"

#include <format>

#include <string>

#include <iostream>


现在 BW_MATH 符号对其他包含的头文件以及被其他头文件包含的头文件都是可用的,以此类推。这是很多可能导致冲突的机会。并且请记住,编译器无法检查这些冲突。它们是宏。这意味着它们在编译器有机会看到它们之前就已经被预处理器翻译了。

现在我们来看头文件的实际要点,模板函数:

template<typename T>

T add(T lhs, T rhs) {

return lhs + rhs;

}


因为它是一个模板,每次使用 add() 函数时,编译器都必须创建一个单独的专业化版本。这意味着每次调用模板函数时都必须解析并专业化。这就是模板必须放在头文件中的原因;源代码必须在编译时可用。随着 STL 的增长和演变,它包含许多大型的模板类和函数,这成为一个重要的可扩展性问题。

模块解决了这些问题以及更多。

作为一个模块,bw-math.h 变成了 bw-math.ixx(在 MSVC 命名约定中),它看起来像这样:

export module bw_math;

export template<typename T>

T add(T lhs, T rhs) {

return lhs + rhs;

}


注意,唯一导出的符号是模块的名称,bw_math,和函数的名称,add()。这保持了命名空间的清洁。

使用方式也更清洁。当我们在 module-test.cpp 中使用它时,它看起来像这样:

import bw_math;
import std.core;

int main() {
    double f = add(1.23, 4.56);
    int i = add(7, 42);
    string s = add<string>("one ", "two");
    cout <<
        "double: " << f << "\n" <<
        "int: " << i << "\n" <<
        "string: " << s << "\n";
}


import 声明用于我们可能使用 #include 预处理器指令的地方。这些导入模块的符号表以供链接。

我们示例的输出看起来像这样:

$ ./module-test

double: 5.79

int: 49

string: one two


模块版本与在头文件中的工作方式完全相同,只是更清洁、更高效。

注意

编译后的模块包括一个单独的元数据文件(在 MSVC 命名约定中为 module-name.ifc),它描述了模块接口。这允许模块支持模板。元数据包含足够的信息供编译器创建模板专业化。

它是如何工作的…

import 和 export 声明是模块实现的核心。让我们再次看看 bw-math.ixx 模块:

export module bw_math;
export template<typename T>
T add(T lhs, T rhs) {
    return lhs + rhs;
}


注意这两个 export 声明。第一个导出了模块本身,export module bw_math。这声明了翻译单元为模块。每个模块文件的顶部都必须有一个模块声明,并且在任何其他语句之前。第二个 export 使得函数名 add() 对模块消费者可用。

如果你的模块需要 #include 指令,或其他全局片段,你将需要首先用一个简单的模块声明来声明你的模块,像这样:

module;

#define SOME_MACRO 42

#include <stdlib.h>

export module bw_math;

...


module; 声明,在文件顶部单独一行,引入了一个全局模块片段。全局模块片段中只能出现预处理器指令。这必须立即跟随一个标准的模块声明(export module bw_math;)和模块的其余内容。让我们更仔细地看看这是如何工作的:

一个 export 声明使一个符号对模块消费者可见,即导入模块的代码。符号默认为私有。

export int a{7};  // 对消费者可见

int b{42};        // 不可见


你可以导出一个块,像这样:

export {
    int a() { return 7; };     // 可见
    int b() { return 42; };    // 也可见
}


你可以导出一个命名空间:

export namespace bw {  // bw 命名空间的全部都可见
    template<typename T>
    T add(T lhs, T rhs) {  // 作为 bw::add() 可见
        return lhs + rhs;
    }
}


或者,你可以从命名空间中导出个别符号:

namespace bw {  // bw 命名空间的全部都可见
    export template<typename T>
    T add(T lhs, T rhs) {  // 作为 bw::add() 可见
        return lhs + rhs;
    }
}


一个 import 声明在消费者中导入一个模块:

import bw_math;
int main() {
    double f = bw::add(1.23, 4.56);
    int i = bw::add(7, 42);
    string s = bw::add<string>("one ", "two");
}

你甚至可以导入一个模块并将其导出给消费者以传递它:

export module bw_math;

export import std.core;


export 关键字必须在 import 关键字之前。

std.core 模块现在对消费者可用:

import bw_math;
using std::cout, std::string, std::format;
int main() {
    double f = bw::add(1.23, 4.56);
    int i = bw::add(7, 42);
    string s = bw::add<string>("one ", "two");
    cout <<
        format("double {} \n", f) <<
        format("int {} \n", i) <<
        format("string {} \n", s);
}


正如你看到的,模块是头文件的一个简单、直接的替代方案。我知道我们很多人都期待着模块的广泛应用。我可以看到这将大大减少我们对头文件的依赖。

注意

在撰写本文时,模块的唯一完整实现是在 MSVC 的预览版本中。模块文件名扩展(.ixx)可能因其他编译器而异。此外,合并的 std.core 模块是 MSVC 在此版本中实现 STL 为模块的方式的一部分。其他编译器可能不使用此约定。当完全兼容的实现发布时,一些细节可能会发生变化。

在示例文件中,我包含了我的基于 format 的 print() 函数的模块版本。这在当前 MSVC 的预览版本中有效。一旦其他系统支持足够的模块规范,它可能需要一些小小的修改才能在其他系统上工作。

Tags:

最近发表
标签列表