Cpp学习手册-进阶学习

57 阅读17分钟

C++标准库和C++20新特性

C++标准库概览:

核心库组件介绍:

容器:

C++ 标准库提供了多种容器,它们各有特点,适用于不同的应用场景。

  • std::vector

    vector:动态数组,支持快速随机访问。

    #include <iostream>
    #include <vector>
    
    int main(){
        // vector
        std::vector<int> vec = {1,2,3,4,5};
        for (auto &item:vec) {
            item *= 2;
        }
        for (auto &item:vec) {
            std::cout << item << ' ';
        }
        return 0;
    }
    

    对于上面的代码我存在一些疑问:

    • auto 有什么用:

      auto 是一个类型说明符,它允许编译器自动推断变量的类型。

      就以上面这个为例:auto &item:vec 这里的 vec 是一个 std::vector<int> 对象,所以这里的 item 会被自动解析为 int& 类型。这表示 item 指向 vec 存储的某块内存的其中一个引用即指向当前元素的引用。

      使用引用的话,不需要重新复制元素,提高性能。

  • std::map

    map:存储键值对,基于红黑树实现。

    #include <map>
    int main(){
        // map
        std::map<std::string,int> mapList = {{"caiping",30},{"chengfeng",20}};
        // map 赋值
        mapList["qianwei"] = 24;
    
        // map 循环
        for (const auto &[key,value] : mapList) {
            std::cout << "key: " << key << " " << "value: " << value << std::endl;
        }
    
        return 0;
    }
    
    • const auto &[key,value] : mapList 是什么用法?有什么用?

      这里采取的是结构化绑定,auto 其实跟上面的用法一样,std::map<std::string,int> mapList 这里的 mapList 是一个对象,它存储的是一个键值对,键是 std::string 类型,值是 int 类型。

      相当于这里的 auto &[key,value] 拆分了 mapList 对象。这里的 const 的作用是它是一个修饰符,表示这里的引用是只读的,你不能通过它们修改原始数据。

  • std::list

    #include <list>
    // std::list
    int main(){
        std::list <int> intList = {1,2,3,4,5};
    
        // 这里的 it 代表的是迭代器,在这里指向的是第一个元素
        auto it = intList.begin();
        ++it; // 即指向第二个元素
        intList.insert(it,10); // 在第二个位置插入 10
    
        std::cout << "origin: ";
        for(const auto& item:intList){
            std::cout << item << " ";
        }
        std::cout << std::endl;
        ++it;  // 移动到第二个元素
        intList.erase(it);  
    
        std::cout << "after: ";
        for(const auto& item:intList){
            std::cout << item << " ";
        }
    
        return 0;
    }
    

    输出结果

    origin: 1 10 2 3 4 5
    after: 1 10 2 4 5
    

    通过输出结果我们可以观察得出:

    迭代器 it 指向在开始的时候确实是指向第二个元素的即 2 , 然后我们插入了一个元素之后在 2 之前插入的 10 这也是正确的,但是我们发现我们再次执行 ++it 之后去删除元素之后打印出来的结果发现 3 被删除了,并不是 2 被删除,表示 it 迭代器指向的还是之前的元素 2 的,然后执行 ++it 之后,指向了 3 ,所以删除的是 3

  • std::unordered_map

    #include <unordered_map>
    
    // unordered_map
    int main() {
        std::unordered_map<std::string, int> mapList = {{"apple",  3},
                                                        {"banana", 5},
                                                        {"orange", 2}};
        // 插入数据
        mapList["grape"] = 4;
    
        // 查找元素
        auto it = mapList.find("grape");
    
        if (it != mapList.end()) {
            std::cout << "Found grape: " << it->second << '\n';
        } else {
            std::cout << "grape not found\n";
        }
    
        // 遍历并打印所有元素
        for (const auto& [key, value] : mapList) {
            std::cout << key << ": " << value << '\n';
        }
        return 0;
    }
    

    输出结果

    grape not found
    orange: 2
    banana: 5
    grape: 4
    apple: 3
    

    这里我们需要注意的是

    it != mapList.end() 这个判断为什么没有问题呢?我们findgrape不就是在最后一项吗?

    • mapList.end() 是一个尾后迭代器,不指向任何实际的元素,而是表示遍历的终点
    • it != mapList.end() 用于检查 find 是否成功找到指定的键。无论 "grape" 在容器中的实际位置如何,只要 find 成功找到它,it 就不会等于 mapList.end()
    • std::unordered_map 不保证任何特定的顺序,所以 "grape" 可能不在容器的 “最后” 位置。

    这里因为使用的是迭代器所以使用的是 it->second用来指向 value

  • std::set

    #include <set>
    // std::set
    int main(){
        std::set<int> setList = {5, 3, 1, 4, 2};
    
        setList.insert(6);
    
        for(auto & item : setList){
            std::cout << item << " ";
        }
        return 0;
    }
    

    输出结果

    1 2 3 4 5 6
    
  • std::unordered_set

    #include <unordered_set>
    // std::unordered_set
    int main(){
        std::unordered_set<int> setList = {5, 3, 1, 4, 2};
    
        setList.insert(6);
    
        for(auto & item : setList){
            std::cout << item << " ";
        }
        return 0;
    }
    

    输出结果

    6 2 4 1 3 5
    

    对于 setunordered_set 来说最主要的特征是存储的元素都是唯一的,只是说 set 是有序的而unordered_set 是无序的。

