《More Effictive C++》学习笔记 — 技术(三)

条款29 —— 引用计数(一)

引用计数这项技术,允许多个等值对象共享同一实值。此技术的发展有两个动机,第一是为了简化堆对象的记录工作(包括其拥有者,何时可以删除等)。引用计数可以消除此种负荷,因为其所有权永远属于它自己。一旦它发现没有人再使用它,它便会自动销毁自己。也因此,引用计数可以协助构建出垃圾回收机制。第二是为了实现一种常识,一种缓式评估的策略。最好是让所有等值对象共享一份实值。

书中提到的内容我们现在完全可以借助 shared_ptr 实现。但是,随着作者的思维去探索如何设计出一个类似 shared_ptr 的功能仍然是很有意义的。

1、许多同值对象

首先让我们看看如何造成许多等值对象:

string a, b, c, d, e;
a = b = c = e = "hello";

a-e都有相同的值hello。我们这里使用msvc中的标准string类。其实现中,每个string对象都会携带一份相同的数据(gcc11.1.0中也是如此)。gccoperator= 的实现为:

basic_string& operator=(const basic_string& __str)
{
	return this->assign(__str);
}

basic_string& assign(const basic_string& __str)
{
#if __cplusplus >= 201103L
	... // 空间配置器的拷贝等
#endif
	this->_M_assign(__str);
	return *this;
}

template<typename _CharT, typename _Traits, typename _Alloc>
void basic_string<_CharT, _Traits, _Alloc>::_M_assign(const basic_string& __str)
{
    if (this != &__str)
	{
	    const size_type __rsize = __str.length();
	    const size_type __capacity = capacity();

	    if (__rsize > __capacity)
	    {
	    	// 分配足够的空间以容纳目标字符串
	        size_type __new_capacity = __rsize;
	        pointer __tmp = _M_create(__new_capacity, __capacity);
	        _M_dispose();
	        _M_data(__tmp);
	        _M_capacity(__new_capacity);
	    }

		// 拷贝目标字符串
	    if (__rsize)
	        this->_S_copy(_M_data(), __str._M_data(), __rsize);

	    _M_set_length(__rsize);
	}
}

从上述代码,我们可以看出其内存结构为:
在这里插入图片描述
这种内存明显浪费了空间。理想情况下我们希望其内存结构为:
在这里插入图片描述
实际上不可能达到这样的结果,因为我们必须借助其他变量已记录有多少个对象共享此值。因此一种更合理地内存布局应该为:
在这里插入图片描述

2、引用计数的实现

引用计数本身并不困难,但是其中有一些细节值得我们注意。我们需要明确我们需要单独的空间,用以为每个string存储引用次数。这个空间不可以设置在string内部,无论string对象是在栈上创建的还是堆上创建的。因为我们需要在引用相同数据的对象中共享该内存。我们是要为每一个字符串值准备一个引用计数,而不是为每一个字符串对象准备。这暗示了对象值和引用计数之间有一种耦合的关系。因此,我们这里可以创建一个类,用以保存对象实值及其引用次数。

(1)引用计数结构

class CLS_MyString
{
	...
private:
	struct StringValue
	{
		int refCount;
		char* pcData;

		StringValue(const char* initValue);
		~StringValue();
	};

	StringValue* pData;
};

CLS_MyString::StringValue::StringValue(const char* initValue) :
	refCount(1)
{
	pcData = new char[strlen(initValue) + 1];
	strcpy_s(pcData, strlen(initValue) + 1, initValue);
}

CLS_MyString::StringValue::~StringValue()
{
	delete[] pcData;
}

我们使用 private 结构体作为保存引用计数的结构,这样便于内部数据访问引用计数对象的数据。这里的 pData 所指向的空间是在指向相同实值的对象间共享的。这个结构体很简单,甚至没有拷贝构造和赋值函数。但是那些操作都不需要它提供,而是由外部CLS_String控制。为什么?因为这个结构体只是为了表现实值和计数之间的耦合而已。

(2)引用计数的控制

class CLS_MyString
{
public:
	CLS_MyString(const char* initValue = "");
	CLS_MyString(const CLS_MyString& other);
	CLS_MyString& operator=(const CLS_MyString& other);
	~CLS_MyString();
	...
}

通常,我们有六个函数涉及到拷贝和构造。我们这里并没有实现出移动构造和赋值函数。因为默认情况下,它们将会调用相应的左值版本 —— 那对我们来说已经足够了。这些函数的实现如下:

CLS_MyString::CLS_MyString(const char* initValue) :
	pData(new StringValue(initValue))
{
}

CLS_MyString::CLS_MyString(const CLS_MyString& other):
	pData(other.pData)
{
	pData->refCount++;
}

CLS_MyString& CLS_MyString::operator=(const CLS_MyString& other)
{
	if (pData == other.pData)
	{
		return *this;
	}

	if (--pData->refCount == 0)
	{
		delete pData;
	}

	pData = other.pData;
	++pData->refCount;

	return *this;
}

CLS_MyString::~CLS_MyString()
{
	if (--pData->refCount == 0)
	{
		delete pData;
	}
}

我们可以比较下这些函数实现与不使用引用计数方式的实现的效率:构造函数要付出一个StringValue构造的代价;拷贝构造函数的效率则提高了很多,因为我们不再需要拷贝空间了;赋值操作仅需要判断是否需要delete原来持有的StringValue对象,同样不需要再发生原始数据的拷贝行为,提高了效率;析构函数仅仅增加了一个数值判断,基本上不会影响什么效率。

(3)copy-on-write

什么时候会发生数值的修改呢?我们这里仅讨论通过 operator[] 返回的引用修改的情况。好吧,事实上,我们无法区分当调用非const版本的 operator[] 后,返回的引用是被用来读取还是写入(除非使用一个代理类)。因此,我们需要保证,每次用户调用该操作,其所属对象的实值仅属于它自身,也就是引用计数值为0。

char& CLS_MyString::operator[](int index) const
{
	return pData->pcData[index];
}

char& CLS_MyString::operator[](int index)
{
	if (pData->refCount > 1)
	{
		// 防止异常
		auto pTmp = new StringValue(pData->pcData);
		--pData->refCount;
		pData = pTmp;
	}

	return pData->pcData[index];
}

和其他对象共享一分实值,知道我们必须对自己所拥有的那一份实值进行写动作。这个观念被称为 copy-on-write

(4)引用,指针,copy-on-write

上述的理念很好,但是 operator[] 实现却并不完美。考虑以下代码:

int main()
{
	CLS_MyString s1 = "Hello";
	auto pc = &s1[1];
	CLS_MyString s2 = s1;
	*pc = 't';
	cout << static_cast<const CLS_MyString>(s2)[1] << endl;
}

在这里插入图片描述
问题产生的原因在于 operator[] 只能监测到其调用之前的引用计数个数。如果在其返回引用之后引用计数发生了改变,它已经无力回天。
想要解决这个问题,至少有三种办法:忽略它;在文件中进行说明;增加标志量。最后一种虽然会降低对象之间共享的实值个数,但是却行之有效。该标志量用于控制对象是否可被共享。当 non-const oeprator[] 被调用时,该标志将被清除。在那之后,该标志永远不会被再置位。以下是对 CLS_MyString 的修改:

class CLS_MyString
{
	...
private:
	struct StringValue
	{
		...
		bool isSharable;
	};
	void copy(const CLS_MyString& other);
	...
};

CLS_MyString::StringValue::StringValue(const char* initValue) :
	refCount(1),
	isSharable(true)
{...}
...
CLS_MyString::CLS_MyString(const CLS_MyString& other)
{
	copy(other);
}

CLS_MyString& CLS_MyString::operator=(const CLS_MyString& other)
{
	...
	copy(other);
	return *this;
}
...
char& CLS_MyString::operator[](int index)
{
	...
	pData->isSharable = false;
	return pData->pcData[index];
}

void CLS_MyString::copy(const CLS_MyString& other)
{
	if (pData->isSharable)
	{
		pData = other.pData;
		++pData->refCount;
	}
	else
	{
		pData = new StringValue(other.pData->pcData);
	}
}

3、引用计数基类

老规矩,每当我们找到一个有用的工具类,必然想到把它作为基类实现 —— CLS_RCObject。任何一个想要使用引用计数能力的类,都必须继承自这个基类。该类内部封装了实值对象、引用计数值及共享标志。为了方便外部查询状态,它需要提供一些函数供外部调用。其声明如下:

class CLS_RCObject
{
public:
	CLS_RCObject() = default;
	CLS_RCObject(const CLS_RCObject& other);
	CLS_RCObject& operator=(const CLS_RCObject&);
	virtual ~CLS_RCObject() = 0;
	void addReference();
	void removeReference();
	void markUnshareable();
	bool isShareable() const;
	bool isShared() const;

private:
	int refCount = 0;
	bool shareable = true;
};

