这一篇中我们将通过设计一个简单的String类来了解为什么说类库的设计就是语言设计。
C++允许用户自定义的类型当作内建类型使用。通过定义自己的类型,我们可以实现定制化C++语言。
字符串
首先我们先定义一个简单的String类型。
class String {
public:
String(char* p):sz(strlen(p)),data(new char[sz+1]) {
strcpy(data, p);
}
~String() { delete [] data; }
operator char*() { return data; } // 允许隐式转换为char*
private:
int sz;
char* data;
};
目前这个类只能让我们定义一个String
类型的对象,还不能对它进行一些操作,也没有处理出错的情况。
内存不足
首先考虑的问题就是在构造函数中的new
操作。如果遇到内存不足的情况,会发生什么?
一般的实现会有三种:库抛出异常,整个程序携带错误信息终止,new
操作符返回一个0
。
new
操作符返回0
是兼容了malloc
类库函数,如果C++的实现也保持了这种兼容,那么执行到strcpy
的时候这个库函数会崩溃。显然我们需要判断一下new
的返回值。
所以构造函数修改为:
String::String(char* p):sz(strlen(p)) {
data = new char[sz + 1];
if (data == 0)
error();
else
strcpy(data, p);
}
但是这里还存在一个问题:error()
要返回吗?如果不能返回,那么用户将无法检查内存耗尽的问题。如果能返回,那么我们创建了一个不完整的String
对象,用户还是不能直接使用。如果要让用户可以使用,我们就必须在所有涉及到data
成员的操作中进行判断,所以我们可能需要下面这个函数:
bool String::valid() const { return data != 0; }
如果这个成员函数作为私有成员,只有我们自己能使用,那么我们提供的操作将会导致用户困惑。所以我们必须开放给用户使用这个函数。并且规定用户在进行操作之前都要先进行valid()
判断。这样我们又回到了上一篇描述的数据抽象的问题。
我们采用异常处理的方式解决这个遇到的问题:
String::String(char* p):sz(strlen(p)) {
data = new char[sz + 1];
if (data == 0)
throw std::bad_alloc();
else
strcpy(data, p);
}
采用抛出异常的方式,我们可以保证只要构造函数成功的返回了,那么一定创建了一个完整的String
对象,同时用户也可以通过try-catch
进行检查和处理。
这样做也会减轻用户使用的负担,因为他不再需要频繁使用valid
检查这个对象是否完整了。
复制
现在我们先不考虑内存分配的问题了,我们来看看如何进行复制。C++编译器默认创建的拷贝构造函数和赋值操作符的实现会导致两个对象的data
指向同一块内存空间,会进一步导致两个对象析构的时候会释放两次内存。所以我们需要自己实现这两个函数,当然也可以声明为delete
来禁止拷贝和赋值。
考虑到拷贝构造函数和赋值操作符的实现都需要拷贝另外一个对象的值,所以我们可以先写一个用来复制的方法:
void String::assign(const char* p, unsigned int len) {
data = new char[len + 1];
if (data == 0)
throw std::bad_alloc();
sz = len;
strcpy(data, p);
}
然后我们来实现拷贝构造函数和赋值操作符:
String::String(const String& other) {
assign(other.data, other.sz);
}
String& String::operator=(const String& other) {
if (this != &other) {
delete [] data;
assign(other.data, other.sz);
}
return *this;
}
隐藏实现
现在我们的类String
提供了一个方法operator char*()
,这个方法可以将String
对象隐式转换为char*
类型,我们的实现中直接返回了对象内部成员data
,这样做用户完全可以通过获取到的data
指针进行修改,用户保留的这个指针会随着对象的释放而失效,但是用户并不知情。同样,如果我们执行了operator=
以后,也会导致用户之前保留的指针失效,用户同样不知情。
基于上述三个问题,我们应该去掉这个隐式转换的方法,改为使用其它方式。
首先我们需要想一下,为什么用户一定要需要char*
这个类型呢?
我们的String
类现在还比较简单,不能满足用户的需要,我们可以通过增加方法的方式满足用户的需求吗?答案是:不能。因为我们没有办法全部实现基于char*
做的非标准的库函数。
所以看来这个需要我们是必须要满足的。我们不使用隐式转换这个不明确的方式,改为使用一个命名的方法:
const char* String::c_str() const {
return data;
}
但是这个方式依旧没有解决掉剩下的两个问题。也许我们可以将data
复制一份返回给用户,但是用户可能会忘记释放对应的内存。也许我们让用户提供目的地址是更好的做法:
void String::c_str(char* p, int len) const {
if (sz <= len) {
strcpy(p, data);
}
else {
strncpy(p, data, len);
}
}
// 用于用户判断现在对象的长度。
const unsigned int String::len() const {
return sz;
}
缺省构造函数
我们目前没有提供缺省构造函数,所以不能定义String str[100];
。要支持这样功能,我们需要确定,String
对象的默认值是什么。data
的默认值应该是空指针还是一个空字符串?
如果data
的默认值是一个空指针,那么我们所有针对data
的操作都需要判断是否为空指针。为了不再修改上面的实现,我们将data
的默认值设置为空字符串。
// 这里忽略了内存不足的问题,也可以在内存不足的时候抛出异常
String::String():sz(0),data(new char[1]) {
*data = '\0';
}
其他操作
事实上,我们的String
类还应该支持一种拼接操作:
String s1("hello"), s2("world");
String s2 = s1 + " " + s2;
我们期望String
可以和char*
,String
进行拼接操作。所以我们需要实现operator+
。按照惯例,operator+
应该实现为普通函数,而不是成员函数。因为如果声明为成员函数,则表明第一个参数必须为String
类型,这样char* + String
就不能实现了。所以我们给出下面的实现:
String& String::operator+=(const String& op) {
char* odata = data;
assign(data, sz + op.sz); // 给data赋值一个新内存空间
strcat(data, s.data);
delete [] odata;
return *this;
}
String operator+(const String& op1, const String& op2) {
String ret(op1); // 拷贝构造
ret += op2; // 使用operator+=
return ret; // 返回值类型
}
String operator+(const char*p, const String& op) {
String ret(p); // 构造函数
ret += op;
return ret;
}
总结
通过简单的String
类的设计,我们也能感觉到,在设计过程中遇到的各种问题。因为C++类机制赋予了库设计者强大的力量,效果上相当于把他们转换为语言设计者。