OOP 学习笔记(4)——引用与复制

OOP 学习笔记(4)——引用与复制

Contents

引用与复制

引用

在语法教程中粗略地讲过引用,当然实际上引用还有更多用法。

这里相当于一次补充。

基本使用

引用存在的意义就是创建具名变量的别名。

一般格式为:

Type & ReferName = VarName;

比如:

int &x = y;

其中 y 是一个 int 型变量。

x 就是 y 变量的另一个名字。

因为引用与其他变量共用内存,所以定义时必须赋初值

同样还有讲过的函数的引用调用和函数返回值为引用的情况。

引用的特性

  • 引用可以更灵活地支持运算符重载。

  • 不存在空引用。

  • 一经初始化便不能更改指向对象。

函数参数中的常量引用

最小特权原则:给函数恰好足够的权限完成相应任务。

为了限制函数只能读取参数的值而无法修改,我们可以使用常量引用:

void func(const int &A, const int &B);

拷贝构造函数

拷贝构造函数是一种特殊的构造函数,其参数被规定为同类对象的常量引用,一般定义为:

ClassName(const ClassName &src)
{
    //...
}

其作用就是利用参数对象 src 的数据初始化当前对象。

拷贝构造函数被调用时机

一般有三种:

  • 用一个类对象定义另一个新的类对象。
    ClassName Obj1;
    ClassName Obj2(Obj1);
    className Obj3 = Obj1;
    
  • 函数传值调用。
    Type func(ClassName Obj);
    

    此时在实参传递到形参时会调用拷贝构造函数。

  • 函数返回类对象。

    ClassName func(parameters);
    

    从函数中的 return 后的对象变为函数返回值时也会调用拷贝构造函数。

    但有时根据条件需要在编译时禁止返回值优化:

    g++ test.cpp --std=c++11 -fno-elide-constructors -o test
    

默认的拷贝构造函数

在没有显式定义拷贝构造函数时,编译器将自动采用位拷贝(Bitcopy),即拷贝成员地址而不是内容。

这种方法在遇到指针类型成员时可能因为多对象指向相同地址而出错。(类比上讲中析构函数调用)

为了避免这种可能,一般有以下解决方案:

  • 使用引用和常量引用传参和返回对象。

  • 将拷贝构造函数声明为 private

  • delete 关键字显式删除默认版本的拷贝构造函数。

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

移动构造函数

左值、右值

语法教程里提过左值和右值。

  • 左值:可以取地址、有名字的值;可出现在等号左右。
  • 右值:不可取地址、无名字的值;常见于常值、一般函数返回值、表达式;只能出现在赋值运算符右侧。

左值可以取地址且可以被 & 引用(左值引用)。

右值引用

虽然右值无法取地址,但可以被 && 引用(右值引用)。

int &&e = a + b;

但右值引用不能绑定左值,比如下面这种用法就会报错:

int &&e = a;

右值引用的本质大概就是,对于一个本应该被销毁的临时值的内存区域,用一个变量指向它使其生命周期延长。

在右值引用之后在修改右值引用变量,相当于修改原来的临时区域,所以其值相应更改。

一般会有这样的应用:

void func(const int &a)
{
    //...
}
int main()
{
    func(3);
}

另一个例子:

#include <iostream>

using namespace std;

void f(int &x)
{
    cout << "left " << x << endl;
}

void f(int &&x)
{
    cout << "right " << x << endl;
    f(x); //延续x的生命周期
}

int main()
{
    f(1); //1是一个常量
    return 0;
}

可以考虑一下输出结果。

课件给出的链接

总结一下

  • 左值引用绑定左值,右值引用绑定右值。

  • 例外:左值常量引用也能绑定右值:

    const int &e = 3;
    

移动构造函数

由上面可知,右值引用可以延续即将销毁变量的生命周期,用于构造函数可以提升处理效率。

使用右值引用作为参数的构造函数称为移动构造函数

声明如下:

ClassName(ClassName&&);
  • 用于“偷”临时变量中的资源。
  • 临时变量被编译器设置为常量形式,所以无法使用拷贝构造函数(这属于对原对象的一种改造)。
  • 而移动构造函数可以接受临时变量且能“偷”出资源。

  • 移动构造函数直接利用原来的临时内存,新对象无需开辟内存,临时对象也不需释放内存,故可大大提高计算效率。

移动语义

对于一个不需要的左值,也可以调用移动构造函数,只需要使用 std::move() 函数。

此函数为解引用,即将左值转化为右值,也就是将变量和变量值分离,变量转化为未初始化变量,变量值处于“无主”状态。

T a;
T b = std::move(a);

这样会使 a 变为一个未赋值的 T 型变量,b 则霸占了 a 原本的值。

右值引用结合此可以显著调高 swap() 函数效率。

