C++ 拷贝语义与移动语义的个人理解

C++的 copy (拷贝)比较好理解,而 move (移动)则相对难理解一点。

我个人认为,C++ 的 移动 只是提供一种 语义上 的内存所有权移动。

注: 本例的测试环境是在 RT-Thread 里进行的。其使用 arm-none-eabi-gcc 是使用不了 c++ 里的一些标准库。

我先插一点关于 C++ 中,初始化操作语义的个人理解。

int a = 3;
image.png

引用,我理解为一种特殊的指针。

int a = 13;    /* 数据 */
int *p = &a;  /* 普通指针 */
int &r = a;    /* 引用 */
image.png

接下来,用几个场景来理解 C++ 的拷贝和移动。

(1) 怎样定义一个具有拷贝和移动语义的类型?

先说使用场景。在 C++ 里拷贝和移动应用得多的地方,主要是

  1. 怎样把一个对象拷贝或移动到一个新建的对象中
  2. 或怎样把一个对象拷贝或移动到另一个对象中。

第一种是使用构造函数来定义,第二种是使用赋值操作来完成。看下面的实例

/* 场景实例 1 */
T a;        /* 已经存在的一个实例 */

T b(a);                /* 语义是 把 a 的内容拷贝到新的对象 b 中 */
T c(move(a));     /* 语义是 把 a 的内容移动到新的对象 c 中 */

/* 场景实例 2 */
T a, b, c;    /* 已经存在的三个实例 */

b = a;               /* 把 a 拷贝到 b 中 */
c = move(b);    /* 把 b 移动到 a 中 */

那么怎样定义这个类型呢?

class T {
public:
    T() = default;
    
    /* 构造函数 */
    T(const T &);    /* 语义上的拷贝构造函数 */
    T(T &&);           /* 语义上的移动构造函数 */

    /* 赋值操作 */
    T & operator=(const T &);    /* 语义上的拷贝 */
    T & operator=(T &&);           /* 语义上的移动 */
};

现在先不要理会左右值的概念,你只需要知道,C++ 上是把参数里的 T&& 在语义上多用于移动就行。

(2)移动这个动作是想移动什么?移动的是内存吗?

最开始我接触移动这个概念是 Rust 上的(一门挺优秀的语言,如果说 C 是接近底层硬件的是话,Rust 就是接近编译的,我是这样理解的,哈哈)。

在 Rust 上,每个非引用类型的变量都拥有一块内存,而且是这块内存的所有权就是只有这个变量所拥有。如果这个变量出让了(就是移动)这个所有权,那么之后他想再使用这块内存,就没有权利了。

例如说,

// Rust 
// 假设类型 T 没有实现 copy 这个 trait
let a : T = ...;
let b = a;

a.xxx();  // 出错,a 没有权利操作了

这样做会有一个好处,就是不用对返回值进行多一次拷贝和析构的操作。

fn function() -> T 
{
    let a: T = T::new();
    a          // Rust 上没有分号,在函数内是返回值的意思
}

就是说,在 function() 创建一个对象 a,直接把这个对象转移到调用者手上,让他来处理并析构。

如果按照 C 这样写的话,其返回后,还要进行一次拷贝操作。也就是说,对象 a 的内容拷贝到调用者那,然后在 function 内析构。而在调用者那的副本就由其来负责析构。

#include <rtthread.h>

int fun() {
    int a = 3;
    rt_kprintf("fun a = %d, addr is %d\n", a, &a);
    return a;
}

int main() {
    int b = fun();
    rt_kprintf("main b = %d, addr is %d\n", b, &b);

    return 0;
}

在调试模式下(避免被编译器优化了),其输出结果是

image.png

那么,我们使用场景 1 中的右值引用来尝试能不能模仿出移动的效果。

#include <rtthread.h>

int &&fun() {
    int a = 3;
    rt_kprintf("fun a = %d, addr is %d\n", a, &a);
    return static_cast<int &&>(a);
}

int main() {
    auto b = fun();
    rt_kprintf("main b = %d, addr is %d\n", b, &b);

    return 0;
}

