【问题标题】:Macro-free Logging and Tracing in C++20, with concepts and template specializationC++20 中的无宏记录和跟踪,具有概念和模板专业化
【发布时间】:2021-10-21 06:04:16
【问题描述】:

我一直在尝试新的 C++20 功能,例如 modulesconcepts。新 模块 的固有属性之一是它们不会将预处理器定义泄露给消费者——这既是福也是祸,因为 C++ 中的某些行为(例如日志记录)传统上具有已使用 #define 宏实现,因此它们可以在 release 构建中成为 #defined。

我的问题很简单:今天应该如何实现日志记录没有宏,假设人们仍然希望保留一些行为,比如让编译器完全删除日志记录调用,没有副作用,在发布版本?

我为实现这一目标所做的努力是利用 lambdas 和 C++20 概念 来驱动模板专业化。

#define NOOP /* no operation */

template <typename T>
concept printable = requires (const T & message) {
    std::cout << message;
};

template <typename F>
concept format_factory = std::regular_invocable<F>
&& std::convertible_to<std::invoke_result_t<F>, std::string_view>;

...

#ifdef _DEBUG
private:
    template<std::regular_invocable F>
    static inline const std::invoke_result_t<F> map(F f) {
        return f();
    }

    template<typename T>
    static inline constexpr const T& map(const T& value) {
        return value;
    }

public:
    template<printable T>
    static inline void trace(const T& message) {
        std::cout << message << std::endl;
    }

    template<typename... Args>
    static inline void trace(const std::string_view& format, Args&&... args) {
        std::cout << std::format(format, map(args)...) << std::endl;
    }

    template<format_factory F, typename... Args>
    static inline void trace(const F& format, Args&&... args) {
        std::cout << std::format(format(), map(args)...) << std::endl;
    }

#else
public:
    template<typename... Args>
    static inline constexpr void trace(const Args&... args) { NOOP; }
#endif

这个想法是......

  • 通常可以传递要记录的任何文字和值,因为在 release 构建中,编译器将优化任何副本或移动,因为它们不会被访问。
  • 任何要记录的 'expensive' 都可以作为访问器 lambda 传递,它不会在 release 构建中调用,因此,也被优化了。

例如,用户代码可能如下所示:

Log::trace("format literal ({}, {})", []() { return "expensive value"; }, "cheap value");

我已经在 Visual C++ 2022(预览版)中进行了尝试,我可以确认它确实按预期工作,但 这是个好主意吗?我怎样才能让它变得更好?

请记住,这样做是因为我想从 C++20 模块export 这个,据我所知,我无法使用预处理器宏来做到这一点。

【问题讨论】:

  • 有趣的想法,但不确定是否可行。您可以使用global module fragments 将宏与模块一起使用。
  • @HolyBlackCat:我知道我可以在全局模块片段中使用#include 标头,但这并不能帮助我导出 宏,是吗?我想我可以为我的日志记录代码制作一个老式的标头,但是我无法实现完全自我强加的、愚蠢的、几乎可以肯定是短命的 100% 基于模块的代码库的理想。不过,我对人们将如何处理这件事很感兴趣。
  • 这个视频是关于测试的,但我想同样的原则也适用于日志记录:youtube.com/watch?v=irdgFyxOs_Y&t=3s
  • 还有一件事,@HolyBlackCat。我的日志记录本质上是一个包装spdlog 的解耦层。如果它在模块Log.ixx 中,那么我可以只使用spdlog 标头,因为模块是作为预编译标头实现的,所以只要我不更改Log.ixx,我就不会遭受构建时间的困扰-封装大量标头库的惩罚。如果我创建了Log.h#included,那么在所有其他模块中,在全局模块片段中,情况就不是这样了,我必须自己静态链接spdlog 或设置一个PCH。我在一片绿地工作。
  • @HolyBlackCat:别担心!嘿......你促使我去阅读折叠表达式,这不是坏事。我已经写 C# 这么久了,最近,Rust 让我的 C++ 非常生锈和过时。 (这也是为什么我在我的未开发代码库中尝试做一些愚蠢的事情,比如只用模块编写。这是一个挑战,而我知道 headers+.cpp 倒退。)

标签: c++ logging c++20 c++-concepts c++-modules


