目录

string类的模拟实现

经典的string类问题

浅拷贝

深拷贝

写时拷贝(了解)

构造函数

string的全缺省的构造函数:

string的拷贝构造函数

传统写法

现代写法

string的赋值重载函数

传统写法

现代写法

string的无参构造函数:

遍历函数

operator[ ]

迭代器

迭代器的底层实现begin和end:

范围for的使用

修改字符串相关函数:

reserve 

push_back

append

operator+=

insert(字符的版本)

insert(字符串常量)

erase

resize

find(查找单个字符)

find(查找子串)

substr

比较运算符的重载:

流插入cout

流提取cin

优化写法

clear


前言:

🎯个人博客Dream_Chaser

🎈博客专栏:C++

📚本篇内容:string类通用函数的模拟实现

string类的模拟实现

经典的string类问题

上面已经对string类进行了简单的介绍,大家只要能够正常使用即可。在面试中,面试官总喜欢让学生自己来模拟实现string类,最主要是实现string类的构造、拷贝构造、赋值运算符重载以及析构函数。大家看下以下string类的实现是否有问题?

浅拷贝

说明:上述String类没有显式定义其拷贝构造函数与赋值运算符重载,此时编译器会合成默认的,当用s1构 造s2时,编译器会调用默认的拷贝构造。最终导致的问题是,s1、s2共用同一块内存空间,在释放时同一块 空间被释放多次而引起程序崩溃,这种拷贝方式,称为浅拷贝。

浅拷贝

浅拷贝:也称位拷贝,编译器只是将对象中的值拷贝过来。如果对象中管理资源,最后就会导致多个对象共 享同一份资源,当一个对象销毁时就会将该资源释放掉,而此时另一些对象不知道该资源已经被释放,以为 还有效,所以当继续对资源进项操作时,就会发生发生了访问违规。

深拷贝

如果一个类中涉及到资源的管理,其拷贝构造函数、赋值运算符重载以及析构函数必须要显式给出。一般情况都是按照深拷贝方式提供。

写时拷贝(了解)

写时拷贝就是一种拖延症,是在浅拷贝的基础之上增加了引用计数的方式来实现的。

引用计数:用来记录资源使用者的个数。在构造时,将资源的计数给成1,每增加一个对象使用该资源,就给计数增加1,当某个对象被销毁时,先给该计数减1,然后再检查是否需要释放资源,如果计数为1,说明该对象时资源的最后一个使用者,将该资源释放;否则就不能释放,因为还有其他对象在使用该资源。

构造函数

string的全缺省的构造函数

我们对string成员函数进行模拟实现的时候,首先要定义一个自定义的命名空间,避免与库里面的冲突:

让我们来看看错误的案例:

①注意常量字符串的用法,权限不能放大:

②尝试给_str声明加上const,这样就会导致初始化的时候出现随机值:

③需要注意的是"string.h"要定义在std的下方:

那么带参的构造可以怎么写呢?初始化列表的顺序应该对应着成员变量声明的顺序

string带参的构造是否可以再优化一下呢?

注意new的时候的+1是为了给'\0'预留的空间:

代码实现:

此构造函数接受一个可选参数(默认为空字符串),用于初始化一个字符串对象,根据传入的C风格字符串(const char* 类型)计算其长度,并据此分配内存空间,最后,将传入的字符串内容复制到新分配的内存区域,以便在C++字符串类中进行管理。

//构造函数,用于初始化一个字符串类对象
string(const char* str = "")
			:_size(strlen(str))// 初始化_size成员变量,存储传入C风格字符串(char数组)str的长度
			, _capacity(_size)// 初始化_capacity成员变量,初始容量与传入字符串长度相同
		{
			_str = new char[_capacity + 1];// 动态分配内存,为字符串对象分配一个新的字符数组
			strcpy(_str, str); // 使用strcpy将传入的字符串str复制到新分配的字符数组中
		}

string的拷贝构造函数

传统写法

//传统写法 -- 拷贝构造
//s2(s1)
string(const string& s)
{
	_str = new char[s._capacity + 1];//仅仅开空间
	strcpy(_str, s._str);//
	_size = s._size;
	_capacity = s._capacity;
}

