C++ string类能否被继承?

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成员函数的机制来达到这一目的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值