文章

modern c++

这是一篇根据Stanford CS106L 所完成的C++学习笔记

modern c++

赋值

  1. 直接赋值
  2. 统一赋值
    1
    2
    3
    
     std::vector<int> numbers{1, 2, 3, 4, 5};
     std::map<std::string, int> ages{ {"Alice", 25}, {"Bob", 30},{"Charlie", 35} };
    student st1 {"Luish",29, true};
    
  3. 结构化绑定 auto [className, buildingName, language] = getClassInfo();

iteration

++it 返回的是引用,it++返回的是copy

input iterators

可以求得迭代器内部的值 ` auto iter = *it`

Output iterators

可以修改内部的值 ` *it = 3`

foward iterator

满足Multipass

Multipass Guarantee

简单说,Multipass Guarantee不仅仅要求迭代器允许拷贝和在解引用后原位置仍有效,同时它还要求相同的两个迭代器,解引用后得到的对象应该是同一对象,进一步说,同一对象指的是相同地址

  • 如果两个迭代器指向同一个范围的同一位置,它们的行为必须一致。
  • 多次遍历相同的数据范围,迭代器的行为必须可重复。
1
2
it1 =it2
++ it1 == ++ it2

Bidirictional iterators

可以++ 也可以–

random access iterator

可以 auto a = it +2

alt text

class

class基础

1
2
3
4
5
6
7
8
9
  class B {
  public:
      B() : i_(0) {}
      virtual ~B() {}
      virtual void f(int i) = 0;
      int get() const { return i_; }
  protected:
      int i_;
  };
  • public:对外部公开的接口和方法,允许用户直接调用。
  • protected:希望继承类可以访问,但不希望外部访问的成员,比如一些实现细节或辅助方法。
  • private:完全隐藏的内部数据和方法,不希望任何外部或继承类访问,通常用于保护数据和实现的细节。 =0 代表不在基类中实现 virtual 代表虚函数

模版

函数模版

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
using namespace std;
template <typename T>
T add(T a, T b) {
	return a + b;
}

template <>  string add<string>(string a, string b)
{
	return string("string:") + a + b;
}

int main()
{
	std::cout << add(string("ad"), string("adawd"));
	return 0;
} 这里的加法就可以是浮点数,也可以是整数。如果想针对字符串类型做特殊处理,需要采用函数模版特化 ### 模版特化  上面的第二个函数就是模版特化的例子,前面的template<>代表全特化(函数没有偏特化),add后面的那个可以不加

默认值

1
2
3
4
template <typename T1 = int, typename T2 = double>
T1 add(T1 a, T2 b) {
    return a + b;
} 如果实例不声明,那么会按照默认值处理

变长模版参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template <typename T>
void print(T value) {
    cout << value << endl;
}

template <typename T, typename... Args>
void print(T value, Args... args) {
    cout << value << " ";
    print(args...);  // 递归调用,展开参数包
}

