我以前写过一个日志函数。
上线前我还挺得意:接口统一收 std::string,省心又安全。
上线后就尴尬了。
CPU 抬头,分配次数也抬头。profile 一看,热路径里一大块都在构造 std::string。
我只是想打印几行字,却在热路径里反复“造字符串”。
那时候我才反应过来:我需要的不是“字符串对象”,我需要的是“能读一段字符”的参数。
当年目标:我只想传进来一段字,让日志打印出去
回到当年,C 里传字符串最常见的办法其实很朴素:const char* 再加一个长度。
#include <cstddef>
void log_line(const char* p, std::size_t n);
它丑,但语义很干净:我只读这一段,不负责内存,也不要求调用方先构造一个对象。
后来有了 std::string,大家很自然就想统一成 string。
#include <string>
void log_line(const std::string& s);
写起来确实舒服。
但语义也悄悄换掉了:我本来只想要“字符序列”,现在却要求调用点提供一个“拥有这段字符的对象”。
先补一个概念:为什么 "hello" 能传给 const std::string&
如果你刚学 C++,这里很容易误会。
"hello" 不是 std::string,它是字符串字面量(你可以粗暴理解成一段静态数组)。
之所以能传进去,是因为 std::string 能从 C 字符串构造,编译器会帮你做一次隐式构造。
#include <string>
void log_line(const std::string& s);
int main() {
log_line("hello");
}
你可以把它“脑补”等价成下面这样。
#include <string>
void log_line(const std::string& s);
int main() {
std::string tmp("hello");
log_line(tmp);
}
这里的 tmp 就是临时对象。
它不是你写出来的,但成本是真实发生的。
旧办法的代价:调用点未必有 std::string,但我逼它临时造一个
最常见的调用点就是字面量:
log_line("hello");
为了匹配 const std::string&,编译器会先构造一个临时 std::string,再把引用绑上去。
有些实现有 SSO,可能不会真的去堆上 new。
但构造、拷贝、算长度这些活还是得干。
在热路径里,你每打一条日志,就额外干一堆“不属于业务”的活。
我当时以为这是“小成本”。
直到它在 QPS 下变成了常驻火焰图。
我先打了补丁:那我加一个 const char* 重载不就好了?
这也是当年很常见的补丁。
#include <string>
void log_line(const std::string& s);
void log_line(const char* s);
这下 log_line("hello") 不会构造 std::string 了。
看起来皆大欢喜。
补丁带来更深的坑:我又回到了“指针语义”和“长度语义”的泥潭
因为调用点不只有字面量。
还有“不是以 \0 结尾”的缓冲区。
如果你刚学 C,这里最容易踩的坑就是把“裸缓冲区”当成“C 字符串”。
C 字符串靠 \0 表示“到这为止”,但裸缓冲区不保证有 \0。
#include <cstdio>
#include <cstring>
void log_line(const char* s) {
std::printf("%zu\n", std::strlen(s));
}
int main() {
char buf[3] = {'a', 'b', 'c'};
log_line(buf);
}
这段代码的问题不在 strlen。
问题在:strlen 的前提是“有 \0 结尾”。
你给它的是“裸缓冲区”。
它只能一直往后读,直到碰巧遇到一个 0。
所以你很自然会想:那我把长度也带上不就行了?
但 log_line(const char*) 这个接口根本没地方放长度。
如果我坚持用 const char*。
那我就只能把“长度”这件事藏起来。
藏起来之后。
它就会以 bug 的形式跑出来。
再补一个重载?
#include <cstddef>
void log_line(const char* p, std::size_t n);
这时候我已经开始分裂了。
字符串有三套入口。
调用点要判断自己“到底是哪一种字符串”。
更烦的是。
我只想读。
但我的接口在逼我反复在“指针 / 长度 / owning string”之间转换。
我意识到:我需要一种性质——只读的“指针 + 长度”,但不拥有
到这里,问题的根其实就浮出来了。
日志函数要的是“字符序列视图”:只读、带长度、但不拥有。
它不应该为了参数匹配去临时构造一个 std::string。
这在别的地方其实早就有人踩过坑。
很多语言都有类似的东西。
Rust 的 &str。
Go 的 string/[]byte 之间的那条线。
甚至早年的 Boost 里也有 string_ref 这类尝试。
它们都在表达同一个意思:
我只引用一段字符。
我不负责它怎么活。
于是 C++17 给了它一个正式名字:std::string_view
std::string_view 你别被名字骗了。
它不是 “string 的轻量版”。
它更像 C 世界的 char* + len,只是把这层语义封装成了一个类型。
#include <string_view>
void log_line(std::string_view s);
当我这样写。
调用点就舒服了。
log_line("hello");
这里不需要为“传参”临时构造 std::string。
string_view 拿到的就是一段视图。
指针 + 长度。
你如果想抓住它的本质,就记住两件事:data() 和 size()。
#include <cstddef>
#include <string_view>
void f(std::string_view s) {
const char* p = s.data();
std::size_t n = s.size();
(void)p;
(void)n;
}
这就是它的本体:引用一段范围,不负责所有权。
如果你手里就是 char* + len。
可以直接构造。
#include <cstddef>
#include <string_view>
void g(const char* p, std::size_t n) {
std::string_view s(p, n);
(void)s;
}
它为什么叫 view:因为它就是一个“窗口”,不是一个“盒子”
我后来喜欢用一个比喻:std::string 是盒子,std::string_view 是窗口。
窗口对着盒子里的某一段。你挪窗口,盒子不动。
#include <string>
#include <string_view>
std::string s = "error: disk full";
std::string_view v = std::string_view(s).substr(0, 5);
这里 v 看的是 s 的前 5 个字符。
没拷贝。
也没有分配。
但也没有任何“托底”。
它的代价:它不会替你管生命周期
窗口的麻烦也在这:盒子如果没了,窗口就对着空气。
这个坑不是“偶尔会踩”,是“你迟早会踩”。
#include <string>
#include <string_view>
std::string_view bad() {
return std::string("hi");
}
std::string("hi") 是临时对象。
函数一返回,它就销毁了。你拿到的 string_view 里是悬空指针。
它有时候还能打印出“看起来正常”的东西,这才是最危险的地方。
如果你刚学 C++,我建议你用一个很土的规则来判断:这段字符活得久不久?
字面量是活得很久的。
#include <string_view>
std::string_view ok() {
return "hi";
}
但临时 std::string 是活不过这一行的。
#include <string>
#include <string_view>
int main() {
std::string_view v = std::string("hi");
}
这一行结束,临时 std::string 就销毁,v 立刻变悬空。
更常见的坑是:函数里把参数 string_view 存起来,但调用点传了一个临时 string。
#include <string>
#include <string_view>
struct Holder { std::string_view v; };
Holder make() {
return {std::string("hi")};
}
这段看起来很自然,但它返回的是一个指向已销毁字符串的视图。
更隐蔽的坑:view 不保证 \0 结尾
还有一个点,我也吃过亏。
string_view 表示的是“范围”。
不是 C 字符串。
所以它不保证末尾有 \0。
\0 就是 C 字符串的结尾标记。没有它,就不知道字符串该在哪里停。
#include <cstdio>
#include <string_view>
void print_c(std::string_view s) {
std::printf("%s\n", s.data());
}
这段代码在很多情况下会“看起来能跑”。
但它其实是不对的。
因为 printf 依赖 \0 找结尾。
你给它的是 “指针”,不是 “C 字符串”。
如果你真要用 printf。
正确的最小写法是把长度也传进去。
#include <cstdio>
#include <string_view>
void print_ok(std::string_view s) {
std::printf("%.*s\n", (int)s.size(), s.data());
}
这就是 string_view 的代价:它把成本省掉了,也把责任还给了你。
工程里怎么用:接口用 view,存储用 string
我后来在代码里就用一个简单的判断。
如果函数只是“读一眼”。
参数用 std::string_view。
#include <string_view>
bool is_error_line(std::string_view s);
如果对象要长期保存这段文本。
那就老老实实存 std::string。
#include <string>
#include <string_view>
struct Item {
std::string name;
explicit Item(std::string_view n) : name(n) {}
};
这段构造函数我很喜欢。
它把语义说清楚了。
你可以用便宜的方式传进来。
但我在边界处把它拷贝成自己的所有权。
关键结论
std::string_view 解决的不是“更快一点”。
它解决的是:把“我只想看一眼”变成类型层面的承诺。
代价也很硬:你必须保证源数据活得比 view 久。
小结:它是一扇窗,不是一个箱子
当年我把日志接口写成 const std::string&。
我以为我在写“统一”。
其实我在把“所有权”强塞给每个调用点。
C++17 给了 std::string_view。
让我能把真实意图说出来。
最后就记住一句话。
别让窗口替你背生命周期的锅。