现代写法

// 定义交换函数,将当前字符串对象与输入参数s的内部数据(字符数组、长度及容量)进行交换
void swap(string& s) {
    // 使用STL中的std::swap交换三个关键成员变量
    std::swap(_str, s._str);
    std::swap(_size, s._size);
    std::swap(_size, s._capacity);
}

// 构造函数,复制输入参数s的字符串内容
// 使用临时字符串对象tmp存储s的内容,然后通过调用swap函数交换数据
string(const string& s) :
    _str(nullptr),
    _size(0),
    _capacity(0)
{
    string tmp(s._str); // 创建临时字符串tmp,拷贝s的内容
    swap(tmp); // 交换当前对象与tmp的数据,实现深拷贝
}

string的赋值重载函数

传统写法

//传统写法--赋值重载
// 使得可以使用 "s2 = s3" 的形式,将 s3 字符串对象的值赋给 s2
string& operator=(const string& s)
{
    // 检查是否为自我赋值,即检查是否同一个对象
    if (this != &s)
    {
        // 创建一个临时字符数组 tmp,大小为 s 的容量加一(包含结束符 '\0')
        char* tmp = new char[s._capacity + 1];

        // 使用 strcpy 函数将 s 的内部字符串复制到临时数组 tmp 中
        strcpy(tmp, s._str);

        // 删除当前对象已有的内部字符串数组
        delete[] _str;

        // 将临时数组 tmp 的地址赋给当前对象的内部字符串指针 _str
        _str = tmp;

        // 将源字符串对象 s 的大小赋给当前对象的大小属性 _size
        _size = s._size;

        // 同样将源字符串对象 s 的容量赋给当前对象的容量属性 _capacity
        _capacity = s._capacity;
    }

    // 返回当前对象的引用,以支持连续赋值如 "s1 = s2 = s3"
    return *this;
}

现代写法

// 重载赋值运算符,实现字符串对象之间的深拷贝赋值操作
string& operator=(const string& s)
{
    // 检查是否自我赋值(即源对象与目标对象相同),避免不必要的资源释放与重新分配
    if (this != &s)
    {
        // 创建临时字符串对象tmp,并使用s的内容初始化
        string tmp(s);

        // 调用自定义的swap函数,将当前对象的数据与临时对象tmp的数据进行交换
        // 这样可以确保原对象的资源被正确释放,并且新内容被赋值给当前对象
        swap(tmp);
        
        // 注:这里原本可能是 "this->swap(tmp);",但因为 "swap" 是成员函数,
        // 在成员函数内部可以直接调用,无需 "this->"。
    }

    // 返回当前对象的引用,以便支持连续赋值操作
    return *this;
}

现代写法优化

//现代写法优化
string& operator=(string tmp)
{
	swap(tmp);
	return *this;
}

string的无参构造函数:

经过尝试之后,可以发现以下问题:

无参的构造应该这样写:

代码实现: 建议只写一个全缺省的构造函数即可,因为默认构造函数只能存在一个。

String()
	:_str(new char[1]{'\0'})//数组的初始化方式
	,_size(0)
	,_capacity(0)
	{}

遍历函数

c_str函数

简要说明

const char* c_str()const
{
	return _str;
}

operator[ ]

这里要演示的话就得遍历这个字符串,需要重载一下operator[ ]:

	void test_string1()
	{
		string s1("hello world");
		cout << s1.c_str() << endl;

		string s2;
		cout << s2.c_str() << endl;

		for (size_t i = 0; i < s1.size(); i++)
		{
			cout << s1[i] << " ";
		}
		cout << endl;
}

需要区分只能读和可读可写的版本:

//可读可写
char& operator[](size_t pos)
{
	assert(pos < _size);
	return _str[pos];
}

//只能读
const char& operator[](size_t pos)const
{
	assert(pos < _size);
	return _str[pos];
}

代码执行: 

既然说到遍历数组的话,那就少不了迭代器。

迭代器

迭代器声明: 

