CS144 中的C++知识积累

目录

Lab 0 

Lab 2

Optional 容器

简单介绍

内存特性

智能指针

简单介绍

分类

std::unique_ptr(独占所有权)

std::shared_ptr(共享所有权)

std::weak_ptr(弱引用)

总结和实践

右值引用

左值 vs 右值

为什么需要右值引用(&&)

核心应用:移动构造函数

std::move


Lab 0 

  1. 代码用现代C++的风格进行编写,基本思想是每个对象都被设计为具有尽可能小的公共接口。具有大量的内部检查,并且知道如何自我清理,避免操作的配对。(如:malloc/free、new/delete等)还有很多现代C++风格的建议,比如不要使用malloc/free、new/delete;不要使用普通指针,要使用智能指针(仅在必要时使用);避免模板、线程、锁和虚函数等;避免C语言的字符串,用C++提供的字符串std::string;不要使用C风格的强制转换,必要的话采用C++静态强制转换static_cast;更喜欢通过常量引用传递函数参数,将变量和方法设置为常量;避免使用全局变量,对每个变量赋予最小范围;遵循RAII(Resource Acquisition Is Initialization)原则,即资源的获取与初始化绑定,资源的释放与对象的销毁绑定。
  2. C++中类的声明和函数实现一般是分开的,类的声明一般在XXX.h中,其中只有极少数简单的函数会直接实现,大部分函数都在XXX.cpp中。在.cpp中需要包含.h文件即include "XXX.h"即可。其中<>主要包含标准库系统级头文件,编译器只会在预定的系统目录中查找头文件。" "则主要包含自定义的头文件,编译器会首先在当前源文件所在目录查找,随后在用户包含路径中查找,最后在系统目录中查找。两种的区别便于区分是自定义还是标准库中的函数。
  3. 在cpp中的函数实现方式为返回类型 类名::函数名(参数列表){函数实现}。这里的::为作用域解析运算符,即告诉编辑器这里实现的函数名是哪个类的。
  4. 拓展:std命名空间。C++的标准库(Standard Library)中的所有组件(string类、cout对象、vector容器等)都被封装在一个名为std的命名空间。命名空间主要用于避免名称冲突,通过使用std::string才能明确引用标准库的字符串类。两种不需要每次都是用std::的方法(极其不推荐,每次都是用std::是最标准的方法):使用using namespace std;(OI/ACM的基因动了。。。),该方法会把std中的所有组件命名导入,但会污染该头文件的全局命名空间;使用using std::string;只引入string这个命名,较为安全。
  5. 在64位系统上,long和long long没区别,unsigned long 和unsigned long long没区别。long long和unsigned long long都是C++11引入的,追求最高移植性使用这两个;在系统编程中通常使用unsigned long(32为系统为unsigned int)表示内存地址、大小或与系统API交互(size_t为其别名)。
  6. 类成员一般_类成员名或者m_类成员名,表示其作为类成员,且权限为private或者protect。
  7. 类成员初始化:(1)类内初始化器,在类成员的定义中后跟{},表示该类型数据初始化为零值(类类型则为默认构造函数)。如size_t _buffer_read{};和std::vector<char> _buffer{};(2)构造函数初始化列表。在类的构造函数参数列表后跟:成员函数名(初始化值),...,成员函数名(初始化值){构造函数实现;} 例子如下:
    ByteStream::ByteStream(const size_t capacity) 
        :_buffer(capacity), _capacity(capacity), _buffer_remain(capacity) {}

    (3)构造函数内部赋值。这就不多说了。(4)需要注意的是常量(const修饰),只能被初始化不能被赋值,1,2都属于初始化,(静态常量例外,即可以在类内使用赋值语句赋初值)3属于赋值语句。(5)构造优先级,会先采取1即类内初始化器,后采取2初始化列表,最后采取构造函数内部赋值。

  8. const成员函数,在成员函数参数列表之后,具体实现{}之间可以加const关键词,表示该函数是一个“只读”函数,不会修改对象的任何非静态数据成员。对于编译器而言,他会将this指针修改为常量指针,即不允许你修改非静态数据。

  9. C++类型的string或者C类型的char*都是以'\0'结尾,C类型强制以这个结尾,C++为了和C兼容也有这个规定,但可以直接使用其内部成员函数更方便。

Lab 2

Optional 容器

简单介绍

一个将值本身和是否有值的状态包装在一起的容器。

在以上代码中将isn用optional容器存储,并且ackno的返回类型也为optional。如下所示:

std::optional<WrappingInt32> _isn{}; //记录isn

std::optional<WrappingInt32> ackno() const;

当想要访问内部值时需现价差是否有值在访问,如下所示:

if(!_isn.has_value()) 
     _isn = header.seqno;
 

if(_isn.has_value()) //这里注意get_index返回的是字节流号,先+1转换成绝对字节流号,再wrap转换成seqno
        ackno = wrap(ab_seqno, _isn.value());

当然也可以直接通过指针解引用访问,但更推荐使用value访问,即使没有has_value的检查,value的访问也会抛出异常,而不会导致未定义行为。

内存特性

