C++面试时,面试官总是喜欢让学生自己来模拟实现一个string类。由于时间原因,这个string类肯定不能包含STL库里的全部功能,但至少要能进行简单的资源管理,比如初始化,赋值,释放。我认为面试应该需要给出一下四个接口:构造函数、析构函数、拷贝构造函数、赋值运算符重载。本篇文章中我会向大家介绍这四个接口的两种实现方式供大家参考,分别是传统版写法和现代版写法。
下面开始介绍。
1. 传统写法
1.1 成员变量
由于只进行简单的资源管理,因此此处string类的成员变量只设置一个指向动态数组的一个字符指针
namespace bit
{
class string
{
public:
private:
char* _str;
};
}
注意要点:
由于全局命名空间std中定义了STL中的string类,为了避免命名发生冲突,这里我将模拟实现的string类封装在一个名为bit的命名空间中去。(当然如果你没有展开std就不用了)
1.2 构造函数
我们先来看构造函数的一种错误写法:
namespace bit
{
class string
{
public:
string(const char* str)
:_str(str)
{};
private:
char* _str;
};
}
int main()
{
bit::string s1 = "hello world";
return 0;
}
有人一上来直接用形参str去初始化成员变量_str,我们来看运行结果:
编译不通过,根据给出的错误信息也不难分析。注意看main函数中,用来初始化s1的字符串"hello world"是常量区一个常量字符串,上面的直接赋值等于让string对象的_str指针指向这个常量字符串。
对于一个string类来说,有许多接口是需要修改字符串本身内容的,所以string类里的字符指针指向的空间一定要是可以修改的,也就是非const指针。用const指针去初始化非const指针属于权限放大错误。
正确写法:
string(const char* str = "")
:_str(new char[strlen(str) + 1])
{
strcpy(_str, str);
}
采用初始化列表和函数体初始化结合的方式去实现构造函数。先在初始化列表中为string对象在堆区开辟与初始化字符串同样大小的空间,再在函数体内用strcpy函数将初始化字符串的内容拷贝过去。
到这里还不算全部完成,string对象还有这样一种定义方式:
string s1;
不传参怎么办?根据我们使用string的经验,不传参时的string对象应该是被初始化为一个空字符串。
而我们上面所写的构造函数只实现是有参数的情况,如果没有参数,定义对象时就没有合适的构造函数调用,因而就会导致错误。
为了解决这个问题,我们可以把上面的构造函数实现成一个带缺省值的函数:
string(const char* str = "")
:_str(new char[strlen(str) + 1])
{
strcpy(_str, str);
}
如果没有没有传参,默认形参是一个空字符串,用空字符串去初始化对象。
1.3 析构函数
析构函数实现起来就比较简单了,只用把成员变量_str delete掉即可:
~string()
{
delete[]_str;
_str = nullptr;
}
1.4 拷贝构造函数
对于string类这种涉及资源管理的类,实现拷贝是必须用深拷贝来实现:
string(const string& s)
{
_str = new char[strlen(s._str) + 1];
strcpy(_str, s._str);
}
为了避免拷贝对象和被拷贝对象指向同一块空间,拷贝构造必须用深拷贝来实现。深拷贝的做法是为拷贝对象开辟一块和被拷贝对象同样大小的空间,再将被拷贝对象的内容拷贝过去,这样就不会发生空间重叠了。
1.5 赋值运算符重载
赋值运算符重载的实现同样涉及深浅拷贝的问题。如果使用默认的赋值运算符重载函数,发生的就是浅拷贝,从而导致空间重叠,因此我们必须自己用深拷贝来实现一个:
string& operator=(const string& s)
{
if (_str != s._str)
{
delete[] _str;
_str = new char[strlen(s._str) + 1];
strcpy(_str, s._str);
}
return *this;
}
代码中注意要点:
-
这里我们要先明确进行赋值运算符重载的两个对象,必定是两个已经创建好的对象。把一个对象的内容拷贝给另一个对象,这必然就会面临一个问题,那就是被赋值对象的空间大小和赋值对象的空间大小不一致。如果空间足够还好说,如果空间不够必然要重新进行扩容。
这里我们采取的做法是,一上来直接把被赋值对象的空间delete掉,放弃被赋值对象原来的空间,重新为被赋值对象开辟出一块和复制对象同样大小的空间,再用strcpy进行内容拷贝。 -
赋值时可能还会出现这样一个种情况,如果是自己给自己赋值,一上来直接把自己的空间释放掉就会导致错误,因为后面拷贝时的空间就不存在。因此一上来还要先判断是不是自己给自己赋值,如果是,直接返回自身引用,不用再进行拷贝了。
1.6 简易string类传统写法源码
传统写法到这就全部结束了,下面附上源码
class string
{
public:
//构造函数
string(const char* str = "")
:_str(new char[strlen(str) + 1])
{
strcpy(_str, str);
}
//析构函数
~string()
{
delete[]_str;
_str = nullptr;
}
//拷贝构造函数
string(const string& s)
{
_str = new char[strlen(s._str) + 1];
strcpy(_str, s._str);
}
//赋值运算符重载
string& operator=(const string& s)
{
if (_str != s._str)
{
delete[] _str;
_str = new char[strlen(s._str) + 1];
strcpy(_str, s._str);
}
return *this;
}
private:
char* _str;
};
2. 现代写法
现代写法的构造函数和析构函数与传统写法完全一样,其差别主要体现在拷贝构造函数和赋值运算符重载函数。
2.1 拷贝构造函数
string(const string& s)
:_str(nullptr)
{
string tmp(s._str);
swap(_str, tmp._str);
}
现代版拷贝构造实现的主要思路是先用形参构造一个临时对象tmp,这时我们会发现,tmp对象所在的这块空间实际上是拷贝构造对象所需要的空间。于是我们就把这两个对象的空间进行交换,这样就等于完成了一次string对象的拷贝构造。
有一点需要注意,这里的临时对象tmp出了函数作用域后就会调用析构函数自动释放掉。tmp对象释放的空间实际上就是拷贝对象原来的空间,而我们知道,拷贝对象的成员变量_str一开始指向的是一块随机空间,直接去释放一块随机空间是不被允许的。所以为了避免这种情况发生,一开始我们先把拷贝对象的成员变量_str初始化为空指针,最后在析构tmp时释放一个空指针,这种做法在语法上是被允许的。
可能有人不是很理解,为什么这种写法被叫做现代版写法,它和我们上面写的传统写法到底有什么区别?
大家可以回看一下上面的传统写法,传统写法在实现是一直是调用对象自己去开辟空间,然后自己进行拷贝。而现代写法则是我让一个临时对象去帮我开辟好我想要的空间,然后我再把这块空间拿过来,最后再把我不要的空间给你,还要让你帮我释放掉。
这有点像什么?是不是就有点像现代社会中的资本运作。一个资本家想要完成一批产品时,他们通常不会自己亲自去做,而是雇一批工人帮他完成。资本家最后会拿走工人完成的产品为自己换取利益,实现自己的资本积累。
现代版写法实际上是把资本运作的思想抽象到了string类的实现中来,我们实现的这个string对象就像是一个资本家,
这样做的好处是使得string对象的实现过程变得精简,不用考虑一些复杂情况
。这种思想很有参考价值,在C++中用到的地方非常广泛。
2.2 赋值运算符重载
下面我们再来通过赋值运算符重载的实现感受一下现代写法的好处。
string& operator=(const string s)
{
string tmp(s._str);
swap(_str, tmp._str);
return *this;
}
赋值运算符重载函数的实现更加直接,直接采取传值传参的形式,让形参去开辟赋值对象想要的空间。最后交换形参和赋值对象的空间,这样赋值对象就拿到了自己想要的空间,并且把自己丢弃不要的空间让形参去释放。
再和上面传统写法做对比,我们看到现代写法不用再判断自己给自己赋值情况,并且不用自己主动释放空间+开辟空间,所有事情都交给形参去做,调用对象只管“坐享其成”。从代码上我们也可以看到,现代写法的代码量更精简,易读。
2.3 简易string类现代写法源码
class string
{
public:
//构造函数
string(const char* str = "")
:_str(new char[strlen(str) + 1])
{
strcpy(_str, str);
}
//析构函数
~string()
{
delete[]_str;
_str = nullptr;
}
//拷贝构造函数
string(const string& s)
:_str(nullptr)
{
string tmp(s._str);
swap(_str, tmp._str);
}
//赋值运算符重载
string& operator=(const string s)
{
string tmp(s._str);
swap(_str, tmp._str);
return *this;
}
private:
char* _str;
};
本篇文章所要介绍的内容到这里就全部结束了。文章中我主要向大家介绍了简单tring类的两种实现方式,两种方法希望大家都能够掌握,我个人比较推荐大家去用现代版写法实现。
最后希望这篇文章能够为大家带来帮助。