C++字符串:从C风格到string类的转型
字符串是C++开发中无处不在的元素,从用户输入到网络传输都离不开它。字符串看似简单,但其实其中埋藏了无数的细节和坑,稍不注意就容易中招。缓冲区溢出、内存泄漏这些问题一旦出现极难排查,错误往往是在不经意间中造成。即使是代码老手也不能对此掉以轻心。今天我们就来看看c++中的字符串的一些实战应用方法
C风格字符串
对于熟悉c语言的程序员来说,比较利好的是c++中也可以使用c风格字符串。简单回顾下,c字符串本质上是字符数组的包装,最典型的定义char str[] = "hello",每个数组元素存储一个字符,通过数组末尾的"\0"标识字符串的结束
c风格字符串需要配合一系列字符串处理函数来使用,这些函数声明在c++中都包含在cstring头文件中
#include <cstring>#include <stdio.h>int main(){char dest[10];char src[20] = "this is a long string";strcpy(dest, src); // 危险!缓冲区溢出printf("%s\n", dest);return 0;}
终止符陷阱:若忘记添加或意外覆盖'\0",会导致strlen()、strcpy()等函数读取越界。例如char str[5] = "hello";会因数组长度刚好等于字符串长度(不含终止符),成为没有"终点"的"危险字符串"。
C风格字符串完全依赖手动内存管理,主要问题包括:
- 缓冲区溢出
使用strcpy(buffer, longString)时若buffer容量不足
- 功能简单
无法直接用运算符如+、=实现拼接、赋值等操作
-
结尾标记管理复杂
"\0"标记处理需要异常小心,有些函数如strlen不计算,拷贝时容易遗漏或覆盖,造成字符串无终点
C++ string类
在现代c++中,一般都不再使用c风格字符串了。c++提供了string类专用于字符串类型的数据处理(需要包含<string>头文件)。它是对 C 风格字符串的封装,string的底层其实就是一个支持动态增长的字符数组,不用你管'\0',也不用手动管理内存,还自带一堆好用的方法。
先从下面例子看看基础用法
#include <string>#include <iostream>using namespace std;int main(){// 1. 定义字符串string s1; // 空字符串string s2 = "hello"; // 直接赋值C风格字符串string s3(5, 'a'); // 5个'a',结果是"aaaaa"// 2. 拼接:比strcat方便s1 = s2 + " world"; // 直接用+拼接,不用管内存s1 += "!"; // 追加,结果是"hello world!"cout << "拼接后:" << s1 << endl; // 输出hello world!// 3. 长度:size()和length()都能用,推荐size()(STL容器统一接口)cout << "长度:" << s1.size() << endl; // 输出12(不含'\0')// 4. 访问字符:[]和at()的区别cout << "第1个字符:" << s1[0] << endl; // 输出'h',越界不抛异常(危险)cout << "第3个字符:" << s1.at(2) << endl; // 输出'l',越界抛异常(安全)// 坑点示例:[]越界会崩溃,at()能捕获错误try {s1.at(100); // 越界,抛out_of_range异常} catch (out_of_range& e) {cout << "错误:" << e.what() << endl; // 捕获异常,程序不崩}return 0;}
从例子中可以看到,string类对象的使用方法和c风格字符串很类似,比如初始化、用下标访问等,还可以直接用cout输出。这些其实都是string类对这些运算符进行重载来实现的
当然string类重载了很多构造函数,初始化的方法还有其他一些形式,如下图所示,但是常用的就上面那几种