该类对应的实现如下:

CLS_RCObject::CLS_RCObject(const CLS_RCObject& other)
{
}

CLS_RCObject& CLS_RCObject::operator=(const CLS_RCObject&)
{
	return *this;
}

CLS_RCObject::~CLS_RCObject()
{
}

void CLS_RCObject::addReference()
{
	++refCount;
}

void CLS_RCObject::removeReference()
{
	if (--refCount == 0)
	{
		delete this;
	}
}

void CLS_RCObject::markUnshareable()
{
	shareable = false;
}

bool CLS_RCObject::isShareable() const
{
	return shareable;
}

bool CLS_RCObject::isShared() const
{
	return refCount > 1;
}

这里的语法比较有意思。我们使用类内初始化的语法,为新创建对象的属性指定默认参数;但是,我们同样声明和定义了拷贝构造函数,这是为了防止编译器默认的位逐次拷贝行为,导致属性发生拷贝。然后我们又声明了其构造函数使用默认版本,这是因为一旦我们声明了拷贝构造函数而不声明构造函数,编译器会声明一个被删除的构造函数。该类的派生类将无法进行默认构造。
同时,我们需要注意我们在引用计数值变为0时,调用了 delete this。这要求对象必须是在堆上进行内存分配的。这种保证可以借助前一个条款中学习过的办法实现(继承 CLS_HeapTracked)。后面我们还会讨论另一种方式。

那么,这里我们不免有几个疑问:
(1)为什么拷贝时不拷贝计数值及共享状态呢?
请注意,我们这个基类并不保存实值对象。这就意味着我们无法控制此基类成分所属派生类的实值到底是和几个对象共享的。因此,我们这里规定:使用 CLS_RCObject 的用户有责任为refCount设置初值
(2)为什么将引用计数的初始设置为0而不是1呢?
可以简化使用引用计数类的代码。例如下面的 CLS_RCPtr 中可以抽取拷贝构造和构造函数的相同代码,正是因为引用计数值的改变完全交给了该类负责,而不是在引用计数类自动处理。
(3)为什么赋值函数中什么都没有做?
事实上,这个操作符基本不会被调用。该类作为一个针对共享实值而设计的基类,其对象一般不会被赋值给另一个对象。即使其派生类有可能发生赋值,此基类成分也仅需要派生类对象控制引用计数的改变。不过,为了避免默认的拷贝行为,我们这里还是体现了正确的赋值语义:不做任何实质行为,直接返回当前对象
让我们举个具体的例子分析:假设 StringValue 类现在继承于 CLS_RCObject,当我们尝试:

sv1 = sv2;

现在 sv1 保存了某个实值的引用数量及共享状态,sv2保持了另一个实值的相应属性。假设我们希望这个赋值动作做些什么,那么应该做什么呢?将 sv1 的引用计数递减,sv2 的引用计数递增?如果二者保存的是相同的实值,这样的行为并没有为两个派生类对象得到正确的引用计数值。

4、自动操作引用计数

CLS_RCObject 给我们提供了便利的功能以保存和操作引用计数。但是这些函数的调用还是得由我们手动完成。而且我们需要重写每个派生类的拷贝构造和赋值函数以增加和删除引用计数。然而,我们会发现这些工作其实都是重复的。它们并不会因派生类的不同而有不同的行为。它们只是跟实值发生绑定而已。 因为我们这里由指针保存共享实值的地址,所以不难现象这样的类其实就是一个智能指针类。它能帮我们解决大多数重复性代码,然而并不包括 CLS_MyString::operator[]
我们这里借助C++20的新特性 concept 声明这个资源处理类:

template<typename T>
concept referencecountable = copy_constructible<T> && requires(T a)
{
	a.addReference();
	a.removeReference();
	a.markUnshareable();
	a.isShareable();
	a.isShared();	
};

template<referencecountable T>
class CLS_RCPtr
{
public:
	CLS_RCPtr(T* _ptr = 0);
	CLS_RCPtr(const CLS_RCPtr& other);
	~CLS_RCPtr();

	CLS_RCPtr& operator=(const CLS_RCPtr& other);

	T* operator->() const;
	T& operator*() const;

private:
	T* pointer;
	void init();
};

其实现如下:

template<referencecountable T>
CLS_RCPtr<T>::CLS_RCPtr(T* _ptr):
	pointer(_ptr)
{
	init();
}

