从单例模式讲起

文章目录

  • 从单例模式讲起
    • 单例模式
      • 1. 意图
      • 2. 动机
      • 3. 适用性
      • 4. 优点
      • 5. 实现
    • RAII(Resource Acquisition Is Initialization)
      • 1. 值语义
      • 2. Rule of Three/Tow/Five/Zero
    • 不可变对象
      • 1. Risks of mutation
      • 2. 可变方法(Mutating method)和迭代器(Iterator)
      • 3. Useful immutable types

从单例模式讲起

单例模式

1. 意图

保证一个类仅有一个实例,并提供一个访问它的全局访问点。

2. 动机

系统中有某些实例是具有唯一性的。我们怎么保证一个类只有一个实例并且这个实例易于被访问?一个全局变量使得一个对象可以被访问,但它不能防止你实例化多个对象。
一个更好的办法是,让类自身负责保存它的唯一实例。这个类可以保证没有其他实例可以被创建,并且它可以提供一个访问该实例的方法。

3. 适用性

  • 当类只能有一个实例而且client可以从一个众所周知的访问点访问它时。
  • 当这个唯一实例应该是通过子类化可扩展的,并且client应该无需更改代码就能使用一个扩展的实例时。

4. 优点

  • 对唯一实例的受控访问
  • 缩小名空间
  • 允许对操作和表示的精化
  • 允许可变数目的实例
  • 比类操作更灵活

5. 实现

  • 经典实现:
    // lazy initiation
    class Singleton 
    {
    publicstatic Singleton* Instance()
       {
       	if(null == _instance)
       	{
       		_instance = new Singleton(); 	// 非线程安全
       	}
       }
       
    private:
       Singleton();	// 保证外界无法创建实例
       Singleton(const Singleton& s);		// 保证外界无法创建实例
       Singleton& operator =(const Singleton& s);		// 保证外界无法创建实例
       static Singletion* _instance = null;
    };
    

不难发现,经典实现的Singleton是非线程安全的。因为我们没有限制new语句的执行。

  • 线程安全实现:
    // lazy initiation
    class Singleton 
    {
    publicstatic Singleton* Instance()	// double-checked locking
    	{
    		Singleton *tmp = _instance.load(std::memory_order_acquire);
    		if(nullptr == tmp)
    		{
    			std::lock_guard<std::mutex> lock(_mutex);
    			tmp = _instance.load(std::memory_order_relaxed);
    			if(nullptr == tmp)
    			{
    				tmp = new Singleton();
    				_instance.store(tmp, std::memory_order_release);
    			}
    		}
    	}
    protected:
    	Singleton();	// 保证外界无法创建实例
    private:
    	Singleton(const Singleton& s);
    	Singleton& operator =(const Singleton& s);
    	static std::atomic<Singletion*> _instance = nullptr;	// 原子操作
    	static std::mutex _mutex;	//互斥量
    };
    

此处要采用双重检查锁优化(double-checked locking, DCL)。如果少了第一层的指针判空,那么线程就在不必要的加锁操作上造成比较大的开销。

  • C++11则推荐使用静态方法
    class Singleton
    {
    public:
    	static Singleton* instance()
    	{
    		static Singleton _instance = Singleton();
    		return  &_instance;
    	}
    private:
    	Singleton() = default;
    	~Singleton() = default;
    	Singleton(const Singleton& s) = default;
    	Singleton& operator =(const Singleton& s) = default;
    };
    

这是饿汉模式,借由静态机制,程序一开始就构建单例,往后也不会再次构建,必然是线程安全的。

  • java DCL实现:
    // double-checked locking
    class Singleton{
       // 必须加volatile修饰词,否则java编译器将会对此代码优化,使得同步不起作用。
       // 但是volatile修饰词只在java5.0以上才出现。
       private volatile static Singleton INSTANCE = null;
       private Singleton(){
       }
       public static Singleton getInstance(){
           if(INSTANCE == null){
       	    synchronized(Singleton.class){
       		    if(INSTANCE == null){
       			    INSTANCE = new Singleton();
       		    }
       		}
           }
           return INSTANCE;
       }
    }
    

