说string是编程时用的最多的库类一点也不为过 , 其实 , 我们也能通过编程来自己实现这个类 , 现在就让我们来一探string库内部的奥秘吧。
实现string可以用两种办法 , 因为string就是一个char的有序表 , 所以可以通过线性表和链表来实现 , 从直观上讲 , 线性表跟符合string的本质 , 不过我们选择用char指针动态分配char数组来实现 。在私有对象里 , 除了用来分配的char数组外 , 还要有一个int用来保存string长度 。对于构造函数 , 首先是一个无参数的默认构造函数 , 然后还要有一个接受C风格字符串的构造函数 , 还要在定义一个析构函数 。 所以先写出下列代码:
class String{
private:
int length;
char * str;
public:
String() = default;
String(const char *);
~String(){ delete [] str}
};
接下来我们考虑如何定义这两个函数:(已引入cstring头文件)
String::String(){
str = new char[1];
length = 0;
str[0] = '\0';
}
String::String(const char* c){
length = strlen(c);
str = new char[length + 1];
strcpy(str , c);
}
接下来我们得考虑几个问题 , 当我们用一个String构造另一个String时 (比如 String s1 = s2;), 如果没有定义拷贝构造函数 , 编译器会帮忙定义一个默认拷贝构造函数 ,然而这却是帮倒忙了 , 因为默认的拷贝构造函数是将函数中的成员变量全部复制 , 换句话说 , 就是对每一个变量执行=赋值操作 , 而对于指针复制之后 , 就变成两个str指向同一块地址 , 这样必然会导致一个问题 , 当我们其中一个对象被销毁后 , 就已经把那块内存delete了 , 那么另一个对象的str指针就指向一块没分配的内存 , 更糟的是 , 当另一个对象被销毁时 , 就要delete一块已经释放的内存 , 这样必然导致程序出错甚至崩溃 , 同样的 , 若我们没重载=运算符 , 也会发生上述问题 , 所以我们要在class中加入上述函数:
class String{
public:
String(const String&);
String& operator=(const String&);
String& operator=(const char*);
}; //在上述的class中增加的
String::String(const String& s){
length = s.length;
str = new char[length + 1];
strcpy(str , s.str);
}
String& String::operator=(const String& s){
if(this == &s) return *this;
delete [] str;
length = s.length;
str = new char[length + 1];
strcpy(str , s.str);
return *this;
}
String& String::operator=(const char* c){
*this = String(c);
return *this;
}
这里有几个问题要说明一下 , 首先是为什么参数都是引用常量 , 第一 , 引用比传值效率更高 , 第二 , 常量可以接纳常量和非常量 , 如果不是常量的话 ,是无法传常量进去的(其实就是非常量可以用常量引用 , 常量却不能用非常量引用) 。 为什么赋值函数返回的是自身的引用 , 其一也是从效率出发 , 其二是因为这是赋值函数的惯例 , 这样就能够进行连续赋值(比如 s1 = s2 = s3) , 最后 ,我们为什么应该对赋值函数定义一个接受C风格字符串的版本呢 , 因为之前我们定义了String(const String &) 这个构造函数 , 当我们要把一个C风格字符串赋值给一个已经定义好的String(即已经执行过构造函数了)时 , 就在这里进行一次类型转换 , 把参数转化成String类型 , 在调用以String为参数的版本的赋值函数 。
接下来一个没有BUG的String类终于出来了 , 但是它还没什么功能 , 于是我们为他增加多一点功能 , 首先重载+运算符:
class String{
public:
String operator+(const String&);
String operator+(const char*);
};
String operator+(const char* c , const String& s);
String String::operator+(const String& s){
char temp[length + s.length + 1];
strcpy(temp , str);
strcpy(&temp[length] , s.str);
return String(temp);
}
String String::operator+(const char* c){
return *this + String(c);
}
String operator+(const char* c , const String& s){
return String(c) + s;
}
接下来还有一个问题 , 为什么这里返回的是一个局部的String , 原因是相加并不改变其中的任意一个String , 而且不能返回局部值得引用(那样的话离开函数作用域那个引用就无效了)所以我们只能返回一个新的String , 这也符合C++其他的类型的定义 。再者 , 为什么第二个函数在类内没有进行声明 , 反而跑到外面去声明 , 这是因为 + 号的左边(即+的调用者 ,const char* c) 并不是一个String , 而且该操作也不会访问String的私有成员 , 所以不需要声明友元函数。顺便说一句 , 关于参数为char*的版本是为了说明其内部的转换过程 , 即使不定义也会自动转换 , 但这其实是一种名为代理的模式 , 即使用其他同名重载函数来代替该函数工作。
既然有了+的定义 , 为什么没有+=的定义呢 , 这个作为练习留给读者完成(思考返回的是引用还是值)。
最后我们再来定义输入输出 , 说白了就是要重载<< 和>> , 对于输出<<非常简单 , 只需要将字符串输出即可 , 输入会稍微复杂一点 , 涉及重载>>和getline版本 :
class String{
public:
friend ostream& operator<<(ostream& , const String&);
friend istream& operator>>(istream& , String&);
};
istream& getline(istream& , String&);
ostream& operator<<(ostream& os , const String& s){
os << s.str;
return os;
}
istream& operator>>(istream& is , String& s){
char c[255];
int i = 0;
while(i < 254 && (c[i] = is.get()) != '\0' && c[i] != '\n'){
i++;
}
c[i] = '\0';
if(is)
s = String(c);
return is;
}
istream& getline(istream& is , String& s){
char c[255];
is.getline(c , 255);
if(is){
s = String(c);
}
while(is && is.get() != '\n')
continue;
return is;
}
与标准库一样 , 重载的版本返回了流本身 , 在这里提一下 , 255为输入的最大字符数 , 超过的话 , >>版本会将后面的字符留在流中(当然前提是没遇到空格或回车) ,
getline的话会将超出部分丢弃 。如果流被破坏 , 则原本的字符不会受到影响 。
还有两个比较重要的函数 , 一个就是返回字符串长度的size() , 另一个就是重载的[ ] 运算符:
class String{
public:
int size(){ return length;}
char& operator[](int index){
return str[index];
}
const char& operator[](int index) const {
return str[index];
}
};
这里提一下为什么要重载两个版本的[ ] 运算符 , 第一个版本是能够任意修改的 , 因为任意的数组和指针访问的结果都是引用 。 第二个版本是适用于不改变内容或常量String访问的。
我们已经做好了一个String的框架 , 还有很多的成员函数没有实现 , 迭代器也没有完成 , 这部分的任务留给读者自己完成 , 我想说的是 , 不必将函数内部实现的与原版一模一样 , 只需要知道它的实现思路即可 。