总结:

std::vector 基于动态数组构造,因此支持随机访问,并且因为是动态的所以会自动管理内存。

std::list 基于双向列表,插入和删除操作快,但不支持随机访问。

std::map 基于红黑树实现,按键排序,提供对数时间复杂度的查找、插入和删除。

std::unordered_map 基于哈希表实现,平均常数时间复杂度的查找、插入和删除。

std::set 基于红黑树实现,存储唯一元素并按顺序排列。

std::unordered_set 基于哈希表实现,存储唯一元素,无序。

算法:
  • std::sort:对范围内的元素进行排序。
  • std::find:在范围内查找指定值。
  • std::transform:将一个范围内的每个元素通过函数转换后存储到另一个范围。
  • std::for_each:对范围内的每个元素应用一个函数。
  • std::copy:将一个范围内的元素复制到另一个范围。
  • std::remove:移除范围内的指定值(不会真正删除元素,而是移动到范围末尾)。
举例:
#include <algorithm>

int main() {
    std::vector<int> vec = {5, 2, 9, 1, 7, 3, 8, 4, 6};
    // std::sort
    std::sort(vec.begin(), vec.end());
    std::cout << "Sorted: ";
    for (auto &item: vec) {
        std::cout << item << " ";
    }
    std::cout << std::endl;

    // std::find
    auto it = std::find(vec.begin(), vec.end(), 7);
    if (it != vec.end()) {
        // std::distance(vec.begin(), it) 用于计算
        std::cout << "Found 7 at position: " << std::distance(vec.begin(), it) << std::endl;
    } else {
        std::cout << "7 not found" << std::endl;
    }

    // std::transform
    // 定义一个初始长度的动态数组
    std::vector<int> increase_vec(vec.size());
    std::transform(vec.begin(), vec.end(), increase_vec.begin(), [](int n) { return n + 2; });
    std::cout << "increased: ";
    for (auto & item: increase_vec) {
        std::cout << item << " ";
    }
    std::cout << std::endl;

    // std::for_each
    std::cout << "Printing each element: ";
    std::for_each(increase_vec.begin(),increase_vec.end(),[](int item){   std::cout << item << " ";});
    std::cout << std::endl;

    // std::copy
    std::vector<int> copy_vec;
    // std::back_inserter是用于创建一个特殊的输出迭代器。它可以被用来在容器的末尾添加元素,类似于调用容器的 push_back 方法。
    // 具体来说,std::back_inserter(copy_of_numbers) 实际上返回的是一个 std::back_insert_iterator 对象,
    // 这个对象重载了赋值操作符(operator=),使得每次向它赋值时都会调用目标容器的 push_back 方法来添加新的元素。
    std::copy(vec.begin(),vec.end(), std::back_inserter(copy_vec));
    std::cout << "Printing each element of copy_vec: ";
    std::for_each(copy_vec.begin(),copy_vec.end(),[](int item){   std::cout << item << " ";});
    std::cout << std::endl;

    // std::remove
    // 使用 std::remove 移除所有值为 3 的元素
    it = std::remove(copy_vec.begin(), copy_vec.end(), 3);
    // 注意:这不会改变容器的实际大小,需要手动调整大小或删除多余的元素
    copy_vec.erase(it, copy_vec.end()); // 删除从 it 到末尾的元素
    std::cout << "After removing 3: ";
    for (int num : copy_vec) std::cout << num << " ";
    std::cout << std::endl;
    return 0;
}
输出结果:
Sorted: 1 2 3 4 5 6 7 8 9
Found 7 at position: 6
increased: 3 4 5 6 7 8 9 10 11
Printing each element: 3 4 5 6 7 8 9 10 11
Printing each element of copy_vec: 1 2 3 4 5 6 7 8 9
After removing 3: 1 2 4 5 6 7 8 9
注意点:

这里 std::back_inserter 在我们 std::copy 的时候经常会配合使用,它对应的具体的一些补充上面代码注释也写了。

最后这个 std::remove 方法,虽然我们看到的确实做到了删除的效果,但它并不会真的做到删除的效果,实际上它并没有改变容器的大小,也没有释放内存。我现在举一个例子:比如说现在有一个容器 vec = {1, 2, 2, 3, 4, 5}; 在它调用remove方法之后,可能会改变容器的内容为 {1, 3, 4, 5, 2, 2} 或者 {1, 3, 4, 5, 2, 2} 中的任何一种排列,只要所有的 2 被移动到了末尾。it 会指向 5 后面的位置。所以为什么我们为什么在这里remove之后调用 erase 方法来删除 it 到向量末尾之间的所有元素。

智能指针:

C++11引入了智能指针来帮助管理动态分配的对象,从而避免内存泄漏和其他资源管理问题。

  • std::unique_ptr:独占所有权的智能指针,不能被复制,只能被移动。

    对于std::unique_ptr 使用场景:

    1. 当你只需要一个对象的所有者时

      • 当你创建一个对象并且希望只有单一的所有者来管理它的生命周期时,可以使用 std::unique_ptr。这避免了多个所有者同时管理同一个对象导致的复杂性和潜在错误。
      // 案例 1: 单一所有者
      // 假设你有一个数据库连接对象,你希望在整个程序中只有一份这个连接对象,并且确保它在不再需要时被正确释放。
      class DatabaseConnection {
      public:
          DatabaseConnection() { std::cout << "连接数据库...." << std::endl; }
          ~ DatabaseConnection() { std::cout << "断开连接数据库...." << std::endl; }
      };
      
      void useDatabase(std::unique_ptr<DatabaseConnection> databaseConnection){
          std::cout << "使用数据库连接" << std::endl;
      }
      
      int main(){
          auto db = std::unique_ptr<DatabaseConnection>();
      
          // 这样就将连接对象转移到方法useDatabase身上了
          useDatabase(std::move(db));
      
          if(!db){
              std::cout << "连接对象已转移" << std::endl;
          }
      
          return 0;
      }
      

      输出结果

      使用数据库连接
      连接对象已转移
      
    2. 在函数之间传递所有权,但不想复制对象

      • 当你需要将一个动态分配的对象从一个函数传递到另一个函数,并且不希望进行深拷贝(因为深拷贝可能会很昂贵),你可以使用 std::unique_ptr 来转移所有权。这样,接收方会成为新的唯一所有者。
    3. 作为类成员变量,自动管理类内部的资源

      • 当你的类需要管理一个动态分配的资源时,可以使用 std::unique_ptr 作为成员变量。这样可以确保当类的对象被销毁时,资源会被自动释放,从而避免内存泄漏。
  • std::shared_ptr:共享所有权的智能指针,允许多个指针指向同一个对象,引用计数为零时释放对象。

    对于std::shared_ptr 使用场景:

    1. 当你需要多个所有者共享同一个资源时

      • 当多个对象都需要访问同一个资源,并且每个对象都希望拥有该资源的所有权时,可以使用 std::shared_ptr。这样,只要有一个 std::shared_ptr 指向该资源,资源就不会被释放。
      #include <thread>
      
      // 案例 1: 多个所有者共享同一个资源
      // 假设你有一个日志记录器对象,多个线程或模块都需要访问这个日志记录器,并且每个线程或模块都希望拥有该日志记录器的所有权。
      class Logger {
      public:
          void log(const string &message) {
              cout << "Log: " << message << "\n";
          }
      };
      
      void threadFunction(shared_ptr<Logger> logger, const string &message) {
          // 这里为什么不是使用.而是使用->的原因就在于我们这里的logger并不是一个对象实例而是一个智能指针,所以指针要调用对象的方法的话需要使用->
          logger->log(message);
      }
      
      int main() {
          auto logger = make_shared<Logger>();
      
          thread t1(threadFunction, logger, "Thread 1");
          thread t2(threadFunction, logger, "Thread 2");
      
          // 等待线程完成
          t1.join();
          t2.join();
      
          return 0;
      }
      

      输出结果

      Log: Thread 1
      Log: Thread 2
      

      这里我发现会有可能出现其他情况的输出,我发现是因为并发导致的。

    2. 当资源的所有权需要在多个对象之间传递,而每个对象都需要访问该资源

      • 在某些情况下,资源的所有权可能需要在多个对象之间传递,但每个对象都需要访问该资源。std::shared_ptr 可以方便地实现这一点,因为复制 std::shared_ptr 会增加引用计数,确保资源不会过早释放。

        // 案例 2: 资源所有权在多个对象之间传递
        // 假设你有一个图形库,其中包含多个图形对象(如 Circle 和 Rectangle),并且这些图形对象需要共享同一个绘图上下文(如 GraphicsContext)。
        class GraphicsContext{
        public:
            void draw(const std::string& shape){
                cout << "Drawing: " << shape << endl;
            }
        };
        
        class Shape {
        protected:
            shared_ptr<GraphicsContext> context;
        public:
            Shape(shared_ptr<GraphicsContext> context): context(context) {}
            virtual void draw() = 0;
        };
        
        class Circle : public Shape {
        public:
            // 其实这个语法就是将Circle的构造函数的ctx赋值给shape中的context
            Circle(std::shared_ptr<GraphicsContext> ctx) : Shape(ctx) {}
        
            void draw() override {
                context->draw("Circle");
            }
        };
        
        class Rectangle : public Shape {
        public:
            Rectangle(std::shared_ptr<GraphicsContext> ctx) : Shape(ctx) {}
        
            void draw() override {
                context->draw("Rectangle");
            }
        };
        
        int main(){
            auto context = make_shared<GraphicsContext>();
            Circle circle(context);
            Rectangle rectangle(context);
        
            circle.draw();
            rectangle.draw();
        
            return 0;
        }
        

        输出结果

        Drawing: Circle
        Drawing: Rectangle
        
    3. 在复杂的对象图中,当多个对象需要引用同一个资源时

      • 在复杂的数据结构或对象图中,可能存在多个对象需要引用同一个资源的情况。使用 std::shared_ptr 可以简化这些引用关系的管理,避免手动管理资源的生命周期。

        // 案例 3: 复杂对象图中的资源共享
        // 假设你有一个树形结构,其中每个节点都包含一个共享的数据块,并且多个节点可能引用同一个数据块。
        #include <vector>
        class DataBlock {
        public:
            int value;
            DataBlock(int val):value(val){}
        };
        
        class TreeNode {
        private:
            shared_ptr<DataBlock> data;
            vector<TreeNode> children;
        
        public:
            TreeNode(shared_ptr<DataBlock> d) :data(d){}
        
            void addChild(TreeNode child){
                children.push_back(child);
            }
        
            void printData() const {
                cout << "Data:" << data->value << endl;
            }
        
            void printChildrenData() const{
                for (const auto& item: children) {
                    item.printData();
                }
            }
        };
        
        
        int main(){
            auto shareData = make_shared<DataBlock>(42);
        
            TreeNode root(shareData);
            TreeNode child1(shareData);
            TreeNode child2(shareData);
        
            root.addChild(child1);
            root.addChild(child2);
        
            root.printData();
            cout << "--------------------------" << endl;
            root.printChildrenData();
        
            return 0;
        }
        

        输出结果

        Data:42
        --------------------------
        Data:42
        Data:42
        
  • std::weak_ptr:不控制对象生命周期的智能指针,用于解决循环引用问题。

    1. 当你需要观察 std::shared_ptr 所管理的对象,但不希望增加引用计数时
      • 有时你需要观察一个由 std::shared_ptr 管理的对象,但不想影响其生命周期。例如,在缓存或观察者模式中,你可能希望在对象仍然存在时访问它,但在对象被销毁后不再访问。
    2. 解决 std::shared_ptr 之间的循环引用问题
      • 当两个或多个 std::shared_ptr 相互引用时,可能会导致内存泄漏,因为每个 std::shared_ptr 都会增加其他 std::shared_ptr 的引用计数,从而永远不会释放资源。使用 std::weak_ptr 可以打破这种循环引用,确保资源能够被正确释放。
    3. 在缓存或观察者模式中,当你需要检查对象是否仍然存在而不影响其生命周期时
      • 在某些设计模式中,如观察者模式或缓存机制,你可能需要存储对对象的弱引用,以便在需要时检查对象是否仍然存在。如果对象已经被销毁,你可以采取相应的措施(如删除缓存条目)。
