C++ 引用计数技术简介(1)

1.引用计数的作用

C++ 引用计数(Reference Counting)是为弥补 C++ 没有垃圾回收机制而提出的内存管理的一个方法和技巧,它允许多个拥有共同值的对象共享同一个对象实体。

引用计数作为内存管理的方法和技术手段主要有两个作用。
(1)简化了堆对象(Heap Objects)的管理。 一个对象从堆中被分配出来之后,需要明确知道是谁拥有了这个对象,因为只有拥有这个对象的所有者才能够销毁它。但在实际使用过程中, 这个对象可能被传递给另一个对象(例如通过传递指针参数),一旦这个过程复杂,我们很难确定谁最后拥有了这个对象。 使用引用计数就可以抛开这个问题,我们不需要再去关心谁拥有了这个对象,因为我们把管理权交给了对象自己。当这个对象不再被引用时,它自己负责销毁自己。

(2)解决了同一个对象存在多份拷贝的问题。引用计数可以让等值对象共享一份数据实体。这样不仅节省内存,也使程序速度加快,因为不在需要构造和析构同值对象的多余副本。

2.等值对象具有多份拷贝的情况

一个未使用引用计数计数实现的 String 类伪代码示例如下:

class String {
public:
	String(const char* value="");
	String& operator=(const String& rhs) {
		if(this==&rhs)    // 防止自我赋值
			return *this;
		delete[] data;    // 删除旧数据
		data=new char[strlen(rhs.data)=1]
		strcpy(data,rhs.data);
		return *this;
	}
	...
private:
	char* data;
};

String a,b,c,d,e;
a=b=c=d=e="Hello";

很显然对象 a~e 都有相同的值 “hello”,这就是等值对象存在多份拷贝。

3.以引用计数实现 String

3.1 含有引用计数的字符串数据实体

引用计数实现String需要额外的变量来描述数据实体被引用的次数,即描述字符串值被多少个String对象所共享。这里重新设计一个结构体StringValue来描述字符串和引用计数。StringValue设计如下:

Struct StringValue {
	int refCount;
	char* data;
};

3.2 含有引用计数的字符串数据实体的String

新的String类的大致定义可描述如下:

class String {
private:
	Struct StringValue {
		int refCount;
		char* data;
		StringValue(const char* initValue);
		~StringValue();
	};
	StringValue* value;  
	
public:
	String(const char* initValue="");//constructor
	String(const String& rhs);//copy constructor
	String& operator=(const String& rhs); //assignment operator
	~String(); //destructor
};

关于StringValue的构造函数和析构函数可定义如下:

String::StringValue::StringValue(const char* initValue):refCount(1) {
	 data=new char[strlen(initValue)+1];
	 strcpy(data,initValue);
 }
 
String::StringValue::~StringValue() {
	delete[] data;
}

String的成员函数可定义如下:
String的构造函数:

String::String(const char* initValue):value(new StringValue(initValue)){}

在这种构造函数的作用下String s1("lvlv");String s2=("lvlv"),分开构造相同初值的字符串在内存中存在相同的拷贝,并没有达到数据共享的效果。其数据结构为:
这里写图片描述

事实上可以令String追踪到现有的StringValue对象,并仅仅在字符串独一无二的情况下才产生新的StringValue对象,上图所显示的重复内存空间便可消除。这样细致的考虑和实现需要增加额外的代码,可有读者自行实现和练习。

String拷贝构造函数:
当String对象被复制时,产生新的String对象共享同一个StringValue对象,其代码实现可为:

String::String(const String& rhs):value(rhs.value) {
	++valus->refCount;
}

如果以图示表示,下面的代码:

String s1("lvlv");
String s2=s1;

会产生如下的数据结构:
这里写图片描述

这样就会比传统的 non-reference-counted String 类效率高,因为它不需要分配内存给字符串的第二个副本使用,也不要再使用后归还内存,更不需要将字符串值复制到内存中。这里只需要将指针复制一份,并将引用计数加1。

String析构函数:
String的析构函数在绝大部分调用中只需要将引用次数减1,只有当引用次数为1时,才回去真正销毁StringValue对象:

String::~String() {
	if(--value->refCount==0) delete value;
}

String的赋值操作符(assignment):
当用户写下s2=s1;时,这是String对象的相互赋值,s1和s2指向同一个StringValue对象,该对象的引用次数应该在赋值过程中加1。此外,赋值动作之前s2所指向的StringValue对象的引用次数应该减1,因为s2不再拥有该值。如果s2是原本StringValue对象的最后一个引用者,StringValue对象将被s2销毁。String的赋值操作符实现如下:

String& String::operator=(const String& rhs) {  
    if (this->value == rhs.value) //自赋值  
        return *this;  
        
    // 赋值时左操作数引用计数减1,当变为0时,没有指针指向该内存,销毁  
    if (--value->refCount == 0)  
        delete value;  

    // 不必开辟新内存空间,只要让指针指向同一块内存,并把该内存块的引用计数加1  
    value = rhs.value;  
    ++value->refCount;  
    return *this;  
}  