因此,在《java并发编程实践》一书建议用Initialization-on-demand holder idiom来替代DCL。

  • java优化实现:

    //实际上是静态内部类
    public class Singleton {
    	private Singleton() {}
    
    	private static class LazyHolder {
    	    static final Singleton INSTANCE = new Singleton();
        }
    
        public static Singleton getInstance() {
            return LazyHolder.INSTANCE;
        }
    }
    
  • C#实现:

    // .NET Framework 4.0以上有Lazy 自带DCL实现
    public class Singleton
    {
    	private static readonly Lazy<Singleton> _Singleton =
    	new Lazy<Singleton>(() => new Singleton());
    	
    	private Singleton() { }
    	
    	public static Singleton Instance
    	{
    	    get
    	    {
    		     return _Singleton.Value;
    	    }
    	}
    }
    

而python就是天然的Singleton语言,python的一个module就是一个Singleton(同时也是Thread-Safe)。

RAII(Resource Acquisition Is Initialization)

细心的同学会发现,我们在用C++实现Singleton的时候,对拷贝函数声明成了delete,这种策略在RAII中经常出现,又或者说在资源管理中,我们对拷贝函数进行相同的控制。我们不妨深入去了解这是为什么。

1. 值语义

值语义(value semantics),又称copy-by-value semantics,即按值赋值语义。与其相对的是引用语义(reference semanticsreference)。

注意:和C++的引用类型概念不一样

  • 如果对象是值语义的,那么对象的拷贝和原对象无关。例如基本类型(int, bool, double, char, etc.)
int a = 1;
int b = a;
a = 2;	// 修改a为2,
cout<< a << " " << b<<endl;	// 2 1  // b的值仍然是1;
  • 如果对象是引用语义的,那么对象的拷贝和原对象是等同的。
class A{
	public int a;
	public A() {
		a = 0;
	}
}

// other class
public static void main(String args[]){
	A pa = new A();
	A qa = pa;
	pa.a = 1;
	System.out.println(qa.a);	// 1	// pa和qa是等同的,修改pa即是修改qa。
}

很明显地,C++的类是值语义的。

class a
{
public:
	a() : _a(0) {}
	int _a;
};

int main()
{
	a m_a;
	a t_a = m_a;
	m_a._a = 1;
	cout << t_a._a << endl;	// 0
}

除此之外,绝大多数流行语言(带OO机制)的对象都是引用语义的。一般地,值语义在资源管理上有着先天优势。不妨作此论断:值语义的对象存放在stack上,而引用语义的对象存放在heap上。这样的效果是值语义的对象生命周期会随着代码块的结束会自动调用析构函数,从而释放资源;而引用语义会长期地存在并等待程序员(或者GC)来释放它(并不会自动调用析构函数 )。一个可以想到原因是: 在日常意义上,一个具体的对象(实例)不存在它的副本。比如李白(唐朝诗人),我们有且只有一个李白,即使有其他人与他同名,但同名的人并不是那位李白的副本(copy);在工程中,我们没必要对某些unique资源创建它们的副本,创建副本不仅在逻辑上不合理,导致代码冗余,同时也导致内存资源的冗余。于是乎,为了不产生不必要的副本,同时又能在不同代码段中用到此资源,我们便把unique资源存放在heap上。又或者从另一个角度说,在工程中,我们并不总是关注variable的“值”,我们还关注variable的状态、关系等,允许variable在内存中发生变化。这时候用stack是不好管理这些需求的(因为stack有自己的callback逻辑),所以我们只能讲这种“非值”的variable,即引用语义的对象存放在heap中。

当然啦,值语义和引用语义并不是按内存分配方式划分的。

2. Rule of Three/Tow/Five/Zero

Rule of Three 是C++程序设计的一个基本原则。对于类的三个特殊成员函数:析构函数、拷贝构造函数、拷贝赋值函数,如果程序员对其中一个进行显式的定义,那么他就应该对其余的两个成员函数也进行显式定义。
让我们看个例子:

// This class contains a subtle error
class IntVec {
public:
   IntVec(int n): data(new int[n]) { }
   ~IntVec() { delete[] data; };
   int& operator[](int n)
      { return data[n]; }
   const int& operator[](int n) const
      { return data[n]; }

private:
   int* data;
};	

如果我们不为一个类显式声明一个拷贝构造函数,那么程序隐式生成的拷贝构造函数将会以Default Memberwise Initialization的方式完成。于是当我们拷贝时,编译器将会把data的值(指针)赋值给新的对象,那么问题也将接踵而来:

{
	IntVec x(100);
	IntVec y = x;
}

当我们作用域结束,进行析构的时候,第一次析构,假设先对x析构,那么编译器将会销毁data指向的数据;而当对y析构的时候,y.data指向一个无意义的数据域,此时的销毁操作将对程序造成严重的破坏。所以如果我们对某一个类要显式地定义析构函数,那么我们也一定同时地显式定义拷贝构造函数和赋值函数。 因为既然我们要显式地定义析构函数,那么我们必然要对对象内部的内存管理要小心翼翼,否则我们何必要显式地定义析构函数呢?
那反过来呢?如果我们有需求必须显式定义拷贝构造函数,那么我们有必要显式定义析构函数吗?答案是不必要的。这就是Rule of Two
让我们看这个例子:

class Example {
	SomeResource* p_;
	SomeResource* p2_;
public:
	Example() :
		p_(new SomeResource()),
		p2_(new SomeResource()) {
		std::cout << "Creating Example, allocating SomeResource!\n";
		}

	Example(const Example& other) :
		p_(new SomeResource(*other.p_)),
		p2_(new SomeResource(*other.p2_)) {}
		
	Example& operator=(const Example& other) {
		// Self assignment?
		if (this==&other)
			return *this;

		*p_=*other.p_;
		*p2_=*other.p2_;
		return *this;
	}

	~Example() {
		std::cout << "Deleting Example, freeing SomeResource!\n";
		delete p_;
		delete p2_;
	}
};

假如当我们进行拷贝构造时,p2_的初始化失败了并抛出,那么此时编译器是不会调用Example的析构函数,因为在编译器角度来说,Example的实例并不存在;然而此时程序已经为p_分配了资源,但是却没有回收,这就造成资源泄漏!所以即便我们显式定义了析构函数,但它却没有起到足够的作用。
而规避此类问题的方案,最合适就是使用RAII:我们可以使用智能指针代替普通的指针,这样每当为指针初始化时,我们就已经为其分配了资源,若此时指针初始化失败,我们也可以正确对其析构,因为此刻智能指针会析构。如此一来,如果我们为某一类运用RAII,那么我们就不必为其显式定义析构函数,而是让RAII来进行析构。
再让我们关注拷贝构造函数的调用语义,不同于拷贝赋值函数,拷贝构造函数往往会隐式地被调用。以下将列举拷贝构造函数被调用的情景:

  • 显式拷贝构造
    int main()
    {
    	A a;
    	A ta(a);	//显式拷贝构造
    }
    
  • 对象初始化
    int main()
    {
    	A a;
    	A ta = a;	//调用拷贝构造函数
    }
    
    C++对初始化赋值有严格区分,如果对象是要进行初始化,那么编译器就会优先调用拷贝构造函数而不是拷贝赋值函数。
  • 值传递
    void func(A a)	// 这里传参会调用拷贝构造函数
    {
    }
    int main()
    {
    	A a;
    	func(a);	
    }
    
    值传递会创造一份copy,此时初始化就会调用拷贝构造函数。而这里就有额外的内存和计算的消耗,所以我们常用引用传递变量。
  • 函数返回值
    A func()
    {
    	A a;
    	return a;	//值返回会调用拷贝构造函数
    }
    
    int main()
    {
    	A a = func();
    }
    
    对于C++11之前,C++对函数返回值做了Named Return Value优化:即在编译器层,会对函数返回值转换成:
    void func(A &_result)
    {
    	_result.A::A();
    	
    	return;
    }
    
    int main()
    {
    	A a;	// 此时编译器不会调用默认构造函数
    	func(a);
    }
    
    首先加上额外的参数,类型是返回对象的一个引用,用来放置返回值。接着在return前调用拷贝构造函数。这样一来原本func()函数要调用一次默认构造函数和一次拷贝构造函数就优化成只需一次拷贝构造函数的计算。
    有时候,我们使用者也可以显式地优化:
    A func()
    {
    	return A();	//NRV
    }
    
    int main()
    {
    	A a = func();
    }
    

