[原创]C++基础系列—const和constexpr

constant 意思是”常量”, 而 const 和 constexpr 这两个关键字和 constant 有一定的关系。常量的广义定义是:不变化的量(比如:重力加速度,圆周率,光速等)。说这两个关键字之前,我们还得提一下同样可以定义常量的关键字:#define

#define

你可能见过如下代码:

# include <stdio.h>
# define PI 3.14
int main(void)
{
    float s;
    s = PI * 5 * 5;
    printf("s = %.6f\n", s);
    return 0;
}

此时,PI 的作用就是一个常量。这样写的好处是凡是代码中用到圆周率计算的地方,直接用 PI 替换,要扩展圆周率的精度,只要修改 PI 的值就行。#define定义常量也有一些问题:

  • #define 是宏,它仅仅是做字符匹配替换,没有语法检测;而且替换以后通常是纯右值,无法当作变量使用
  • 无作用域,容易字符冲突(比如:Max,Min)

当然,#define 也有以下一些优势:

  • 防止头文件重复包含(#ifndef XXX #define XXX #endif)
  • 条件编译(ifdef XXX… #else …#endif)

const

const关键字,其实有两种语义

  • 表示常量 const float PI = 3.14f
  • 修饰的对象不可改变

const也可以修饰函数,表示当前函数不允许修改被调用对象的值(注意: const 函数内不可调用非 const 函数)

const还可以修饰指针,它所在的位置不同,语义也有不同(*左定值,*右定向) :

  • 修饰指针指向的内容,则内容为不可变量 const int* p = &a; *p =3; //error
  • 修饰指针,则指针为不可变量 int* const p = &a; p = &b; //error

在C++11引入constexpr以后,官方不推荐用const表示常量,因此也可”片面”的认为,const关键字其实就是C#的关键字:readonly

constexpr

这是在C++11引入的关键字,在C++20以后,限制就越来越少,甚至可以修饰 lamda 表达式。它和const最主要的区别是:constexpr所修饰的常量必须是编译期常量(注意:constexpr 所修饰的函数,返回值则不一定要求是编译期常量),如下代码:

const int len = 5; //不推荐
constexpr int len = 5; //推荐
int a[len];

len是一个编译期就需要知道的常量,const 并不一定编译期就可获取它的值,因此严谨的写法应该是下面那种。至于constexpr的其他用处,那就得在元编程中大显身手了,这不在本篇基础文章讲解范围内

[原创]C++基础系列—右值引用

既然有了左值引用,看似一切都很美好了。为什么还需要右值引用这个东西呢?引入左值引用,解决了运算符重载的问题;C++11引入右值引用,则是为了追求极致性能

左值( lvalues ) 右值( rvalues )

为了了解右值引用,我们再次明确一下,左值和右值的概念。 Lvalue Persist;Rvalue Are Ephemeral《C++ Primier》,更简单的概念就是:左值可获取存储地址;右值不可获取地址,只能获取值本身。详细判断左值右值的地址:Value categories

左值引用

左值引用,只能绑定左值,注意下面例子最后一个,1是右值没错,但其实它可以分解为注释后的两句代码,因此也就能理解,它并没有超脱左值引用的定义

int foo = 1;        //foo is lvalue, 1 is rvalue
int* bar = foo;     //foo is lvalue , bar is lvalue
int* baz = 1;       //error: 1 is rvalue
const int* qux = 1; // const int temp = 1, const int* qux = temp;

右值引用

右值引用,只能绑定右值(也就是绑定的是 生命周期在下一行就会结束的临时值)

int foo = 1;
int&& baz = foo;      //error: foo is lvalue
int&& qux = 1;        //1 is rvalue
int&& quux = foo * 1; //(foo * 1) is rvalue
int&& quuz = foo++;   //foo++ return a rvalue

右值引用它能绑定临时值,本来在下一行之前就会”消失”的数据,我们能再次利用,最重要的一点:可以任意用它而不用担心会影响其它地方;可能还是一头雾水,我们通过一个例子来展示一下:

#include <iostream>
#include <vector>
using namespace std;

class Foo
{
public:

    Foo() { data = nullptr; }

    Foo(const char* str)
    {
        initdata(str,"构造");
    }

    Foo(const Foo& foo)
    {
        initdata(foo.data, "拷贝构造");
    }

    Foo& operator=(const Foo& foo)
    {
        initdata(foo.data, "拷贝赋值");
        return *this;
    }

    ~Foo()
    {
        if (data != nullptr) {  free(data); }
    }

private:

    void initdata(const char* str, const char*  des)
    {
        data = new char[strlen(str) + 1];
        memcpy(this->data, str, strlen(str));
        data[strlen(str)] = '\0';
        cout << des << data << endl;
    }

    char* data;
};

int main()
{
    Foo foo = Foo("hello");
    Foo bar = foo;

    vector<Foo> vec;
    vec.push_back(Foo("world"));
}

打印结果:
构造hello
拷贝赋值hello
构造world
拷贝构造world

每一次都有 new char[] 操作,相当于有4次内存申请操作,我们在Foo中插入以下代码;再输出:

Foo(Foo&& str)
{
    cout << "移动构造" << str.data << endl;
    data = str.data;
    str.data = nullptr;
}

Foo& operator=(Foo&& str)
{
    cout << "移动拷贝赋值" << str.data << endl;
    if (this != &str) {
        this->data = str.data;
        str.data = nullptr;
    }
    return *this;
}

打印结果:
构造hello
移动拷贝赋值hello
构造world
移动构造world

虽然也有4条输出,但实际 new char[] 只执行了2次。移动拷贝赋值 和 移动构造 里面没有 new char[] 操作,只是指针重定向。你可能会有疑问:你内部实现方式都不一样,那肯定不需要 new char[] 啊 ?很高兴,你已经Get到点了,那为什么可以直接重定向而不需要 new char[] ?

因为它的参数是右值引用。请翻到上文加粗部分,右值引用它绑定的是临时值,它不会被其它任何地方的代码引用,因此可以放心将它的 data 指向自己而不用担心会牵扯其它地方。

此刻你是不是灵机一动?想到了右值引用的核心了:就是告诉编译器,这个”值”已经没人疼没人爱,你带走随便操作吧。那是不是我可以把我认为后面我不会再用到的值,直接转成右值引用呢?是的,可以。上个示例,main函数改成如下:

int main()
{
    Foo foo = Foo("hello");             //第一行
    Foo bar = static_cast<Foo&&>(foo);  //第二行
    return 0;                           //第三行
}

在第三行断点,你可已发现,foo内的 data 值已经变为NULL,hello 从foo “转移”到了 bar 。这就引出了经常讨论的 move semantics (移动语义)

move semantics(移动语义)

其实移动语义,就是上文main函数的第二行,更改如下:

int main()
{
    Foo foo = Foo("hello");  //第一行
    Foo bar = std::move(foo);//第二行
    return 0;                //第三行
}

std::move(foo) 等于 static_cast<foo&&>(foo) ,就是将左值强转为右值引用。就是告诉编译器,这个左值绑定的值,左值再也不对它负责。因此,第二行以后就不应该再使用 foo 做一些操作,可能导致崩溃

那既然这样,为什么还要std::move() 语义?直接 static_cast<T&&>() 不就好了吗?两点原因:

  • std::move() 语法更简洁,方便代码理解
  • std::move() 不需要显示书写类型(T&&)

注意:右值引用本身是左值,这是啥意思呢?代码如下:

void func(Foo&& foo)
{
    //foo do something
}

int main()
{
    Foo foo =  Foo("hello");
    Foo bar = std::move(foo);
    func(bar); //error     //第四行
    func(Foo("hello"));    //第五行
    func(std::move(foo));  //第六行
    func(std::move(bar));  //第七行
    return 0;
}

bar 变量本身是左值,可以取地址 (虽然他绑定的是一个右值引用) ,因此第四行的代码编译报错。move 除了使用在移动构造,还可以用于智能指针。而且 std::swap就是通过move实现的,伪代码如下:

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

至此,我们已经知晓左值引用,右值引用,而且语义明确,不会有任何歧义。但是在C++中,有个核弹——模板,它让情况变复杂了

万能引用

一个方法的形参,类型要么是左值引用,要么是右值引用

void func(Foo& foo) {}
void func(Foo&& foo){}

有没有同时接受左值引用和右值引用的方法呢?有的,万能的模板可以实现你的愿望

template<typename T>
void func(T&& param) 
{
    cout << param << endl;
}

int main() 
{
    int foo= 1;
    func(foo); //foo的类型:int&;(因为func只接受引用)
    func(1); //数值1的类型:int&&
    return 0;
}

是不是很神奇,将 T 替换成我们传入的类型,那么函数实际参数类型如下:

void func(int& && param) 
{
    cout << param << endl;
}
void func(int&& && param) 
{
    cout << param << endl;
}

“& &&” 和 “&& &&” 是不存在的类型,如下定义全都会报错:

(int&)&& foo;
int& &&  bar;
int&&&   baz;

那 T&& 为啥可以呢?因为C++ 11 定义了折叠引用规则

引用折叠(Universal Collapse)

func(foo) 传入的 T 推导类型:int&,因此实际类型是:int& &&,这是个不存在的类型,因此 C++ 标准强行规定了一个规则:

如果任一引用为左值引用,则结果为左值引用;否则结果为右值引用。 有点不好理解,我换个说法,左值引用是 0 ,右值引用时 1,折叠规则就是:左值引用 and 右值引用(这里的and是算数操作符 与 ),因此:

int& && = 0 and 1 = int&
int&& & = 1 and 0 = int&
int& & = 0 and 0 = int&
int&& && = 1 and 1= int&&

这也是为什么万能引用可以既接受左值引用又可以接受右值引用的原因。但是请注意折叠引用仅适用于编译器自己推出来的类型,手动定义的类型编译器不会遵循折叠引用,因此上文的定义 (int&)&& foo; int& && bar; int&&& baz; 都会报错

既然万能引用技能接受左值引用,又能接受右值引用。那有个问题,如果我func里面要调用其它方法,将 param 当作参数传给它,那么它的类型是不是就不对了呢?

完美转发(Perfect Forwarding)

template<typename T>
void bar(T& param) 
{
    cout << "param is lvalue" << endl;
}

template<typename T>
void bar(T&& param)
{
    cout << "param is rvalue" << endl;
}

template<typename T>
void foo(T&& param)
{
   bar(param);
}

int main() 
{
    int baz = 1;
    foo(baz);
    foo(1);
    return 0;
}
//Consloe:
//param is lvalue
//param is lvalue

结果都是左值,为什么呢?因为 param 相当于一个变量,它绑定的是传进来的T&&类型的值(无论这个值折叠以后是什么类型,变量总是左值)。这显然和我们的期望相违背,那怎么办呢?再强转一次呗

template<typename T>
void foo(T&& param)
{
    bar(static_cast<T&&>(param)); //替换bar(param);
}

将 param 强转回 T&& 类型,再传给 bar 函数,编译器就能准确找到对应的函数。同样,static_cast<T&&>(),也有对应的简洁写法,std::forward<T>()

总结

C++ 11引入右值引用,主要目的是为了提高性能(弥补 拷贝构造,拷贝赋值等操作必须进行内存申请等很消耗性能的操作),最大限度的利用了临时值。而移动语义,完美转发 的本质其实就是类型转换 static_cast<T&&>() ,并不神秘

[原创]C++基础系列—左值引用

有没有想过一个问题,为什么C++ 有了指针( Pointer),仍然引进 “引用(Reference)” 这个概念?C++之父的解释:

C++ inherited pointers from C, so I couldn’t remove them without causing serious compatibility problems. References are useful for several things, but the direct reason I introduced them in C++ was to support operator overloading (简译: C++ 为了兼容 C,所以不可能会移除指针;引用在很多地方有用处,但主要是为了解决运算符重载 )

operator overloading (运算符重载)

运算符重载为什么必须要用到引用呢?我们看看下面一个实例,你就能理解为什么需要引用

#include <iostream>
using namespace std;

class Box
{
public:
    int length;
    Box(int len){ length = len; }

    //值方式重载
    Box operator+=(const Box b)
    {
        Box box(0);
        box.length = this->length + b.length;
        return box;
    }

    //引用方式重载
    Box& operator+=(const Box& b)
    {
        this->length = this->length + b.length;
        return *this;
    }

    //指针方式重载
    Box* operator+=(const Box* b)
    {
        this->length = this->length + b->length;
        return this;
    }
};

Box GetBox() { return Box(1); }

int main()
{
    Box box(1);
    //值方式重载示例
    (box += box) += box; //result:1
    //引用方式重载示例
    (box += box) += box;//result:4
    //指针方式重载示例
    *(box += &box) += &box;//result:4
    //只有引用方式的重载才能正确编译
    box += GetBox();
    cout << "box.Length = " << box.length << endl;
    return 0;
}

值方式重载有两个问题:

  • 构造函数被调用多次;浪费性能
  • 功能未实现;(box += box) += box 结果不符合预期

指针方式重载也有以下两个问题:

  • 写法不够简洁,晦涩难懂
  • box += GetBox() 编译不过(不能用 “右值” 当实参)

函数参数

C++ 之父说还说,引用在有些地方也很有用。除了重载运算符,作为函数参数也很有用,示例如下:

void Func(Foo foo);//有拷贝构造,不推荐
void FuncPtr(Foo* foo);//无拷贝构造,但是foo使用之前需判空
void FuncRef(const Foo& foo);//无拷贝构造,官方推荐(可传入右值实参)

你是不是以为就这了?其实它还有个隐藏的用处,请看以下代码:

void foo(const int& value)
{
    int temp = value + 1;
}

void bar(const int* value)
{
    int temp = *value + 1;
}

int main()
{
    int value = 1;
    int& ref = value;
    int* ptr = &value;

    foo(2);
    foo(value);
    foo(ref);

    bar(2);      //error
    bar(value);  //error
    bar(ptr);    //error
    return 0;
}

函数 foo(const int& value) 可以接受三种实参, 这一点在模板里面很有用 ;而 bar(const int* value) 只能接受指针类型实参

函数返回值

还有没有其它用处呢?我觉得写单例的时候会用到。以下是 C++ 单例的 “标准” 写法:

class Singleton 
{
 private:
    Singleton (void)

 public:
    static Singleton& getInstance()
    {
        static CSingleton m_pInstance;
        return m_pInstance;
    }
};

为什么引用的单例比指针的更好?因为安全,返回的是引用不可被更改,而且不会为空

总结

综上,引用的作用有以下地方:

  • operator overloading;不仅是为了简化语法,还是为了实现功能
  • 作为函数形参
  • 作为函数返回值

当然,我们这里讨论的都是普通引用,也就是左值引用;C++ 11以后引入了右值引用,初看很迷惑,实际也是为了解决问题,下一篇我们再来谈谈

[原创]C++基础系列—智能指针

如果你熟悉 Java 或者 C# ,那么以下代码你肯定不陌生

class Students { public int value = 0; }

void main()
{
 Students a = new Students();
 Students b = a;
 a.value = 10;
//此时,变量 a 和变量 b 的 value 值都是 10
}

很简单,生成了一个Students对象,变量 a 和变量 b 都是对该对象的引用;但是在C++ 中,结果并不是这样

class Students
{
 public:
    int value = 0;
};

void main()
{
    Students a;
    Students b = a;
    a.value = 10;
    //此时,变量 a 的 value 是10,变量 b 的 value 仍是 0
}

编译器会为 class 默认生成 copy constructor 和 assignment operator,C++ 对象默认是可以拷贝的 ,因此 b = a,相当于拷贝了一份全新的对象。那要怎么实现类似Java 或 C# 的效果呢?C++ 就发明了指针这么个东西,它就是指向对象实际存储在内存中的地址,用法如下:

int main()
{
    Students* a = new Students();
    Students* b = a;
    a->value = 5;
    //此时,变量 a 和 b 的value 都是 5
    //delete a;
}

new做的事情就是,调用底层 malloc 获取 Students 类型大小的内存地址(一般会对字节补齐),然后调用 Students 的构造函数(如果没有,编译器会自动补全空构造),返回该对象的内存地址。从此,内存上的这一块地址就永远被占用,除非调用 delete 函数释放。但如果 delete 过早,其他函数还在用这个指针,就可能导致程序崩溃

void foo(Students* ptr)
{
    ptr->value = 10; //crash
}

void main()
{
    Students* ptr = new Students();
    delete ptr;
    foo(ptr);
}

使用裸指针(raw pointer)最大的问题就是,你不知道它当前的所有权(ownership)属于谁,因此也就很难判断应该在哪个位置 delete 它。C++ 编委会的巨佬们本着为了让程序员少掉头发的原则,设计了一个封装指针的类,同时赋予它一个响亮的名字: 智能指针( Smart Pointers )

既然也叫 “指针”,那么就要求它用起来跟普通指针没区别。因此这个封装裸指针的类重载了很多操作符,比如:* ,& ,bool ,== 等。针对各种使用场景,一共设计了三种智能指针类 :

  • std::unique_ptr(single ownership)
  • std::shared_ptr (shared ownership)
  • std::weak_ptr ( no ownership ,与 shared_ptr 伴生关系)

针对上面会崩溃的问题,我们代码改下如下:

void foo(std::unique_ptr<Students> ptr)
{
    ptr->value = 10; //no crash
}

void main()
{
    auto ptr =std::make_unique<Students>();
    foo(std::move(ptr)); 
}
// no memory leak

崩溃解决,同时也不会造成内存泄漏。构造 unique_ptr 的官方推荐的写法是: auto ptr = std::make_unique<class>(); 转移 ownership 的写法是 std::move(ptr); 为了能更详细的理解,我们来写个小实例,并打印相关讯息:

class Students
{
public:
    Students(){ std::cout << "Students初始化创建" << std::endl; }
    ~Students() { std::cout << "Students析构销毁" << std::endl; }
};

int foo(std::unique_ptr<Students> ptr2)
{
    std::cout << "进入foo函数" << std::endl;
    std::cout << "退出foo函数" << std::endl;
}

int main()
{
    std::cout << "进入main函数" << std::endl;
    //注释段1
    //{
        //std::cout << "进入main函数scope1" << std::endl;
        //auto ptr1 = std::make_unique<Students>();
        //std::cout << "退出main函数scope1" << std::endl;
    //}

    //注释段2
    //auto ptr2 = std::make_unique<Students>();
    //foo(std::move(ptr2));
    std::cout << "退出main函数" << std::endl;
}

打开注释段1,打印如下左边;打开注释段2,打印如下图右边

可以看出 ptr1 的 ownership 属于 block scope,scope代码执行完以后, ptr1 自动执行析构函数;ptr2 的 ownership 一开始属于 main 函数, 通过 std::move 转移给 foo 函数,因此一旦 foo 函数生命周期结束,ptr2 也会自动执行析构函数

看似很完美,然而 unique 的意思是唯一,也就是说这种智能指针的 ownership 只能唯一,因此如下代码,就会崩溃:

void foo(std::unique_ptr<Students> ptr)
{
    ptr->value = 10;
}

void bar(std::unique_ptr<Students> ptr)
{
    ptr->value = 10; //ptr is empty, crash
}

int main()
{
    auto ptr = std::make_unique<Students>();
    foo(std::move(ptr));
    bar(std::move(ptr));
}

原因是什么呢?std::move(ptr) 的意思就是把 ptr 的 ownership 从 main 函数转移给 foo 函数,转移后 ptr 也就没有 ownership了,自动析构了; 针对这种情况,我们就需要用到 shared_ptr , 使用规范如下:

跟 unique_ptr 相比,多了一个use_count。因为它就是用来记录,当前有多少个 ownership,为什么要记录个数呢?因为只有确定它最后一个 ownership 的生命周期结束以后,它才能自动析构。针对上面那个问题,我们用shared_ptr改写:

void foo(const std::shared_ptr<Students> ptr)
{
    puts("enter foo");
    std::cout << ptr.use_count() << std::endl;
    puts("exit foo");
}

int main()
{
    puts("enter main");
    const auto ptr = std::make_shared<Students>();
    std::cout << ptr.use_count() <<std::endl;
	
    {
	puts("enter scope");
	auto ptr_scope1 = ptr;
	std::cout << ptr.use_count() << std::endl;
	auto ptr_scope2 = std::move(ptr_scope1);//transfer ownership
	std::cout << ptr.use_count() << std::endl;
	puts("exit scope");
     }
	
    std::cout << ptr.use_count() << std::endl;
    foo(ptr);
    std::cout << ptr.use_count() << std::endl;
    puts("exit main");
}

打印结果如下:

也许下面这张图,你能理解的更透彻

shared_ptr 的 use_count 的增减做了防 data_race ,因此它默认支持多线程。目前来看,unique_ptr 和 shared_ptr 好像可以解决所有问题,为什么还需要一个 weak_ptr 呢?那么来看下面的情况:

class Students
{
public:
    Students() { puts("Students create"); }
    ~Students() { puts("Students destroy"); }
    std::shared_ptr<Students> next;
    //std::weak_ptr<Students> next;
};

int main()
{
    auto ptr_1 = std::make_shared<Students>();
    auto ptr_2 = std::make_shared<Students>();
    ptr_1->next = ptr_2;
    ptr_2->next = ptr_1;
    std::cout << ptr_1.use_count() << std::endl;
    std::cout << ptr_2.use_count() << std::endl;
}

//print:
//Student create
//Student create
//2
//2

哦豁,并没有打印 Student destroy;也即是说出现了 memory leak;智能指针失灵了?问题就出在赋值的那两句代码导致 use_count = 2,main函数退出后,use_count 仍然为1,因此不会执行析构。因此如果有一种指针,我想用shared_ptr,但又不想它 use_count 有变化,只是在用的时候,检查一下它还在不在,那不就完美了吗?因此,上面的注释打开,就能正确析构。详细用法如下:

    auto ptr = std::make_shared<Students>();
    std::cout << ptr.use_count() << std::endl;
    {
	//必须从 shared_ptr 构造(不会增加 shared_ptr 的 use_count)
	std::weak_ptr<Students> ptr_w = ptr;
		
	//必须从weak_ptr 重新转换成 shared_ptr 再使用
	//ptr的 use_cunt + 1
	std::shared_ptr<Students> ptr_w2s = ptr_w.lock();
	if(ptr_w2s)
	{
	    std::cout << ptr.use_count() << std::endl;
	}
    }	
    std::cout << ptr.use_count() << std::endl;
    //print: 1 2 1

打印结果与预期的结果一致。因此,很多时候,为了避免循环引用,一般shared_ptr 和 weak_ptr 配合使用。官方给出的使用建议是:

  • 尽量使用智能指针替代裸指针
  • 优先使用std::unique_ptr 而不是 shared_ptr (可以想想为什么?)
  • 尽可能 std::move(share_ptr)而不是直接 foo(share_ptr) (理由同上)

内容参考:

  1. B站 花花酱的表世界 Smart Pointers 智能指针 – C++ Weekly EP3
  2. 知乎用户 sin1080 关于 c++ 11 的shared_ptr多线程安全?的优秀回答
  3. C++ ownership semantics