3.3 String 的写时复制

字符串应该支持以下标读取或者修改某个字符,需要重载方括号操作符。String应该有

const char& operator[](size_t index) const;//重载[]运算符,针对const Strings  
char& operator[](size_t index);//重载[]运算符,针对non-const Strings  

对于 const 版本,因为是只读动作,字符串内容不受影响:

const char& String::operator[](size_t index) const {
	return value->data[index];
}

对于 non-const 版本,该函数可能用来读取,也可能用来写一个字符,C++ 编译器无法告诉我们operator[]被调用时是用于写还是取,所以我们必须假设所有的 non-const operator[] 的调用都用于写。此时,我能就要确保没有其他任何共享的同一个 StringValue 的 String 对象因写动作而改变。也就是说,在任何时候,我们返回一个字符引用指向String的StringValue对象内的一个字符时,我们必须确保该 StringValue 对象的引用次数为1,没有其他的String对象引用它。

// 重载[]运算符,针对non-const Strings  
char& String::operator[](size_t index) {  
    if (value->refCount>1) {  
        --value->refCount;  
        value = new StringValue(value->data);  
    }  
    if (index<strlen(value->data))  
        return value->data[index];  
}  

和其他对象共享一份数据实体,直到必须对自己拥有的那份实值进行写操作,这种在计算机科学领域中存在了很长历史。特别是在操作系统领域,各进程(processes)之间往往允许共享某些内存分页(memory pages),直到它们打算修改属于自己的那一分页。这项技术非常普及,就是著名的写时复制(copy-on-write)。

注意: 实现了String的写时复制,但存在一个问题,比如:

String s1="Hello";
char* p=&s1[1];
String s2=s1;

这样就会出现如下数据结构:
这里写图片描述

这表示下面的语句会导致其他的String对象也被修改。

*p = 'd';

这个不问题不限于指针,如果有人以引用的方式将String的non-const operator[]返回值存储起来,也会发生同样的问题。

解决这种问题主要有三种方法。
(1)忽略
允许这种操作,即使出错也不错处理。这种方法很不幸被那些实现reference-counted字符串的类库所采用。考察如下程序,

#include <iostream>
#include <string>
using namespace std;

std::string a="lvlv";

int main() {
	char* p=&a[1];
	*p='a';
	std:: string b=a;
	std::cout<<"b:"<<b<<endl;
	return 0;
}

上面代码在VS2017中编译运行输出"lalv"。

(2)警告
有些编译器知道会有这种问题,并给出警告。虽然无力解决,却会说明不要那么做,如果违背,后果不可预期。

(3)避免
彻底解决这种问题,采取零容忍态度。但是会降低对象之间共享的数据实体的个数。基本解决办法是:为每一个StringValue对象加上一个flag标志,用以指示是否可被共享。一开始,我们先树立此标志为true,表示对象可被共享,但只要non-const operator[]作用于对象值时就将标志清除。一旦标志被设为false,那么数据实体可能永远不会再被共享了。

下面是StringValue的修改版,包含一个可共享标志flag。

Struct StringValue {
	int refCount;
	char* data;
	bool shareable;
	
    StringValue(const char* initValue);
	~StringValue();
};

String::StringValue::StringValue(const char* initValue):refCount(1),shareable(true) {
	 data=new char[strlen(initValue)+1];
	 strcpy(data,initValue);
 }
 
String::StringValue::~StringValue() {
	delete[] data;
}

相比之前的StringValue的构造函数和析构函数,并没有什么大的修改。当然String member functions也要做相应的修改。以copy constructor为例,修改如下:

String::String(const String& rhs) {
	if(rhs.value->shareable) {
		value=rhs.value;
		++valus->refCount;
	}
}

其他的String的成员函数都应该以类似的方法检查shareable。对于Non-const operator[]是唯一将shareable设为false者,其实现代码可为:

char& String::operator[](size_t index) {  
    if (value->refCount>1) {  
        --value->refCount;  
        value = new StringValue(value->data);  
    }  
    value->shareable=false;// 新增此行
    if (index<strlen(value->data))  
        return value->data[index];  
}  

4.小结

以上描述了引用计数的作用和使用引用计数来实现自定义的字符串类String。使用引用计数来实现自定义类时,需要考虑很多细节问题,尤其是写时复制是提升效率的有效手段。

要几本掌握引用计数这项技术,需要我们明白引用计数是什么,其作用还有如何在自定义类中实现引用计数,如果这些都掌握了,那么引用计数也算是基本掌握了。


参考文献

[1] 内存管理之引用计数
[2] Scott Meyers著,侯捷译.More Effective C++.P183-213.
[3] more effective c++读书笔记

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值