template<referencecountable T>
CLS_RCPtr<T>::CLS_RCPtr(const CLS_RCPtr& other):
	pointer(other.pointer)
{
	init();
}

template<referencecountable T>
CLS_RCPtr<T>::~CLS_RCPtr()
{
	if (pointer)
	{
		pointer->removeReference();
	}
}

template<referencecountable T>
CLS_RCPtr<T>& CLS_RCPtr<T>::operator=(const CLS_RCPtr& other)
{
	if (pointer != other.pointer)
	{
		if (pointer)
		{
			pointer->removeReference();
		}

		pointer = other.pointer;
		init();
	}
	
	return *this;
}

template<referencecountable T>
T* CLS_RCPtr<T>::operator->() const
{
	return pointer;
}

template<referencecountable T>
T& CLS_RCPtr<T>::operator*() const
{
	return *pointer;
}

template<referencecountable T>
void CLS_RCPtr<T>::init()
{
	if (pointer == nullptr)
	{
		return;
	}

	if (pointer->isShareable() == false)
	{
		pointer = new T(*pointer);
	}

	pointer->addReference();
}

对于构造过程中重复的步骤,我们提取出了 init 方法。该方法首先判断指针是否支持共享。如果不支持共享,则在堆上构造一个新的对象并将新地址赋值给pointer。最后增加 pointer 的引用计数。
这里我们要求模板参数支持拷贝构造。如果拷贝构造的引用对象不支持共享,则将调用模板类型的拷贝构造函数。对于 StringValue 来说,我们使用了编译器为它默认生成的版本 —— 仅拷贝实值对象的地址。这样的行为将导致重大的灾难。因此,我们需要为之显式声明拷贝构造函数,以实现深拷贝。我们应该形成这样的习惯:为内含指针的类实现拷贝构造函数
这样的实现方式还隐式要求了用于初始化 CLS_RCPtr 的指针必须是其模板类型对象的指针。其意思是说,不能使用 T 的派生类指针来初始化该类对象。否则我们这里的拷贝过程将发生切割(因为我们使用 T 的拷贝构造函数实现拷贝)。如果想解决这个问题,我们可以使用前面学习过的原型模式,要求模板类型 T 实现 clone 方法以在对象不支持共享的情况下实现新对象的拷贝构造。

5、使用 CLS_RCObject 和 CLS_RCPtr 的 CLS_MyString

这几个类的关系如图(增加了堆对象检查):
在这里插入图片描述

class CLS_MyString
{
public:
	CLS_MyString(const char* initValue = "");

	char& operator[](int index) const;
	char& operator[](int index);

private:
	struct StringValue : public CLS_RCObject
	{
		char* pcData;

		StringValue(const char* initValue);
		StringValue(const StringValue& other);
		~StringValue();

		void init(const char* initValue);
	};

	CLS_RCPtr<StringValue> pData;
};

CLS_MyString::StringValue::StringValue(const char* initValue)
{
	init(initValue);
}

CLS_MyString::StringValue::StringValue(const StringValue& other)
{
	init(other.pcData);
}

CLS_MyString::StringValue::~StringValue()
{
	delete[] pcData;
}

void CLS_MyString::StringValue::init(const char* initValue)
{
	pcData = new char[strlen(initValue) + 1];
	strcpy_s(pcData, strlen(initValue) + 1, initValue);
}

CLS_MyString::CLS_MyString(const char* initValue) :
	pData(new StringValue(initValue))
{
}

char& CLS_MyString::operator[](int index) const
{
	return pData->pcData[index];
}

char& CLS_MyString::operator[](int index)
{
	if (pData->isShared())
	{
		pData = new StringValue(pData->pcData);
	}
	pData->markUnshareable();
	return pData->pcData[index];
}

我们可以发现,这次 CLS_MyStringpublic 接口减少了很多。我们没有声明拷贝构造、赋值和析构函数。准确地说,现在编译器默认的行为已经足够满足我们的要求了。编译器生成的默认版本将为我们调用 CLS_RCPtr 的相应方法。那就是我们想要的结果。
同时,我们这里借由 CLS_RCPtr,不需要在 operator[] 中操作引用计数值。这样再简单不过了。除此之外,还有一个事情让我们满意:CLS_MyString 的接口没有发生任何变化。如果我们采用 pImpl 手法实现这个类,用户既不用重新编译也不用改变调用方式

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值