int main() {
    print(1, 2.5, "hello", true);  // 打印多个不同类型的值
    return 0;
} 这个例子使用了 Args... 作为一个变长模板参数包,允许 print 函数接受任意数量的参数并递归处理它们。 > 本质上是一种递归的处理。首先写出base版本,即可以终止递归的基础函数,然后在写一个调用它的递归函数,其中递归部分的参数是不确定的,采用可变参数表示。编译器在这个过程中会帮我们展开递归,并生成对应参数个数的重载函数![alt text](https://github.com/18241950925/18241950925.github.io/raw/main/images/2024-12-26-modern%20C%2B%2B/image-5.png)

类模版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
template <typename T>
class Box {
public:
    Box(T value) : value(value) {}
    T getValue() const { return value; }
private:
    T value;
};

int main() {
    Box<int> intBox(10);           // T 被推导为 int
    Box<std::string> stringBox("Hello");  // T 被推导为 std::string
    return 0;
} ### 模版特化
template <typename T>
class Box {
public:
    Box(T value) : value(value) {}
    T getValue() const { return value; }
private:
    T value;
};

// 特化版本
template <>
class Box<std::string> {
public:
    Box(std::string value) : value(value) {}
    std::string getValue() const { return "String: " + value; }
private:
    std::string value;
}; ### 模版的类型限制 因为一些函数模版,比如min函数,需要要求两个参数可以比较,因此引入concept来限制参数类型 #### concept ![alt text](https://github.com/18241950925/18241950925.github.io/raw/main/images/2024-12-26-modern%20C%2B%2B/image-2.png) 为了应用这个约束,可以按照如下方式 ``` template <Comparable T> T min(const T& a,const T& b); ``` c++有一些内置的concept ![alt text](https://github.com/18241950925/18241950925.github.io/raw/main/images/2024-12-26-modern%20C%2B%2B/image-3.png)

同样的,针对迭代器也有一些concept alt text

模版元编程

利用模版的类型推导来做一些将程序放到编译时执行的优化,比如通过模版计算斐波那契数列 alt text

constexp/consteval

因为模版元编程可能语法不太美丽,可以采用这种方式提醒编译器,可以在编译时计算该常量表达式 alt text

functions

将函数作为参数的一部分传入模版,可以进一步实行泛型编程的概念。 比如find函数,如果只有一个type的模板,就没法指定寻找策略,但是传入一个所谓的谓词,即predicate,也就是一个bool的函数,就可以自定义这一点 c++里的find_if 就支持在后面传入一个类型为函数指针的参数。bool(*)(typename)

1
2
3
4
5
6
7
8
9
10
11
12
template <typename T,typename pred>
T find_if(T begin, T end,pred predicate)
{
    for(auto it = begin; it != end;++it)
    {
        if(predicate(*it))
        {
            return it;
        }
    }
    return end;
}

但是如上面代码所描述的那样,在调用这个谓词的时候,只能传入一个参数,这可能无法满足所有功能,比如传入函数的参数为多个时,这时引入lambda函数的概念

lambda function

alt text

  1. 作用:主要是避免大量传参。比如一个函数里要调用另一个函数,但是有很多变量要传参,就可以不在外边定义一个新的函数,而是直接在这个函数里面定义一个lambda函数,可以自动获取外部变量,即caller函数中的变量
  2. 形式: auto f[捕获外围变量] (参数列表)-> 返回类型(可以为空){……} alt text [&] 意思是自动所有的外部变量按照引用捕获,[=]意思是按照值捕获,即make a copy. [this]捕获当前实例的指针,[& x]是除了x都是引用
  3. 扩展用途:可以将参数类型设置为auto,当做函数模版来用 [](auto a,auto b) {return a+b;} 参数中auto &&self代表这个函数可以递归调用自身

    1
    2
    3
    4
    5
    6
    
     auto dfs = [&](auto &&self, int idx) -> void {
             vis[idx] = 1;
             for (int i = 1; i <= n; i++)
             if (g[idx][i] && !vis[i])
                     self(self, i);
     };
    

仿函数

表现像函数的对象,这是通过重载()运算符来实现的, 例如

1
2
3
4
5
6
7
8
struct my_functor
{
    bool operator()(int a,int b){return a<b;}    
    private:
        int n;
};
my_functor f;
bool a = f(1,2);

lambda 函数的实质

本质上是一种语法糖,编译器会将他转换为仿函数,利用()调用函数,[]中捕获的变量实质上是仿函数的私有变量,通过对其实例化时传入

range /c++20

这个是stl中的一个新特性,range库里的算法可以直接将容器作为参数

1
2
std::vector<int> a{1,2,3,4,5,6,7,8,9,10,11}
auto x = std::range::find(a,3);

该函数也可以接受迭代器作为参数,比如像限制搜索范围的时候。 并且range模版使用了concept,这代表了更好地报错信息

view

view是一种对range的惰性处理,包含filter,tranform等操作,这些操作不会对原本的容器做修改抑或是生成新的对象,只是对现有range执行操作并返回操作后的视图。alt text 在前面的操作并不会发生什么,直到最后一步才会产生变化。

操作符可以用来链接多个view操作。

运算符重载

不能被重载的运算符

:: ? . .* sizeof() typeid() cast()

例子

1
2
3
4
5
bool Student::operator<(const Student&other)
{
    return id < other.id;
} ### 两种重载模式 1. 成员式重载:在类的作用域内部声明重载 2. 非成员式重载:在类的定义外部声明,但是操作符的左值和右值都要加入到参数当中。(上面的例子是在类内定义的,因此参数只有右值)
`bool operator<(const Student &lhs,const Student &rhs);`

其中第二种更符合c++,而且在stl中也基本是这种写法,因为这种写法允许左值是非类的类型。并且可以利用定义的其他类来重载这个运算符

friend 关键字

friend 关键字允许非成员函数与类访问其他类的私有变量。 在目标类的头文件中,将操作符重载函数声明为好友函数

.h

class student { private: int id; public: friend bool operator<(const student lhs&, const student rhs&); }

.cpp
1
2
3
bool operator< (const StudentID& lhs, const StudentID& rhs) {
    return lhs.id < rhs.id;
}

对上面代码的一点解释:

  1. 为什么需要friend,首先虽然这个运算符重载定义在类的里面,但是他是非成员函数式重载,并不是成员函数,因此需要friend才能访问private变量
  2. 如果是成员函数式重载,只传一个参数,因为第一个参数默认是this

    运算符重载的目的

    经过上述的重载,现在可以对两个类进行比较了,最重要的是,运算符允许表达类型的含义,但是函数不能

    ps

1
2
3
4
5
operator bool() const {
    if (value != std::nullptr_t)
    return true;
    return false;
} 可以用于用()判断指针是否为空的情况

特殊成员函数

6种特殊函数

class widget{ public: widget(); //默认构造函数 widget(const &widget w); // 复制构造函数 widget operator=(const &widget w); // 复制赋值构造函数 ~widget(); // 析构函数 widget (widget&& rhs); // 移动构造函数 widget &operator=(widget&& rhs); // 移动赋值构造函数

}

  • 复制构造函数:创建一个新对象,作为另一个对象的成员复制

    1
    2
    
      Widget widgetOne;
      Widget widgetTwo = widgetOne; // Copy constructor is called
    
  • 复制赋值构造函数:将一个已存在的对象指定给另一个对象

    1
    2
    3
    
      Widget widgetOne;
      Widget widgetTwo;
      widgetOne = widgetTwo
    

初始化链表

相比于在构造函数里初始化成员值,这个方式更加高效。因为前者实际上是先赋一个默认值,之后再对其赋值。

  • 使用样例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    
      template <typename T>
          Vector<T>::Vector() : _size(0), _capacity(4), _data(new
          T[_capacity]) { }
    
      template <typename T>
          class MyClass {
              const int _constant;
              int& _reference;
              public:
              // Only way to initialize const and reference members
              MyClass(int value, int& ref) : _constant(value),
              _reference(ref) { }
          }; ### 为什么需要重载特殊函数
      1. 对于复制构造函数,编译器默认是浅拷贝,编译器会对类里的成员都做复制,但是对于指针成员变量,他的复制实际上还是指向同一个区域
    
      Vector<T>::Vector(const Vector<T>& other)
      : _size(other._size), _capacity(other._capacity), _data(new
      T[other._capacity]) {
          for (size_t i = 0; i < _size; ++i) {
              _data[i] = other._data[i];
          }
      }
    
  1. 对于默认的析构函数,会递归调用内部的成员对象的析构函数,但不会释放new这种动态分配的,会造成内存泄漏,因此需要手动添加这个函数,释放内存

    default 和 delet

  2. 在特殊成员函数后面加上 =delet 代表这个函数不会被用到。比如对一个=的运算符重载,加上delet可以说明这个类不允许被复制
  3. 在我们手动为类实现构造函数与析构函数时,编译器就不会为我们自动生成默认的特殊成员函数,如果代码中需要默认的拷贝功能(如 PasswordManager pm1 = pm2;),但没有显式声明 = default,会导致编译错误。比如下面的例子,我们显式的声明的构造函数,为了保留默认的拷贝函数,就在下面加上这个 alt text

一些建议

  1. 如果默认的函数能用,就不要自己去定义它。(程序能跑就不要动他的意思2333)
  2. 如果不需要构造函数、析构函数或复制赋值等。那就干脆不用!如果你的类依赖于已经实现了这些 SMF 的对象/类,那么就没有必要重新实现这些逻辑!
  3. 如果您需要自定义析构函数,那么您可能还需要为您的类定义一个复制构造函数和一个复制赋值操作符

MoveSementics

move assignment

1
2
3
obj A();
obj B();
B=A; > 如果A后面没有用到,那么就是move assignment,此时如果调用A.data会报错,因为A在move给B之后就被销毁了 ### 左值与右值 - 左值有一个明确的地址,右值没有,也就是说,他是一个临时的变量. - 左值既可以在赋值左侧,也可以在右侧,右值只能在右侧 - 左值的声明周期在作用域结束,右值在那一行结束 ### 如何避免复制 1. 对于左值,可以通过传引用代替,但必须保证在函数结束的时候,左值依然是有效的 2. 对于右值,可以通过&&引用,可以移动其资源,也不用管最后它怎么样,反正是临时的 3. 对于一个函数,可以重载&和&&两个版本,由编译器决定用哪个

两个新的构造函数

移动构造函数

class photo { public: photo::photo(photo&& other) { keywords = std::move(other.keywords); } private: std::string keywords; }

移动赋值构造函数

强制移动语义

1
2
3
4
for(int i=size;i>pos;--i)
{
    elem[i] = std::move(elem[i-1]);
} move会将左值转换为右值,允许编译器选择正确的重载

rule of zero

  1. 如果一个类没有对内存的管理,或调用其他资源,那么默认是SMF就够用了

    rule of three

    如果一个类管理外部资源,那么需要手动定义复制赋值,复制构造函数,避免出现两个指针指向同一个位置的情况

    rule of five

    如果有了析构函数,复制构造,复制赋值,那么再加上移动构造,移动赋值会对是一个好选择。不是必须,但是可以优化速度

std::optional and type safety

类型安全

语言在多大程度上保证了程序的行为 考虑下面这种情况

1
2
3
4
5
6
bool is_odd(vector<int> &array)
{
    if(array.back()%2==1)
        return true;
    else return false;
}

由于不能确保array不为空,所以这个程序可能引发错误,那么为了解决这个情况,可以考虑

1
2
3
4
5
6
std::pair<bool, valueType&> vector<valueType>::back(){
    if(empty()){
    return {false, valueType()};
    }
    return {true, *(begin() + size() - 1)};
} 这样设计,之后在使用的时候返回back().second; 那么这又引发一个问题,就是该类型不一定有构造函数,或者构造函数可能默认生成的就是奇数。 因此我们引入optional ### std::optional std::optional 是一个模板类,要么包含一个 T 类型的值,要么什么都不包含(表示为 nullopt)。 - nullptr:可转换为任何指针类型值的对象  - nullopt:可转换为任何可选类型值的对象 #### 使用 - .value()方法:返回所包含的值或抛出bad_optional_access错误 - .value_or(valueType val)返回所包含值或默认值,参数val - .has_value() 如果包含的值存在,则返回true,否则返回false 使用时可以通过`if(result)`这样判断 #### .at() 与[] []会直接返回结果,不做检查,而at会检查是否超出范围,进而决定是抛出错误还是返回值

ps

c++并不会在大部分stl使用这个特性,因为这个使得时间开销增大,但是js,rust等语言会

智能指针与项目构建

异常处理

c++的异常是被throw出来的,可以通过catching来对其进行处理

1
2
3
4
5
6
7
8
9
10
11
12
try {
// code that we check for exceptions
}
catch([exception type] e1) { // "if"
// behavior when we encounter an error
}
catch([other exception type] e2) { // "else if"
// ...
}
catch { // the "else" statement
// catch-all (haha)
} ### RAII:Resource Acquisition is Initialization  - 类使用的所有资源都应在构造函数中获取 - 类使用的所有资源都应在析构函数中释放。

unique-ptr

不能被复制,例如

1
2
unique-ptr<Node> n(new Node);
unique_ptr<Node> copy =n;

此时n因为没用了,所以就调用了析构函数,但是由于copy指向的是同一块内存,就会导致copy指向的是已删除的内存

shared-ptr

通过在所有共享指针离开作用域之前不重新分配底层内存,解决了我们试图复制 std::unique_ptr 的问题

  1. 作用:自动回收内存,用于对于一块物体有多个指向的指针时
  2. 用法:

    1
    
     auto p=std::make_shared_ptr<int>(100);
    
  3. 如果必须获取裸指针,可以使用p.get()获取裸指针,但是在所有共享指针都被会收的时候,对象也一样会被销毁、
  4. 可以通过reset方法将指针指向新的对象,p.reset(new ball);

Initialization

永远使用std::make_unique and std::make_shared

  1. 最重要的原因是:如果不这样做,我们就要分配两次内存,一次分配给指针本身,另一次分配给新的 T
  2. 我们还应该保持一致–如果使用 make_unique,也要使用 make_shared。

    weak_ptr

    他的作用是用来避免循环依赖,比如说对于两个共享指针,他们对应的类的内部成员包括对方,即他们互相依赖,这说明这两个指针永远也释放不了。 这时候,将他们其中一个设为weak_ptr,那么就可以打破依赖,因为weak不被统计到使用计数里面

    类的静态多态 (CRTP)

    在CRTP中,基类是一个模板类,它接受派生类作为模板参数。派生类则通过继承基类并提供自身类型作为模板参数来实现特定功能。

    目的

    因为虚函数性能开销很大,所以采用这种方式减少开销

    具体做法

    例子如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
template <typename Derived>
class Shape {
public:
    void draw() {
        static_cast<Derived*>(this)->doDraw();
    }
};

class Circle : public Shape<Circle> {
public:
    void doDraw() {
        std::cout << "Drawing Circle" << std::endl;
    }
};

class Square : public Shape<Square> {
public:
    void doDraw() {
        std::cout << "Drawing Square" << std::endl;
    }
};

int main() {
    Circle c;
    Square s;
    c.draw();  // Outputs: Drawing Circle
    s.draw();  // Outputs: Drawing Square
}

通过模版来实现原本虚函数干的事情

浅拷贝与深拷贝 memberwise和bitwise

特性浅拷贝深拷贝
动态内存处理仅复制指针地址,原对象和拷贝对象共享同一块内存为拷贝对象分配新的内存,内容独立
资源独立性原对象和拷贝对象不独立,可能相互干扰拷贝对象和原对象完全独立
安全性可能出现重复释放或意外修改的问题无资源冲突,更安全
性能快速,仅复制指针较慢,需要分配新内存并复制数据
适用场景对象包含简单数据类型且无需独立管理资源时适用对象包含动态内存或资源需要独立管理时适用

debug

  1. case 一定加default
  2. 使用map时应该保证键一定不会重复,如果有重复的话会导致前一个记录被覆盖

参考链接

作业答案 课程主页

本文由作者按照 CC BY 4.0 进行授权