string 类提供了许多成员函数来操作字符串,以下是一些常用的成员函数:
size()返回字符串的长度。
empty()检查字符串是否为空。
-
append()
在字符串后追加内容
substr()获取子字符串。
find()查找子字符串在主字符串中的位置。
replace()替换字符串中的某些字符。
-
insert()
在指定位置插入内容
-
clear()
清空字符串
-
compare()
比较字符串是否相同
-
c_str()
返回c风格字符串,带结束符\0
每个成员函数的具体用法可以单独查阅资料,限于篇幅就不一一列举了。但是这里要单独说一下c_str()方法:
c_str()返回的指针生命周期与string对象绑定。若修改字符串(如+=、append操作),原指针会成为“悬垂指针”,直接访问会出现错误,必须重新获取:
std::string s = "old";const char* p = s.c_str(); // 初始指针有效s += "new"; // 修改字符串后,p可能失效p = s.c_str(); // 重新获取指针以确保安全
要记住通过c_str()返回的const char*指针仅用于读取,直接强转(如const_cast<char*>(s.c_str()))后再去修改很可能导致程序崩溃
string类核心特性与内存管理
string类最核心的特性就体现在对内存的自动管理能力上,它可以自动根据字符串的长度进行扩容。另外现代编译器对std::string实现了短字符串优化(SSO):当字符串长度较小时,字符会直接存储在对象本身的栈空间中,避免昂贵的堆内存分配。可以从下面的例子看出来
int main(){std::string short_str = "123456789"; // 9个字符,触发SSOstd::string long_str(32, 'x'); // 32个字符,堆存储auto print_addr = [](const std::string& s) {std::cout << "对象地址: " << (void*)&s<< " | 数据地址: " << (void*)s.c_str() << '\n';};print_addr(short_str); // 地址紧挨(栈内直接存储)print_addr(long_str); // 地址不同(堆内存间接存储)}
输出结果
![]()
这个具体长度大小也是随着不同编译器和平台而不同,我在windows上的vs code下这个大小是16
要理解string的内存管理,还必须区分两个核心概念:容量(capacity) 和大小(size/length)。前者代表当前分配的物理内存空间总量,后者是实际存储的有效字符数。可以用"水杯"来类比:size是当前装了多少水,capacity是杯子最多能装多少水。这里与vector容器很类似
频繁的自动扩容会导致内存重分配和数据拷贝,这是字符串拼接等操作的性能瓶颈。而reserve(n)方法可以提前预留至少n个字符的容量,避免多次扩容:
// 未使用reserve:可能触发多次扩容std::string str1;for (int i = 0; i < 50; ++i) {str1 += 'a';}// 使用reserve:一次分配到位std::string str2;str2.reserve(100); // 预分配100个字符容量for (int i = 0; i < 50; ++i) {str2 += 'a'; // 无扩容,直接追加}
避坑提示:
-
reserve仅改变容量,不影响实际字符数;而resize会直接修改size,若n超过原size会用'\0'填充新增空间,同时capacity 也会扩大
-
未初始化的空字符串直接通过下标访问会触发错误,需先通过reserve/resize分配空间或使用push_back追加
string类与C风格字符串的转换
C++允许const char*到std::string之间隐式转换,这意味着可以直接将字符串字面量(如"C-style")传入接收std::string参数的函数,反之亦然。例如:
void process_string(std::string str) { /* ... */ }process_string("C-style"); // 隐式转换为std::string临时对象
但是这种转换背后隐藏着临时对象开销:每次调用都会创建一个std::string临时实例,若在高频场景(如循环调用)中使用,可能导致性能损耗。更危险的是,使用者容易忽略这种转换的存在,进而引发后续内存管理问题。
如下例子
#include <string>#include <cstring>void process_data(const char* data){// 使用data指针访问字符串...}int main(){std::string str = "temporary string";const char* c_ptr = str.c_str(); // 获取C风格指针str += " extended"; // 隐式触发内存重分配,原c_ptr指向旧内存//process_data(c_ptr); // 危险!c_ptr已悬空,访问释放内存c_ptr = str.c_str(); //正确,重新获取指针process_data(c_ptr);return 0;}
这里str.c_str()返回的指针在str修改后可能失效,因为字符串扩容可能导致内存重分配,旧指针指向的内存被释放。但使用中很容易遗忘而继续使用这个指针,导致内存异常访问
C++11原始字符串字面量
C 风格字符串最麻烦的就是 “转义字符”—— 写个 Windows 路径C:\Users\test,得写成"C:\\Users\\test";写个多行 SQL 语句,还得用\n或者拼接符,可读性很差。
C++11 引入的原始字符串完美解决了这个问题,语法是R"(字符串)",括号里的内容原样输出,不会转义。比如:
#include <stdio.h>int main(){// 1. 路径不用转义const char path[] = R"(C:\Users\test\code)";printf("%s\n", path); // 直接输出C:\Users\test\code// 2. 多行字符串不用拼接const char sql[] = R"(SELECT id, nameFROM userWHERE age > 18)";printf("SQL:%s\n", sql); // 保留换行和空格,原样输出// 3. 特殊字符直接写const char quote[] = R"(He said "I'm a C++ developer!")";printf("%s\n", quote); // 输出He said "I'm a C++ developer!"return 0;}
如果内容里刚好有)",可以给原始字符串加 “分隔符”,比如R"delimiter(内容)delimiter",只要分隔符不重复就行:
const char special[] = R"abc(This is a "(test)")abc";cout << special << endl;// 输出This is a "(test)"
上面例子中的分隔符就是abc,夹在"abc( 和 )abc"之间的字符串会原样输出
原始字符串在写配置文件路径、多行日志、正则表达式时会经常用到
字符串格式化
字符串的格式化是对字符串的一个重要应用,也存在传统风格和现代c++标准引入的新方法之间的差异,一起来看看
传统c风格格式化方案始终面临着"安全与效率"的双重困境。以C风格的sprintf为例,其最致命的缺陷在于完全依赖开发者手动匹配格式字符串与参数类型:
// 危险!类型不匹配导致运行时错误char buffer[100];sprintf(buffer, "%s", 42); // 将整数42当作字符串指针解析
sprintf还要求开发者手动管理目标缓冲区大小,在动态字符串场景下几乎是灾难。即便使用sscanf或snprintf等改进版本,也难以完全避免缓冲区溢出风险。这种做法在c++开发中基本已被摈弃
c++标准的流操作则免去了手动匹配类型的麻烦,但写起来语句冗长,而且性能较差:
// 冗长的流操作语法std::cout << "Name: " << name << ", Age: " << age<< ", Score: " << score << std::endl;
在c++20之前,一般字符串格式化都要依赖第三方库,如FMT等
C++20 std::format的革命性改进
C++20引入的std::format现代化格式化方案,这个被称为"Python式格式化走进C++"的新特性,彻底改变了C++字符串格式化的方式。它提供一种更现代、更安全、更灵活的格式化方法
核心特点:
-
类型安全
编译期检查格式说明符与参数类型匹配
-
语法直观
用
{}替代晦涩的%d/%f -
性能跃升
浮点格式化速度达snprintf的10倍
-
灵活控制
通过格式说明符实现对齐、填充、精度等细粒度控制
std::format的语法遵循[[fill]align][sign][0][width][.precision][type]模式,其中各部分含义如下
-
fill: 可选,指定用于对齐的填充字符。例如,
{:=^10}中=是填充字符。 -
align: 可选,指定对齐方式(
<左对齐,>右对齐,^居中对齐)。 -
sign: 可选,指定数字的符号显示方式(
+表示总是显示符号,-表示只对负数显示符号)。 -
0: 可选,如果指定了宽度,此选项表示用零填充。例如,
{:08d}会将数字格式化为至少8位的宽度,不足部分用0填充。 -
width: 可选,指定最小宽度。如果值短于这个宽度,会根据对齐和填充规则进行填充。
-
.precision: 可选,指定小数点后的位数或者字符串的最大长度等。例如,
{:.2f}表示保留两位小数。 -
type: 可选,指定值的类型转换方式(如
d为十进制整数,f为浮点数等)。
以经典的右对齐示例{:*>10}为例,*是填充字符,>表示右对齐,10指定宽度,三者组合实现"用*填充至10个字符宽度并右对齐"的效果:
通过下面例子看看基本用法
#include <format> //必须包含此头文件#include <cstdio>#include <string>#include <iostream>int main() {std::string result = std::format("Name: {:<10} Age: {:03d} Score: {:.2f}","Alice", 25, 95.5);std::cout << result << std::endl;return 0;}
输出
![]()
上述代码中,{:<10}实现左对齐10字符宽度,{:03d}用0填充至3位整数,{:.2f}保留两位小数
再看看一些常用的格式控制
double price = 19.9;// 1. 指定小数位数:{:.2f}表示保留2位小数string price_str = format("价格:{:.2f}元", price);cout << price_str << endl; // 输出:价格:19.90元// 2. 指定宽度和对齐:{:>10}表示右对齐,总宽度10string right_align = format("右对齐:{:>10}", price);cout << right_align << endl; // 输出:右对齐: 19.9// 3. 左对齐+填充:{:<10#}表示左对齐,总宽度10,空白处用#填充string left_align = format("左对齐:{:<10#}", price);cout << left_align << endl; // 输出:左对齐:19.9#######
format的格式控制语法与python的str.format()很类似,支持很丰富的格式控制选项,同时可读性强,是现代c++格式化控制的首选推荐用法
总结:现代C++字符串最佳实践
在现代C++标准中已经为我们提供了足够好用的字符串特性处理方法,充分利用这些特性可以极大地简化字符串的操作复杂性。简单总结一下基本原则:
-
当需要存储字符串时,优先使用std::string,摒弃传统c风格字符串
-
处理格式化需求时,优先用std::format替代传统printf或I/O流,但需编译器支持(GCC 11+、Clang 14+、MSVC 19.3+),旧环境可使用fmt库作为替代
-
遇到多行文本或含特殊字符的字符串,采用C++11原始字符串字面量简化转义逻辑
-
优先采用reserve预分配空间优化字符串拼接性能
更多推荐
所有评论(0)