这时候我们则是调用默认构造函数(或者其他构造函数),而不会再调用一次拷贝构造函数。
在上面的函数返回值中我们可以看出,每一次返回值总无法避免地创造了一次临时对象,这样次数多了将会造成性能下降:

class A
{
	A () { cout << "ctor" << endl; }
	A (const A &a) { cout << "copy_ctor" << endl; }	
}

int main()
{
	vector<A> v_a;
	v_a.push_back(A());
	v_a.push_back(A());
}

//print
ctor
copy_ctor
ctor
copy_ctor
copy_ctor

一般我们如果要显式定义拷贝构造函数,将如同上面的Example类一样,申请获取资源。那么当我们程序(必然)长期地执行push_back函数时,那么将会造成“资源窃取”:临时请求不必要的资源,导致资源不足。
在C++11,我们引入了移动语义,用于解决这样的困境(当然移动语义作用不止于这样)。C++11为在类的默认函数中多了移动构造函数和移动赋值函数,用于优化拷贝构造函数和拷贝赋值函数中的内存存取效率。于是在维护以上说的三个特殊函数的基础上,再加两个移动函数,就是Rule of Five,即如果显式定义了析构函数,必须显式声明剩余的四个特殊成员函数。
一般地,我们的资源策略分有以下几种:

  • 使用默认的特殊函数。
  • 显式声明拷贝函数用于定义深拷贝,但不提供移动语义。

    模拟正常值语义

  • 只定义移动函数而不允许拷贝。

    允许独占资源所有权,也允许转让资源所有权,例如unique_ptr

  • Rule of Five

    用于多个对象拥有同一资源的所有权,这在线程安全的要求下实现难度比较高。常见的做法是维护一个引用计数,例如shared_ptr

  • 禁用拷贝和移动语义,使用默认的析构函数。

Rule of Three/Two/Five规则固然重要,它令我们在编程中对资源管理的策略有更深入了解,但是如果太沉迷规则就会“为了一棵树放弃整个森林”。实际上这些规则在日常来说并不需要了解,这是因为C++允许我们把所有权策略封装到一个通用的可重用类中(Single Responsibility Principle,单一责任原则)。同时,如果如果我们总是为自己的类显式定义析构函数,又或是拷贝构造函数,我们都常常不得不完整编写5个函数,特别是当函数重载时,这代码的复杂度将大大提高。
正如我们在Rule of Two所阐述的那样,我们可以利用智能指针来帮助我们管理资源。智能指针也足够灵活,几乎可以管理任何资源类型,由此我们引申出Rule of Zero:尽可能避免定义默认操作,把资源管理交予一个专门管理类完成。这样的好处是有的:

  • 避免重复的所有权的逻辑
  • 所有权逻辑和其他逻辑解耦
  • 更少的代码量,更少的代码修改
  • 安全的资源释放

Rule of Two更进一步的是,Rule of Zero不提倡显式声明拷贝函数(甚至移动函数)。因为在基类使用多态时,那么它将必然遇到动态绑定的变量传递问题:如果使用移动语义,则将会产生切片问题;如果使用拷贝语义,则多态行为被破坏。如果必要显式声明,则最好显式声明为default(删除则声明为delete)。

不可变对象

说完RAII,那么我们应该了解到RAII是一种线程安全的资源管理的编程风格。而和线程安全有关的另一种编程风格就是不可变对象(Immutable object)。

