string类是C++当中用的非常频繁的一个类,它提供了很多处理字符串的函数,让字符串的使用变得像int、float等built-in类型一样简单。string类的实现包含了大量c++语言的知识,其中有很多值得讨论的问题。自己动手实现一个string类是学习c++语言的好方法,可以检验自己一下C++基础知识掌握的如何。下面我们来尝试动手实现一个字符串类。

1.定义成员变量

我们的字符串类需要处理变长的字符串,需要根据字符串的长度动态的调整内存空间。因此我们需要一个指针,指向存储字符串的内存位置。我们还需要一个变量来记录字符串的长度,同时我们还要一个变量记录所申请的内存空间大小。另外,我们的字符串也不能无限长,我们需要定义一个最大的长度。因此,我们的string类中需要四个数据成员。

class myString {
private:
    char        *p;     // 指向存储字符串的内存空间
    int         len;    // 保存字符串长度
    int         size;   // 保存申请的内存空间的长度
    const int   max_size = 65536;   //最大长度
}

由于最大长度对于每个myString类是固定的,因此我们把它声明为const,初始化为65536,也就是说我们的string类能保存的字符串最大长度为65536。

2.构造函数

成员变量定义好后,我们需要确定如何构造我们的myString类。C++98为string类提供了7种构造函数,c++11又新增了两种,实在是太多了!我们不管那么多,先实现两个简单的吧!

2.1.默认构造函数

首先来实现一个默认构造函数。既然是默认构造函数,那么就设置一下各个成员变量的值,然后存一个空字符串吧。 在默认构造函数,虽然存的是一个空字符串,但是我们也给它分配一点空间,主要是为了方便以后拓展。默认构造函数很简单,实现如下。

myString::myString()
{   
    size = 1;              
    p = new char[size + 1];
    p[0] = '\0';
    len = 0;
}

2.2 带参数的构造函数

通常我们希望在构造string类的时候就给它一个初始值。那么我们需要实现一个带参数的构造函数,传入一个字符串指针,通过一个c风格的字符串来构造一个myString对象。

myString::myString(const char *s)
{
    size = 1;
    len = strlen(s);
    if (len > max_size) {
        len = max_size;
    }
    while (size < len) {
        size *= 2;
    }
    p = new char[size + 1];
    strncpy(p, s, size);
    p[size] = '\0';
}

因为在构造函数中,不需要修改传入的字符串,因此我们把输入的参数设置为const。如果传入的字符串长度超过了max_size,那么我们只把字符串中前65536个字符写入myString。然后要做的就是申请空间了,你可能会想申请空间当然是根据字符串的长度来了,只要不超过最大长度,字符串有多长就申请多少个字节的内存。

但是想一想这样做有没有什么坏处呢?如果以后我们想拓展字符串怎么办?是不是又要重新申请空间呢?要知道申请空间可是相当耗时间的,这个办法不好。那么我们是不是可以直接申请max_size个字节的内存呢?当然可以的,但是对于每一个myString对象我们都申请max_size个字节的内存,实在是太浪费了!要知道我们平常处理的大多数字符串都是很短的。

两种办法都不行,那怎么办?在这里我们采取跟vector申请内存一样的策略,如果当前内存不够用,那么就申请一块大小为当前空间两倍的内存块来存储。上面代码中的while循环就是为了确定要申请的内存空间的大小。假设我们传入构造函数的字符串是"hello,world",一共11个字符(结束符另算),那么我们申请16个字节来存储它(结束符也另算)。申请好内存后(实际上这里应该检查申请内存是否成功,p==NULL?,但是这里忽略这个问题),我们把传入的字符串拷贝到申请好的内存。注意,这里用strncpy,不要用strcpy,同时记得把最后一个字节的值置为0(如果没有置为0会造成什么后果,自己试验一下)。

3. 重载<<运算符

现在我迫不及待的想知道myString的构造函数是否可以正常工作。怎么验证呢?我们当然可以写一个print函数,在函数中把myString的值打印出来。但是这种办法实在是太不CPP了,我们来采用一个更专业的写法,那就是通过重载<<来实现。通过重载<<输出myString可以像输出int、float等类型一样方便。

// myString.h
friend ostream& operator<< (ostream& out, const myString& s);
// myString.cpp
ostream& operator<< (ostream& out, const myString& s)
{
    out << s.p ;
    return out;
}