//声明
typedef char* iterator;//可读可写
typedef const char* const_iterator;//只可读

迭代器的底层实现begin和end:

begin:函数的作用就是返回字符串中第一个字符的地址

//实现:
iterator begin()
{
	return _str;
}

const_iterator begin()const
{
	return _str;
}

end函数的作用就是返回字符串中最后一个字符的后一个字符的地址(即’\0’的地址)

//实现:
iterator end()
{
	return _str + _size;//指向'\0'
}
const_iterator end()const
{
	return _str + _size;//指向'\0'
}

迭代器外层调用:

范围for的使用

同时为了方便理解,把范围for的实现列举出来:

当咱们将begin改为Begin时,发现会出错,还有一个点:范围for的本质是迭代器

迭代器与范围for的应用: 

怎么能够让改变后的字符串,不再变回原来的字符串呢?使用引用即可:

实例代码:

void test_string1()
	{
		string s1("hello world");
		cout << s1.c_str() << endl;

		string s2;
		cout << s2.c_str() << endl;

	/*	for (size_t i = 0; i < s1.size(); i++)
		{
			cout << s1[i] << " ";
		}
		cout << endl;*/

		string::iterator it = s1.begin();
		while (it != s1.end())
		{
			(*it)++;
			cout << *it << " ";
			++it;
		}
		cout << endl;
		for (auto& ch : s1)//使用引用
		{
			ch++;//让字符串里每个字符的ascll码值+1
			cout << ch << " ";
		}
		cout << endl;
		cout << s1.c_str() << endl;
	}

修改字符串相关函数:

reserve 

        其主要作用是预先为容器分配足够的未使用容量,以应对未来可能的元素添加,而不立即改变容器的大小(元素数量)。使用 reserve() 可以有策略地控制容器的内存分配,提高连续添加元素时的效率

void reserve(size_t n)
{
	// 检查请求的容量n是否大于当前内部缓冲区的实际容量(_capacity)
	if (n > _capacity)
	{
	 // 如果是,则需要重新分配更大的内存空间。新分配的内存大小为n+1,+1用于存储结尾的空字符'\0
	    char* tmp  = new char[n + 1];//+1存“\0'
	    strcpy(tmp,_str);//将当前字符串内容(包括结尾的'\0')复制到新分配的tmp缓冲区中
	    delete[] _str;// 释放原有的内部缓冲区(_str),以避免内存泄漏
	    _str = tmp;// 更新内部缓冲区指针,指向新分配的tmp缓冲区
	    _capacity = n;// 更新内部记录的缓冲区容量为请求的值n
	 }
}

push_back

  push_back() 是序列容器(如 std::vectorstd::liststd::deque 等)的成员函数,用于在容器末尾添加新元素。它自动处理内存管理,确保新元素顺利加入,且保持原有元素顺序不变。对于动态扩容容器(如 std::vector),在必要时会自动增大容量。 

尝试:可以写成这样吗?不行,当_capacity = 0 时,空间还没分配到,此时如果访问就属于越界访问

代码实现:

void push_back(char ch)
{
	// 检查当前内部字符串长度(_size)是否已达到内部缓冲区容量(_capacity)
	if (_size == _capacity)
	{
	    // 若已满,调用reserve函数预分配新的内存,扩大容量。
		// 首次扩容时容量设为4,后续扩容按当前容量翻倍。
			reserve(_capacity == 0 ? 4 : 2 * _capacity);
	}
		// 将待添加字符ch 存储在 内部字符串缓冲区的当前长度索引处(_size)
	         _str[_size] = ch;
			 ++_size;// 更新内部字符串长度(_size),增1以包含新添加的字符
			 _str[_size] = '\0';// 在新添加字符之后添加空字符('\0'),确保字符串正确终止
}

append

对于std::string:append()用于在字符串末尾追加其他字符串、字符数组或单个字符,可以指定追加的起始位置和长度。

