C++ 奇淫技巧之耦断丝连:临时 C 字符串
这里想分享一个许是微不足道 (trivial) 的问题及其解决思路。
问题及方案
工作背景是一个 C++ 服务项目,采用 printf 风格的日志系统。在一个函数中想打印正要处理的消息,当然已知消息正文是采用文本协议(如 json)。传进函数的参数是字符串指针及长度,因为底层库获取或消费消息时就给出这样的 C 风格的二元组,而不是 C++ 的字符串对象(避免不必要的拷贝)。显然,加一行日志是很简的事情,如:
void DealMessage(const char* pMsg, size_t nMsgLen)
{
LOG_DEBUG("to deal message: %s", pMsg);
// todo the bussiness ...
}
问题在于,底层库给出的 pMsg
字符串未必能保证在第 nMsgLen
字节是以 \0
结尾,虽然文本协议能基本保证在 [0, nMsgLen) 区域是可打印字符。这样,打印 %s
就会超出长度,直至遇到 \0
,运气好时不过是在日志中多打印出一些乱码,运气不好就难说了。
直接的解决方案是从指针长度二元组临时构造出一个 std::string
再取其 c_str()
方法打印 %s
,如:
LOG_DEBUG("to deal message: %s", std::string(pMsg, nMsgLen).c_str());
现在的 STL 版本,已能保证 std::string
以空字符结尾。据说早期蛮荒时代的 STL
版本标准,std::string
却是不保证能空字符结尾的,所以因此及其他诸多原因备受嫌弃。
这样修改比较膈应的是,人家用指针与长度传参本来是为了性能的原因,结果为了打印日志这种非核心业务需求构造出一个临时 std::string
对象,凭空多了一次拷贝,性价比很低的呀。即使用其他第三方认为比 std::string
更好的字符串实现,甚至就额外用一个 C 缓冲区,也是无法避免这次拷贝再附加 \0
封尾的操作。
优化及封装
不过再仔细想一下,其实也没必要额外开缓冲区拷贝字符串,完全可以利用原缓冲区来搞事。原缓冲区的问题不就是缺个空字符结尾吗?那就临时改出一个空字符,顺利打印日志后再改回去。如:
{
char* buffer = const_cast<char*>(pMsg);
char save = buffer[nMsgLen];
buffer[nMsgLen] = '\0';
LOG_DEBUG("to deal message: %s", pMsg);
buffer[nMsgLen] = save;
}
// todo the bussiness ...
这里多了好几行代码,用 {}
包装了一下限制作用域。主要是还要先将 const char*
强行转换为 char*
才可写。但要注意如果是指向字面量字符串的指针,强行转为可写指针,是会失败的。但在这个业务场景中,只要是指向堆区的字符串,都是可以强行修改的。
代码有点多,可以抽出来封装成一个类,把篡改与回滚尾字符的操作分别放在对象的构造函数与析构函数中:
class CutString
{
const char* ptr_ = nullptr;
size_t len_ = 0;
char last_ = '\0';
public:
CutString(const char* ptr, size_t len)
: ptr_(ptr), len_(len)
{
char* buffer = const_cast<char*>(ptr_);
last_ = buffer[len_];
buffer[len_] = '\0';
}
~CutString()
{
char* buffer = const_cast<char*>(ptr_);
buffer[len_] = last_;
}
const char* c_str() const
{
return ptr_;
}
};
这就能简化使用了:
void DealMessage(const char* pMsg, size_t nMsgLen)
{
LOG_DEBUG("to deal message: %s", CutString(pMsg, nMsgLen).c_str());
// todo the bussiness ...
}
也就只是把原来的 std::string
改成 CutString
,后者的临时对象不涉及字符串拷贝,经编译器优化内联函数后基本就是零成本抽象。主要是利用了 C++ 类的 RAII
(Resource Acquisition Is Initialization) 特性,让构造函数中切断字符串,析构时再自动粘回去——所以我称之为耦断丝连的骚操作。
对此还有困惑的朋友,可在上面的构造函数与析构函数中添加打印观察测试一下,如:
class CutString
{
CutString(const char* ptr, size_t len)
{
// ...
printf("in CutString, save last: %c\n", last_);
}
~CutString()
{
// ...
printf("in ~CutString, restore last: %c\n", last_);
}
};
int main()
{
char* buffer = (char*)malloc(1024);
for (int i = 0; i < 1024; ++i)
{
buffer[i] = i % (127-32) + 32;
}
printf("at buffer[16]: %c\n", buffer[16]);
printf("first buffer[16]: %s\n", CutString(buffer, 16).c_str());
printf("at buffer[16]: %c\n", buffer[16]);
free(buffer);
}
输出如下:
at buffer[16]: 0
in CutString, save last: 0
first buffer[16]: !"#$%&'()*+,-./
in ~CutString, restore last: 0
at buffer[16]: 0
注意 buffer
循环存放着可打印 [32, 127) 区间的 ascii 字符,第 16 个字符正好是数字 0
,而不是空字符 \0
。
结语
关于本文提出的问题,现代 C++ (20 以后)也可以利用 std::format
与
std:: string_view
解决。不过呢,工程项目的代码是妥协的结果,printf
风格的打印已经广泛使用,一时也难以改过来,何况编译器升级牵涉更广。所以本文提出用临时切断字符串的方案,利用构造析构的 RAII 来使影响最小化。当然此修改非线程安全,不过从线程池分发消息到处理函数后,一般也是只有本函数在处理了,故不应在此考虑线程安全。