【解决方案1】:

模块化方法

不鼓励使用模块在标头中使用宏定义的整个方法。并不是说不可能,而是编译器需要为包含它们的每个源文件解析那些带有日志记录配置的头文件。

我想你可以在命令行中添加宏定义,例如使用 gcc:

g++ [...] -DDEBUG_LOGGING

使用 Visual C++(来自 here):

[...] /p:DefineConstants="DEBUG_LOGGING"

使用标题

“请记住,这样做是因为我想从 C++20 模块中导出它,据我所知,我无法使用预处理器宏来做到这一点。”

--> 如果你想export 日志功能作为一个模块,那么实现不能依赖于头文件的配置。在 C++20 中,我们有两种包含标头的方式:在全局模块片段内部和模块声明内部:

module;
#define DEBUG_LOGGING      // or similar configuration
#include "logging.hpp"     // inside global module fragment

export module some_module;
// 1)
#define DEBUG_LOGGING
#include "logging.hpp"     // inside module declaration
// 2)
#define DEBUG_LOGGING
import logging;            // defined is ignored with module unit

引用cppreference:

"#include 不应在模块单元中使用(在全局模块片段之外),因为所有包含的声明和定义都将被视为模块的一部分。相反,标头也可以通过导入声明导入: "
并且:
"导入头文件将使其所有定义和声明都可以访问。预处理器宏也可以访问(因为导入声明被但是与#include相反,翻译单元中定义的预处理宏不会影响头文件的处理,这在某些情况下可能会不方便(有些头文件使用预处理宏作为一种配置形式),在这种情况下需要使用全局模块片段。"

所以日志头可能包含在全局模块片段中,但是编译器需要对其进行多次处理。如果该标头使用 spdlog 或类似的 3rd 方库,这尤其麻烦,在这种情况下,标头包含成为递归的噩梦。

另外,日志标头可能包含在模块声明中,但随后其定义将泄漏到该模块之外,并且头文件可能根本不导入任何模块。最后一点我尝试使用 gcc 并得到以下错误:

log_helper.hpp:3:1: error: post-module-declaration imports must not be from header inclusion

代码示例

我们不应该依赖头文件配置来声明(和解析)我们的模板化日志函数,因为这是一个非常缓慢的过程,因此我们可以将配置静态地提供给编译器(通过实例化一个类型)。总体思路是编译一次模块,并依赖模块的消费者来生成适当的函数调用。

这是您建议的代码:

template<format_factory F, typename... Args>
static inline void trace(const F& format, Args&&... args) {
    std::cout << std::format(format(), map(args)...) << std::endl;
}

如果我们用trace ("arg={}", my_obj); 调用它,那么编译器将为特定的重载生成代码。但是,如果我们随后使用不同数量的参数调用它,例如trace ("arg_1={}, arg_2={}", my_obj1, my_obj2);,那么编译器将不得不再次对跟踪函数进行 lex 和解析(包括通过依赖模板类型进行递归),这是一个缓慢的过程。使用一个模块,我们编译一次并将其存储为二进制格式的抽象语法树,因此之后生成代码会更快。

这是我对代码示例的建议。为了便于阅读,我省略了可变参数模板扩展:

// logging.cpp
export module logging;

export class ILogger
{
public:
    virtual void Trace() = 0;
};

export enum class LogType
{
    null,
    default
};

class NullLogger : public ILogger
{
public:
    void Trace() override {};
};

export class Log
{
public:
    static void Configure(LogType lt) {
        switch(lt) {
            case LogType::null:
                mInstance = std::make_shared<NullLogger>();
                return;
            default:
                break;
        }
    }
    static void Trace() { mInstance->Trace(); }

private:
    std::shared_ptr<ILogger> mInstance;
};

我将不得不看一下优化的程序集(链接时优化),以检查 null 记录器的间接寻址是否被优化掉了。可能一些更有经验的 C++ 人员可以立即判断。

优化的一个想法是为mInstance-&gt;Trace() 存储一个函数指针以避免vtable 查找。但同样,编译器可能会自动推断出这一点。我对这个特定主题的经验非常有限。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2021-09-04
    • 2022-01-04
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2021-12-24
    • 1970-01-01
    • 2022-01-18
    相关资源
    最近更新 更多