原来:

template <class T>
void swap(T &a, T &b)
{
    T tmp(a); // copy a to tmp
    a = b; // copy b to a
    b = tmp; // copy tmp to b
}

现在:

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

可避免 $3$ 次非必要拷贝操作。

练习题

#include <iostream>

class Test
{
public:
    Test()
    {
        printf("Test()\n");
    } //默认构造函数

    ~Test()
    {
        printf("~Test()\n");
    } //析构函数

    Test(const Test &con)
    {
        printf("Test(const Test &con)\n");
    } //拷贝构造函数  

    Test(Test &&con)
    {
        printf("Test(Test &&con)\n");
    } //移动构造函数

};

Test func(Test a)
{
    return Test();
}

int main()
{
    Test a;
    Test b = func(a);
    return 0;
}

答案加解释:

Test()                  main() 中 a 构造
Test(const Test &con)   func() 中 a 拷贝
Test()                  func() 中 Test() 调用
Test(Test &&con)        Test() 移动构造转为 func() 返回值
~Test()                 Test() 析构
Test(Test &&con)        func() 返回值移动构造转为 b
~Test()                 func() 返回值析构
~Test()                 func() 中 a 析构
~Test()                 main() 中 b 析构
~Test()                 main() 中 a 析构

:可以通过在每个构造、析构函数中加上 std::cout << this << std::endl; 判断析构对应的对象。

赋值运算符

赋值运算符函数

已定义的对象之间相互赋值,是调用对象的赋值运算符函数实现的。

一般定义为:

ClassName& operator=(const ClassName &right)
{
    if (this != &right) // 避免自己赋值给自己
    {
        // 具体复制
    }
    return *this;
}

注意初始化直接的赋值调用的是拷贝构造函数而非赋值运算符函数

赋值运算符函数的重载,必须是类的非静态成员函数,也必须是非友元函数

移动赋值运算

和移动构造函数原理类似。

一个例子:

ClassName& operator=(ClassName &&right)
{
    if (this == &right) cout << "same obj!\n";
    else
    {
        // 具体复制
        cout << "operator=(ClassName&&) called.\n";
    }
    return *this;
}

类型转换

当编译器发现表达式和函数调用所需的数据类型和实际类型不同时,就会进行自动类型转换

自动类型转换可通过定义特定的转换运算符和构造函数完成。

除自动类型转换外,有必要时还可以进行强制类型转换。

两种实现方法

法一:在源类中定义目标类型转换运算符

#include <iostream>
using namespace std;

class Dst
{
public:
    Dst()
    {
        cout << "Dst::Dst()" << endl;
    }
};

class Src
{
public:
    Src()
    {
        cout << "Src::Src()" << endl;
    }
    operator Dst() const // 不标注返回类型、无参数列表
    {
        cout << "Src::operator Dst() called" << endl;
        return Dst(); 
    }
};

法二:在目标类中定义源类对象作为参数的构造函数。

#include <iostream>
using namespace std;

class Src;  // 前置类型声明,因为在Dst中要用到Src类
class Dst
{
public:
    Dst()
    {
        cout << "Dst::Dst()" << endl;
    }
    Dst(const Src &s)
    {
        cout << "Dst::Dst(const Src&)" << endl; 
    }
};

class Src
{
public:
    Src()
    {
        cout << "Src::Src()" << endl;
    }
};

上述两种方法选且仅能选择一种,不能同时使用。

之后运行下面代码,均可运行:

void Func(Dst d)
{
    //...
}

int main()
{
    Src s;
    Dst d1(s);

    Dst d2 = s;
    Func(s);
    return 0;
}

禁止自动类型转换

如果用 explicit 修饰类型转换运算符或类型转换构造函数,则相应的类型转换必须显式地进行:

explicit operator Dst() const;

或者:

explicit Dst(const Src &s);

这样注明一种后,原来的 main() 函数:

int main()
{
    Src s;
    Dst d1(s); // 可以执行,显式初始化

    Dst d2 = s; // 错误,隐式转换
    Func(s); // 错误,隐式转换
    return 0;
}

强制类型转换

  • const_cast,去除类型的 constvolatile (告诉编译器不做任何优化)属性。
  • static_cast,类似于 C 风格的强制转换。无条件转换,静态类型转换。
  • dynamic_cast,动态类型转换。如子类和父类之间的多态类型转换。(之后内容)
  • reinterpret_cast,仅仅重新解释类型,但没有进行二进制的转换,也就是实际上内存上存储内容未改变。

上面的 main() 函数应修改为:

int main()
{
    Src s;
    Dst d1(s);

    Dst d2 = static_cast<Dst>(s);
    Func(static_cast<Dst>(s));
    return 0;
}

 

点赞 0

No Comments

Add your comment