从string类理解C++的一些基础概念

761 阅读4分钟

刚接触C++的时候就听说,string不是一个基本类型,而是一个类(class)。但实际使用的时候,却感觉它用起来就像一个基本类型一样方便,这是如何实现的?假如我们自己实现一个string类,要怎样做?

注意:以下所有代码只是为了方便理解,并非string类的真实实现。

构造函数与赋值运算符

首先我们看看string类对象如何初始化。

string可以直接用字符串常量来初始化,也可以用另一个string对象初始化,用起来就和基本类型一样方便。例如:

string s = "string"; // 1 初始化 等价于 string s("string");
string t = s;        // 2 初始化 等价于 string t(s);
string u;            // 3 初始化
u = "string";        // 4 赋值语句
u = s;               // 5 赋值语句

实际上,这是利用了C++类的构造函数实现的。在C++中,初始化一个类可以用括号的方式,也可以用等号的方式,都会调用类的构造函数。上例中,要支持 1 和 2 两种初始化方式分别调用了下面两种构造函数:

class string {
public:
  string(const char* s);
  string(const string& s); // 也叫复制构造函数
};

而对象u则是先初始化一个对象,再用等号赋值,那么 4 和 5 就不再调用构造函数,而是用到的C++中的运算符重载的特性,重载了"="。3 则是调用默认构造函数。

class string {
public:
  string(); // 默认构造函数
  string operator=(const char* s);   // 重载了赋值运算符
  string operator=(const string& s); // 也叫复制赋值运算符
};

除了赋值运算符 = 需要重载,我们还可以重载其他常用的运算符,例如+、<、>、== 等,实现字符串的拼接、比较等操作,让string用起来更像基本类型。

new与delete的配合

实际上,string类内部,字符串还是存储为C-字符串。但是,字符串长度不确定,因此字符串不能存储在栈中,只能用new的方式在堆中申请内存。实际上,平时的开发中不建议用new,可以用智能指针代替。因为用new很容易出现内存泄漏等问题。

那么,为什么string类不会造成内存泄漏呢?

动态内存的使用规则很简单,用new申请的内存,一定要记得用delete来释放掉,而且只能释放一次。

聪明的你可能已经想到,只要把delete语句放到析构函数中,相应的,在每个构造函数中都加上new语句,这样就可以保证在创建对象的时候执行一次new语句,销毁对象时执行一次delete语句。

例如,复制构造函数我们可以这样写:

class string {
public:
  string(const string& st);
private:
  char* str;
  int len;
};
string::string(const string& st) {
  len = st.len;
  str = new char[len+1];
  strcpy(str, st.str);   // 注意不能用 str = st.str直接赋值,因为str是指针,必须用strcopy复制内容。
}

除了构造函数和析构函数,任何修改字符串内容的操作都可能需要使用new和delete,但一定要记得成对使用。例如复制赋值运算符:

string string::operator=(const string& st) {
  if (this == &st) {  // 相同地址则不需要复制
    return *this;
  delete []str;       // 先删除上次分配的内存
  len = st.len;
  str = new char [len + 1]; // 再申请新内存
  strcpy(str, st.str);
  return *this;
}

当然为了减少内存分配过程造成的开销,如果当前分配的内存空间能容纳新的字符串,则不需要重新申请新的内存,可以直接复用原有内存空间。

需要注意的是,默认构造函数、复制构造函数、赋值运算符 如果没有显示定义,编译器会帮我们定义默认版本,而这可能导致构造函数中没有new却在析构函数中delete了,所以一定要显示定义这些方法。

友元函数

我们知道可以用cout来打印字符串:

char * s = "string";
string str = s;
cout << s << str << endl;

<<是一个二目运算符,那么它能够打印string对象,是string重载了<<运算符吗?

事实上并不是。因为<<的左边是ostream对象,右边是string对象,所以string提供成员函数重载<<运算符并不能达到效果,那到底是怎么做的呢?

其实,运算符重载不一定要作为成员函数,也可以是一个普通函数,例如:

ostream& operator<<(ostream& os, const string& st) {
  ...
}

唯一的问题是普通函数无法访问string对象的私有成员。要解决这个问题,只要将函数声明为类的友元函数即可:

class string {
	friend ostream& operator<<(ostream& os, const string& st);
}