多线程:

C++11引入了多线程支持,包括线程创建、同步机制和异步任务处理。

  • <thread>:创建和管理线程。这个头文件提供了创建和管理线程的功能。你可以使用std::thread来创建一个新线程,并传递给它一个可调用对象(如函数、lambda表达式等)。当线程运行完毕后,可以使用join()方法等待线程结束,或者使用detach()方法让线程独立于创建它的线程执行。

    #include <iostream>
    #include <thread>
    
    void threadFuc(){
        std::cout << "Hello from a thread!" << std::endl;
    }
    
    // thread
    int main(){
        std::thread t(threadFuc);
        t.join(); // 等待线程t完成
        return 0;
    }
    
  • <mutex>:互斥锁,用于保护共享数据。这个头文件提供了一系列的互斥锁类型,用于同步访问共享资源,防止数据竞争。std::mutex是最基本的一种互斥锁,而std::lock_guardstd::unique_lock是用于简化锁定/解锁过程的RAII风格的包装器。

    #include <mutex>
    
    std::mutex m;
    int shared_data = 0;
    
    void safe_increment() {
        std::lock_guard<std::mutex> lock(m); // 自动管理锁
        ++shared_data;
    }
    
    int main(){
        std::thread t1(safe_increment), t2(safe_increment);
        t1.join();
        t2.join();
        std::cout << "Shared data: " << shared_data << std::endl;
        return 0;
    }
    

    这里最重要的是 std::lock_guard<std::mutex> lock(m); 结合 std::mutex m; 使用。

  • <future>:异步计算的结果,可以通过std::asyncstd::promise来获取。

    这个头文件允许你进行异步计算,并且能够获取结果。std::async是一个非常方便的工具,它可以启动一个异步任务,并返回一个std::future对象,该对象可以用来查询任务的状态或获取最终的结果。另外,std::promisestd::future一起工作,可以在不同的线程之间安全地传递结果。

    #include <future>
    
    int compute(int x) {
        return x * x;
    }
    
    int main() {
        std::future<int> result = std::async(compute, 5); // 异步调用compute
        std::cout << "Result: " << result.get() << std::endl; // 获取结果
        return 0;
    }
    