1. Risks of mutation

  • 传递一个可变对象
    我们先写一个求和方法:

    /** @return the sum of the numbers in the list */
    public static int sum(List<Integer> list) {
    	int sum = 0;
    	for (int x : list)
    	sum += x;
    	return sum;
    }
    

    而此时我们还需要一个求绝对值和的方法。根据DRY原则(Don’t repeat yourself),我们可以在sum方法的基础上实现:

    /** @return the sum of the absolute values of the numbers in the list */
    public static int sumAbsolute(List<Integer> list) {
    	// let's reuse sum(), because DRY, so first we take absolute values
    	for (int i = 0; i < list.size(); ++i)
    	list.set(i, Math.abs(list.get(i)));
    	return sum(list);
    }
    

    这是通过直接修改list的数据来实现。对实现者来说,这种做法似乎是可行的,第一它符合DRY原则;其次,如果list是存有百万条数据,那么这么写就会节省很多时间和很大的空间,效率高。然而实际上,这个方法用起来是有问题的:

    // meanwhile, somewhere else in the code...
    public static void main(String[] args) {
        // ...
    	List<Integer> myData = Arrays.asList(-5, -3, -2);
    	System.out.println(sumAbsolute(myData));
        System.out.println(sum(myData));
    }
    

    不难发现,输出的结果都是10。sum方法无法求出原来myData的和。

  • 返回一个可变对象
    假设我们现在实现一个返回春天第一个月的方法,这里我们使用一个“土拨鼠算法”(并不是真实算法,取名来自电影,土拨鼠日)来获得春天的第一个月时间:

    /** @return the first day of spring this year */
    public static Date startOfSpring() {
    	 return askGroundhog();
    }
    

    Data是Java内置类型,它的对象是可变的。

    然后用户开始使用这个接口,例如用来获取过节的时间:

    // somewhere else in the code...
    public static void partyPlanning() {
    	Date partyDate = startOfSpring();
    	// ...
    }
    

    这代码似乎工作很正常。可是,很不幸代码存在着两个方面的问题:首先,askGroundhog方法计算的结果在未来的一年里都是不变的,而startOfSpring可能被不同的用户在同一时间段内调用,那么就会消耗cpu。因此我们可以用一个变量保存结果:

    /** @return the first day of spring this year */
    public static Date startOfSpring() {
    	if (groundhogAnswer == null) groundhogAnswer = askGroundhog();
    	return groundhogAnswer;
    }
    private static Date groundhogAnswer = null;
    
    • 这里使用私有静态变量是有问题的。
    • 因为这个私有静态变量并不是全局变量,它的作用域是受到startOfSpring方法所在的类约束的。它的作用域仅仅只是比startOfSpring方法大一点,此时startOfSpring方法需要依赖比上面更多的代码来维护一个正确的逻辑。

    其次,如果我们并不是想在春天第一个月过节,而是下一个月过节:

    // somewhere else in the code...
    public static void partyPlanning() {
        // let's have a party one month after spring starts!
        Date partyDate = startOfSpring();
        partyDate.setMonth(partyDate.getMonth() + 1);
        // ... uh-oh. what just happened?
    }
    

    Date类型也有隐藏的bug,在新的java中已经被弃用。

    一般而言,我们都是在不期望的情况下修改了不应该被修改的对象,而这种对象往往是设置成可以被修改。究其原因就是java的对象是引用语义的,再赋值的时候,新的对象和旧对象共用同一份内存。

2. 可变方法(Mutating method)和迭代器(Iterator)

接下来我们再探讨另一种可变对象:迭代器。在java的for循环语法糖中,我们就有迭代器的影子:

List<String> lst = ...;
for (String str : lst) {
	System.out.println(str);
}

上面的代码将会解析成:

List<String> lst = ...;
Iterator iter = list.iterator();
for(iter.hasNext()) {
	String str = iter.next();
	System.out.println(str);
}

为了更好的了解iterator的原理,我们现在自己实现一个简易的iterator类:

/**
 * A MyIterator is a mutable object that iterates over
 * the elements of an ArrayList, from first to last.
 * This is just an example to show how an iterator works.
 * In practice, you should use the ArrayList's own iterator
 * object, returned by its iterator() method.
 */
public class MyIterator {

    private final ArrayList<String> list;
    private int index;
    // list[index] is the next element that will be returned
    //   by next()
    // index == list.size() means no more elements to return

    /**
     * Make an iterator.
     * @param list list to iterate over
     */
    public MyIterator(ArrayList<String> list) {
        this.list = list;
        this.index = 0;
    }