虽然std::optional内部也是指针管理,但它和指针在内存上有很大的区别。指针通常需要在堆上分配内存,而std::optional是在栈上分配,随对象内部存储,不需要动态分配内存。当std::optional被销毁时,如果有对象,该对象的析构函数自动调用。

智能指针

简单介绍

智能指针是C++11引用的最重要特性之一(头文件<memory>),彻底改变了C++的内存管理方式,旨在解决原始指针的两个核心问题:内存泄漏和悬垂指针。

智能指针的核心思想是RAII(资源获取即初始化Resource Acquisition Is Initialization),利用栈对象的生命周期自动管理堆内存,当智能指针对象超出作用域后,析构函数函数自动释放其持有的内存。

分类

std::unique_ptr(独占所有权)

语义:“这块内存只属于我一个人”。

特性:同一时刻只能有一个unique_ptr指向该对象。不可拷贝,不能把一个unique_ptr赋值给另一个,因为这违反了独占原则。可移动,可以通过std::move将所有权“移交”给另一个unique_ptr。

例子如下图所示:

#include <memory>
#include <iostream>

struct Task {
    Task() { std::cout << "Task created\n"; }
    ~Task() { std::cout << "Task destroyed\n"; }
    void run() { std::cout << "Task running\n"; }
};

void demo_unique() {
    // 推荐使用 std::make_unique (C++14) 创建
    std::unique_ptr<Task> task1 = std::make_unique<Task>();
    task1->run();

    // std::unique_ptr<Task> task2 = task1; // ❌ 编译错误!禁止拷贝
    
    // ✅ 允许移动:所有权从 task1 转移给 task2
    std::unique_ptr<Task> task2 = std::move(task1);

    if (!task1) {
        std::cout << "task1 is now empty\n";
    }
    // 函数结束,task2 离开作用域,自动调用 delete
}
std::shared_ptr(共享所有权)

当需要在多个地方同时使用同一个对象,且无法确定谁最后销毁时使用。

语义:这块内存归大家共有,只要还有人用,就不释放。

特性:内部维护一个引用计数;每次拷贝shared_ptr,引用次数+1;每次销毁shared_ptr,引用次数-1;引用次数归零时,真正的对象被删除。性能上比unique_ptr慢,因为内部需要分配“控制块”存储计数器,且计数器增减是“原子操作”以保证线程安全。

void demo_shared() {
    // 推荐使用 make_shared,它比 new shared_ptr 更高效(内存分配优化)
    std::shared_ptr<Task> ptr1 = std::make_shared<Task>(); 
    
    {
        std::shared_ptr<Task> ptr2 = ptr1; // 拷贝,引用计数变为 2
        std::cout << "Count: " << ptr1.use_count() << "\n"; // 输出 2
    } // ptr2 离开作用域,引用计数 -1,变为 1。对象未销毁。

    std::cout << "Still alive. Count: " << ptr1.use_count() << "\n";
} // ptr1 离开作用域,引用计数变为 0 -> Task 析构
std::weak_ptr(弱引用)

通常不单独使用,而是和shared_ptr配套使用。

语义:我看着这个对系那个,但我没有所有权,如果它还在,我就用;如果它不在,我就不用。

特性:指向由shared_ptr管理的对象,但不增加引用计数;不能直接访问对象(没有*或者->操作符)。必须调用.lock()方法升级为shared_ptr才能使用。

核心用途:解决shared_ptr的循环引用问题。

struct Node {
    std::shared_ptr<Node> next;
    std::weak_ptr<Node> prev; // ✅ 使用 weak_ptr 防止循环引用
    ~Node() { std::cout << "Node destroyed\n"; }
};

void demo_weak() {
    auto nodeA = std::make_shared<Node>();
    auto nodeB = std::make_shared<Node>();

    nodeA->next = nodeB;
    nodeB->prev = nodeA; // 如果这里是 shared_ptr,A和B永远不会析构
} // 正常析构
总结和实践
特性std::unique_ptrstd::shared_ptrstd::weak_ptr
所有权独占共享无 (观察者)
拷贝禁止允许 (增加计数)允许 (不增计数)
开销极低 (同原始指针)较高 (原子操作+控制块)中等
用途90% 的场景资源共享 (如缓存、DAG图)打破循环引用、观察者模式

黄金法则:

  1. 优先使用std::make-unique(C++14起)创建对象。
  2. 如果确实需要共享,优先使用std::make_shared。
  3. 尽量避免使用new和delete,现代C++代码中几乎不应该出现裸露的new/delete。
  4. 不要用原始指针来管理所有权,原始指针只应该用来观察。

在CS144中也存在智能指针,Buffer类是TCP段的负载类,可以发现其私有属性就是std::shared_ptr<std::string>。

具体如下:

private:
    std::shared_ptr<std::string> _storage{};
    size_t _starting_offset{};

public:
    Buffer() = default;

    //! \brief Construct by taking ownership of a string
    Buffer(std::string &&str) noexcept : _storage(std::make_shared<std::string>(std::move(str))) {}