其中 static_case<int &&>(a); 是把 a 的类型由 int 强转为 int && 右值引用。这也是 std::move 里移动的本质,但因 arm-none-eabi-gcc 没法使用,故这里直接强转了。

结果是,

image.png

细看,很明显,在 fun() 函数里是调用正常的。但在 main() 里,就出现问题了。

我们先不讨论为什么这个明显的异常为什么不出现在编译阶段,而是在运行阶段产生。

在编译时,其实编译器已经是做了一个友善的提醒。

image.png

换言之,就是你返回了一个本地变量的引用。

这个时候,你会突然想起 Rust 引用的生命周期的概念(万恶的开始)。C++ 右值的概念虽然长期和移动绑定,但他还真的只是引用,不是真正的移动。

在上面的例子里,func() 返回的是其内部的一个变量的引用,即其地址。当调用者 main() 想通过引用变量(相当于指针)b 去使用这块内存时,就会出现问题。问题就是,afunc() 返回时,就被析构了。换言之,b 指向的是一块无效的内存。

最后,我终于明白了,C++ 里的 "移动",是真的什么也没移动。。。

(3) 既然其什么也没移动,那还费这么大劲来定这个标准,有何用?

如果你是这样想,你就是太小看那群 “老家伙” 了(经验老到)。

我个人理解为,这样可以做到语义上的统一。因为我开始想去理解 C++ 移动,就是为了上面的那种场景。但是结果被另一种场景给吸引了,就是智能指针。接下来,试让我用一张图来说明。

image.png

接下来是实例,我先把使用场景写出来,

int main() {
    SmartPoint p1, p2(13);

    rt_kprintf("...before move...\n");
    rt_kprintf("p1: ");
    p1.print();
    rt_kprintf("p2: ");
    p2.print();

    p1 = move(p2);

    rt_kprintf("...move...\n");
    rt_kprintf("p1: ");
    p1.print();
    rt_kprintf("p2: ");
    p2.print();

    return 0;
}

输出结果应是

image.png

然后把移动的细节给写出来

template <typename T>
T && move(T &obj) {
    return static_cast<T &&>(obj);
}

最后把类型的定义给写出来,

class SmartPoint {
public:
    typedef SmartPoint Self;

public:
    SmartPoint(): ptr(nullptr) {}

    // 下面使用了左值引用和右值引用
    // 1. 左值引用 int &   是为了方便传递的是 int 对象
    // 2. 右值引用 int &&是为了方便传递的是 字面量
    SmartPoint(int &data): ptr(new int(data)) {}
    SmartPoint(int &&data): ptr(new int(data)) {}

    /* 析构里保证其能在 RAII 机制下,把堆内存自动析构 */
    virtual ~SmartPoint() {
        if (this->ptr != nullptr) {
            delete this->ptr;
        }

        this->ptr = nullptr;
    }

    /* 移动的构造函数 */
    SmartPoint(Self &&obj) {

        /* 避免自己做了自己 */
        if (this != &obj) {
            this->ptr = obj.ptr;
            obj.ptr   = nullptr;
        }
    }

    /* 移动的赋值 */
    Self & operator= (Self &&obj) {
        if (this != &obj) {
            this->ptr = obj.ptr;
            obj.ptr   = nullptr;
        }

        return *this;
    }

    /* 测试用 */
    void print(char end='\n') const {
        if (this->ptr == nullptr) {
            rt_kprintf("nullptr%c", end);
        } else {
            rt_kprintf("%d%c", *(this->ptr), end);
        }
    }

private:
    int *ptr;
};

============================================================================

上面关于移动的语义说了很多,就只为说明一点个人理解,C++ 的移动 真的只是语义上的移动。其定义的目标是为使用者提供一种方便。所以,你要把移动的动作实现封装在你的框架内,提供给用户的是移动构造函数和移动赋值操作 这两个接口

然后,说一下,左右值和左右值引用的个人理解。

A = B ,就出现两种情况。

  1. 可以 出现在左边的,一定是左值
  2. 只能 出现在右边的,一定是右值

所有变量,一定是左值。临时变量,大部分是右值(在 c++ primer 里有一个函数调用放在左边的例子,我还未弄明白,所以这里只使用 大部分)。

