前言

我们在写并发程序的时候,一个非常常见的需求就是保证在某一个时刻只有一个线程执行某段代码,像这种代码叫做临界区,而通常保证一个时刻只有一个线程执行临界区的代码的方法就是锁。在本篇文章当中我们将会仔细分析和学习自旋锁,所谓自旋锁就是通过while循环实现的,让拿到锁的线程进入临界区执行代码,让没有拿到锁的线程一直进行while死循环,这其实就是线程自己“旋”在while循环了,因而这种锁就叫做自旋锁。

自旋锁

原子性

在谈自旋锁之前就不得不谈原子性了。所谓原子性简单说来就是一个一个操作要么不做要么全做,全做的意思就是在操作的过程当中不能够被中断,比如说对变量data进行加一操作,有以下三个步骤:

  • data从内存加载到寄存器。

  • data这个值加一。

  • 将得到的结果写回内存。

原子性就表示一个线程在进行加一操作的时候,不能够被其他线程中断,只有这个线程执行完这三个过程的时候其他线程才能够操作数据data

我们现在用代码体验一下,在Java当中我们可以使用AtomicInteger进行对整型数据的原子操作:

import java.util.concurrent.atomic.AtomicInteger;public class AtomicDemo {public static void main(String[] args) throws InterruptedException {AtomicInteger data = new AtomicInteger();data.set(0); // 将数据初始化位0Thread t1 = new Thread(() -> {for (int i = 0; i < 100000; i++) {data.addAndGet(1); // 对数据 data 进行原子加1操作}});Thread t2 = new Thread(() -> {for (int i = 0; i < 100000; i++) {data.addAndGet(1);// 对数据 data 进行原子加1操作}});// 启动两个线程t1.start();t2.start();// 等待两个线程执行完成t1.join();t2.join();// 打印最终的结果System.out.println(data); // 200000}}

从上面的代码分析可以知道,如果是一般的整型变量如果两个线程同时进行操作的时候,最终的结果是会小于200000。

我们现在来模拟一下一般的整型变量出现问题的过程:

  • 主内存data的初始值等于0,两个线程得到的data初始值都等于0。

  • 现在线程一将data加一,然后线程一将data的值同步回主内存,整个内存的数据变化如下:

  • 现在线程二data加一,然后将data的值同步回主内存(将原来主内存的值覆盖掉了):

我们本来希望data的值在经过上面的变化之后变成2,但是线程二覆盖了我们的值,因此在多线程情况下,会使得我们最终的结果变小。

但是在上面的程序当中我们最终的输出结果是等于20000的,这是因为给data进行+1的操作是原子的不可分的,在操作的过程当中其他线程是不能对data进行操作的。这就是原子性带来的优势。

自己动手写自旋锁

AtomicInteger类

现在我们已经了解了原子性的作用了,我们现在来了解AtomicInteger类的另外一个原子性的操作——compareAndSet,这个操作叫做比较并交换(CAS),他具有原子性。

public static void main(String[] args) {AtomicInteger atomicInteger = new AtomicInteger();atomicInteger.set(0);atomicInteger.compareAndSet(0, 1);}

compareAndSet函数的意义:首先会比较第一个参数(对应上面的代码就是0)和atomicInteger的值,如果相等则进行交换,也就是将atomicInteger的值设置为第二个参数(对应上面的代码就是1),如果这些操作成功,那么compareAndSet函数就返回true,如果操作失败则返回false,操作失败可能是因为第一个参数的值(期望值)和atomicInteger不相等,如果相等也可能因为在更改atomicInteger的值的时候失败(因为可能有多个线程在操作,因为原子性的存在,只能有一个线程操作成功)。

自旋锁实现原理

我们可以使用AtomicInteger类实现自旋锁,我们可以用0这个值表示未上锁,1这个值表示已经上锁了。

  • AtomicInteger类的初始值为0。

  • 在上锁时,我们可以使用代码atomicInteger.compareAndSet(0, 1)进行实现,我们在前面已经提到了只能够有一个线程完成这个操作,也就是说只能有一个线程调用这行代码然后返回true其余线程都返回false,这些返回false的线程不能够进入临界区,因此我们需要这些线程停在atomicInteger.compareAndSet(0, 1)这行代码不能够往下执行,我们可以使用while循环让这些线程一直停在这里while (!value.compareAndSet(0, 1));,只有返回true的线程才能够跳出循环,其余线程都会一直在这里循环,我们称这种行为叫做自旋,这种锁因而也被叫做自旋锁

  • 线程在出临界区的时候需要重新将锁的状态调整为未上锁的上状态,我们使用代码value.compareAndSet(1, 0);就可以实现,将锁的状态还原为未上锁的状态,这样其他的自旋的线程就可以拿到锁,然后进入临界区了。

自旋锁代码实现

import java.util.concurrent.atomic.AtomicInteger;public class SpinLock {// 0 表示未上锁状态// 1 表示上锁状态protected AtomicInteger value;public SpinLock() {this.value = new AtomicInteger();// 设置 value 的初始值为0 表示未上锁的状态this.value.set(0);}public void lock() {// 进行自旋操作while (!value.compareAndSet(0, 1));}public void unlock() {// 将锁的状态设置为未上锁状态value.compareAndSet(1, 0);}}

上面就是我们自己实现的自旋锁的代码,这看起来实在太简单了,但是它确实帮助我们实现了一个锁,而且能够在真实场景进行使用的,我们现在用代码对上面我们写的锁进行测试。

测试程序:

public class SpinLockTest {public static int data;public static SpinLock lock = new SpinLock();public static void add() {for (int i = 0; i < 100000; i++) {// 上锁 只能有一个线程执行 data++ 操作 其余线程都只能进行while循环lock.lock();data++;lock.unlock();}}public static void main(String[] args) throws InterruptedException {Thread[] threads = new Thread[100];// 设置100个线程for (int i = 0; i < 100; i ++) {threads[i] = new Thread(SpinLockTest::add);}// 启动一百个线程for (int i = 0; i < 100; i++) {threads[i].start();}// 等待这100个线程执行完成for (int i = 0; i < 100; i++) {threads[i].join();}System.out.println(data); // 10000000}}

在上面的代码单中,我们使用100个线程,然后每个线程循环执行100000data++操作,上面的代码最后输出的结果是10000000,和我们期待的结果是相等的,这就说明我们实现的自旋锁是正确的。

自己动手写可重入自旋锁

可重入自旋锁

在上面实现的自旋锁当中已经可以满足一些我们的基本需求了,就是一个时刻只能够有一个线程执行临界区的代码。但是上面的的代码并不能够满足重入的需求,也就是说上面写的自旋锁并不是一个可重入的自旋锁,事实上在上面实现的自旋锁当中重入的话就会产生死锁。

我们通过一份代码来模拟上面重入产生死锁的情况:

public static void add(int state) throws InterruptedException {TimeUnit.SECONDS.sleep(1);if (state <= 3) {lock.lock();System.out.println(Thread.currentThread().getName() + "\t进入临界区 state = " + state);for (int i = 0; i < 10; i++)data++;add(state + 1); // 进行递归重入 重入之前锁状态已经是1了 因为这个线程进入了临界区lock.unlock();}}
  • 在上面的代码当中加入我们传入的参数state的值为1,那么在线程执行for循环之后再次递归调用add函数的话,那么state的值就变成了2。

  • if条件仍然满足,这个线程也需要重新获得锁,但是此时锁的状态是1,这个线程已经获得过一次锁了,但是自旋锁期待的锁的状态是0,因为只有这样他才能够再次获得锁,进入临界区,但是现在锁的状态是1,也就是说虽然这个线程获得过一次锁,但是它也会一直进行while循环而且永远都出不来了,这样就形成了死锁了。

可重入自旋锁思想

针对上面这种情况我们需要实现一个可重入的自旋锁,我们的思想大致如下:

  • 在我们实现的自旋锁当中,我们可以增加两个变量,owner一个用于存当前拥有锁的线程,count一个记录当前线程进入锁的次数。

  • 如果线程获得锁,owner = Thread.currentThread()并且count = 1

  • 当线程下次再想获取锁的时候,首先先看owner是不是指向自己,则一直进行循环操作,如果是则直接进行count++操作,然后就可以进入临界区了。

  • 我们在出临界区的时候,如果count大于一的话,说明这个线程重入了这把锁,因此不能够直接将锁设置为0也就是未上锁的状态,这种情况直接进行count--操作,如果count等于1的话,说明线程当前的状态不是重入状态(可能是重入之后递归返回了),因此在出临界区之前需要将锁的状态设置为0,也就是没上锁的状态,好让其他线程能够获取锁。

可重入锁代码实现:

实现的可重入锁代码如下:

public class ReentrantSpinLock extends SpinLock {private Thread owner;private int count;@Overridepublic void lock() {if (owner == null || owner != Thread.currentThread()) {while (!value.compareAndSet(0, 1));owner = Thread.currentThread();count = 1;}else {count++;}}@Overridepublic void unlock() {if (count == 1) {count = 0;value.compareAndSet(1, 0);}elsecount--;}}

下面我们通过一个递归程序去验证我们写的可重入的自旋锁是否能够成功工作。

测试程序:

import java.util.concurrent.TimeUnit;public class ReentrantSpinLockTest {public static int data;public static ReentrantSpinLock lock = new ReentrantSpinLock();public static void add(int state) throws InterruptedException {TimeUnit.SECONDS.sleep(1);if (state <= 3) {lock.lock();System.out.println(Thread.currentThread().getName() + "\t进入临界区 state = " + state);for (int i = 0; i < 10; i++)data++;add(state + 1);lock.unlock();}}public static void main(String[] args) throws InterruptedException {Thread[] threads = new Thread[10];for (int i = 0; i < 10; i++) {threads[i] = new Thread(new Thread(() -> {try {ReentrantSpinLockTest.add(1);} catch (InterruptedException e) {e.printStackTrace();}}, String.valueOf(i)));}for (int i = 0; i < 10; i++) {threads[i].start();}for (int i = 0; i < 10; i++) {threads[i].join();}System.out.println(data);}}

上面程序的输出:

Thread-3	进入临界区 state = 1Thread-3	进入临界区 state = 2Thread-3	进入临界区 state = 3Thread-0	进入临界区 state = 1Thread-0	进入临界区 state = 2Thread-0	进入临界区 state = 3Thread-9	进入临界区 state = 1Thread-9	进入临界区 state = 2Thread-9	进入临界区 state = 3Thread-4	进入临界区 state = 1Thread-4	进入临界区 state = 2Thread-4	进入临界区 state = 3Thread-7	进入临界区 state = 1Thread-7	进入临界区 state = 2Thread-7	进入临界区 state = 3Thread-8	进入临界区 state = 1Thread-8	进入临界区 state = 2Thread-8	进入临界区 state = 3Thread-5	进入临界区 state = 1Thread-5	进入临界区 state = 2Thread-5	进入临界区 state = 3Thread-2	进入临界区 state = 1Thread-2	进入临界区 state = 2Thread-2	进入临界区 state = 3Thread-6	进入临界区 state = 1Thread-6	进入临界区 state = 2Thread-6	进入临界区 state = 3Thread-1	进入临界区 state = 1Thread-1	进入临界区 state = 2Thread-1	进入临界区 state = 3300

从上面的输出结果我们就可以知道,当一个线程能够获取锁的时候他能够进行重入,而且最终输出的结果也是正确的,因此验证了我们写了可重入自旋锁是有效的!

总结

在本篇文章当中主要给大家介绍了自旋锁和可重入自旋锁的原理,并且实现了一遍,其实代码还是比较简单关键需要大家将这其中的逻辑理清楚:

  • 所谓自旋锁就是通过while循环实现的,让拿到锁的线程进入临界区执行代码,让没有拿到锁的线程一直进行while死循环。

  • 可重入的含义就是一个线程已经竞争到了一个锁,在竞争到这个锁之后又一次有重入临界区代码的需求,如果能够保证这个线程能够重新进入临界区,这就叫可重入。

  • 我们在实现自旋锁的时候使用的是AtomicInteger类,并且我们使用0和1这两个数值用于表示无锁和锁被占用两个状态,在获取锁的时候使用while循环不断进行CAS操作,直到操作成功返回true,在释放锁的时候使用CAS将锁的状态从1变成0。

  • 实现可重入锁最重要的一点就是需要记录是那个线程获得了锁,同时还需要记录获取了几次锁,因为我们在解锁的时候需要进行判断,之后count = 1的情况才能将锁的状态从1设置成0。


以上就是本篇文章的所有内容了,我是LeHung,我们下期再见!!!更多精彩内容合集可访问项目:https://github.com/Chang-LeHung/CSCore

一、面向对象与面向过程的区别

面向过程就是分析出解决问题所需要的步骤,然后用函数把这些步骤一步一步实现,使用的时候一个一个依次调用就可以了;

面向对象是把构成问题事务分解成各个对象,建立对象的目的不是为了完成一个步骤,而是为了描叙某个事物在整个解决问题的步骤中的行为。

可以拿生活中的实例来理解面向过程与面向对象,例如五子棋,面向过程的设计思路就是首先分析问题的步骤:

1、开始游戏,

2、黑子先走,

3、绘制画面,

4、判断输赢,

5、轮到白子,

6、绘制画面,

7、判断输赢,

8、返回步骤2,

9、输出最后结果。

把上面每个步骤用不同的方法来实现。

如果是面向对象的设计思想来解决问题 面向对象的设计则是从另外的思路来解决问题

整个五子棋可以分为

1、黑白双方,这两方的行为是一模一样的,

2、棋盘系统,负责绘制画面,

3、规则系统,负责判定诸如犯规、输赢等。

第一类对象(玩家对象)负责接受用户输入,并告知第二类对象(棋盘对象)棋子布局的变化,棋盘对象接收到了棋子的变化就要负责在屏幕上面显示出这种变化,同时利用第三类对象(规则系统)来对棋局进行判定。

可以明显地看出,面向对象是以功能来划分问题,而不是步骤。

同样是绘制棋局,这样的行为在面向过程的设计中分散在了多个步骤中,很可能出现不同的绘制版本,因为通常设计人员会考虑到实际情况进行各种各样的简化。

而面向对象的设计中,绘图只可能在棋盘对象中出现,从而保证了绘图的统一。

上述的内容是从网上查到的,觉得这个例子非常的生动形象,我就写了下来,现在就应该理解了他俩的区别了吧,其实就是两句话,面向对象就是高度实物抽象化、面向过程就是自顶向下的编程!

二、面向对象的特点

在了解其特点之前,咱们先谈谈对象,对象就是现实世界存在的任何事务都可以称之为对象,有着自己独特的个性

属性用来描述具体某个对象的特征。

比如小志身高180M,体重70KG,这里身高、体重都是属性。

面向对象的思想就是把一切都看成对象,而对象一般都由属性+方法组成!

属性属于对象静态的一面,用来形容对象的一些特性,方法属于对象动态的一面,咱们举一个例子,小明会跑,会说话,跑、说话这些行为就是对象的方法!所以为动态的一面, 我们把属性和方法称为这个对象的成员!

类:具有同种属性的对象称为类,是个抽象的概念。

比如“人”就是一类,期中有一些人名,比如小明、小红、小玲等等这些都是对象,类就相当于一个模具,他定义了它所包含的全体对象的公共特征和功能,对象就是类的一个实例化,小明就是人的一个实例化!

我们在做程序的时候,经常要将一个变量实例化,就是这个原理!

我们一般在做程序的时候一般都不用类名的,比如我们在叫小明的时候,不会喊“人,你干嘛呢!”而是说的是“小明,你在干嘛呢!”

面向对象有三大特性,分别是封装性、继承性和多态性,这里小编不给予太多的解释,因为在后边的博客会专门总结的!

三、面向过程与面向对象的优缺点

很多资料上全都是一群很难理解的理论知识,整的小编头都大了,后来发现了一个比较好的文章,写的真是太棒了,通俗易懂,想要不明白都难!

用面向过程的方法写出来的程序是一份蛋炒饭,而用面向对象写出来的程序是一份盖浇饭。

所谓盖浇饭,北京叫盖饭,东北叫烩饭,广东叫碟头饭,就是在一碗白米饭上面浇上一份盖菜,你喜欢什么菜,你就浇上什么菜。

我觉得这个比喻还是比较贴切的。

蛋炒饭制作的细节,我不太清楚,因为我没当过厨师,也不会做饭,但最后的一道工序肯定是把米饭和鸡蛋混在一起炒匀。

盖浇饭呢,则是把米饭和盖菜分别做好,你如果要一份红烧肉盖饭呢,就给你浇一份红烧肉;

如果要一份青椒土豆盖浇饭,就给浇一份青椒土豆丝。

蛋炒饭的好处就是入味均匀,吃起来香。

如果恰巧你不爱吃鸡蛋,只爱吃青菜的话,那么唯一的办法就是全部倒掉,重新做一份青菜炒饭了。

盖浇饭就没这么多麻烦,你只需要把上面的盖菜拨掉,更换一份盖菜就可以了。

盖浇饭的缺点是入味不均,可能没有蛋炒饭那么香。

到底是蛋炒饭好还是盖浇饭好呢?其实这类问题都很难回答,非要比个上下高低的话,就必须设定一个场景,否则只能说是各有所长。

如果大家都不是美食家,没那么多讲究,那么从饭馆角度来讲的话,做盖浇饭显然比蛋炒饭更有优势,他可以组合出来任意多的组合,而且不会浪费。

盖浇饭的好处就是"菜"“饭"分离,从而提高了制作盖浇饭的灵活性。

饭不满意就换饭,菜不满意换菜。用软件工程的专业术语就是"可维护性"比较好,“饭” 和"菜"的耦合度比较低。

蛋炒饭将"蛋”“饭"搅和在一起,想换"蛋”"饭"中任何一种都很困难,耦合度很高,以至于"可维护性"比较差。

软件工程追求的目标之一就是可维护性,可维护性主要表现在3个方面:可理解性、可测试性和可修改性。

面向对象的好处之一就是显著的改善了软件系统的可维护性。

看了这篇文章,简单的总结一下!

面向过程

优点:性能比面向对象高,因为类调用时需要实例化,开销比较大,比较消耗资源;比如单片机、嵌入式开发、 Linux/Unix等一般采用面向过程开发,性能是最重要的因素。

缺点:没有面向对象易维护、易复用、易扩展

面向对象

优点:易维护、易复用、易扩展,由于面向对象有封装、继承、多态性的特性,可以设计出低耦合的系统,使系统 更加灵活、更加易于维护

缺点:性能比面向过程低


图片

单例模式(Singleton Pattern) 是一种常用的软件设计模式,该模式的主要目的是确保某一个类只有一个实例存在。当你希望在整个系统中,某个类只能出现一个实例时,单例对象就能派上用场。

比如,某个服务器程序的配置信息存放在一个文件中,客户端通过一个 AppConfig 的类来读取配置文件的信息。如果在程序运行期间,有很多地方都需要使用配置文件的内容,也就是说,很多地方都需要创建 AppConfig 对象的实例,这就导致系统中存在多个 AppConfig 的实例对象,而这样会严重浪费内存资源,尤其是在配置文件内容很多的情况下。

事实上,类似 AppConfig 这样的类,我们希望在程序运行期间只存在一个实例对象。
在 Python 中,我们可以用多种方法来实现单例模式:

  1. 使用模块
  2. 使用装饰器
  3. 使用类
  4. 基于 __new__ 方法实现
  5. 基于 metaclass 方式实现

下面来详细介绍:

使用模块
其实,Python 的模块就是天然的单例模式,因为模块在第一次导入时,会生成 .pyc 文件,当第二次导入时,就会直接加载 .pyc 文件,而不会再次执行模块代码。

因此,我们只需把相关的函数和数据定义在一个模块中,就可以获得一个单例对象了。

如果我们真的想要一个单例类,可以考虑这样做:

class Singleton(object):
    def foo(self):
        pass
singleton = Singleton()

将上面的代码保存在文件 mysingleton.py 中,要使用时,直接在其他文件中导入此文件中的对象,这个对象即是单例模式的对象

from mysingleton import singleton

使用装饰器

def Singleton(cls):
    _instance = {}

    def _singleton(*args, **kargs):
        if cls not in _instance:
            _instance[cls] = cls(*args, **kargs)
        return _instance[cls]

    return _singleton


@Singleton
class A(object):
    a = 1

    def __init__(self, x=0):
        self.x = x


a1 = A(2)
a2 = A(3)

使用类

class Singleton(object):

    def __init__(self):
        pass

    @classmethod
    def instance(cls, *args, **kwargs):
        if not hasattr(Singleton, "_instance"):
            Singleton._instance = Singleton(*args, **kwargs)
        return Singleton._instance

一般情况,大家以为这样就完成了单例模式,但是当使用多线程时会存在问题:

class Singleton(object):

    def __init__(self):
        pass

    @classmethod
    def instance(cls, *args, **kwargs):
        if not hasattr(Singleton, "_instance"):
            Singleton._instance = Singleton(*args, **kwargs)
        return Singleton._instance

import threading

def task(arg):
    obj = Singleton.instance()
    print(obj)

for i in range(10):
    t = threading.Thread(target=task,args=[i,])
    t.start()

程序执行后,打印结果如下:

<__main__.Singleton object at 0x02C933D0>
<__main__.Singleton object at 0x02C933D0>
<__main__.Singleton object at 0x02C933D0>
<__main__.Singleton object at 0x02C933D0>
<__main__.Singleton object at 0x02C933D0>
<__main__.Singleton object at 0x02C933D0>
<__main__.Singleton object at 0x02C933D0>
<__main__.Singleton object at 0x02C933D0>
<__main__.Singleton object at 0x02C933D0>
<__main__.Singleton object at 0x02C933D0>

看起来也没有问题,那是因为执行速度过快,如果在 __init__ 方法中有一些 IO 操作,就会发现问题了。

下面我们通过 time.sleep 模拟,我们在上面 __init__ 方法中加入以下代码:

def __init__(self):
    import time
    time.sleep(1)

重新执行程序后,结果如下:

<__main__.Singleton object at 0x034A3410>
<__main__.Singleton object at 0x034BB990>
<__main__.Singleton object at 0x034BB910>
<__main__.Singleton object at 0x034ADED0>
<__main__.Singleton object at 0x034E6BD0>
<__main__.Singleton object at 0x034E6C10>
<__main__.Singleton object at 0x034E6B90>
<__main__.Singleton object at 0x034BBA30>
<__main__.Singleton object at 0x034F6B90>
<__main__.Singleton object at 0x034E6A90>

问题出现了!按照以上方式创建的单例,无法支持多线程。

解决办法:加锁!未加锁部分并发执行,加锁部分串行执行,速度降低,但是保证了数据安全。

import time
import threading


class Singleton(object):
    _instance_lock = threading.Lock()

    def __init__(self):
        time.sleep(1)

    @classmethod
    def instance(cls, *args, **kwargs):
        with Singleton._instance_lock:
            if not hasattr(Singleton, "_instance"):
                Singleton._instance = Singleton(*args, **kwargs)
        return Singleton._instance


def task(arg):
    obj = Singleton.instance()
    print(obj)
    

for i in range(10):
    t = threading.Thread(target=task,args=[i,])
    t.start()


time.sleep(20)
obj = Singleton.instance()
print(obj)

打印结果如下:

<__main__.Singleton object at 0x02D6B110>
<__main__.Singleton object at 0x02D6B110>
<__main__.Singleton object at 0x02D6B110>
<__main__.Singleton object at 0x02D6B110>
<__main__.Singleton object at 0x02D6B110>
<__main__.Singleton object at 0x02D6B110>
<__main__.Singleton object at 0x02D6B110>
<__main__.Singleton object at 0x02D6B110>
<__main__.Singleton object at 0x02D6B110>
<__main__.Singleton object at 0x02D6B110>

这样就差不多了,但是还是有一点小问题,就是当程序执行时,执行了 time.sleep(20) 后,下面实例化对象时,此时已经是单例模式了。

但我们还是加了锁,这样不太好,再进行一些优化,把 intance 方法,改成下面这样就行:

@classmethod
def instance(cls, *args, **kwargs):
    if not hasattr(Singleton, "_instance"):
        with Singleton._instance_lock:
            if not hasattr(Singleton, "_instance"):
                Singleton._instance = Singleton(*args, **kwargs)
    return Singleton._instance

这样,一个可以支持多线程的单例模式就完成了。+

import time
import threading


class Singleton(object):
    _instance_lock = threading.Lock()

    def __init__(self):
        time.sleep(1)

    @classmethod
    def instance(cls, *args, **kwargs):
        if not hasattr(Singleton, "_instance"):
            with Singleton._instance_lock:
                if not hasattr(Singleton, "_instance"):
                    Singleton._instance = Singleton(*args, **kwargs)
        return Singleton._instance


def task(arg):
    obj = Singleton.instance()
    print(obj)
    
    
for i in range(10):
    t = threading.Thread(target=task,args=[i,])
    t.start()
    
    
time.sleep(20)
obj = Singleton.instance()
print(obj)

这种方式实现的单例模式,使用时会有限制,以后实例化必须通过 obj = Singleton.instance()

如果用 obj = Singleton(),这种方式得到的不是单例。

基于 __new__ 方法实现

通过上面例子,我们可以知道,当我们实现单例时,为了保证线程安全需要在内部加入锁。

我们知道,当我们实例化一个对象时,是先执行了类的 __new__ 方法(我们没写时,默认调用 object.__new__),实例化对象;然后再执行类的 __init__ 方法,对这个对象进行初始化,所有我们可以基于这个,实现单例模式。

import threading


class Singleton(object):
    _instance_lock = threading.Lock()

    def __init__(self):
        pass


    def __new__(cls, *args, **kwargs):
        if not hasattr(Singleton, "_instance"):
            with Singleton._instance_lock:
                if not hasattr(Singleton, "_instance"):
                    Singleton._instance = object.__new__(cls)  
        return Singleton._instance

obj1 = Singleton()
obj2 = Singleton()
print(obj1,obj2)

def task(arg):
    obj = Singleton()
    print(obj)

for i in range(10):
    t = threading.Thread(target=task,args=[i,])
    t.start()

打印结果如下:

<__main__.Singleton object at 0x038B33D0> <__main__.Singleton object at 0x038B33D0>
<__main__.Singleton object at 0x038B33D0>
<__main__.Singleton object at 0x038B33D0>
<__main__.Singleton object at 0x038B33D0>
<__main__.Singleton object at 0x038B33D0>
<__main__.Singleton object at 0x038B33D0>
<__main__.Singleton object at 0x038B33D0>
<__main__.Singleton object at 0x038B33D0>
<__main__.Singleton object at 0x038B33D0>
<__main__.Singleton object at 0x038B33D0>
<__main__.Singleton object at 0x038B33D0>

采用这种方式的单例模式,以后实例化对象时,和平时实例化对象的方法一样 obj = Singleton() 。

基于 metaclass 方式实现

相关知识:

  1. 类由 type 创建,创建类时,type 的 __init__ 方法自动执行,类() 执行 type 的 __call__ 方法(类的 __new__ 方法,类的 __init__ 方法)
  2. 对象由类创建,创建对象时,类的 __init__ 方法自动执行,对象()执行类的 __call__ 方法

例子:

class Foo:
    def __init__(self):
        pass

    def __call__(self, *args, **kwargs):
        pass

obj = Foo()
# 执行type的 __call__ 方法,调用 Foo类(是type的对象)的 __new__方法,用于创建对象,然后调用 Foo类(是type的对象)的 __init__方法,用于对对象初始化。

obj()    # 执行Foo的 __call__ 方法    

元类的使用:

class SingletonType(type):
    def __init__(self,*args,**kwargs):
        super(SingletonType,self).__init__(*args,**kwargs)

    def __call__(cls, *args, **kwargs): # 这里的cls,即Foo类
        print('cls',cls)
        obj = cls.__new__(cls,*args, **kwargs)
        cls.__init__(obj,*args, **kwargs) # Foo.__init__(obj)
        return obj

class Foo(metaclass=SingletonType): # 指定创建Foo的type为SingletonType
    def __init__(self,name):
        self.name = name
    def __new__(cls, *args, **kwargs):
        return object.__new__(cls)

obj = Foo('xx')

实现单例模式:

import threading

class SingletonType(type):
    _instance_lock = threading.Lock()
    def __call__(cls, *args, **kwargs):
        if not hasattr(cls, "_instance"):
            with SingletonType._instance_lock:
                if not hasattr(cls, "_instance"):
                    cls._instance = super(SingletonType,cls).__call__(*args, **kwargs)
        return cls._instance

class Foo(metaclass=SingletonType):
    def __init__(self,name):
        self.name = name


obj1 = Foo('name')
obj2 = Foo('name')
print(obj1,obj2)

作者:听风

https://www.cnblogs.com/huchong/p/8244279.html


PV-UV-IP 与埋点技术实现

前言

埋点技术在很多流量较大的应用中经常使用到的热门技术,旨在通过在用户进行操作或者请求的时候,记录下这些操作,然后针对记录的操作数量、操作频率才重新设计产品,提高用户体验,而埋点技术在前端的实现相比起后端来说也较为多见,这是因为用户的操作并不一定会带来http请求,而且前端SPA盛行后后端很难监测后用户对页面浏览的情况,所以重担当然就压在前端身上来。

小插曲:其实在大部分的功能性应用中,通过对各功能模块的埋点监测,Banner轮播图的用户访问率非常低,也就是所Banner的存在大部分情况下只是一个好看的装饰品,这一点也引发了大量产品经理的争论。

PV、UV、IP在网站运营和管理中是非常常见的3个Metric,也是产品经理心心念念每天茶不思饭不想的三个宝贝,对于应用开发者来说,也必然会接到相关的开发需求,那么待会儿就会来聊一下这三个小宝贝。


什么是PV、UV、IP

PV(Page View)访问量, 即页面浏览量或点击量,衡量网站用户访问的网页数量;在一定统计周期内用户每打开或刷新一个页面就记录1次,多次打开或刷新同一页面则浏览量累计。 说白了就是统计一下某些页面在一段时间比如一天内被访问了多少次,哪怕是同一个用户访问多次也没关系,说不定这个用户就是特别钟爱这个页面呢?所以重复访问是计算为有效的。


image.pngUV(Unique Visitor)独立访客,统计1天内访问某站点的用户数(以cookie为依据);访问网站的一台电脑客户端为一个访客。可以理解成访问某网站的电脑的数量。网站判断来访电脑的身份是通过来访电脑的cookies实现的。如果更换了IP后但不清除cookies,再访问相同网站,该网站的统计中UV数是不变的。如果用户不保存cookies访问、清除了cookies或者更换设备访问,计数会加1。00:00-24:00内相同的客户端多次访问只计为1个访客。 说白了就是根据用户登陆后所记录的cookie(来源可能是session或者token)来标识一个用户,以统计有多少用户访问应用。



image.png


IP(Internet Protocol)独立IP数,是指1天内多少个独立的IP浏览了页面,即统计不同的IP浏览用户数量。同一IP不管访问了几个页面,独立IP数均为1;不同的IP浏览页面,计数会加1。 IP是基于用户广域网IP地址来区分不同的访问者的,所以,多个用户(多个局域网IP)在同一个路由器(同一个广域网IP)内上网,可能被记录为一个独立IP访问者。如果用户不断更换IP,则有可能被多次统计。 说白了就是根据用户的IP来标识一个用户,以统计有多少用户访问应用。


误差来自于哪里

但是PV、UV和IP的统计方式都是会有误差的。

例如统计PV的时候,来源不明的水军可能会利用脚本不断的重复访问某个网页,使得PV数不正常激增,此时PV > 真实数据。

统计UV的时候,如果有个用户不断清除cookie或者换了很多台设备来访问的话,那么这个用户会被统计多次,此时 UV > 真实数据。 如果多个使用者共用一个账号和同一个设备的时候,此时UV < 真实数据

统计IP的时候,如果用户切换了手机的网络模式(4g -> wifi)此时IP > 真实数据,多个使用者共用同一个设备的时候,此时IP < 真实数据。 ** 也就是误差总是存在的,在数据量较多,统计周期较长并明显存在某些规律的情况下,其实这些误差也是可以忽略的。


技术实现

触发时机

其实无论是PV、UV的统计(IP的统计一般后端可以独立完成,不需要前端的参与),还是埋点技术,说穿了就是要在合适的时机向后端发送一个合适的请求。

什么时机才算是合适的时机呢?这也要具体情况具体分析,比如,PV量统计一般是在路由跳转监听中进行,我们可以在一个全局性的路由钩子中实现。而UV的统计则依赖后端多一些,前端只需要把种好的cookie信息再携带到任意请求的请求体中即可,后端来进行过滤筛选。

埋点技术场景则复杂一些,比如在某些按钮的点击操作中,滚动条的监听事件处理程序中等等。

如何发送请求

PV、UV以及埋点中请求的时机各有不同,发送请求的方式其实也会有选择的空间。

常见的请求的方式例如使用ajax或者fetch来发送GET/POST请求当然可以解决需要,但是这样的方式往往消耗比较大,相应速度也会较慢,好处在于传输到后端的数据可以携带的稍微多一些,请求回来的数据一般都是JSON格式的数据,处理起来也很方便。

但是用Ajax或者fetch的话,很可能带来跨域的问题,因为有的时候记录埋点数据和PV、UV数据的服务器是与应用服务器分离的。

而且其实很多情况下前端在发送此类请求的时候,并不会传递很多信息,往往只是几个简单的query params字段,而且也并不期待服务端的返回信息来使用。

所以真正使用场景较多的实现方式是以img请求的方式来进行的。

Image Beacon技术

上面收到大部分情况下使用img请求的方式是最可行的,那么是为什么呢?具体要怎么实现呢?

首先,利用Image Beacon是不会碰到跨域问题的,浏览器的安全级别限制不针对这个。

而且请求体的体积较小,请求速度较快,网络资源消耗较少。

既然如此,那为什么不选择其他类型文件的请求例如JS文件、CSS文件或者TTF字体类的文件呢?

是因为JS等类型的文件必须要插入到文档树中浏览器才会发送请求,很可能带来渲染的成本而且有可能会阻塞文档树的渲染,但是图片请求则不然,构造图片打点不仅不用插入DOM,只要在js中new出Image对象就能发起请求,而且还没有阻塞问题,在没有js的浏览器环境中也能通过img标签正常打点,这是其他类型的资源请求所做不到的。

说到这里,还有最后一个选择要做,就是应该使用什么格式的Img呢?

首先,1x1像素是最小的合法图片。而且,因为是通过图片打点,所以图片最好是透明的,图片透明只要使用一个二进制位标记图片是透明色即可,不用存储色彩空间数据,可以节约体积。

那么BMP、PNG和GIF格式都支持透明格式,我们要选择哪种呢?答案是GIF格式,据统计,最小的BMP文件需要74个字节,PNG需要67个字节,而合法的GIF,只需要43个字节,体积小请求成本当前就会较低。

最后总结一下为什么此类请求最佳方案是使用GIF格式的1 * 1尺寸透明图片呢?

  1. 能够完成整个 HTTP 请求+响应(尽管不需要响应内容)

  2. 触发 GET 请求之后不需要获取和处理数据、服务器也不需要发送数据

  3. 跨域友好

  4. 执行过程无阻塞

  5. 相比 XMLHttpRequest 对象发送 GET 请求,性能上更好

  6. GIF的最低合法体积最小(最小的BMP文件需要74个字节,PNG需要67个字节,而合法的GIF,只需要43个字节)

最最神奇的是,其实后端都不需要返回前端这个1 * 1的图片,因为前端根本不需要将其渲染到body中,所以后端返回204 -- Not Content都没啥关系,因为我们的目标只有发送请求,请求发送之后,其他的都可以不计较了。


项目中的具体应用

接下来我们来看一下在项目中要怎么去实现PV、UV的统计以及埋点,在这里我以Vue项目为例。

封装beacon动作方法

/**  * @module beacon实现模块 */ import Qs from 'querystring' /**  * @function 具体的beacon动作  * @param {String} apiUrl 发送的接口请求  * @param {Object} params 具体发送的数据  * @example beaconAction('/api/recored', { url: '...' }).then((res) => {}).catch((error) => {})  */ export const beaconAction = (apiUrl, params) => {   /** 如果参数不是字符串则转换为query-string  */   let _params = typeof params === 'string' ? params : Qs.stringify(params)   /** 创建Image对象来发送请求  */   let img = new Image(1, 1)   let src = `${apiUrl}?${_params}`   img.src = src   /** 其实并不需要将此图片append到body中,请求此时已经发送,目的已经达成了  */   /** 利用load和error事件来监听动作的完成,返回Promise便于操作  */   return new Promise((resolve, reject) => {     img.onload = function () {       resolve({ code: 200, data: 'success!' })     }     img.onerror = function (e) {       reject(new Error(e.error))     }   }) } 复制代码

在路由监听中记录PV、UV

/**  * PV/UV记录  */ router.afterEach((to, from) => {   const path = to.path   /** 如果开启了登陆权限验证 */   if (process.env.AUTH_ENABLED) {     /** 除了登陆界面,其他路由界面都请求记录 */     if (to.path !== '/login') {       pathBeaconAction(path)     }   } }) 复制代码

Vue中实现埋点

在vue中推荐大家使用自定义指令来实现埋点操作,封装好合适的自定义指令后,就可以在需要埋点的dom或者组件上通过非常轻松的指令设置来实现埋点效果。

在这里推荐大家使用一个第三方指令** v-track** 来完成需求,大家可以移步官网来查看详细的使用说明。

使用方式:

<!-- 页面行为埋点(track-view为v-track全局注册的组件) --> <track-view v-track:18015></track-view> <track-view v-track:18015.watch="{ rest }"></track-view> <track-view v-track:18015.watch.delay="{ rest }"></track-view> <track-view v-if="rest" v-track:18015></track-view>   <!-- 事件行为埋点(DOM) --> <div v-track:18015.click="handleClick"></div> <div v-track:18015.click="{ handleClick, item, index }"></div> <div v-track:18015.click.async="{ handleSearch, rest }"></div> <div v-track:18015.click.delay="handleClick"></div>   <!-- 事件行为埋点(组件) --> <cmp v-track:18015.click="handleClick"></cmp> <cmp v-track:18015.[自定义事件名]="handleSearch"></cmp> <cmp v-track:18015.[自定义事件名].delay="handleSearch"></cmp> <cmp v-track:18015.[自定义事件名].async="{ handleSearch, rest }"></cmp>   <!-- 区域展现埋点(block 可以是 DOM 或者组件) --> <block v-track:18015.show></block> <block v-track:18015.show.once></block> <block v-track:18015.show.custom="{ ref: 'scroll' }"></block> <block v-track:18015.show.custom.once="{ ref: 'scroll' }"></block> 复制代码

修饰符说明:

  • .click 表示事件行为的埋点

  • .[custom-event] 表示自定义事件行为的埋点

  • .native 表示监听组件原生click事件行为的埋点

  • .watch 表示页面异步行为的埋点

  • .async 配合.click指令,表示异步事件行为的埋点

  • .delay 表示埋点是否延迟执行,默认先执行埋点再执行回调

  • .show 表示区域曝光埋点

  • .once 配合.show指令,只执行一次埋点

  • .custom 配合.show指令,表示使用自定义scroll事件


后语

埋点和PVUV的实现方案实在是太多了,而每一种方案都会引起很多人的讨论,在此希望诸君也能有自己的想法,欢迎来讨论。


作者:半盏屠苏
链接:https://juejin.cn/post/6844903955642728456
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。


一直以来,C++中基于值语义的拷贝和赋值严重影响了程序性能。尤其是对于资源密集型对象,如果进行大量的拷贝,势必会对程序性能造成很大的影响。为了尽可能的减小因为对象拷贝对程序的影响,开发人员使出了万般招式:尽可能的使用指针、引用。而编译器也没闲着,通过使用RVO、NRVO以及复制省略技术,来减小拷贝次数来提升代码的运行效率。

但是,对于开发人员来说,使用指针和引用不能概括所有的场景,也就是说仍然存在拷贝赋值等行为;对于编译器来说,而对于RVO、NRVO等编译器行为的优化需要满足特定的条件(具体可以参考文章编译器之返回值优化)。为了解决上述问题,自C++11起,引入了移动语义,更进一步对程序性能进行优化 。

C++11新标准重新定义了lvalue和rvalue,并允许函数依照这两种不同的类型进行重载。通过对于右值(rvalue)的重新定义,语言实现了移动语义(move semantics)和完美转发(perfect forwarding),通过这种方法,C++实现了在保留原有的语法并不改动已存在的代码的基础上提升代码性能的目的。

本文的主要内容如下图所示:

图片

值语义

值语义(value semantics)指目标对象由源对象拷贝生成,且生成后与源对象完全无关,彼此独立存在,改变互不影响,就像int类型互相拷贝一样。C++的内置类型(bool/int/double/char)都是值语义,标准库里的complex<> 、pair<>、vector<>、map<>、string等等类型也都是值语意,拷贝之后就与原对象脱离关系。

C++中基于值语义的拷贝构造和赋值拷贝,会招致对资源密集型对象不必要拷贝,大量的拷贝很可能成为程序的性能瓶颈。

首先,我们看一段例子:

BigObj fun(BigObj obj) {
  BigObj o;
  // do sth
  return o;
}

int main() {
  fun(BigObj());
  return 0;
}

在上述代码中,我们定义了一个函数fun()其参数是一个BigObj对象,当调用fun()函数时候,会通过调用BigObj的拷贝构造函数,将obj变量传递给fun()的参数。

编译器知道何时调用拷贝构造函数或者赋值运算符进行值传递。如果涉及到底层资源,比如内存、socket等,开发人在定义类的时候,需要实现自己的拷贝构造和赋值运算符以实现深拷贝。然而拷贝的代价很大,当我们使用STL容器的时候,都会涉及到大量的拷贝操作,而这些会浪费CPU和内存等资源。

正如上述代码中所示的那样,当我们将一个临时变量(BigObj(),也称为右值)传递给一个函数的时候,就会导致拷贝操作,那么我们该如何避免此种拷贝行为呢?这就是我们本文的主题:移动语义

左值、右值

关于左值、右值,我们在之前的文章中已经有过详细的分享,有兴趣的同学可以移步【Modern C++】深入理解左值、右值,在本节,我们简单介绍下左值和右值的概念,以方便理解下面的内容。

左值(lvalue,left value),顾名思义就是赋值符号左边的值。准确来说,左值是表达式结束(不一定是赋值表达式)后依然存在的对象。

可以将左值看作是一个关联了名称的内存位置,允许程序的其他部分来访问它。在这里,我们将 "名称" 解释为任何可用于访问内存位置的表达式。所以,如果 arr 是一个数组,那么 arr[1] 和 *(arr+1) 都将被视为相同内存位置的“名称”。

左值具有以下特征:

  • 可通过取地址运算符获取其地址
  • 可修改的左值可用作内建赋值和内建符合赋值运算符的左操作数
  • 可以用来初始化左值引用(后面有讲)

C++11将右值分为纯右值将亡值两种。纯右值就是C++98标准中右值的概念,如非引用返回的函数返回的临时变量值;一些运算表达式,如1+2产生的临时变量;不跟对象关联的字面量值,如2,'c',true,"hello";这些值都不能够被取地址。而将亡值则是C++11新增的和右值引用相关的表达式,这样的表达式通常是将要移动的对象、T&&函数返回值、std::move()函数的返回值等。

左值引用、右值引用

在明确了左值和右值的概念之后,我们将在本节简单介绍下左值引用和右值引用。

按照概念,对左值的引用称为左值引用,而对右值的引用称为右值引用。既然有了左值引用和右值引用,那么在C++11之前,我们通常所说的引用又是什么呢?其实就是左值引用,比如:

int a = 1;
int &b = a;

在C++11之前,我们通过会说b是对a的一个引用(当然,在C++11及以后也可以这么说,大家潜移默化的认识就是引用==左值引用),但是在C++11中,更为精确的说法是b是一个左值引用。

在C++11中,为了区分左值引用,右值引用用&&来表示,如下:

int &&a = 1// a是一个左值引用
int b = 1;
int &&c = b; // 错误,右值引用不能绑定左值

跟左值引用一样,右值引用不会发生拷贝,并且右值引用等号右边必须是右值,如果是左值则会编译出错,当然这里也可以进行强制转换,这将在后面提到。

在这里,有一个大家都经常容易犯的一个错误,就是定右值的右值引用,其变量本身是个左值。为了便于理解,代码如下:

int fun(int &a) {
  std::cout << "in fun(int &)" << std::endl;
}

int fun(int &&a) {
  std::cout << "in fun(int &)" << std::endl;
}

int main() {
  int a = 1;
  int &&b = 1;
  
  fun(b);
  
  return 0;
}

代码输出如下:

in fun(int &)

左值引用和右值引用的规则如下:

  • 左值引用,使用T&,只能绑定左值
  • 右值引用,使用T&&,只能绑定右值
  • 常量左值,使用const T&,既可以绑定左值,又可以绑定右值,但是不能对其进行修改
  • 具名右值引用,编译器会认为是个左值
  • 编译器的优化需要满足特定条件,不能过度依赖

好了,截止到目前,相信你对左值引用和右值引用的概念有了初步的认识,那么,现在我们介绍下为什么要有右值引用呢?我们看下述代码:

BigObj fun() {
  return BigObj();
}
BigObj obj = fun(); // C++11以前
BigObj &&obj = fun(); // C++11

上述代码中,在C++11之前,我们只能通过编译器优化(N)RVO的方式来提升性能,如果不满足编译器的优化条件,则只能通过拷贝等方式进行操作。自C++11引入右值引用后,对于不满足(N)RVO条件,也可以通过避免拷贝延长临时变量的生命周期,进而达到优化的目的。

但是仅仅使用右值引用还不足以完全达到优化目的,毕竟右值引用只能绑定右值。那么,对于左值,我们又该如何优化呢?是否可以通过左值转成右值,然后进行优化呢?等等

为了解决上述问题,标准引入了移动语义。通移动语义,可以在必要的时候避免拷贝;标准提供了move()函数,可以将左值转换成右值。接下来,就开始我们本文的重点-移动语义。

移动语义

移动语义是Howard Hinnant在2002年向C++标准委员会提议的,引用其在移动语义提案上的一句话:

移动语义不是试图取代复制语义,也不是以任何方式破坏它。相反,该提议旨在增强复制语义

对于刚刚接触移动语义的开发人员来说,很难理解为什么有了值语义还需要有移动语义。我们可以想象一下,有一辆汽车,在内置发动机的情况下运行平稳,有一天,在这辆车上安装了一个额外的V8发动机。当有足够燃料的时候,V8发动机就能进行加速。所以,汽车是值语义,而V8引擎则是移动语义。在车上安装引擎不需要一辆新车,它仍然是同一辆车,就像移动语义不会放弃值语义一样。所以,如果可以,使用移动语义,否则使用值语义,换句话说就是,如果燃料充足,则使用V8引擎,否则使用原始默认引擎。

好了,截止到现在,我们对移动语义有一个感官上的认识,它属于一种优化,或者说属于锦上添花。再次引用Howard Hinnant在移动语义提案上的一句话:

移动语义主要是性能优化:将昂贵的对象从内存中的一个地址移动到另外一个地址的能力,同时窃取源资源以便以最小的代价构建目标

在C++11之前,当进行值传递时,编译器会隐式调用拷贝构造函数;自C++11起,通过右值引用来避免由于拷贝调用而导致的性能损失。

右值引用的主要用途是创建移动构造函数和移动赋值运算符。移动构造函数和拷贝构造函数一样,将对象的实例作为其参数,并从原始对象创建一个新的实例。但是,移动构造函数可以避免内存重新分配,这是因为移动构造函数的参数是一个右值引用,也可以说是一个临时对象,而临时对象在调用之后就被销毁不再被使用,因此,在移动构造函数中对参数进行移动而不是拷贝。换句话说,右值引用移动语义允许我们在使用临时对象时避免不必要的拷贝。

移动语义通过移动构造函数移动赋值操作符实现,其与拷贝构造函数类似,区别如下:

  • 参数的符号必须为右值引用符号,即为&&
  • 参数不可以是常量,因为函数内需要修改参数的值
  • 参数的成员转移后需要修改(如改为nullptr),避免临时对象的析构函数将资源释放掉

为了方便我们理解,下面代码包含了完整的移动构造和移动运算符,如下:

class BigObj {
public:
    explicit BigObj(size_t length)
        : length_(length)data_(new int[length]) 
{
    }

    // Destructor.
    ~BigObj() {
     if (data_ != NULL) {
       delete[] data_;
        length_ = 0;
     }
    }

    // 拷贝构造函数
    BigObj(const BigObj& other)
     : length_(other.length_), data(new int[other.length_]) {
   std::copy(other.mData, other.mData + mLength, mData);
    }

    // 赋值运算符
    BigObj& operator=(const BigObj& other) {
   if (this != &other;) {
      delete[] data_;  
      length_ = other.length_;
        data_ = new int[length_];
        std::copy(other.data_, other.data_ + length_, data_);
   }
   return *this;
    }

    // 移动构造函数
    BigObj(BigObj&& other) : data_(nullptr), length_(0) {
        data_ = other.data_;
        length_ = other.length_;

        other.data_ = nullptr;
        other.length_ = 0;
    }

    // 移动赋值运算符
    BigObj& operator=(BigObj&& other) {  
      if (this != &other;) {
          delete[] data_;

          data_ = other.data_;
          length_ = other.length_;

          other.data_ = NULL;
          other.length_ = 0;
       }
       return *this;
    }

private:
    size_t length_;
    int* data_;
};

int main() {
   std::vector<BigObj> v;
   v.push_back(BigObj(25));
   v.push_back(BigObj(75));

   v.insert(v.begin() + 1, BigObj(50));
   return 0;
}

移动构造

移动构造函数的定义如下:

BigObj(BigObj&& other) : data_(nullptr), length_(0) {
        data_ = other.data_;
        length_ = other.length_;

        other.data_ = nullptr;
        other.length_ = 0;
    }

从上述代码可以看出,它不分配任何新资源,也不会复制其它资源:other中的内存被移动到新成员后,other中原有的内容则消失了。换句话说,它窃取了other的资源,然后将other设置为其默认构造的状态。在移动构造函数中,最最关键的一点是,它没有额外的资源分配,仅仅是将其它对象的资源进行了移动,占为己用。

在此,我们假设data_很大,包含了数百万个元素。如果使用原来拷贝构造函数的话,就需要将该数百万元素挨个进行复制,性能可想而知。而如果使用该移动构造函数,因为不涉及到新资源的创建,不仅可以节省很多资源,而且性能也有很大的提升。

移动赋值运算符

代码如下:

BigObj& operator=(const BigObj& other) {
   if (this != &other;) {
      delete[] data_;  
      length_ = other.length_;
        data_ = new int[length_];
        std::copy(other.data_, other.data_ + length_, data_);
   }
   return *this;
    }

移动赋值运算符的写法类似于拷贝赋值运算符,所不同点在于:移动赋值预算法会破坏被操作的对象(上述代码中的参数other)。

移动赋值运算符的操作步骤如下:

  1. 释放当前拥有的资源
  2. 窃取他人资源
  3. 将他人资源设置为默认状态
  4. 返回*this

在定义移动赋值运算符的时候,需要进行判断,即被移动的对象是否跟目标对象一致,如果一致,则会出问题,如下代码:

data = std::move(data);

在上述代码中,源和目标是同一个对象,这可能会导致一个严重的问题:它最终可能会释放它试图移动的资源。为了避免此问题,我们需要通过判断来进行,比如可以如下操作:

if (this == &other) {
  return *this
}

生成时机

众所周知,在C++中有四个特殊的成员函数:默认构造函数、析构函数,拷贝构造函数,拷贝赋值运算符。之所以称之为特殊的成员函数,这是因为如何开发人员没有定义这四个成员函数,那么编译器则在满足某些特定条件(仅在需要的时候才生成,比如某个代码使用它们但是它们没有在类中明确声明)下,自动生成。这些由编译器生成的特殊成员函数是public且inline。

自C++11起,引入了另外两只特殊的成员函数:移动构造函数和移动赋值运算符。如果开发人员没有显示定义移动构造函数和移动赋值运算符,那么编译器也会生成默认。与其他四个特殊成员函数不同,编译器生成默认的移动构造函数和移动赋值运算符需要,满足以下条件:

  • 如果一个类定义了自己的拷贝构造函数,拷贝赋值运算符或者析构函数(这三者之一,表示程序员要自己处理对象的复制或释放问题),编译器就不会为它生成默认的移动构造函数或者移动赋值运算符,这样做的目的是防止编译器生成的默认移动构造函数或者移动赋值运算符不是开发人员想要的
  • 如果类中没有提供移动构造函数和移动赋值运算符,且编译器不会生成默认的,那么我们在代码中通过std::move()调用的移动构造或者移动赋值的行为将被转换为调用拷贝构造或者赋值运算符
  • 只有一个类没有显示定义拷贝构造函数、赋值运算符以及析构函数,且类的每个非静态成员都可以移动时,编译器才会生成默认的移动构造函数或者移动赋值运算符
  • 如果显式声明了移动构造函数或移动赋值运算符,则拷贝构造函数和拷贝赋值运算符将被 隐式删除(因此程开发人员必须在需要时实现拷贝构造函数和拷贝赋值运算符)

与拷贝操作一样,如果开发人员定义了移动操作,那么编译器就不会生成默认的移动操作,但是编译器生成移动操作的行为和生成拷贝操作的行为有些许不同,如下:

  • 两个拷贝操作是独立的:声明一个不会限制编译器生成另一个。所以如果你声明一个拷贝构造函数,但是没有声明拷贝赋值运算符,如果写的代码用到了拷贝赋值,编译器会帮助你生成拷贝赋值运算符。同样的,如果你声明拷贝赋值运算符但是没有拷贝构造函数,代码用到拷贝构造函数时编译器就会生成它。上述规则在C++98和C++11中都成立。
  • 两个移动操作不是相互独立的。如果你声明了其中一个,编译器就不再生成另一个。如果你给类声明了,比如,一个移动构造函数,就表明对于移动操作应怎样实现,与编译器应生成的默认逐成员移动有些区别。如果逐成员移动构造有些问题,那么逐成员移动赋值同样也可能有问题。所以声明移动构造函数阻止编译器生成移动赋值运算符,声明移动赋值运算符同样阻止编译器生成移动构造函数。

类型转换-move()函数

在前面的文章中,我们提到,如果需要调用移动构造函数和移动赋值运算符,就需要用到右值。那么,对于一个左值,又如何使用移动语义呢?自C++11起,标准库提供了一个函数move()用于将左值转换成右值。

首先,我们看下cppreference中对move语义的定义:

std::move is used to indicate that an object t may be "moved from", i.e. allowing the efficient transfer of resources from t to another object.

In particular, std::move produces an xvalue expression that identifies its argument t. It is exactly equivalent to a static_cast to an rvalue reference type.

从上述描述,我们可以理解为std::move()并没有移动任何东西,它只是进行类型转换而已,真正进行资源转移的是开发人员实现的移动操作

该函数在STL中定义如下:

 template<typename _Tp>
    constexpr typename std::remove_reference<_Tp>::type&&
    move(_Tp&& __t) noexcept
    
return static_cast<typename std::remove_reference<_Tp>::type&&>(__t); }

从上面定义可以看出,std::move()并不是什么黑魔法,而只是进行了简单的类型转换:

  • 如果传递的是左值,则推导为左值引用,然后由static_cast转换为右值引用
  • 如果传递的是右值,则推导为右值引用,然后static_cast转换为右值引用

使用move之后,就意味着两点:

  • 原对象不再被使用,如果对其使用会造成不可预知的后果
  • 所有权转移,资源的所有权被转移给新的对象

使用

在某些情况下,编译器会尝试隐式移动,这意味着您不必使用std::move()。只有当一个非常量的可移动对象被传递、返回或赋值,并且即将被自动销毁时,才会发生这种情况。

自c++11起,开始支持右值引用。标准库中很多容器都支持移动语义,以std::vector<>为例,**vector::push_back()**定义了两个重载版本,一个像以前一样将const T&用于左值参数,另一个将T&&类型的参数用于右值参数。如下代码:

int main() {
  std::vector<BigObj> v;
  v.push_back(BigObj(10));
  v.push_back(BigObj(20));
  
  return 0;
}

两个push_back()调用都将解析为push_back(T&&),因为它们的参数是右值。push_back(T&&)使用BigObj的移动构造函数将资源从参数移动到vector的内部BigObj对象中。而在C++11之前,上述代码则生成参数的拷贝,然后调用BigObj的拷贝构造函数。

如果参数是左值,则将调用push_back(T&):

int main() {
  std::vector<BigObj> v;
  BigObj obj(10);
  v.push_back(obj); // 此处调用push_back(T&)
  
  return 0;
}

对于左值对象,如果我们想要避免拷贝操作,则可以使用标准库提供的move()函数来实现(前提是类定义中实现了移动语义),代码如下:

int main() {
  std::vector<BigObj> v;
  BigObj obj(10);
  v.push_back(std::move(obj)); // 此处调用push_back(T&&)
  
  return 0;
}

我们再看一个常用的函数swap(),在使用移动构造之前,我们定义如下:

template<class T>
void swap(T &a, T &b) 
{
    T temp = a; // 调用拷贝构造函数
    a = b; // 调用operator=
    b = temp; // 调用operator=
}

如果T是简单类型,则上述转换没有问题。但如果T是含有指针的复合数据类型,则上述转换中会调用一次复制构造函数,两次赋值运算符重载。

图片

而如果使用move()函数后,则代码如下:

template<class T>
void swap(T &a, T &b) 
{
    T temp = std::move(a);
    a = std::move(b);
    b = std::move(temp);
}

与传统的swap实现相比,使用move()函数的swap()版本减少了拷贝等操作。如果T是可移动的,那么整个操作将非常高效。如果它是不可移动的,那么它和普通的swap函数一样,调用拷贝和赋值操作,不会出错,且是安全可靠的。

图片

经验之谈

对int等基础类型进行move()操作,不会改变其原值

对于所有的基础类型-int、double、指针以及其它类型,它们本身不支持移动操作(也可以说本身没有实现移动语义,毕竟不属于我们通常理解的对象嘛),所以,对于这些基础类型进行move()操作,最终还是会调用拷贝行为,代码如下:

int main()
{
  int a = 1;
  int &&b = std::move(a);

  std::cout << "a = " << a << std::endl;
  std::cout << "b = " << b << std::endl;

  return 0;
}

最终结果输出如下:

a = 1
b = 1

move构造或者赋值函数中,请将原对象恢复默认值

我们看如下代码:

class BigObj {
public:
    explicit BigObj(size_t length)
        : length_(length)data_(new int[length]) 
{
    }

    // Destructor.
    ~BigObj() {
     if (data_ != NULL) {
       delete[] data_;
        length_ = 0;
     }
    }

    // 拷贝构造函数
    BigObj(const BigObj& other) = default;

    // 赋值运算符
    BigObj& operator=(const BigObj& other) = default;

    // 移动构造函数
    BigObj(BigObj&& other) : data_(nullptr), length_(0) {
        data_ = other.data_;
        length_ = other.length_;
    }

private:
    size_t length_;
    int* data_;
};

int main() {
   BigObj obj(1000);
   BigObj o;
   {
    o = std::move(obj);
   }
   
   // use obj;
   return 0;
}

在上述代码中,调用移动构造函数后,没有将原对象回复默认值,导致目标对象和原对象的底层资源(data_)执行同一个内存块,这样就导致退出main()函数的时候,原对象和目标对象均调用析构函数释放同一个内存块,进而导致程序崩溃。

不要在函数中使用std::move()进行返回

我们仍然以Obj进行举例,代码如下:

Obj fun() {
  Obj obj;
  return std::move(obj);
}

int main() {
  Obj o1 = fun();
  return 0;
}

程序输出:

in Obj()  0x7ffe600d79e0
in Obj(const Obj &&obj)
in ~Obj() 0x7ffe600d79e0

如果把fun()函数中的std::move(obj)换成return obj,则输出如下:

in Obj()  0x7ffcfefaa750

通过上述示例的输出,是不是有点超出我们的预期