从 C 到 C++17:手写日志系统的进化之路
在 C/C++ 开发中,日志是定位问题、监控程序运行的 “眼睛”。直接用printf打印信息虽然简单,但缺乏分级控制、上下文信息(时间、位置、线程),生产环境中还会产生大量冗余输出。而第三方日志库(如 spdlog、log4cpp)虽功能强大,但有时会引入不必要的依赖,对于轻量级项目或嵌入式场景并不友好。
这篇文章就从实际开发需求出发,一步步实现从 C 语言宏定义到 C++17 折叠表达式的日志系统,带你体会不同实现方案的设计思路、优缺点,最终得到一个轻量、高效、类型安全的日志工具。
一、核心需求:一个实用日志系统该具备什么?
在动手之前,先明确日志系统的核心需求 —— 毕竟好的设计都是从需求出发的:
-
日志分级:支持 INFO(信息)、DEBUG(调试)、ERROR(错误)三级,可按需开关(开发时输 DEBUG,生产时只输 ERROR);
-
上下文完备:自动携带时间戳、文件名、行号、线程 ID,调试时能快速定位问题;
-
类型安全:避免
printf的格式符与参数不匹配(如%d对应string)导致的运行时崩溃; -
轻量无依赖:不依赖第三方库,仅用标准库实现;
-
易用性强:调用方式接近
printf,无需复杂配置。
带着这些需求,我们从最基础的 C 语言版本开始,逐步迭代优化。
二、V1.0:C 语言宏定义版(兼容 C/C++,轻量灵活)
这是最基础也最常用的实现方式,基于宏定义和stdarg.h,兼容 C 和 C++ 项目,适合对依赖有严格要求的场景。
2.1 实现代码
#include <stdio.h>
#include <stdarg.h>
#include <time.h>
#include <pthread.h>
// 日志分级
#define LOG_LEVEL DEBUG // 全局输出阈值:DEBUG=输出所有,ERROR=只输出错误
#define INFO 0
#define DEBUG 1
#define ERROR 2
// 核心日志宏
#define LOG(level, format, ...) do { \
if (level < LOG_LEVEL) break; \
/* 时间戳处理 */ \
time_t t = time(NULL); \
struct tm ltm; \
localtime_r(&t, <m); // 线程安全版本(POSIX),Windows用localtime_s \
char time_buf[32] = {0}; \
strftime(time_buf, 31, "%Y-%m-%d %H:%M:%S", <m); \
/* 输出日志:线程ID+时间+文件+行号+内容 */ \
fprintf(stdout, "[%p] [%s] [%s:%d] " format "\n", \
(void*)pthread_self(), time_buf, __FILE__, __LINE__, ##__VA_ARGS__); \
} while(0)
// 简化调用接口
#define INFO_LOG(format, ...) LOG(INFO, format, ##__VA_ARGS__)
#define DEBUG_LOG(format, ...) LOG(DEBUG, format, ##__VA_ARGS__)
#define ERROR_LOG(format, ...) LOG(ERROR, format, ##__VA_ARGS__)
// 测试代码
int main() {
INFO_LOG("程序启动,版本号:%s", "v1.0.0");
DEBUG_LOG("初始化缓冲区,大小:%d字节", 1024);
int ret = -1;
if (ret != 0) {
ERROR_LOG("接口调用失败,错误码:%d", ret);
}
return 0;
}
2.2 核心设计思路
-
分级控制:通过
LOG_LEVEL阈值过滤日志,低于阈值的日志直接跳过,无需修改业务代码即可切换环境; -
上下文封装:利用
__FILE__(文件名)、__LINE__(行号)预定义宏,自动获取代码位置;pthread_self()获取线程 ID,适配多线程场景; -
可变参数处理:
stdarg.h的__VA_ARGS__接收可变参数,##用于消除参数为空时的多余逗号,避免语法错误; -
线程安全:用
localtime_r替代localtime(后者非线程安全),避免多线程环境下时间戳错乱。
2.3 优缺点
-
优点:轻量无依赖、兼容 C/C++、调用简单、无运行时开销;
-
缺点:类型不安全(格式符与参数类型不匹配编译不报错)、不支持自定义类型(如自定义类)、参数中含逗号需特殊处理。
三、V2.0:C++11 递归模板版(类型安全,兼容旧标准)
C++11 引入的可变参数模板解决了类型安全问题,通过编译期类型推导,避免格式符不匹配的错误。这个版本兼容 C++11 及以上标准,适合无法升级到 C++17 的项目。
3.1 实现代码
#include <iostream>
#include <typeinfo>
#include <time.h>
#include <thread>
#include <cassert>
// 日志分级
enum class LogLevel { INFO, DEBUG, ERROR };
const LogLevel G_LOG_LEVEL = LogLevel::DEBUG;
// 递归终止函数:参数包为空时调用
void log_impl() {
std::cout << std::endl;
}
// 递归展开参数包:每次处理一个参数
template<typename T, typename... Args>
void log_impl(const T& first, const Args&... rest) {
// 打印参数类型和值(typeid需开启RTTI)
std::cout << "[" << typeid(T).name() << ":" << first << "] ";
log_impl(rest...); // 递归处理剩余参数
}
// 日志前缀:时间戳+线程ID+文件+行号
void log_prefix(const char* file, int line) {
// 时间戳(线程安全)
time_t t = time(NULL);
struct tm ltm;
localtime_r(&t, <m);
char time_buf[32] = {0};
strftime(time_buf, 31, "%Y-%m-%d %H:%M:%S", <m);
// 线程ID(C++11跨平台)
auto tid = std::this_thread::get_id();
// 输出前缀
std::cout << "[" << tid << "] [" << time_buf << "] [" << file << ":" << line << "] ";
}
// 对外接口:支持分级控制
template<typename... Args>
void LOG(LogLevel level, const char* file, int line, const Args&... args) {
if (level < G_LOG_LEVEL) return;
log_prefix(file, line);
log_impl(args...);
}
// 简化调用(自动传入文件和行号)
#define INFO_LOG(...) LOG(LogLevel::INFO, __FILE__, __LINE__, ##__VA_ARGS__)
#define DEBUG_LOG(...) LOG(LogLevel::DEBUG, __FILE__, __LINE__, ##__VA_ARGS__)
#define ERROR_LOG(...) LOG(LogLevel::ERROR, __FILE__, __LINE__, ##__VA_ARGS__)
// 测试自定义类(需重载operator<<)
class Person {
public:
std::string name;
int age;
friend std::ostream& operator<<(std::ostream& os, const Person& p) {
os << p.name << "(" << p.age << ")";
return os;
}
};
// 测试代码
int main() {
INFO_LOG("程序启动", "v2.0.0");
DEBUG_LOG("用户信息", Person{"张三", 23}, "分数", 95.5f);
int ret = -2;
if (ret != 0) {
ERROR_LOG("数据库连接失败", "错误码", ret);
}
return 0;
}
3.2 核心设计思路
-
类型安全:通过可变参数模板
typename... Args推导参数类型,无需手动指定格式符,编译期就能检查类型错误; -
递归展开参数包:用
log_impl递归处理每个参数,直到参数包为空(触发无参log_impl终止); -
跨平台兼容:用
std::this_thread::get_id()替代pthread_self(),支持 Windows、Linux 跨平台; -
支持自定义类:只要重载
operator<<,就能直接输出自定义对象,灵活性更强。
3.3 优缺点
-
优点:类型安全、支持任意类型参数、跨平台、可扩展;
-
缺点:需要手动实现递归终止函数、代码略显冗余、依赖 C++11 及以上标准。
四、V3.0:C++17 折叠表达式版(极致简洁,高效优雅)
C++17 引入的折叠表达式(Fold Expression)彻底解决了递归模板的冗余问题,一行代码就能展开参数包,让日志系统的实现变得极致简洁。
4.1 实现代码
#include <iostream>
#include <typeinfo>
#include <time.h>
#include <thread>
#include <string>
// 日志分级与全局阈值
enum class LogLevel { INFO, DEBUG, ERROR };
constexpr LogLevel G_LOG_LEVEL = LogLevel::DEBUG;
// 友好的类型名转换(解决typeid.name()可读性差的问题)
template<typename T>
std::string get_type_name() {
std::string name = typeid(T).name();
if (name == "PKc") return "string"; // GCC: const char*
if (name == "i") return "int"; // GCC: int
if (name == "f") return "float"; // GCC: float
if (name == "d") return "double"; // GCC: double
if (name == "b") return "bool"; // GCC: bool
return name;
}
// 日志前缀:时间戳+线程ID+文件+行号
void log_prefix(const char* file, int line) {
// 时间戳(线程安全)
time_t t = time(nullptr);
struct tm ltm;
localtime_r(&t, <m);
char time_buf[32] = {0};
strftime(time_buf, 31, "%Y-%m-%d %H:%M:%S", <m);
// 线程ID转换为字符串(便于输出)
std::string tid_str = std::to_string(std::hash<std::thread::id>{}(std::this_thread::get_id()));
// 输出前缀
std::cout << "[" << tid_str.substr(0, 8) << "] [" << time_buf << "] [" << file << ":" << line << "] ";
}
// 核心日志函数:C++17折叠表达式展开参数包
template<typename... Args>
void LOG(LogLevel level, const char* file, int line, const Args&... args) {
if (level < G_LOG_LEVEL) return;
log_prefix(file, line);
// 折叠表达式:一行展开所有参数(逗号运算符)
(std::cout << "[" << get_type_name<Args>() << ":" << args << "] ", ...) << std::endl;
}
// 简化调用宏
#define INFO_LOG(...) LOG(LogLevel::INFO, __FILE__, __LINE__, ##__VA_ARGS__)
#define DEBUG_LOG(...) LOG(LogLevel::DEBUG, __FILE__, __LINE__, ##__VA_ARGS__)
#define ERROR_LOG(...) LOG(LogLevel::ERROR, __FILE__, __LINE__, ##__VA_ARGS__)
// 测试自定义类
class Student {
public:
std::string name;
int id;
friend std::ostream& operator<<(std::ostream& os, const Student& s) {
os << "Student{id:" << s.id << ", name:" << s.name << "}";
return os;
}
};
// 测试代码
int main() {
INFO_LOG("服务启动成功", "端口", 8080);
DEBUG_LOG("请求处理", "用户", Student{"李四", 1001}, "耗时", 3.2f, "ms");
bool is_connect = false;
if (!is_connect) {
ERROR_LOG("网络连接失败", "状态", is_connect);
}
return 0;
}
4.2 核心设计思路
-
折叠表达式简化参数展开:用
(std::cout << ... , args)一行代码替代递归,编译期直接展开为多个输出语句,效率更高、可读性更强; -
友好的类型名:自定义
get_type_name函数,将编译器晦涩的类型名(如PKc)转换为直观的名称(如string),日志更易读; -
线程 ID 处理:用
std::hash将thread::id转换为字符串,避免不同平台线程 ID 输出格式不一致的问题; -
** constexpr 优化 **:
G_LOG_LEVEL用constexpr定义,编译期就能确定是否输出日志,无运行时开销。
4.3 优缺点
-
优点:极致简洁、类型安全、编译效率高、支持任意类型、日志可读性强;
-
缺点:依赖 C++17 及以上标准,需要编译器开启
-std=c++17(GCC)或对应选项(VS2017+)。
五、三个版本对比与选型建议
| 特性 | C 语言宏定义版(V1.0) | C++11 递归模板版(V2.0) | C++17 折叠表达式版(V3.0) |
|---|---|---|---|
| 语言支持 | C/C++ | C++11+ | C++17+ |
| 类型安全 | ❌ | ✅ | ✅ |
| 支持自定义类 | ❌ | ✅(需重载 <<) | ✅(需重载 <<) |
| 代码简洁度 | 中 | 低 | 高 |
| 线程安全 | ✅(需手动处理) | ✅(跨平台) | ✅(跨平台) |
| 编译器依赖 | 无 | 支持 C++11 即可 | 需支持 C++17 |
| 适用场景 | C 项目、嵌入式、轻量场景 | 无法升级 C++17 的 C++ 项目 | 现代 C++ 项目、追求简洁高效 |
选型建议:
-
若开发 C 项目或嵌入式场景:选 V1.0,无依赖、兼容性强;
-
若 C++ 项目但无法升级到 C++17:选 V2.0,兼顾类型安全和兼容性;
-
若使用 C++17 及以上标准:选 V3.0,简洁高效,是最优解。
六、实战扩展:让日志系统更实用
无论选择哪个版本,都可以根据实际需求添加以下扩展功能,让日志系统更贴合项目场景:
1. 输出到文件(而非控制台)
将日志输出到文件,避免程序退出后日志丢失:
#include <fstream>
std::ofstream g_log_file("app.log", std::ios::app); // 追加模式
// 替换cout为g_log_file
g_log_file << "[" << time_buf << "] " << ...;
2. 日志轮转(防止文件过大)
当日志文件达到指定大小(如 100MB)或每天凌晨,自动创建新文件:
void check_log_rotate() {
std::ifstream file("app.log");
file.seekg(0, std::ios::end);
if (file.tellg() > 1024 * 1024 * 100) { // 100MB
g_log_file.close();
rename("app.log", "app.log.1"); // 重命名旧日志
g_log_file.open("app.log", std::ios::app); // 新建日志文件
}
}
3. 关闭 RTTI 依赖
若项目关闭了 RTTI(-fno-rtti),可移除typeid相关代码,或用模板特化实现类型判断:
// 模板特化获取类型名(无需RTTI)
template<typename T> struct TypeName { static const char* name; };
template<> struct TypeName<int> { static const char* name; };
const char* TypeName<int>::name = "int";
// 其他类型同理...
// 调用:TypeName<Args>::name
4. 支持格式化字符串
C++20 引入std::format,可替代cout实现更灵活的格式化输出:
#include <format>
// 折叠表达式结合format
(std::cout << std::format("[{}:{}]", TypeName<Args>::name, args) << " ", ...) << std::endl;
七、总结
从 C 语言的宏定义到 C++17 的折叠表达式,日志系统的进化本质上是 “兼顾灵活性、安全性和简洁性” 的过程。每个版本都有其适用场景,没有绝对的 “最优解”,只有最适合项目的选择。
核心收获:
-
宏定义版胜在兼容和轻量,适合 C 项目或嵌入式场景;
-
C++11 模板版解决了类型安全问题,兼容旧标准;
-
C++17 折叠表达式版将简洁性和效率推向极致,是现代 C++ 的首选。
实际开发中,可根据项目的语言版本、依赖要求、功能需求,选择合适的实现方案,或基于本文代码进行二次扩展。如果你的项目有特殊场景(如嵌入式、高并发),欢迎在评论区分享,我们可以一起探讨优化方案~
8万+

被折叠的 条评论
为什么被折叠?