void append(const char* str)
{
	int len = strlen(str);// 获取输入字符串str长度
	// 检查是否需扩大内部缓冲区容量以容纳追加的str
	if (_size + len > _capacity)
	{
		reserve(_size + len);
	}
		//这个地方要是忘记 +_size,从头到尾覆盖新字符串
		strcpy(_str+_size,str);// 将str追加到内部字符串缓冲区末尾
		_size += len;// 更新内部字符串长度
}

push_backappend 的区别在于一个追加字符,另一个追加字符串

示例:

operator+=

+=运算符的重载:分别复用了 push_back  和  append, 实现字符串与字符,字符串与字符串之间能够直接使用+=运算符进行尾插。

字符串追加字符:

string& operator+=(char ch)
{
	push_back(ch);
	return *this;
}

字符串追加字符串:

string& operator+=(const char* str)
{
	append(str);
	return *this;
}

示例运行:

void test_string2 ()
{
	string s1("hello world");
	s1 += '#';
	s1 += "css";
	cout << s1.c_str() << endl;

	string s2;
	s2 += '#';
	s2 += "csgo";
	cout << s2.c_str() << endl;
}

insert(字符的版本)

尝试写一下insert:

测试一下代码的可行性:

 可以发现头插会出错:

头插:

那就换成有符号,此时pos为0,end为-1,此时end<pos,但是依然进入了循环,明显的不对吧,这里是发生了类型转换,end变成无符号整型了。

怎么解决呢:以下两种写法都是正确的:

此时函数的功能:

在动态字符数组 _str 的指定位置 pos 插入一个字符 ch。首先,它验证插入位置的有效性(必须小于等于当前数组大小_size)。

若当前数组容量已满,函数会自动扩容,初始容量为4或者当前容量的两倍。

然后,函数通过循环将 pos位置之后的所有元素向右移动一位,为新字符腾出空间。

最后,函数在指定位置pos插入字符ch,并将数组大小_size加一,表示数组元素数量增加。 

//上图的版本二
void insert(size_t pos, char ch)
{
	assert(pos <= _size);
	if (_size == _capacity)
	{
		reserve(_capacity == 0 ? 4 : 2 * _capacity);
	}
	size_t end = _size + 1;
	while (end > pos)
	{
		_str[end] = _str[end - 1];
		--end;
	}
    _str[pos] = ch;
	_size++;
}

insert(字符串常量)

该函数的作用:

        用于在动态字符数组(或类似字符串结构)的指定位置pos插入一个C风格字符串str。首先,它通过断言确保插入位置的有效性。

        接着计算待插入字符串的长度,并检查现有容量是否足够容纳插入后的完整字符串,不足则调用reserve进行扩容。

        然后,函数将插入位置之后的所有字符向右移动适当距离,为新字符串腾出空间。最后,使用strncpy函数将待插入字符串复制到目标位置,并更新整个字符串的大小,反映出新增字符的影响。

首先要注意:

所以需要强转:

//   pos:插入位置,从0开始计数
//   str:要插入的C风格字符串

void insert(size_t pos, const char* str)
{
    // 断言检查,确保插入位置pos不大于当前字符串的大小
    assert(pos <= _size);

    // 获取要插入字符串str的长度,即需要移动的字符数量
    size_t len = strlen(str);

    // 检查插入后字符串总长度是否超过当前容量,如果超过则进行扩容
    if (_size + len > _capacity)
    {
        reserve(_size + len);  // 调用reserve函数来增加内部缓冲区的容量
    }

    // 数据挪动部分:
    // 将插入位置pos之后的所有字符向右移动len个位置
    int end = static_cast<int>(_size);  // 使用int类型方便进行减操作,注意这里假设_size不会超出int表示范围
    while (end >= static_cast<int>(pos))
    {
        _str[end + len] = _str[end];  // 将原字符串中下标为end的字符移动到end + len的位置
        --end;                        // 移动指针至下一个待移动的字符
    }

    // 在指定位置pos处插入字符串str
    strncpy(_str + pos, str, len);  // 使用 strncpy 函数将str复制到目标字符串相应位置

    // 更新字符串的大小
    _size += len;  // 插入操作完成后,字符串的新长度应增加len
}