我们在头文件中声明一个重载<<操作符函数,为了直接在函数中操作myString对象的成员变量,我们把这个函数声明为友元函数。这个函数的目的是为了输出字符串的值,当然不会对myString有什么更改,因此把传入的myString引用设置为const。函数的实现我们放在cpp文件中,代码就两行,so easy!鉴于这个函数如此简单,我们也可以把它声明为inline函数,自己试一下。现在我们可以用cout来打印myString对象了,就像操作int、float等基本类型一样简单,试一下看看。

#include<iostream>
using namespace std;
int main()
{
    myString a("hello,world");
    cout << a << endl;
    return 0;
}

这段代码的的执行结果是

$ ./a.exe
hello,world

Nice work! 看来我们的构造函数可以正常工作,我们重载的<<函数貌似也没啥问题,哈哈!
嗯…………不要高兴的太早!完成构造函数后,我们就要考虑如何析构 。向操作系统老大申请内存,大部分时候老大总是慷慨的借给我们,但是我们也要记得及时归还,否则以后老大不肯借了怎么办?现在是时候考虑一下析构函数的问题了。

4.析构函数

大家都知道,如果没有显式地定义析构函数,编译器会自动为我们提供一个析构函数。真是个贴心的大暖男!可惜的是这个大暖男有点笨啊,他只会傻傻的把成员变量挨个释放掉,对于成员变量指向的动态申请的内存块,他就装作没看见一样,任由这个内存块变成一个幽灵游荡在内存之中。这就会带来一个另操作系统暴怒,另所有程序员心惊胆战的严重问题–内存泄漏!所以我们必须为myString提供一个析构函数,在析构函数中释放在构造函数中申请的内存。在myString类的析构函数应该这样写:

myString::~myString()
{
    delete[] p;
}

看看这个函数,就这么短短一行,比重载的<<函数还短。但是你不写试试,内存泄漏给你看哦~真是人狠话不多的典型!

5. 拷贝构造函数

在我们使用string的时候,经常需要用一个构造好的string来构造一个新的string,这个时候就需要用到拷贝构造函数。跟析构函数一样,如果没有显式定义拷贝构造函数,编译器会为我们提供一个默认的拷贝构造函数。大暖男再次登场,可惜的是同样不靠谱。默认的拷贝构造函数只会傻傻的把传入对象的成员变量挨个赋值给新构造对象的成员变量。对于我们的myString类,默认的拷贝构造函数做的事情就相当于下面这个函数

myString::myString(const myString& s) 
{
    p = s.p;
    len = s.len;
    size = p.size;
}

默认拷贝构造函数会造成什么问题呢?对于不含指针的类来说,默认构造函数可以正常工作,没啥问题。暖男可以帮我们少写几行代码。但是对于含有指针的类来说,默认拷贝构造函数造成的麻烦大了去了。

假设存在myString对象a,用a来构造对象b,此时会调用默认拷贝构造函数。b构造完成后,b的p指针和a的p指针值相等,也就是说两个指针指向同一块内存。那么问题来了,当我们通过a的p指针修改内存内容时,b的p指针指向的内存块也会跟着改变!这显然是不符合设计需求的。

如果你以为这就完了,那就大错特错,更麻烦的问题还在后面呢!b跟着a修改虽然不符合我们的需求,但是好歹不会造成程序崩溃。那如果我们构造b之后把a对象给析构掉了会怎么样呢?

a对象析构之后,a的p指针指向的内存块(同时也是b的p指针指向的内存块)也随之被释放掉,此时b的p指针就变成了一个野指针。当b的生命周期结束后,b的析构函数会尝试释放b的p指针指向的内存块,然而这块内存实际上已经在a被析构的时候被释放掉了,因此造成内存二次析构的问题,产生意想不到的后果。

既然默认的拷贝构造函数不靠谱,那我们只好自己实现一个。实现拷贝构造函数的关键是要避免复制构造前后两个对象的指针指向同一片内存。原有对象的内容当然不能乱改,只能重新申请一块内存,用新对象的指针指向这块内存了。因为不能改动原有的对象,因此把传入的参数设置为const。拷贝构造函数实现如下:

myString::myString(const myString& s) 
{
    size = s.size;
    p = new char[size + 1];
    strcpy(p, s.p);
    len = s.len;
}

6. 总结

在这一篇文章中,我们实现了默认构造函数、带参数的构造函数、拷贝构造函数、析构函数和<<符重载函数。在构造函数当中,我们讨论了内存申请策略的问题;在析构函数中我们讨论了内存释放的问题;在拷贝构造函数中,还讨论了构造前后两个对象的指针成员指向同一片内存(浅拷贝)带来的问题和解决办法(深拷贝),这也是后面讨论赋值运算符重载的基础。现在myString类的基本框架已经有了,下一篇文章我们继续实现myString的各种功能。