在C++标准库中,string是一个非常重要的类,它封装了字符串的各种操作,使得开发者可以更方便地处理字符串。理解string的底层实现对于掌握C++的内存管理和类设计非常有帮助。本文将深入解析string类的底层实现细节,包括成员变量、构造函数、析构函数、拷贝控制、遍历访问以及增删改查等操作。
string类通常包含三个核心成员变量:
char* _str:指向动态分配的字符数组,存储实际的字符串内容int _size:记录当前字符串的长度(不包括结尾的'\0')int _capacity:记录当前分配的内存空间大小注意:在实际的标准库实现中,string可能会使用更复杂的内存管理策略,如短字符串优化(SSO),但本文主要关注基本的实现原理。
string类通常提供多种构造函数来满足不同的初始化需求。以下是两种常见的构造函数实现方式:
cpp复制// 第一种:分开实现默认构造和C字符串构造
string() :
_str(new char[1]), // 至少分配1字节空间存放'\0'
_size(0),
_capacity(0)
{
_str[0] = '\0';
}
string(const char* str) :
_str(new char[strlen(str) + 1]), // 多分配1字节存放'\0'
_size(strlen(str)),
_capacity(strlen(str))
{
strcpy(_str, str);
}
// 第二种:使用全缺省参数的统一实现
string(const char* str = "") :
_size(strlen(str))
{
_capacity = _size;
_str = new char[_size + 1];
strcpy(_str, str);
}
第一种实现方式将默认构造函数和C字符串构造函数分开实现,逻辑更清晰;第二种方式使用全缺省参数,代码更简洁。在实际开发中,第二种方式更为常见。
析构函数负责释放动态分配的内存:
cpp复制~string()
{
delete[] _str; // 释放字符数组
_str = nullptr; // 避免野指针
_size = _capacity = 0; // 重置大小和容量
}
重要提示:delete[]必须与new[]配对使用,否则会导致内存泄漏。同时,将指针置为nullptr是一个良好的编程习惯,可以避免悬垂指针问题。
拷贝构造函数实现深拷贝,确保每个string对象拥有独立的字符串存储:
cpp复制// 传统写法
string(const string& s)
{
_str = new char[s._capacity + 1];
strcpy(_str, s._str);
_size = s._size;
_capacity = s._capacity;
}
// 现代写法
string(const string& s)
{
string tmp(s._str); // 利用构造函数创建临时对象
swap(tmp); // 交换资源
}
现代写法利用了已有的构造函数和swap函数,代码更简洁且不易出错。swap函数的实现将在后面介绍。
赋值运算符也需要实现深拷贝:
cpp复制// 传统写法
string& operator=(const string& s)
{
if(this != &s) { // 防止自赋值
char* tmp = new char[s._capacity + 1];
strcpy(tmp, s._str);
delete[] _str;
_str = tmp;
_size = s._size;
_capacity = s._capacity;
}
return *this;
}
// 现代写法
string& operator=(string s) // 注意这里是传值,会调用拷贝构造
{
swap(s); // 交换资源
return *this;
}
现代写法利用了拷贝构造函数和swap函数,代码更加简洁。传值的方式会自动创建临时对象,通过swap交换资源后,临时对象会在函数结束时自动销毁,释放原对象的资源。
string类通常重载[]运算符来支持下标访问:
cpp复制char& operator[](size_t pos)
{
assert(pos < _size); // 检查越界
return _str[pos];
}
const char& operator[](size_t pos) const
{
assert(pos < _size);
return _str[pos];
}
提供了const和非const两个版本,分别用于可修改和不可修改的string对象。assert用于在调试时检查下标越界问题。
string的迭代器可以用指针简单模拟:
cpp复制typedef char* iterator;
typedef const char* const_iterator;
iterator begin() { return _str; }
iterator end() { return _str + _size; }
const_iterator begin() const { return _str; }
const_iterator end() const { return _str + _size; }
这种实现方式使得string可以支持范围for循环:
cpp复制string s = "hello";
for(char c : s) {
cout << c << " ";
}
注意:标准库中的实现可能更复杂,但基本原理类似。迭代器的设计使得string可以与其他STL算法无缝配合。
reserve函数用于预分配内存空间:
cpp复制void reserve(size_t n)
{
if(n > _capacity) {
char* tmp = new char[n + 1]; // 多分配1字节存放'\0'
strcpy(tmp, _str);
delete[] _str;
_str = tmp;
_capacity = n;
}
}
reserve不会缩小容量,只有当请求的大小大于当前容量时才会重新分配内存。重新分配的过程包括:
在添加字符时,如果空间不足,需要进行扩容:
cpp复制void push_back(char ch)
{
if(_size == _capacity) {
reserve(_capacity == 0 ? 4 : 2 * _capacity); // 扩容策略
}
_str[_size] = ch;
++_size;
_str[_size] = '\0'; // 确保字符串以'\0'结尾
}
常见的扩容策略是倍增法(当前容量的2倍),这种策略可以平摊多次插入操作的时间复杂度,使得单次操作的平均时间复杂度为O(1)。对于初始容量为0的情况,通常直接分配一个较小的初始值(如4)。
string提供了多种追加字符和字符串的方式:
cpp复制void append(const char* str)
{
size_t len = strlen(str);
if(_size + len > _capacity) {
reserve(_size + len); // 精确扩容
}
strcpy(_str + _size, str);
_size += len;
}
string& operator+=(char ch)
{
push_back(ch);
return *this;
}
string& operator+=(const char* str)
{
append(str);
return *this;
}
append函数在追加字符串时采用精确扩容策略,避免频繁扩容。operator+=提供了更直观的语法糖。
insert函数支持在指定位置插入字符或字符串:
cpp复制void insert(size_t pos, char ch)
{
assert(pos <= _size);
if(_size == _capacity) {
reserve(_capacity == 0 ? 4 : 2 * _capacity);
}
// 将pos后的字符后移
for(size_t i = _size; i > pos; --i) {
_str[i] = _str[i - 1];
}
_str[pos] = ch;
++_size;
_str[_size] = '\0';
}
void insert(size_t pos, const char* str)
{
assert(pos <= _size);
size_t len = strlen(str);
if(_size + len > _capacity) {
reserve(_size + len);
}
// 移动原有字符
for(size_t i = _size; i >= pos; --i) {
_str[i + len] = _str[i];
if(i == 0) break; // 防止无符号整数下溢
}
// 插入新字符串
for(size_t i = 0; i < len; ++i) {
_str[pos + i] = str[i];
}
_size += len;
}
插入操作需要注意边界条件和内存管理。对于字符串插入,需要先移动原有字符为新内容腾出空间。
erase函数用于删除部分字符:
cpp复制void erase(size_t pos, size_t len = npos)
{
assert(pos < _size);
if(len == npos || pos + len >= _size) {
_str[pos] = '\0';
_size = pos;
} else {
strcpy(_str + pos, _str + pos + len);
_size -= len;
}
}
删除操作可以通过移动剩余字符覆盖被删除部分来实现。如果删除到字符串末尾,只需简单地截断字符串。
swap函数用于高效交换两个string对象的内容:
cpp复制void swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
swap操作仅交换指针和大小值,不涉及内存分配和内容拷贝,因此效率很高。这也是现代写法中拷贝控制和赋值操作的基础。
c_str函数返回C风格字符串指针:
cpp复制const char* c_str() const
{
return _str;
}
这个函数很简单,但很重要,因为它使得string可以与需要C风格字符串的函数交互。
cpp复制size_t size() const { return _size; }
size_t capacity() const { return _capacity; }
bool empty() const { return _size == 0; }
这些函数提供了字符串的基本信息查询功能。
异常安全:在内存分配和字符串操作中要考虑异常安全,确保在异常发生时对象仍处于有效状态。
移动语义:C++11引入了移动构造函数和移动赋值运算符,可以进一步优化性能:
cpp复制string(string&& s) noexcept
: _str(s._str), _size(s._size), _capacity(s._capacity)
{
s._str = nullptr;
s._size = s._capacity = 0;
}
短字符串优化(SSO):标准库实现通常会使用SSO技术,对小字符串直接存储在对象内部,避免动态内存分配。
引用计数:某些实现可能使用引用计数来实现写时复制(copy-on-write),但现代实现通常避免这种方式,因为它不利于多线程环境。
内存对齐:在分配内存时考虑对齐要求可以提高访问效率。
迭代器失效:任何可能导致内存重新分配的操作都会使迭代器失效,需要在文档中明确说明。
在实际项目中,string的实现会更加复杂,需要考虑更多的边界条件、性能优化和线程安全问题。理解这些基本原理有助于更好地使用标准库中的string,以及在需要时实现自己的字符串类。