std::string能否被继承?在一个C++技术群中,这个问题作为入群的面试题之一,经常被拿来考察新加入群友的C++基础知识。我也颇感兴趣,对此进行了研究和思考,在这里简单地的讨论一下这个问题。
引言
首先,string类并没有使用final修饰,它的缺省构造函数和析构函数既没有被delete,也没有使用private修饰,因此把它作为基类来使用,是可以的。比如下面的例子:
class MyString : public string {
int x; // x和y都是trivial对象,不需要在析构中专门析构它们
long y;
public:
MyString(const char *s, int x_, long y_) : string(s), x(x_), y(y_) {}
void print_prefix(const char *prefix) {
printf("%s: %s\n", prefix, c_str());
}
};
类MyString继承了string类,新加了两个基本类型的数据成员:x和y,它们都是trivial数据,不需要在析构函数中析构它们,并新加了一个成员函数:print_prefix(),它输出一个前缀prefix后,接着输出string字符内容。因为是公开继承的,因此string中的public函数都是MyString的public成员函数,它们可以被外部程序正常访问。
比如下面的代码片段:
MyString str("123", 42, 24);
cout << str.size() << "\n";
str.print_prefix("content");
MyString *pMy = new MyString("123", 42, 24);
pMy->print_prefix("content");
string *pStr = pMy;
cout << pStr->size() << "\n";
delete pStr;
创建一个派生类MyString的对象str,通过str可以调用string::size()成员函数,也可以调用自己新加的成员函数MyString::print_prefix()。因为str是在栈中创建的对象,在离开作用域时,运行时(runtime)会调用它的析构函数销毁它。同时也可以使用new操作符在堆上创建一个MyString对象,并使用基类string的指针类型来操作子类对象,最后使用delete基类指针来销毁它。
看起来貌似一切正常。在这个例子中,派生类MyString中新加的数据成员是trivial类型,它们不需要在对象析构时进行销毁操作,如果数据成员是non-trivial,比如是资源型的数据成员,上面的例子又会发生什么呢?
资源泄漏
这里所谓资源是指使用它们时先要分配,使用完之后要释放的东西,比如内存、文件、socket等,体现在c++的类中,资源就是在构造函数中进行分配、在析构函数中进行释放的数据成员。如果在派生类中定义了资源型的数据成员,使用公有继承string的方式来设计派生类,当使用派生类在堆中创建对象,而使用string类型指针来析构子类对象时,会导致资源泄露。
我们看一下这个例子:
首先,重载new及delete操作符,以方便在对象创建和销毁时进行内存资源管理的分析,重载函数非常简单,只是简单的打印一下分配和释放内存时的log信息。
void *operator new(size_t sz) {
void *p = malloc(sz);
printf("operator new: %d: %p\n", sz, p);
return p;
}
void operator delete(void *p) {
printf("operator delete: %p\n", p);
free(p);
}
void *operator new[](size_t sz) {
void *p = malloc(sz);
printf("operator new[]: %d: %p\n", sz, p);
return p;
}
void operator delete[](void *p) {
printf("operator deleted[]: %p\n", p);
free(p);
}
再定义一个公有继承自string的派生类MyString,为它新增加了一个指针类型的数据成员:prefix,它指向的内存需要使用new分配:
class MyString : public string {
char *prefix;
public:
MyString(const char *s, const char *pre) : string(s) {
prefix = new char[strlen(pre) + 1];
strcpy(prefix, pre);
}
void print_prefix() {
printf("%s: %s\n", prefix, c_str());
}
~MyString() {
puts("destruct MyString");
delete[] prefix;
}
};
这里定义的派生类和前面不同的是,新加的数据成员是指针,需要在构造函数中分配内存资源,在析构函数中释放内存资源。
下面是一段测试代码:
MyString *pMy = new MyString("123", "content");
pMy->print_prefix();
string *pStr = pMy;
cout << pStr->size() << "\n";
delete pStr;
因为string是MyString的公有基类,可以让基类指针pStr指向派生类对象pMy,最后通过delete pStr 来销毁对象。下面是相关的log信息:
operator new: 40: 0x21d12b0
operator new[]: 4: 0x21d22f0
content: 123
3
operator delete: 0x21d12b0
没有输出派生类析构函数被调用时的log:“destruct MyString”,并且分配的地址为0x21d22f0的那段内存空间也没有被释放,造成了内存泄漏。如果把delete pStr;语句换成delete pMy;,那么相关的log信息:
operator new: 40: 0x1c272b0
operator new[]: 4: 0x1c282f0
content: 123
3
destruct MyString
operator deleted[]: 0x1c282f0
operator delete: 0x1c272b0
输出了派生类析构函数被调用时的log:“destruct MyString”,所有分配的内存资源也全部释放了,没有造成内存泄漏。
可见,在这个例子中,如果通过基类指针来delete派生类对象,会发生内存泄漏。原因是在堆上创建的MyString对象是通过它的基类string类型来delete的,然而string的析构函数并不是virtual函数,这样在delete pStr时,调用的是string的析构函数,并没有调用派生类MyString 的析构函数,从而导致了MyString的数据成员prefix所分配的内存没有被释放。
因此,尽管string可以用来作为基类,因为它没有虚析构函数和其它虚函数,也就是它不是专门设计可以用来作为实现多态机制的基类的。因此想按照多态机制那样来使用string的派生类对象是不可能的,如果派生类新增了资源型数据成员,并在析构时进行相关资源的销毁,那么,在析构派生类对象时要保证是通过调用派生类的析构函数来销毁的,否则会发生派生类对象中的资源没有释放的错误。
程序崩溃
上面介绍的例子是单重继承的场景,如果使用不慎,顶多会导致资源泄露。下面看一个会发生更严重错误的例子,当string用于多重继承中的第二个或者更后面的基类时(即不是primary base class),如果通过string类型的指针来销毁派生类对象时,会发生程序崩溃:
定义一个基类,它是多重继承的primary base类:
class first_base {
public:
int x;
};
string是多重继承的第二个基类,定义派生类derive:
class derive: public first_base, public string {
public:
~derive() {
puts("~derive");
}
};
下面是测试代码,使用基类string类型的指针pstr来引用new在堆上创建的derive对象,然后通过delete pstr;来销毁对象,结果程序发生了崩溃。
void test() {
string *pstr = new derive();
delete pstr;
}
程序崩溃后输出的错误log:
free(): invalid pointer
Program terminated with signal: SIGSEGV
发生崩溃的原因是,因为delete的地址不是new操作符返回的地址,而是被编译器在类型转换时调整后的地址,因为string没有虚析构函数,在使用delete pstr销毁对象时,无法在运行时进行this指针偏移量的调整,使其pstr指向的地址没有调整回new操作符分配内存成功返回后的地址。
几种实现方式
让string作为基类来派生子类,在使用时发生问题大都是因为通过string基类来操作在堆上创建的派生类对象,尤其是在析构时。因此,在设计stirng的派生类时,如果想避免在使用时发生错误,需要想办法防止通过使用string基类来析构派生类的对象。方法有下面几种:
1、让派生类无法转换为基类,只能按照派生类类型使用
既然不允许派生类对象能够以string类型的形式出现,那就使用非公有继承的方式,比如私有继承或者保护继承,也就是所谓的实现继承。这样派生类无法被编译器认为是string的子类类型,不能转化为string类型。如:
class MyString1 : string {
char *prefix;
public:
using string::c_str;
MyString1(const char *s, const char *pre) : string(s) {
prefix = new char[strlen(pre) + 1];
strcpy(prefix, pre);
}
void print_prefix() {
printf("%s: %s\n", prefix, c_str());
}
~MyString1() {
puts("destruct MyString1");
delete[] prefix;
}
};
下面是测试代码片段:
void test1() {
MyString1 my("1234", "prefix");
cout << my.c_str() << "\n";
MyString1 *p = new MyString1("1234", "prefix");
p->print_prefix();
delete p;
//! string *pstr = new MyString1("1234", "prefix"); // 此处无法编译
}
既然不能使用基类指针来引用这个派生类对象,也就不存在通过基类指针来析构对象时,会发生没有调用派生类析构函数的错误。如果试图使用string *p = new MyString1();来得到一个指向MyString1的string类型指针,会编译失败。
2、限制派生类创建对象的方式,禁止它能够在堆上创建对象
我们知道,C++对象可以在栈中、数据区中和堆中创建。如果对象是在栈中创建的,当离开它的作用域时,运行时(runtime)会自动调用对象的析构函数;如果在数据区创建一个static对象,可以在进程退出时由运行时自动调用对象的析构函数;如果在堆中创建对象,需要使用delete手动来调用析构函数。
如果要通过基类指针来销毁对象,只有一种可能,那就是派生类的对象是在堆中创建的,然后赋值给基类指针,最后手动通过delete基类指针来销毁对象。如果能够禁止对象在堆中创建,就避免了这种应用场景,从而也就保证了派生类对象在析构时,调用的是自己的析构函数。
可以通过禁止重载或者删除new-delete的方式来限制在堆中创建派生类对象,例子如下所示:
class MyString2 : public string {
char *prefix;
public:
MyString2(const char *s, const char *pre) : string(s) {
prefix = new char[strlen(pre) + 1];
strcpy(prefix, pre);
}
void print_prefix() {
printf("%s: %s\n", prefix, c_str());
}
~MyString2() {
puts("destruct MyString3");
delete[] prefix;
}
private:
void *operator new(size_t sz) = delete;
void operator delete(void *p) = delete;
void *operator new[](size_t sz) = delete;
void operator delete[](void *p) = delete;
};
void test2() {
MyString2 my("1234", "prefix");
cout << my << endl;
my.print_prefix();
// 下面无法编译,只能在栈上创建对象,也就意味着只能调用派生类的析构函数
//! MyString2 *p = new MyString2("abcef", "prefix");
//! MyString2 *p = new MyString2[4];
}
从测试代码可以看出,MyString3只能用它来创建栈上对象,如果试图使用new和new[]来创建堆上对象时,会编译失败。
3、使用特殊的方式,销毁对象时可以调用派生类的析构函数
如果既想在堆中创建string的派生类对象,又想使用string基类指针来访问派生类对象时不会发生资源泄漏,可以考虑使用智能指针。下面的例子是使用工厂方法来控制对象的创建形式:既可以在栈中也可以在堆中创建派生类对象,当在堆中创建对象时,使用shared_ptr智能指针来管理这个堆上对象。
class MyString3 : public string {
char *prefix;
MyString3(const char *s, const char *pre) : string(s) {
prefix = new char[strlen(pre) + 1];
strcpy(prefix, pre);
}
public:
void print_prefix() {
printf("%s: %s\n", prefix, c_str());
}
~MyString3() {
puts("destruct MyString3");
delete[] prefix;
}
// 在栈上创建
static MyString3 make_string(const char *s, const char *t) {
return MyString3(s, t);
}
// 在堆上创建
static shared_ptr<MyString3> make_shared(const char *s, const char *t) {
return shared_ptr<MyString3>(new MyString3(s, t));
}
};
MyString3的构造函数是private修饰的,这样就无法在外部直接创建对象。如果要创建对象只能通过调用它的工厂方法来创建,MyString3提供了两个static工厂方法,一个用于在栈中创建,创建的对象是值对象,调用者接收的返回值也是在栈中的值对象,在离开作用域时会自动调用MyString3的析构函数,为prefix分配的内存资源能够正常释放;另一个用于在堆中创建,返回时使用智能指针shared_ptr来包装这个返回指针,当调用者使用完毕引用计数为0时,也会自动销毁智能指针,从而自动调用MyString3的析构函数。
下面是测试代码:
void test3() {
//! MyString3 my("1234", "prefix"); // 无法编译,只能通过工厂方法创建对象
//! MyString3 *p = new MyString3("1234", "prefix"); // 无法编译,只能通过工厂方法
auto my = MyString3 ::make_string("1234", "prefix"); // my在栈中
cout << my.c_str() << "\n";
my.print_prefix();
}
因为MyString3 的构造函数是private限定的,外界无法直接在栈中或者堆中创建对象,如果这样做,会发生编译失败。下面是在堆中创建MyString3对象的测试:
void test4() {
vector<shared_ptr<string>> vec;
shared_ptr<string> sp = MyString3::make_shared("MyString3", "prefix");
cout << *sp << endl;
vec.push_back(move(sp));
vec.push_back(make_shared<string>("string"));
//! vec.push_back(make_shared<MyString3>("string", "prefix")); // 失败
for (auto spp : vec) {
cout << *spp << endl;
}
}
虽然sp的类型是shared_ptr<string>,模板参数是基类string而不是MyString3,但因为在工厂方法中创建返回shared_ptr对象时,new操作符使用的是MyString3类型,删除器deleter绑定的是MyString3类型,在析构时会调用MyString3的析构函数,从测试代码输出的log也可以看到这一点。派生类MyString3创建的堆上对象通过shared_ptr包装后,也可以放入元素类型为基类类型shared_ptr<string>的容器中。
思考
因为string类没有虚函数,它本身就不是为作为一个接口基类设计的,因此,如果通过公有继承string类来设计派生类并不是一个好方案,如果使用不当,可能会导致资源泄露,甚至导致程序崩溃。
我们知道在面向对象开发中,如果要复用一个类的功能,一般有两种机制,一种是使用继承,即派生类通过继承基类来复用基类的功能,另一种是使用组合,即基类作为一个数据成员出现在派生类中。
我们以string为例先看一下组合机制:
class MyString {
string str;
public:
MyString(const char *s) : str(s) {}
void foo() {
auto l = str.size(); // 使用string的成员函数
...
}
char *c_str() const { // 和string相同的成员函数
return s.c_str();
}
...其它和string相同的成员函数
};
类MyString里面定义一个string类型的数据成员,这样,在MyString使用string的功能时,可以直接使用string的成员函数,如在foo()中直接使用了string::size()成员函数。如果它同时要对外提供某些string成员函数时,需要定义和string成员函数完全一样的成员函数,并把请求转发给string的成员函数,如成员函数c_str()。显然,需要一个就得手动添加一个,如果这样的成员函数有很多,为编程带来了不便。因此为了方便,有时候也可以考虑使用非公继承string的方式。比如:
class MyString : string {
public:
using string::string;
using string::size;
using string::c_str;
using string::data;
void foo() {
// 使用string的成员函数
}
void bar() {
// 使用string的成员函数
}
};
由于使用继承的形式是非公有继承,派生类继承而来的成员函数都是非公有的,这样外部就无法访问派生类从基类继承来的成员函数。为了解决此问题,可以通过委托基类的方式来让这些成员函数成为public形式的,如:using string::c_str,这样在外部就可以使用MyString来调用从string继承来的c_str成员函数了。
总之,string可以作为基类来派生子类,但是string不能以多态形式使用它的派生类对象。首先string类没有虚函数,无法通过string的引用类型来实现派生类的多态行为,其次,通过基类指针来析构子类对象时可能有未定义行为,甚至程序崩溃。对于像string这样没有虚函数的类,如果要复用它的功能,首先应该考虑使用对象组合的机制,让string对象是派生类的一个成员,在派生类中通过转发的方式使用string的成员函数。如果为了方便,需要使用继承机制来复用string功能,在定义派生类时,可以使用实现继承的方式,即前面介绍的派生类MyString1的实现方式,如果需要MyString1对外提供一些string的成员函数,可通过委托string成员函数的机制来达到这一目的。