C++20新特性:

  • 概念(Concepts):学习如何定义和使用约束来改进模板代码。

    概念允许我们为模板参数指定更具体的约束条件。这不仅增加了代码的可读性,还帮助编译器在早期发现类型错误。

    比如说我们可以定义一个整数类型的概念,在使用的时候直接通过定义好的这个类型并配合关键字 auto 进行调用,如果传入是一个浮点数值,则会出现编译错误。

  • 模块(Modules):探索模块化编程以提高构建效率和代码组织。

    模块旨在替代传统的头文件机制,提供更好的封装性和更快的编译速度。

    比如说我们可以创建一个包含数学方法的模块文件,通过 export 将定义好的模块以及方法暴露出去,在我们程序中想要使用这个模块的内容时,使用 import 导入并直接调用对应的方法即可。

  • 其他特性

    std::span:提供了对数组或容器的一段连续元素的轻量级视图。

    std::format:用于格式化字符串输出

    constexpr if:它允许在编译时根据常量表达式的值选择性地编译代码分支。

模板元编程、类型推导和 constexpr

模板元编程:

基础概念

  • 模板参数:可以是类型、非类型参数(如整数或指针)或模板。

    比如说,template<typename T, int N> class Array {};

  • 特化:为特定类型的模板提供具体的实现。

    比如说,template<> class Array<int, 10> {};

  • 偏特化:对模板的部分参数进行特化,通常用于类模板。

    比如说,template<typename T> class Array<T, 5> {};