    /**
     * Test whether the iterator has more elements to return.
     * @return true if next() will return another element,
     *         false if all elements have been returned
     */
    public boolean hasNext() {
        return index < list.size();
    }

    /**
     * Get the next element of the list.
     * Requires: hasNext() returns true.
     * Modifies: this iterator to advance it to the element 
     *           following the returned element.
     * @return next element of the list
     */
    public String next() {
        final String element = list.get(index);
        ++index;
        return element;
    }
}

next方法是一个可变方法,它改变了iterator类内的index值。它不仅返回当前值,还推进了迭代器。

迭代器模型的好处不用多讲,我们都知道程序中有许多不同的数据结构和类型,而迭代器允许用统一的方式对它们进行访问和操作。大多数现代计算机语言都有迭代器,最著名莫不过是C++的STL。但是可变性却有可能导致迭代器失效,现在就让我们看一下:

public static void drop(ArrayList<String> subjects) {
	MyIterator iter = new MyIterator(subjects);
	while(iter.hasNext()) {
		String subject = iter.next();
		subjects.remove(subject);
	}
}

会发现drop函数并不能完全把subjects的内容清空。

这不仅仅是我们的Iterator的错,java内置的iterator也是如此:

for(String subject : subjects) {
	subjects.remove(subject);
}

当然这么写,java编译器会抛出错误的。

当然,java也给出了相应的解决办法:

Iterator iter = subjects.iterator();
while(iter.hasNext()) {
	String subject = iter.next();
	iter.remove(subject);
}

这种做法是比较好的,iter的remove知道它删除的元素位置,而subjects的remove还要去遍历。
当然这种做法也是不完善的,它并不能通知同样使用着subjects的其他迭代器说subjects已经被改变了。

即便可变对象可以使我们获取方便和更高的效率,但是往往在维护着可变对象的时候,需要花费多几倍的功夫,让代码架构变得不再“可变”。

3. Useful immutable types

由于不可变对象可以避免以上的陷阱,所以java鼓励使用不可变对象(immutable object)。不可变对象一般有着这些优点:

  • 避免不知名bugs
  • 容易理解
  • 代码架构易于改变
  • 不可变对象可以安全地使用引用传递
  • 线程安全

不可变对象还有一些实现上特性:

  • copy-on-write
    写入时复制(copy-on-write)是一种资源管理技巧。C++的std::string就有用到copy-on-write来优化性能。copy-on-write很好地融合了不可变对象和可变对象的优点:当对线复制的时候,仅创建新的一个引用来指向原本的对象;而只当用户改变的时候才为新的对象开辟新的内存空间。这样用户既能“修改”他们的对象,同时在不修改的时候,保持不可变对象的节省空间和效率优势。
    然而在比较新的C++11中(GCC 5开始),std::string开始放弃COW策略。原因是COW在并发时表现不好:
    const string empty("");
    vector<string> v;
    
    #pragma omp parallel for
    for( size_t i = 0, n = v.size(); i < n; ++i ) {
        v[i] = empty;
    }
    
    例如对这段代码,每一个迭代器都要重写空值,那么它将运行的很慢,而且cache的命中率也会下降。除此之外,COW还会在修改后导致原先的迭代器和引用失效,无法安全地同时执行迭代器和元素访问操作。
  • String interning
    Interning是java的string主要特点,诸如python,php(5.4以上),c#,lisp等都是采用Iterning来存储string语言。而java的装箱转换(Boxing Conversion)也同样使用了Interning技术。熟悉java的人都应该知道string的机制:它们会对不同的string值只存储一次,存储在string intern pool中,并且不能被修改。Iterning使得string处理更具有效率(譬如字符串比较),减少内存的使用量,而代价是在创建的时候消耗更多的时间。
    往往Interning在多线程编程中需要花费更多的精力来维护string intern pool的同步操作,一般是在创建和修改的时候;另一方面,string intern pool的内存一般不会手动回收,所以此时要通过GC机制自动回收,所以一般String interning用于带有GC的语言。

你可能感兴趣的:(编程)