对于我来说,没必要把左右值分得那么清。反正,常用的例子就比较好理解了。

接下来是左右值引用。下面说几个例子

  1. T &a = obj; 左值引用,其右边的值只能是左值。

    int obj = 3;
    int func();
    
    int &r1 = obj;  /* 正确 */
    int &r2 = r1;   /* 正确 */
    
    int &r3 = 13;  /* 错误, 13 是字面量,属于临时变量 */
    int &r4 = func(); /* 错误,返回值未知其绑定的变量名称,故也是临时变量 */
    

    临时变量,就是没名字的内存。参考这篇文章最开始的图。

  2. T &&a = 13 右值引用,其右边的值只能是右值。

    int obj = 13;
    int func();
    int &lr = obj;
    
    int &&r1 = 3;  /* 正确 */
    int &&r2 = func();  /* 正确 */
    
    int &&r3 = lr;  /* 错误,左值引用变量本身是左值 */
    

    注意,不管是左值引用,还是右值引用,其本质也是变量,只是没有在运行时占用内存而已,故其也是左值。

  3. const T&a = 13; 常量左值引用,这是正确的。这个左值引用比较特殊。我个人理解为,编译器看到 const 后,知道这个引用是不被对内容进行修改的,故把 13 这个临时变量放在 常量 区内,这就拥有了全局的生命周期了,非临时变量所能冀望的。

上面就是我对左右值所了解到的情况了。

==================================================================================

写到这,我突然产生一个疑惑,为什么右值引用会和移动沾上边呢?

一定要对比一下 C++ 和 Rust 这两门语言的差异。

在 Rust 上移动的是内存的所有权,而引用,没有太明显的左右值之分。也就是说,引用就只是引用,就是指针。

而在学 C++ 时,我一直有一个误区,强制把一个变量变成右值引用,就是返回其所有权,这样就产生了场景 2 里的情况。

在 Rust 里,场景 2 如果返回的是引用,那这个引用所指向的内存,一定存在生命周期。所以,我们要通过一系列生命周期标记系统(可爱,又揪心)来标记,从面使得编译器能在编译时发现悬挂指针的问题。这个指针只能指向没有被销毁的内存,即有效内存上。而 C++ 里好像对这个检查功能只是警告,没有强制错误(同样也是揪心啊)。

当你学到 Rust 引用和生命周期时,你就能体会到其有多难,同时也多重要。此时回顾到 C++ 的设计时,你对引用和指针的使用就能更多一分谨慎。

这里只是记录一个疑惑,我也还没有答案。。。

==================================================================

还有一样的是,C++ 里拷贝构造函数和拷贝赋值操作是默认存在的,不管你是否有定义其他的构造函数和赋值函数。

  1. T(const T &&);
  2. T & operator= (const T &&);

若你想你的类不要存在拷贝功能,则这样声明

class T {
public:
    T(const T &&) = delete;
    T & operator= (const T &&) = delete;
};

而移动操作是没有默认版本,只能手动实现。举例如下,

/* 可以拷贝,但没有移动 */
class T1 { };

/* 既可以拷贝,也可以移动 */
class T2 {
public:
    T2(T2 &&);
    T2 & operator= (T2 &&);
};

/* 既不能拷贝,也不能移动 */
class T3 {
public:
    T(const T &&) = delete;
    T & operator= (const T &&) = delete;
};

/* 不能拷贝,但可以移动 */
class T4 {
public:
    T(const T &&) = delete;
    T & operator= (const T &&) = delete;

    T2(T2 &&);
    T2 & operator= (T2 &&);
};

================================================================================

上面,是我对这段时间看 《C++ primer》 关于移动的笔记整理。如果发现什么纰漏,欢迎讨论。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 220,002评论 6 509
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,777评论 3 396
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 166,341评论 0 357
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,085评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,110评论 6 395
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,868评论 1 308
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,528评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,422评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,938评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,067评论 3 340
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,199评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,877评论 5 347
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,540评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,079评论 0 23
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,192评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,514评论 3 375
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,190评论 2 357

推荐阅读更多精彩内容