这里采用std::shared_ptr管理字符串是为了方便网络部分对字符串频繁进行去头部操作。如果采用std::unique_ptr则比如想要拿tcp字段,则必须拿另一个unique指针进行深拷贝,这样效率太低了。采用shared指针可以浅拷贝,修改offset即可。

随后构造函数中,采用了移动语义,首先传入的是一个右值引用(意思是传入的str是一个即将销毁的临时对象,或者调用者明确表示“我不再需要,你拿走吧”的对象)。std::move(str)将其转为右值,告诉编译器可以进行移动操作,随后std::make_shared<std::string>(std::move(str))表示新建一个字符串,该字符串接管原字符串的所有数据(字符串本身只有指针,大小,容量三个成员,指针指向数据内容,这里只是简单的指针赋值,不存在数据深拷贝),然后用一个std::shared_ptr智能指针接管该字符串的所有权,随后将该智能指针赋值给_storage存储。

右值引用

左值 vs 右值
  1. 左值,特征是有名字、有固定内存地址、生命周期较长;例子:变量名;判定int a = 10; 随后&a是合法的,所以a是左值。隐喻:“有户口的居民”,你可以找到它的家。
  2. 右值,特征是没有名字(通常是临时变量)、无法取地址、即将被销毁。例子:字面量,表达式结果,函数返回的临时对象。判定:int a = 10;&10是违法的,所以10是右值。&(x+y)也是非法的。隐喻:“路过的幽灵”,出现一下就马上消失,你抓不住他的地址。
为什么需要右值引用(&&)

在C++11之前,只有左值引用(&),如下;

void func(int& x) { ... }       // 只能接左值
void func(const int& x) { ... } // 万能引用,能接左值也能接右值(因为 const 承诺不修改)

但当我们把一个右值(一个临时的string对象)传给函数时,如果用const string&去接收,虽然可以接收,但由于时const,不可以修改,更不能占据其资源,只能进行深拷贝。

右值引用T&&的出现,就是为了专门“捕捉”这些即将死亡的右值。核心逻辑:既然你知道了这个对象马上就要销毁了,不如在销毁之前把资源让出来,在C++里这叫做“移动(Move)”。

举例如下:

int a = 10;

// 1. 左值引用
int& ref1 = a;      // ✅ 合法
// int& ref2 = 10;  // ❌ 错误!10 是右值,左值引用连不上它

// 2. 右值引用
int&& ref3 = 10;    // ✅ 合法!ref3 延长了 10 的生命周期
// int&& ref4 = a;  // ❌ 错误!a 是左值,右值引用不要它
核心应用:移动构造函数

右值引用最常见的用途还是移动构造函数,以以下代码为例:

class String {
    char* data;
public:
    // 拷贝构造函数 (接收 const 左值引用)
    // 必须老老实实地申请新内存,把数据复制过来
    String(const String& other) {
        std::cout << "Copying...\n";
        data = new char[strlen(other.data) + 1];
        strcpy(data, other.data);
    }

/ 移动构造函数 (接收右值引用 String&&)
    // 注意没有 const,因为我们要修改 other
    String(String&& other) noexcept {
        std::cout << "Moving (Stealing)...\n";
        
        // 1. 偷梁换柱:把它的指针拿过来
        data = other.data; 
        
        // 2. 毁灭证据:把它的指针置空
        // 否则 other 析构时会 delete data,我们也完了
        other.data = nullptr; 
    }
};

int main() {
    String s1("Hello"); // 正常构造

    // 情况 A: 拷贝
    String s2(s1);      
    // s1 是左值 -> 匹配 String(const String&) 
    // 输出: Copying... (慢,两份内存)

    // 情况 B: 移动
    String s3(String("World")); 
    // String("World") 是临时对象(右值) -> 匹配 String(String&&)
    // 输出: Moving... (快,仅仅是指针赋值)
}
std::move

如果你有一个左值s1,但明确知道,以后再也不会用s1了,想把资源转给s4。

但是s1是左值,直接写String s4(s1)会调用拷贝构造。这时就需要用到std::move()了,他的作用非常简单,强制类型转换成一个右值引用。如下所示:

// 编译器看到 std::move(s1) 返回的是 String&&
// 于是它高兴地选择了 移动构造函数
String s4(std::move(s1)); 

// s4 偷走了 s1 的资源
// s1 变成了空壳(valid but unspecified state)

这里有个坑点!!!

void process(String&& x) {
    // 这里的 x 本身是左值还是右值?
    // 答案:x 是左值!
}

这里x的类型是右值引用,但x变量本身在process函数内有名字,所以他是左值。

也就是说,在移动构造函数中,虽然行参类型是右值引用,但行参在构造函数内有名字,所以是左值,想传递给别的对象且继续保持移动语义,需要再次使用std::move()。重新看Buffer类的移动构造函数如下:

 Buffer(std::string &&str) noexcept : _storage(std::make_shared<std::string>(std::move(str))) {}

注意这里的noexcept关键字表示向编译器和程序员承诺该函数不会抛出异常,通常用于移动语义的性能优化,在移动构造函数和移动赋值运算符函数中必须加!!(析构函数默认就是noexcept的)。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值