前言
初学 C/C++ 的人几乎都会经历同一个困惑:一会儿写char s[] = "abc";,一会儿写char* p = "abc";,一会儿又写std::string s = "abc";——三者看起来都在"存字符串",但行为、生命周期、可否修改、能不能相互赋值,处处不同。更麻烦的是,C 与 C++ 混用时,编译器给出的报错往往只能意会不能言传,比如const char*无法转换为char*、sizeof出来的值跟想象的字符数不一样。
这背后的核心矛盾其实只有一句话:C 语言的字符串是"以'\0'结尾的char数组"这一约定,不是一种类型;而 C++ 的std::string是一个真正管理内存的类。理解了这句话,剩下的全是细节推论。本文从char本身讲起,再讲 C 风格字符串的三种形态,最后讲std::string,并给出互转、传参、以及真实踩坑的完整示例。
一、char 到底是什么:一个"最小的整数"
char首先是一个整数类型,大小恒为 1 字节(sizeof(char) == 1,这是标准规定)。它能否表示负数取决于实现:
char | -128 ~ 127或0 ~ 255 | 由实现定义,x86 上多为有符号 |
signed char | -128 ~ 127 | 明确有符号 |
unsigned char | 0 ~ 255 | 明确无符号 |
这一点带来的第一个实用结论是:凡是把char当作"字节值"参与算术或下标运算(如哈希、位图、UTF-8 解析),一律用unsigned char。一个字节0xE4(UTF-8 汉字首字节)作为有符号char是-28,直接用作数组下标就会越界。
#include <stdio.h>
int main(void) {
char c = (char)0xE4;
unsigned char u = 0xE4;
printf("char=%d, unsigned char=%u, sizeof(char)=%zu\n",
(int)c, (unsigned)u, sizeof(char)); // char=-28, unsigned char=228, 1
return 0;
}
字符串字面量"abc"的类型也不是char*,而是const char[4](含结尾'\0')。它在 C 中被隐式转换为char*仅为兼容历史,修改它属于未定义行为;在 C++ 中则根本不允许把const char*赋给char*。
二、C 风格字符串的三种形态
char a[] = "abc"; | 栈(数组,拷贝一份) | ✅ 可改 | 随作用域结束 |
char* p = "abc"; | 字面量在只读段(rodata) | ❌ 改了崩溃 | 程序全程有效 |
char* p = malloc(n) | 堆 | ✅ 可改 | 到free为止 |
static char a[] = "abc"; | 静态数据区 | ✅ 可改 | 程序全程有效 |
关键区别在于数组会拷贝,指针只指向字面量:
#include <stdio.h>
void demo(void) {
char a[] = "hello"; // 栈上 6 字节,内容是 'h','e','l','l','o','\0'
char* p = "hello"; // p 指向只读段的 6 字节
a[0] = 'H'; // ✅ 合法,输出 Hello
// p[0] = 'H'; // ❌ 未定义行为,多数平台直接段错误
printf("%s %s\n", a, p);
}
sizeof在这里也是坑:sizeof(a)是数组大小6,sizeof(p)是指针大小8(64 位),与字符串长度无关。求长度必须用strlen。
char a[] = "hello";
char* p = a;
printf("%zu %zu %zu\n", sizeof(a), sizeof(p), strlen(a)); // 6 8 5
三、std::string 是什么
std::string是std::basic_string<char>的别名,是一个管理动态内存的类:
- 内部维护"数据指针 + 长度 + 容量",字符串内容存在堆上(短字符串可能被 SSO 优化存进对象内部,见后文坑点)。
- 自动管理内存:拷贝构造 / 移动构造 / 析构都由类完成,不需要
free。
- 提供长度
size()、拼接+、比较==、子串substr()等成员,语义都是"值语义"(拷贝得到独立副本)。
- 保证
data()返回的缓冲区以'\0'结尾(C++11 起),因此可安全传给 C API。
| 维度 | char[]/char* | std::string |
|---|
| 类型本质 | 数组或指针(无长度信息) | 类,内含长度与容量 |
| 长度获取 | strlen遍历 O(n) | size()直接返回 O(1) |
| 拼接 | strcat,需自行保证容量 | +/append,自动扩容 |
| 比较 | strcmp,返回三态 | ==/<,返回 bool 或字典序 |
| 内存管理 | 手动malloc/free | 自动(RAII) |
| 越界安全 | 无保护,直接 UB | at()会抛异常,[]仍无保护 |
可存'\0' | 否(会截断) | ✅ 可以,size()才准 |
最后两行是std::string相对 C 风格字符串的质的优势:std::string可以保存中间含'\0'的二进制数据,长度以size()为准;而 C 风格字符串一旦中间有'\0',后面全部丢失。
四、互转:三条路
#include <string>
#include <cstring>
#include <vector>
void convert() {
// C 风格 -> std::string:直接构造(会拷贝)
const char* c = "hello";
std::string s1(c);
std::string s2(c, 3); // 只取前 3 字节 "hel"
std::string s3 = c; // 拷贝赋值
// std::string -> C 风格:c_str() / data() 返回 const char*
const char* p = s1.c_str(); // 只读,勿修改
const char* q = s1.data(); // C++11 起同样以 '\0' 结尾
// 需要可写缓冲区时,不能用 c_str(),要自己拷一份
std::vector<char> buf(s1.begin(), s1.end());
buf.push_back('\0'); // 别忘了结尾
// 或者(C++17):
// std::string tmp = s1; tmp.data()[0] = 'H'; // data() 返回可写指针(C++17 起非 const 重载)
}
要点:c_str()返回的指针指向std::string内部缓冲区,任何导致扩容的操作都会让它失效;也不能通过它修改内容(是const char*)。想改,就自己拷贝到std::vector<char>或新的std::string。
五、代码实战:一个跨 C/C++ 的字符串处理示例
下面这份代码可直接编译,演示了长度计算、拼接、比较、互转以及常见错误的正确写法。
// 编译:g++ -std=c++17 -O2 -Wall -Wextra demo_char_string.cpp -o demo
#include <cstdio>
#include <cstring>
#include <string>
#include <iostream>
// 把 C 风格字符串按给定分隔符切分,返回 std::string 版本(二分查找更好,此处演示)
void toUpperC(char* s) { // 参数是"可写"的,调用者保证
for (; *s; ++s) {
if (*s >= 'a' && *s <= 'z') *s = static_cast<char>(*s - 'a' + 'A');
}
}
int main() {
// 1) 长度:sizeof 与 strlen 的区别
char arr[] = "abc";
char* ptr = arr;
std::printf("sizeof(arr)=%zu strlen=%zu sizeof(ptr)=%zu\n",
sizeof(arr), std::strlen(arr), sizeof(ptr)); // 4 3 8
// 2) 数组可改,字面量不可改
toUpperC(arr);
std::printf("after toUpperC: %s\n", arr); // ABC
// 3) std::string 拼接收尾无需关心 '\0'
std::string s = "Hello";
s += ", ";
s += "World";
s.push_back('!');
std::cout << s << " len=" << s.size() << '\n'; // Hello, World! len=13
// 4) 互转
const char* cs = s.c_str(); // string -> C,只读
std::string s2(cs); // C -> string,拷贝
std::cout << std::strlen(cs) << ' ' << s2.size() << '\n'; // 13 13
// 5) 二进制安全:std::string 可以存 '\0'
std::string bin("a\0b", 3);
std::cout << "bin.size()=" << bin.size() << '\n'; // 3
std::printf("C strlen=%zu\n", std::strlen(bin.c_str())); // 1(被 '\0' 截断)
// 6) 比较语义不同
std::printf("strcmp=%d\n", std::strcmp("abc", "abd")); // 负数(<0)
std::cout << ("abc" < std::string("abd")) << '\n'; // 1(bool 真)
return 0;
}
常见坑点
坑 1:把c_str()的指针长期保存
❌ 错误写法:
std::string s = "hello";
const char* p = s.c_str();
s += " world, this is a very long string that forces reallocation";
printf("%s\n", p); // p 可能已失效 → 输出乱码或崩溃
✅ 正确写法:
std::string s = "hello";
s += " world, ...";
const char* p = s.c_str(); // 内容定了再取指针
printf("%s\n", p);
c_str()/data()返回的指针在字符串扩容或修改后即失效。若确需长期持有,请拷贝成std::string或std::vector<char>。
坑 2:修改字符串字面量
❌ 错误写法:
char* p = "hello";
p[0] = 'H'; // 未定义行为,Linux 上段错误,部分嵌入式平台"居然能过"
✅ 正确写法:
char p[] = "hello"; // 拷贝到栈上,可改
p[0] = 'H';
// 或者
const char* q = "hello"; // 明确只读,编译器会帮你挡
坑 3:缓冲区溢出 ——strcpy/sprintf不检查长度
❌ 错误写法:
char buf[8];
strcpy(buf, userInput); // 只要输入超过 7 字节就溢出
sprintf(buf, "%s-%d", name, id);
✅ 正确写法:
char buf[8];
snprintf(buf, sizeof(buf), "%s", userInput); // C99 起,自动截断并保证 '\0'
// 或使用 strncpy 并手动补 '\0'(注意 strncpy 不保证结尾)
strncpy(buf, userInput, sizeof(buf) - 1);
buf[sizeof(buf) - 1] = '\0';
缓冲区溢出(buffer overflow)是 C 代码最经典的安全漏洞,栈上溢出可被利用来劫持返回地址。新代码应优先使用std::string或std::span(C++20)替代裸数组。
坑 4:返回局部数组的指针
❌ 错误写法:
char* getName(void) {
char buf[32] = "tom";
return buf; // buf 在函数返回后即销毁 → 悬垂指针
}
✅ 正确写法:
// 方案 A:调用者提供缓冲区
void getName(char* out, size_t n) { snprintf(out, n, "%s", "tom"); }
// 方案 B(C++):直接返回 std::string,由返回值优化/移动语义接管
std::string getName() { return "tom"; }
坑 5:strncpy不保证以'\0'结尾
char buf[4];
strncpy(buf, "hello", sizeof(buf)); // buf = {'h','e','l','l'},无 '\0'
printf("%s\n", buf); // ❌ 越界读取,直到偶然遇到 '\0'
strncpy(dst, src, n)在strlen(src) >= n时不会补'\0'。要么手动补,要么用snprintf。
坑 6:SSO 导致c_str()地址"看起来"稳定
主流标准库对小字符串有短字符串优化(SSO, Small String Optimization):长度不超过 15(libstdc++)或 15(MSVC)的字符串直接存在对象内部的栈缓冲区里,不分配堆内存。这会造成一种假象——"我c_str()之后改字符串,指针内容没变"。一旦字符串变长触发堆分配,同样的代码立刻崩溃。不要依赖 SSO 行为写代码,它不在标准里,且各实现阈值不同。
坑 7:std::string与'\0'的长度陷阱
std::string s("a\0b", 3); // size()==3
const char* p = s.c_str();
printf("%s\n", p); // 输出 a(被第一个 '\0' 截断)
printf("%zu\n", strlen(p)); // 1,不是 3
传给 C API 时会"丢数据"。处理二进制数据时,务必同时传长度,不要只传指针。
总结
| 需要可修改的字符串常量 | char a[] = "..."; | 栈上拷贝,可写 |
| 只读字符串字面量 | const char* p = "..."; | 明确只读,避免 UB |
| 一般业务字符串 | std::string | 自动内存管理、O(1) 长度、可存'\0' |
| 传给 C API | s.c_str() | 保证'\0'结尾(勿长期持有) |
| 接收 C API 写回 | std::vector<char> | 可写、可自行控制长度 |
| 字节/哈希运算 | unsigned char | 避免负下标与符号扩展 |
一句话收尾:C 的字符串是"约定",C++ 的std::string是"类型"。约定需要你手工维护(长度、结尾、容量),类型则由编译器与标准库替你维护。能用类型的地方就不要用约定,这是从 C 走向 C++ 最重要的一步。