erase

        作用是从动态字符数组(或类字符串结构)的特定位置pos开始删除指定长度len的子串。

        首先,它通过断言确保删除起始位置的有效性(小于当前字符串长度_size)。根据传入的参数,函数会判断是否需要删除从pos到结尾的所有字符,

        如果是,则将pos位置之后的字符置为结束符\0,并将字符串大小更新为pos;否则,将从pos+len开始到结尾的字符依次向前移动len个位置以覆盖待删除区域,并相应减少字符串大小_size

        简而言之,该函数实现了对字符串的指定范围擦除操作。

// 函数:删除指定位置起始的子串,若未指定长度,默认删除到字符串末尾
void erase(size_t pos, size_t len = npos)
{
    // 检查删除起始位置是否合法(必须在当前字符串内)
    assert(pos < _size);

    // 如果未指定长度或指定长度使删除范围超出字符串末尾,则删除从pos到字符串末尾的部分
    if (len == npos || pos+len >= _size)
    {
        // 截断字符串,将pos位置置空字符,字符串长度变为pos
        _str[pos] = '\0';
        _size = pos;
    }
    // 否则,按指定长度删除子串
    else
    {
        // 计算删除范围结束后的新起始位置
        size_t begin = pos + len;

        // 将删除范围后的字符逐个向前移动len位,覆盖待删除部分
        while (start <= _size)
        {
            _str[begin - len] = _str[begin];
            ++begin;
        }

        // 更新字符串长度,减少len
        _size -= len;
    }
}

resize

作用: 改变动态字符数组(或类字符串结构)的大小,并可选地用指定字符ch填充新添加的空间。

        若新指定长度n小于等于当前长度,函数将截断字符串,使其长度变为n

        若新长度大于当前长度且可能超过当前容量时,函数先调用reserve进行扩容,然后使用给定字符ch填充新增的字符位置,直到字符串长度达到指定的n,并在字符串末尾添加结束符\0,以确保字符串的完整性和正确性。

        总之,此函数灵活地调整了字符串的长度,并保持其内容的有效性。

void resize(size_t n, char ch = '\0')
{
    // 当新长度n小于等于当前字符串长度时,执行删除操作
    if (n <= _size)
    {
        // 将第n个字符设置为空字符,相当于截断字符串,并更新字符串的实际长度为n
        _str[n] = '\0';
        _size = n;
    }
    // 当新长度n大于当前字符串长度但小于等于当前容量时,或新长度n大于当前容量时
    else
    {
        // 先调用reserve函数确保容量足够容纳新长度的字符串
        reserve(n);

        // 循环填充字符,直至字符串长度达到n
        while (_size < n)
        {
            _str[_size] = ch; // 使用指定字符ch填充
            ++_size;
        }

        // 最后,在新字符串末尾添加结束符'\0'
        _str[_size] = '\0';
    }
}

find(查找单个字符)

// 定义一个成员方法,用于在当前字符串对象中查找指定字符 ch 的首次出现位置
//ch --  要查找的字符
// 查找的起始位置,默认从字符串开头(索引0)开始

size_t find(char ch, size_t pos = 0)
{
    // 使用一个循环遍历从 pos 位置开始到字符串结尾的所有字符
    for (size_t i = pos; i < _size; i++) 
    {
        // 检查当前遍历到的字符是否与要查找的字符 ch 相同
        if (_str[i] == ch) 
        {
            // 如果相同,则返回该字符在字符串中的索引位置
            return i;
        }
    }

    // 若遍历完整个指定区间后仍未找到字符 ch,则返回 npos
    // npos 是一个特殊的值,通常表示“未找到”或“无效位置”
    return npos;
}

find(查找子串)

size_t find(const char* sub, size_t pos = 0)
{
    // 使用C语言库函数strstr查找子串sub在当前字符串(从pos开始的部分)中首次出现的位置
    const char* p = strstr(_str + pos, sub);

    // 如果找到了子串sub,则返回子串首字符在当前字符串中的相对索引(即下标)
    if (p)
    {
        return p - _str;
    }
    // 若找不到子串,则返回特殊值npos,表示子串不在当前字符串中
    else
    {
        return npos;
    }
}