这里只做一个简单的描述,后期会出详细的一些讲解。

泛型编程

#include <iostream>

template <typename T>
T max(T a, T b) {
    return (a > b) ? a : b;
}


int main(){
    int a = 1;
    int b = 2;

    auto maxValue = max(a,b);

    std::cout << "max Value is " << maxValue << std::endl;
    return 0;
}

输出结果

max Value is 2

类型推导:

  • auto:正确地使用auto关键字简化变量声明。

    举个例子

    int main(){
        std::vector<int> numbers = {1,2,3,4,5,6};
    
        for (auto item: numbers) {
            std::cout << item << " ";
        }
    
        return 0;
    }
    

    在这里 auto 允许编译器自动推断出变量的类型即 item 就是对应的 int 类型。

  • decltype:获取表达式的类型,并用于更复杂的类型推导。

    int main(){
        int x = 5;
        const int& y = x;
        auto z1 = y; // 这里使用 auto 推导出来的 z1 的类型是 int
        decltype(y) z2 = y; // 这里如果使用 decltype 推导出来的 z2 的类型是 const int & 类型
        return 0;
    }
    

constexpr:

  • 常量表达式:编写可以在编译期求值的函数和变量。

  • 编译期计算:利用constexpr进行数学运算和其他计算,减少运行时开销。

    举例

    constexpr int square (int x) {
        return x * x;
    }
    
    constexpr int factorial(int n) {
        return (n <= 1) ? 1 : (n * factorial(n - 1));
    }
    
    
    int main(){
        constexpr int result = square(5); // 编译时计算结果
        std::cout << "result: " << result << std::endl;
        constexpr int fact_5 = factorial(5); // 在编译时计算阶乘
        std::cout << "fact_5: " << fact_5 << std::endl;
        return 0;
    }
    

    输出结果

    result: 25
    fact_5: 120
    

总结:

这一章节我们学会了进阶的 C++ 的理论知识。包括对一些核心库的内容介绍:容器、算法、智能指针、多线程等等。接着我们讲述了 C++20 新特性以及模板元编程、类型推导和 constexpr。接下来我会做一个实战来练习一下我们之前所学的内容,实现一个高性能数值计算库。