substr

// 定义一个方法,用于从原始字符串中提取子串
string substr(size_t pos, size_t len /* 默认为最大长度 */) {
    // 创建一个新字符串 s 来保存子串
    string s;

    // 计算子串的结束位置(默认取到原始字符串结尾)
    size_t end = pos + len;
    if (len == npos || pos + len >= _size) {
        // 若指定长度超过原始字符串剩余长度,则取剩余全部字符
        len = _size - pos;
        end = _size;
    }

    // 为新字符串预分配足够内存以存放子串
    s.reserve(len);

    // 循环遍历原始字符串,从 pos 位置开始,复制到新字符串 s 中,直到结束位置 end
    for (size_t i = pos; i < end; i++) {
        s += _str[i];
    }

    // 返回包含子串的新字符串 s
    return s;
}

比较运算符的重载:

bool operator<(const string& s)const
{
	return strcmp(_str,s._str);
}
bool operator==(const string& s)const
{
	return strcmp(_str, s._str)==0;
}
//<= 复用<
bool operator<=(const string& s)const
{
	return *this < s || *this == s;
}
//>复用 !(<=)
bool operator>(const string& s)const
{
	return !(*this < s || *this == s);
}
//>=
bool operator>=(const string& s)const
{
	return !(*this < s);
}

bool operator!=(const string& s)const
{
	return !(*this == s);
}

流插入cout

这里的范围for可以访问吗?不可以:这里要用const迭代器,因为是一个被const修饰的对象,const迭代器指针本身是可以++(修改)的,指针指向的内容不可以被修改:

所以要这样做:

代码:

class string
{
public:
	typedef char* iterator;
	typedef const char* const_iterator;//const迭代器
}

ostream& operator<<(ostream& out, const string& s)
{
/*	for (size_t i = 0; i < s.size(); i++)
{
	   out << s[i];
}*/
    for (auto ch : s)
	   out << ch;
		
	return out;
}

流提取cin

需要注意的地方:

那么应该这样去写:

代码: 

istream& operator>>(istream& in, string& s)
{
	s.clear();//清理原来的字符数据,不然就变成尾插了
	char ch;
	//in >> ch;//拿不到 空格 或者 换行
	ch = in.get();//一个字符一个字符的拿
	while (ch != ' ' && ch != '\n')
	{
		s += ch;
		//in >> ch;
		ch = in.get();
	}
		return in;
}

优化写法

// 重载输入流提取运算符 >>,使得可以从输入流(如cin)中读取一串连续的非空格和非换行符字符到string对象s中
istream& operator>>(istream& in, string& s)
{
// 清空目标string对象s,准备接收新的输入
s.clear();

// 定义一个固定大小的缓冲区buff,用于暂存从输入流中读取的字符
    char buff[129];
    size_t i = 0; // 初始化缓冲区索引为0

// 从输入流in中获取第一个字符
    char ch;
    ch = in.get();

// 当读取到的字符不是空格也不是换行符时,继续循环
while (ch != ' ' && ch != '\n')
{
    // 将字符放入缓冲区
    buff[i++] = ch;

    // 如果缓冲区已满(达到128个字符)
    if (i == 128)
    {
        // 在缓冲区末尾添加结束符'\0',将其视为一个C风格字符串添加到目标string对象s中
        buff[i] = '\0';
        s += buff; // 更新s的内容

        // 重置缓冲区索引为0,以便继续填充下一个子字符串
        i = 0;
    }

    // 继续从输入流中获取下一个字符
    ch = in.get();
}

// 若缓冲区中仍有剩余字符(循环结束后)
if (i != 0)
{
    // 添加结束符'\0',将剩余的子字符串添加到目标string对象s中
    buff[i] = '\0';
    s += buff;
}

// 返回输入流in的引用,以支持链式输入操作
    return in;
}

clear

不清理之前的数据就变成尾插了:

void clear()
{
	_str[0] = '\0';
	_size = 0;
}

本篇结束。

🔧本文修改次数:0

🧭更新时间:2024年4 月 24 日 

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