From 485c2cdb8133b7f33d15e5d4e6d3b3cb1e55cac2 Mon Sep 17 00:00:00 2001 From: SPeak Date: Wed, 22 May 2024 22:53:05 +0800 Subject: [PATCH] Embedded slist (#17) * embedded list * update tests * update --- .gitignore | 3 +- .vscode/settings.json | 23 + exercises/linked-list/EmbeddedList.hpp | 10 + src/1_linkedlist.md | 564 +++++++++++++++++++++++ src/SUMMARY.md | 8 +- src/chapter_04_embeddedlist.md | 331 +++++++++++++ tests/embedded-list/embedded-slist.0.cpp | 23 + tests/embedded-list/embedded-slist.1.cpp | 41 ++ tests/embedded-list/embedded-slist.2.cpp | 47 ++ tests/embedded-list/embedded-slist.3.cpp | 50 ++ tests/embedded-list/embedded-slist.4.cpp | 55 +++ xmake.lua | 25 + 12 files changed, 1177 insertions(+), 3 deletions(-) create mode 100644 exercises/linked-list/EmbeddedList.hpp create mode 100644 src/1_linkedlist.md create mode 100644 src/chapter_04_embeddedlist.md create mode 100644 tests/embedded-list/embedded-slist.0.cpp create mode 100644 tests/embedded-list/embedded-slist.1.cpp create mode 100644 tests/embedded-list/embedded-slist.2.cpp create mode 100644 tests/embedded-list/embedded-slist.3.cpp create mode 100644 tests/embedded-list/embedded-slist.4.cpp diff --git a/.gitignore b/.gitignore index 856667b..19d8f52 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ book .xmake -build \ No newline at end of file +build +.vscode \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index e314f30..a00ab75 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,5 +3,28 @@ "**/.vscode": true, "**/build": true, "/.xmake": true + }, + "files.associations": { + "array": "cpp", + "string": "cpp", + "string_view": "cpp", + "deque": "cpp", + "list": "cpp", + "*.tcc": "cpp", + "hash_map": "cpp", + "unordered_map": "cpp", + "vector": "cpp", + "functional": "cpp", + "optional": "cpp", + "ratio": "cpp", + "system_error": "cpp", + "tuple": "cpp", + "type_traits": "cpp", + "utility": "cpp", + "istream": "cpp", + "ostream": "cpp", + "chrono": "cpp", + "compare": "cpp", + "cstddef": "cpp" } } \ No newline at end of file diff --git a/exercises/linked-list/EmbeddedList.hpp b/exercises/linked-list/EmbeddedList.hpp new file mode 100644 index 0000000..bcc6b19 --- /dev/null +++ b/exercises/linked-list/EmbeddedList.hpp @@ -0,0 +1,10 @@ +#ifndef EMBEDDED_LIST_HPP_D2DS +#define EMBEDDED_LIST_HPP_D2DS + +namespace d2ds { +// show your code + + +} + +#endif \ No newline at end of file diff --git a/src/1_linkedlist.md b/src/1_linkedlist.md new file mode 100644 index 0000000..f7b3b8d --- /dev/null +++ b/src/1_linkedlist.md @@ -0,0 +1,564 @@ +# 动态数组Vector + +**预览** + +--- + +- 基本介绍 + - 定长数据 + - 变长数组 +- Vector核心实现 + - 类型定义和数据初始化 - 自定义分配器支持 + - BigFive - 行为控制 + - 常用函数和数据访问 + - 数据增删和扩容机制 - resize + - 迭代器支持 - 范围for + - 功能扩展 - 向量加减法 +- 总结 + +--- + +Vector是一个动态大小的数组, 元素存储在一个动态分配的连续空间。在使用中, 可以向Vector添加元素或删除数据结构中已有的元素, 内部会自动的根据数据量的大小进行扩大或缩小容量 + +**手动管理** + +```cpp +int main() { + int *intArr = (int *)malloc(sizeof(int) * 2); + intArr[0] = 1; intArr[1] = 2; // init + + // do something + + int *oldIntArr = intArr; + intArr = (int *)malloc(sizeof(int) * 4); + + intArr[0] = oldIntArr[0]; intArr[1] = oldIntArr[1]; // copy + free(oldIntArr); + + intArr[2] = 3; + intArr[3] = 4; + + for (int i = 0; i < 4; i++) { + std::cout << intArr[i] << " "; + } + std::cout << std::endl; + for (int i = 0; i < 2; i++) { + std::cout << intArr[i] << " "; + } + + free(intArr); + + return 0; +}; + +``` + +**自动管理** + +```cpp +int main() { + d2ds::Vector intArr = { 1, 2 }; + + intArr.push_back(3); + intArr.push_back(4); + + for (int i = 0; i < 4; i++) { + std::cout << intArr[i] << " "; + } + std::cout << std::endl; + + intArr.pop_back(); + intArr.pop_back(); + + for (int i = 0; i < intArr.size() /* 2 */; i++) { + std::cout << intArr[i] << " "; + } + + return 0; +}; +``` + +**输出结果** + +```cpp +1 2 3 4 +1 2 +``` + +上面使用`Vector`创建了一个`intArr`数组, 并在使用中通过`push_back`和`pop_back`改变了数组的长度, 而关于存储数据的内存的扩大和缩小全由`Vector`内部完成, 对使用者是"透明"的, 从而降低了开发者手动去管理内存分配的负担 + +## Vector核心实现 + +### 类型定义和数据初始化 + +**统一分配器接口** + +使用一个分配器类型作为作用域标识, 类型中包含两个静态成员函数用于内存的分配和释放 + +```cpp +struct Allocator { + static void * allocate(int bytes); + static void deallocate(void *addr, int bytes); +}; +``` + +其中`allocate`用于分配内存, 它的参数为请求的内存字节数; `deallocate`用于内存的释放, addr为内存块地址, bytes为内存块的大小 + +**类型定义** + +第一个模板参数用于接收数据类型, 第二个参数用于接收一个满足上面标准的分配器类型。为了方便使用, 使用`DefaultAllocator`作为分配器模板参数的默认类型, 这样开发者在不明确指定分配器的时候就会使用默认的分配器进行内存分配 + +```cpp +template +class Vector { + +}; +``` + +> 注: `DefaultAllocator`是一个已经定义在`d2ds`命名空间的分配器。文件: common/common.hpp + +**数据初始化** + +```cpp +d2ds::Vector vec1; +d2ds::Vector vec2(10); +d2ds::Vector vec3 = { 1, 2, 3 }; +``` + +定义数据成员, 并实现常见的默认初始化、指定长度的初始化、列表初始化器初始化 + +```cpp +template +class Vector { +public: + + Vector() : mSize_e { 0 }, mDataPtr_e { nullptr } { } + + Vector(int size) : mSize_e { size } { + mDataPtr_e = static_cast(Alloc::allocate(sizeof(T) * mSize_e)); + for (int i = 0; i < mSize_e; i++) { + new (mDataPtr_e + i) T(); + } + } + + Vector(std::initializer_list list) { + mSize_e = list.end() - list.begin(); + mDataPtr_e = static_cast(Alloc::allocate(sizeof(T) * mSize_e)); + auto it = list.begin(); + T *dataPtr = mDataPtr_e; + while (it != list.end()) { + new (dataPtr) T(*it); + it++; dataPtr++; + } + } + +private: + int mSize_e; + T * mDataPtr_e; +}; +``` + +定义一个`mSize_e`来标识元素数量, 使用`Alloc`进行内存分配来存储数据, 并由`mDataPtr_e`来管理。 +同时配合使用**定位new**(placenment new)来完成数据的构造。这里把元素对象的创建划分成了两步: 第一步, 分配对应的内存; 第二步, 基于获得的内存进行构造对象 + +> 注: C++中使用`new Obj()`创建对象, 可以看作是`ptr = malloc(sizeof(Obj)); new (ptr) Obj();`这两步的组合。详情见[深入理解new/delete]()章节 + + +### BigFive - 行为控制 + +**析构行为** + +由于使用了内存分配和对象构造分离的模式, 所以在析构函数中需要对数据结构中的元素要先析构, 最后再释放内存。即需要满足如下构造/析构链, 让对象的创建和释放步骤对称: + +- 分配对象内存A +- 基于内存A构造对象B +- 析构对象B +- 释放B对应的内存A + +```cpp +template +class Vector { +public: + ~Vector() { + if (mSize_e) { + for (int i = 0; i < mSize_e; i++) { + (mDataPtr_e + i)->~T(); + } + Alloc::deallocate(mDataPtr_e, mSize_e * sizeof(T)); + } + } +} +``` + +**拷贝语义** + +在拷贝构造函数中, 使用`new (addr) T(const T &)`把**拷贝构造语义**传递给数据结构中存储的元素 + +```cpp +template +class Vector { +public: + Vector(const Vector &dsObj) : mSize_e { dsObj.mSize_e } { + mDataPtr_e = (T *) Alloc::allocate(sizeof(T) * mSize_e); + for (int i = 0; i < mSize_e; i++) { + new (mDataPtr_e + i) T(dsObj.mDataPtr_e[i]); + } + } +} +``` + +在拷贝赋值函数中, 先调用析构函数进行数据清理, 同时也使用`operator=`进行语义传递 + +```cpp +template +class Vector { +public: + Vector & operator=(const Vector &dsObj) { + D2DS_SELF_ASSIGNMENT_CHECKER + this->~Vector(); + mSize_e = dsObj.mSize_e; + mDataPtr_e = static_cast(Alloc::allocate(sizeof(T) * mSize_e)); + for (int i = 0; i < mSize_e; i++) { + mDataPtr_e[i] = dsObj.mDataPtr_e[i]; + } + return *this; + } +} +``` + +**移动语义** + +在移动构造函数中, 只需要把要目标对象的资源移动到该对象, 然后对被移动的对象做重置操作即可。对于Vector来说, 只需进行**浅拷贝**数据成员, 并对被移动的对象置空 + +```cpp +template +class Vector { +public: + Vector(Vector &&dsObj) : mSize_e { dsObj.mSize_e } { + mDataPtr_e = dsObj.mDataPtr_e; + // reset + dsObj.mSize_e = 0; + dsObj.mDataPtr_e = nullptr; + } +} +``` + +在移动赋值函数中, 比移动构造多了对对象本身资源的释放操作 + +```cpp +template +class Vector { +public: + Vector & operator=(Vector &&dsObj) { + D2DS_SELF_ASSIGNMENT_CHECKER + this->~Vector(); + mSize_e = dsObj.mSize_e; + mDataPtr_e = dsObj.mDataPtr_e; + // reset + dsObj.mSize_e = 0; + dsObj.mDataPtr_e = nullptr; + return *this; + } +} +``` + +### 常用函数和数据访问 + +**常用函数 - size / empty** + +```cpp +template +class Vector { +public: + int size() const { + return mSize_e; + } + + bool empty() const { + return mSize_e == 0; + } +} +``` + +**数据访问** + +```cpp +d2ds::Vector intArr3 = { -1, -2, -3 }; +const d2ds::Vector constIntArr3 = { 1, 2, 3 }; +``` + +Vector存在被`const`修饰的情况, 所以`operator=`也要对应实现一个`const`版本, 返回值为`const T &` + +```cpp +template +class Vector { +public: + T & operator[](int index) { + return mDataPtr_e[index]; + } + + const T & operator[](int index) const { + return mDataPtr_e[index]; + } +} +``` + +### 数据增删 - 扩容和缓存机制 + +当动态数组Vector执行push操作进行添加元素时, 如果每次都需要重新分配内存这会极大的影响效率 + +```cpp +void push(const int &obj) { + newDataPtr = malloc(sizeof(int) * (size + 1)); // 分配内存 + copy(newDataPtr, oldDataPtr); // 复制数据 + free(oldDataPtr); // 释放内存 + newDataPtr[size + 1] = obj; // 添加新元素 + size++; // 数量加1 +} +``` + +通过引入内存容量的缓存或者说预分配机制, 来避免过多的内存分配释放, 可以有效的降低它的影响。所以就需要引入另外一个标识`mCapacity_e`来标识当前内存最大容量, 而`mSize_e`用来标识当前数据结构中的实际元素数量, 所以`mCapacity_e`是大于等于`mSize_e`的 + +```cpp +template +class Vector { +private: + int mSize_e, mCapacity_e; + T * mDataPtr_e; +} +``` + +这里需要先说明一下, 扩容(缩容)机制通常是包含两个概念或步骤: + +- 第一个是, 扩容(缩容)的条件, 也是执行实际操作的时机。通常扩容发生再数据增加操作, 缩容发生数据删除操作中 +- 第二个是, 具体的扩容(缩容)规则。最简单的就是二倍扩容(缩容) + +> 注: 成员变量的变动, 意味着对应的**BigFive**也需要修改 + +**push_back 和 扩容** + +在每次扩容的时候, 可以选择基于当前容量的二倍进行扩容。例如: 当`mCapacity_e`等于4时, 做扩容时应该分配可以容纳8个元素的内存 + +```cpp +d2ds::Vector intArr = {0, 1, 2, 3}; +intArr.push_back(4); +/* +old: mCapacity_e == 4, mSize_e == 4 + +---------------+ +mDataPtr_e -> | 0 | 1 | 2 | 3 | + +---------------+ +new: mCapacity_e == 8, mSize_e == 5 + +-------------------------------+ +mDataPtr_e -> | 0 | 1 | 2 | 3 | 4 | | | | + +-------------------------------+ +*/ +``` + +什么时候扩容? 最直观的是增加元素, 但容量又不够的时候。执行push_back时, 当`mSize_e + 1 > mCapacity_e`时就需要扩容来获取更大的空间用于新数据/元素的存放, 既是否扩容需要在存储新元素操作之前 + +```cpp +template +class Vector { +public: + void push_back(const T &element) { + if (mSize_e + 1 > mCapacity_e) { + resize(mCapacity_e == 0 ? 2 : 2 * mCapacity_e); + } + new (mDataPtr_e + mSize_e) T(element); + mSize_e++; + } +} +``` + +**pop_back 和 缩容** + +当数据量减少时, 同样需要释放过多的内存容量来避免内存浪费。这时就引入一个问题, 如果使用二倍原则, 是当数据结构中的真实数据量等于最大容量的1/2时进行重新分配吗? 考虑一下这样的场景: + +```cpp +d2ds::Vector intArr = { 1, 2, 3, 4 }; +for (int i = 0; i < 10; i++) { + intArr.push_back(i); // 触发扩容 + // ... + intArr.pop_back(); // 触发缩容 +} +``` + +当频繁小数据量的增加和减少, 就会造成Vector内部不停的扩容和缩容操作, 这种现象也称为——**抖动**。 + +为了近可能的避免这种情况, 在执行缩容之后也应该保留/缓存一部分未使用的内存空间, 用于后续可能的数据增加操作。即扩容或者缩容都要保证一定的空闲内存, 用于后续可能的操作。如: 下面就是1/3触发条件, 2倍(1/2)扩容机制的内存变化情况 + +```cpp +mCapacity_e == 8, mSize_e == 5 + +-------------------------------+ +mDataPtr_e -> | 0 | 1 | 2 | 3 | 4 | | | | + +-------------------------------+ + +intArr.pop_back(); + +mCapacity_e == 8, mSize_e == 4 + +-------------------------------+ +mDataPtr_e -> | 0 | 1 | 2 | 3 | | | | | + +-------------------------------+ + +intArr.pop_back(); + +mCapacity_e == 8, mSize_e == 3 + +-------------------------------+ +mDataPtr_e -> | 0 | 1 | 2 | | | | | | + +-------------------------------+ + +intArr.pop_back(); + +mCapacity_e == 4, mSize_e == 2 + +---------------+ +mDataPtr_e -> | 0 | 1 | | | + +---------------+ +``` + +当`mSize_e <= mCapacity_e / 3`时就触发一次二倍扩容机制的执行, 把容量从8缩小一半到4, 此时实际存储的数据量`mSize_e == 2`。这里需要注意的是, 虽然`pop_back`不一定会释放Vector管理的内存, 但依然需要去调用被删除元素的析构函数去释放它额外管理的资源(如果存在) + +```cpp +template +class Vector { +public: + void pop_back() { + mSize_e--; + (mDataPtr_e + mSize_e)->~T(); + if (mSize_e <= mCapacity_e / 3) { + resize(mCapacity_e / 2); + } + } +} +``` + +**resize实现** + +对于resize的实现, 需要关注的核心点: + +- 新老内存的分配和释放 +- 老数据的迁移 + +首先进行分配一块能存n个元素的内存块, 然后在对数据进行迁移, 最后释放老的内存块。在进行数据迁移的过程中, 如果使用拷贝语义则需要通过**显式调用**析构进行释放老的内存, 如果使用移动语语义则可以避免**在所管理元素对象内部的资源的频繁分配释放**。为了能呈现主要骨架但有不过于复杂, 下面只实现了`mSize_e <= n`的情况的简化版本 + +```cpp +template +class Vector { + void resize(int n) { // only mSize_e <= n + auto newDataPtr = n == 0 ? nullptr : static_cast(Alloc::allocate(n * sizeof(T))); + + for (int i = 0; i < mSize_e; i++) { + new (newDataPtr + i) T(mDataPtr_e[i]); + (mDataPtr_e + i)->~T(); + } + + if (mDataPtr_e) { + // Note: + // memory-size is mCapacity_e * sizeof(T) rather than mSize_e * sizeof(T) + Alloc::deallocate(mDataPtr_e, mCapacity_e * sizeof(T)); + } + + mCapacity_e = n; + mDataPtr_e = newDataPtr; + } +} +``` + +### 迭代器支持 + +由于Vector用于存储数据元素的内存是连续的, 所以可以使用原生指针作为数据访问的迭代器 + +```cpp +const d2ds::Vector constIntArr = intArr; +int sum = 0; +for (auto &val : constIntArr) { + sum += val; +} +``` + +为了让被`const`修饰的Vector, 可以正常使用迭代器访问数据, 所以可以再实现一套const版本的begin和end + +```cpp +template +class Vector { +public: + T * begin() { + return mDataPtr_e; + } + + T * end() { + return mDataPtr_e + mSize_e; + } + + const T * begin() const { + return mDataPtr_e; + } + + const T * end() const { + return mDataPtr_e + mSize_e; + } +}; +``` + +### 功能扩展 - 向量加减法 + +假设有如下**OQ**、**OP**、**QP**三个向量 + +```cpp +^ +| * P(2, 4) +| +| *Q(4, 1) +*--------------> +O(0, 0) +``` + +```cpp +d2ds::Vector OQ = { 4, 1 }; +d2ds::Vector OP = { 2, 4 }; +d2ds::Vector QP = { -2, 3 }; +d2ds_assert(OQ + QP == OP); +d2ds_assert(OP - OQ == QP); +``` + +下面通过重载`operator+`和`operator-`来扩展下Vector再向量中的应用。这里为了直观我们直接假设向量是2维的, 在运算符重载函数中分别再实现向量的加减算法即可。怎么支持N维向量? 想必你心中已有答案 + + +```cpp +namespace d2ds { + +template +bool operator==(const Vector &v1, const Vector &v2) { + bool equal = v1.size() == v2.size(); + if (equal) { + for (int i = 0; i < v1.size(); i++) { + if (v1[i] != v2[i]) { + equal = false; + break; + } + } + } + return equal; +} + +template +Vector operator+(const Vector &v1, const Vector &v2) { + Vector v(2); + v[0] = v1[0] + v2[0]; + v[1] = v1[1] + v2[1]; + return std::move(v); +} + +template +Vector operator-(const Vector &v1, const Vector &v2) { + Vector v(2); + v[0] = v1[0] - v2[0]; + v[1] = v1[1] - v2[1]; + return std::move(v); +} + +} +``` + +## 总结 + +本章节先是对比了一下, 对变长数组有需求的场景下。使用Vector自动管理内存和手动管理内存的差异和优势。然后,介绍了需要动态分配内存的数据结构如何去支持用户自定义分配的方法; 以及在内部自动管理内存的扩容机制的核心原理和对应**二倍扩容机制**的简单实现; 最后, 介绍了一个对Vector进行在向量领域的扩展应用。当然, 为了能够在呈现出动态数组Vector的核心原理下, 但又不过于复杂和拘迂细节, 本章中并没有去实现同样很常用的一些功能如: erase、back、data等。**但我相信在你学习完本章内容后的此时此刻, 你已基本具备自己去实现他们的能力** \ No newline at end of file diff --git a/src/SUMMARY.md b/src/SUMMARY.md index 161c8de..8f280d2 100644 --- a/src/SUMMARY.md +++ b/src/SUMMARY.md @@ -8,13 +8,15 @@ - [数组](0_array.md) - [定长数组Array](chapter_01_array.md) - [动态数组Vector](chapter_02_vector.md) + - [Array和Vector对比]() -- [链表]() - - [嵌入式单链表]() +- [链表](1_linkedlist.md) + - [嵌入式单链表](chapter_04_embeddedlist.md) - [单链表]() - [嵌入式双链表]() - [双链表]() - [静态链表]() + - [几种链表对比]() - [栈]() - [栈适配器]() @@ -24,6 +26,8 @@ - [双端队列]() - [循环队列]() +- [数组VS链表]() + # 非线性数据结构 - [树]() diff --git a/src/chapter_04_embeddedlist.md b/src/chapter_04_embeddedlist.md new file mode 100644 index 0000000..160d6a0 --- /dev/null +++ b/src/chapter_04_embeddedlist.md @@ -0,0 +1,331 @@ +# 嵌入式单链表 + +**预览** + +--- + +- 核心原理 + - 结构 - 链域和数据域 + - 操作 - 链表的逻辑抽象 + - 使用 - 数据存储和访问 +- 设计/使用技巧 + - 通信库 - 组合式 + - Linux内核 - 嵌入式 + - V8引擎 - 继承式(C++) +- 总结 + +--- + +嵌入式链表是一种高性能且范型支持的链表。同时也是一种"底层"的数据结构。它在内存效率和性能上的优秀的表现, 使得在[Linux内核](https://github.com/torvalds/linux/blob/master/include/linux/list.h)、[浏览器的v8引擎](https://source.chromium.org/chromium/chromium/src/+/main:v8/src/heap/list.h?q=ListNode&ss=chromium%2Fchromium%2Fsrc)、[Redis数据库](https://github.com/redis/redis/blob/unstable/src/adlist.h)等许多大型的开源项目中都有使用。对于大多数的数据结构实现, 关注的核心点可以归为**内存管理**、**类型控制**、**操作**这三个方面。通常这是库作者的工作, 而使用者只需要关心数据。而在嵌入式链表的使用中**内存管理**、**类型控制**是常需要使用者来显示控制的, 这使得它的使用难度远大于普通数据结构。这也是为什么它常应用到一些追求性能的系统模块, 而应用软件中却很少见到它的身影, 下面我们将从它的最小代码实现开始一步一步介绍其设计理念和使用技巧 + +### 结构 - 链域和数据域 + +```cpp +struct ListNode { + struct ListNode *next; // link区域 + int data; // data区域 +}; +``` + +一个链表节点可以分成**link区域**和**数据区域**。其中,数据区用于储存数据,link区域用于存储指向下一个节点的指针,把分散的节点串连成一个链表。 + +### 操作 - 链表的逻辑抽象 + +如果我们像上面一样, 把数据类型和链表进行耦合。就会发现, 每定义一个链表就**只能给特定的数据类型使用**, 很难实现通用数据结构。当然, 在C++中有很多方法来实现这种通用性。例如: 编译器代码生成技术 - 模板 + +```cpp +template +struct ListNode { + struct ListNode *next; // link区域 + T data; // data区域 +}; +``` + +但模板的本质就是需要手写两遍的代码量, 转为编译器来帮你手写了。从底层角度看**数据类型**和**链表**依然是耦合的, 并且C语言中是不支持模板的。对于链表的很多操作一定要和存储的数据类型进行绑定吗?显然,链表的操作从逻辑上是和数据类型无关的。例如下面把**数据区域**丢弃的链表代码: + +```cpp +struct SinglyLink { + struct SinglyLink *next; +}; + +static void insert(SinglyLink *prev, SinglyLink *target) { + target->next = prev->next; + prev->next = target; +} + +static void remove(SinglyLink *prev, SinglyLink *target) { + prev->next = target->next; + target->next = target; +} +``` + +这是不是链表? 是。但不存数据的链表有什么意义呢? 如果这时我说——这就是**嵌入式链表**的最小原型。你会不会产生如下疑问: + +- 它的使用方法? +- 它存储和管理数据的原理? + +下面我们将逐一回答 + +### 使用 - 数据存储和访问 + +开头的简介里也说了, 嵌入式链表只管理数据, 内存的分配和释放是由使用者完成的。这样只要节点中包含一个统一的**SinglyLink链接区域**, 所有节点就可以被组织起来 + +```cpp +struct ListNodeInt { + SinglyLink link; // link区域 + int data; // data区域 +}; + +struct ListNodeDouble { + SinglyLink link; // link区域 + double data; // data区域 +}; + +int main() { + ListNodeInt node1; + auto node2Ptr = new ListNodeInt(); + + insert(&(node1.link), (SinglyLink *)node2Ptr); + + auto linkPtr = (SinglyLink *)(&node1); + while (linkPtr != nullptr) { + auto nodePtr = (ListNodeInt *)linkPtr; + std::cout << nodePtr->data << std::endl; + linkPtr = linkPtr->next; + } + delete node2Ptr; +} +``` + +嵌入式链表, 可以**忽视**一个节点中除去link以外的数据。通过操作每个节点中link对链表做增加、删除和遍历的操作。在循环遍历链表时, link下面的数据类型的处理是交给使用这显示控制的, 即通过类型转换把link类型转为它本身的节点或数据类型, 进而区访问这个节点真实携带的数据信息。而这些数据的结构、大小等细节链表操作是不关心的, 它只关注对link区域的处理 + +### 优点 + +**C语法实现通用链表** + +不需要使用复杂的代码生成技术和范型编程支持, 就可以实现高效的通用数据结构 + +**性能更好** + +对于可变数据, 不使用二次分配内存的方式。link区域和data区域是位于同一块连续内存上, Cache更友好(相对两次分配)。同时相对于std::list在链表数据迁移的时候不需要额外释放和分配内存。 + +**节点可位于多个链表** + +一个节点可以同时位于多个链表中。如: 一个Task节点可以同时位于eventList和runList中 + +```cpp +struct Task { + SinglyLink eventList; + // ... + SinglyLink runList; +}; +``` + +## 设计/使用技巧 + +虽然上面介绍了嵌入式链表的总体设计思想——**只关心统一的链表操作**。而**数据类型**和**内存分配**的处理上会有些许不同, 下面就介绍三种经典的处理方法: + +### 通信库 - 组合式 + +在很多消息通信的场景, 每个消息携带的数据量可能是不一样的。管理消息的链表, 可以通过组合的形式把Link域和变长数据域的内存做**物理拼接** + +```cpp +template +struct Msg { + int size; + char data[N]; + + static void init(void *msg) { + reinterpret_cast(msg)->size = N; + // fill data + } +}; + +int main() { + auto node1 = (SinglyLink *) malloc(sizeof(SinglyLink) + sizeof(Msg<1024>)); + auto node2 = (SinglyLink *) malloc(sizeof(SinglyLink) + sizeof(Msg<1024 * 3>)); + auto node3 = (SinglyLink *) malloc(sizeof(SinglyLink) + sizeof(Msg<1024 * 2>)); + + Msg<1024>::init(node1 + 1); + Msg<1024 * 3>::init(node2 + 1); + Msg<1024 * 2>::init(node3 + 1); + + SinglyLink msg_list; + + insert(&msg_list, node1); + insert(&msg_list, node2); + insert(&msg_list, node3); + + //... + + free(node1); + free(node2); + free(node3); + return 0; +} +``` +在使用malloc分配内存时, 申请的大小是`sizeof(link) + sizeof(data)`, 这样在物理上数据和只有link的嵌入式链表的节点在一块连续的内存上。在链表视角相当于 在每个节点下面挂载了一个**隐藏**的消息数据 + +```bash + +-------+ +-------+ +-------+ +List: | next | -> | next | -> ... -> | next | + +-------+ +-------+ +-------+ + +-------+ +-------+ +-------+ +Data: |payload| | | | | + +-------+ |payload| |payload| + | | +-------+ + +-------+ +``` + +这样可以使得每个链表节点的消息负载(长度)是可变的。数据的使用者解析时只需要对节点地址做1单位的偏移, 就能去解析数据的结构/格式。并由于link和data是通过一次申请来分配的, 所以在解析失败或节点释放的时候可以直接通过`free(nodePtr)`去释放内存块, 而不需要分两次释放 + +### Linux内核 - 嵌入式 + +在最开始介绍设计思想的时候使用的就是者中把`SinglyLink`"嵌入"到一个数据结构中的形式 + +```cpp +struct MemBlock { + SinglyLink link; + int size; +}; +``` + +由于link是被嵌入数据结构的第一个数据成员(无继承), 所以从link到MemBlock和MemBlock到link的转换都比较方便, 可以直接通过强制类型转换来实现 + +```cpp +auto mbPtr = new MemBlock; +auto linkPtr = static_cast(mbPtr); +//... +auto mbPtr_tmp = static_cast(linkPtr); +``` + +但是在内核源码中常可以见到如下的**嵌入方式** + +```cpp +struct Demo { + char member1; + int member2 + SinglyLink link; + double member3; +}; +``` +link成员并不是结构的第一个成员, 这个时候怎么做地址和类型的相互转换呢? + +**成员偏移量计算** + +```cpp +#define offset_of(Type, member) ((size_t) & ((Type *)0)->member) + +int main() { + auto offset = offset_of(Demo, link); + /* + demoPtr = (Demo *)0; + linkPtr = &(demoPtr->link); + offset = (size_t) linkPtr - 0; + */ + return 0; +} +``` + +这里通过把0转为一个Demo指针类型, 在取这个对象中link的地址。由于对象的基地址是0, 那么Demo中link成员的地址就是该成员相对Demo对象首地址的偏移量。由于中间设计到对空指针的操作, 虽然没有访问数据。但有些编译器上可能不生效或产生未知行为, 我们可以按照这个思路把0换成一个有效地址即可 + +```cpp +#define offset_of(Type, member) \ +[]() { Type t; return ((size_t) & (&t)->member - (size_t)&t); } () + +int main() { + auto offset = offset_of(Demo, link); + /* + auto offset_of_func = []() { + Demo t; + Demo ptr = &t; + baseAddr = (size_t)ptr; + memberAddr = (size_t) & (ptr->link); + return memberAddr - baseAddr; + }; + offset = offset_of_func(); + */ + return 0; +} +``` + +以上只是为了解释**成员偏移量计算**的原理和可行性, 实际上编译器内部已经帮我们实现了这样的"函数", 我们直接使用就可以了 + +```cpp +#include +size_t offset = offsetof(Demo, link); +``` + +**通用类型转换操作** + +```cpp +#define to_link(nodePtr) (&((nodePtr)->link)) +#define to_node(linkPtr, NodeType, member) \ + (NodeType *)( (size_t)linkPtr - offset_of(NodeType, member) ) +``` + +通过node指针转向link指针可以直接通过取成员地址实现, 而通过link指针**还原为原来的node指针**则需要上面介绍的**成员偏移量计算**工具的辅助。即: link指针 - link成员偏移量 -> 原数据结构地址 + +```cpp +int main() { + Demo nodeList; + Demo node1, node2, node3 + + insert(to_link(&nodeList), to_link(&node1)); + insert(to_link(&nodeList), to_link(&node2)); + insert(to_link(&nodeList), to_link(&node3)); + + // ... + + SinglyLink *p = nodeList.link.next; + while (p != to_link(&nodeList)) { + Demo *demo = to_node(p, Demo, link); + std::cout << demo->member1 << std::endl; + p = p->next; + } + return 0; +} +``` + +### V8引擎 - 继承式(C++) + +继承的好处是, 子类指针可以向上转型, 自动完成从实际数据结构到link的转换。同时在link中通过模板来携带类型信息(并未有额外定义)。通过直接继承SinglyLink(编译期多态)来使用链表功能 + +```cpp +template +struct SinglyLink { + struct T *next; +}; + +struct Demo : public SinglyLink { + int a; + double b; +}; +``` + +这里的SinglyLink里只使用了Demo类型的指针, 并不会引起循环定义问题。 + +```cpp +int main() { + + SinglyLink demoList; + Demo d1, d2, d3 + + insert(&demoList, &d1); + insert(&demoList, &d2); + insert(&demoList, &d3); + + for (auto it = demoList.next; it != nullptr; it = it->next) { + std::cout << it->a << std::endl; + } + + return 0; +} +``` + +这样实现的链表, 在节点遍历和数据访问上实现了一定的统一, 避免了使用to_link和to_node等类型转换操作 + + +## 总结 + +本章节介绍了嵌入式(侵入式)链表的核心原理, 及常见的几种实现/使用方式。虽然嵌入式链表在性能和控制力上有一些优势, 但其使用上的复杂度对开发人员确是一种"负担"。所以, 它常出现在追求性能的系统编程场景, 而对于应用软件的开发, 往往封装度完整的链表(如:std::list)会更加适合。总的来说, 数据结构的选择是一个`trade-off`权衡的结果 \ No newline at end of file diff --git a/tests/embedded-list/embedded-slist.0.cpp b/tests/embedded-list/embedded-slist.0.cpp new file mode 100644 index 0000000..08bb624 --- /dev/null +++ b/tests/embedded-list/embedded-slist.0.cpp @@ -0,0 +1,23 @@ +// embedded-slist.0.cpp - readonly +// +// 描述: +// 定义嵌入式单链表节点 +// +// 目标/要求: +// - 不修改该代码检测文件 +// - 在exercises/linked-list/EmbeddedList.hpp中完成你的代码设计 +// - 通过所有编译器检测 和 断言 +// + +#include "common/common.hpp" + +#include "exercises/linked-list/EmbeddedList.hpp" + +int main() { + + d2ds_assert_eq(sizeof(d2ds::SinglyLink), 8); + + D2DS_WAIT + + return 0; +} \ No newline at end of file diff --git a/tests/embedded-list/embedded-slist.1.cpp b/tests/embedded-list/embedded-slist.1.cpp new file mode 100644 index 0000000..01533c0 --- /dev/null +++ b/tests/embedded-list/embedded-slist.1.cpp @@ -0,0 +1,41 @@ +// embedded-slist.1.cpp - readonly +// +// 描述: +// 定义嵌入式单链表的插入操作(默认为循环链表) +// +// 目标/要求: +// - 使用SinglyLink的insert操作 +// - 在exercises/linked-list/EmbeddedList.hpp中完成你的代码设计 +// - 通过所有编译器检测 和 断言 +// + +#include "common/common.hpp" + +#include "exercises/linked-list/EmbeddedList.hpp" + +using namespace d2ds; + +int main() { + + SinglyLink head; + + for (int i = 0; i < 10; i++) { + auto linkPtr = new SinglyLink(); + SinglyLink::insert(&head, linkPtr); + } + + int cnt = 0; + SinglyLink *p = head.next; + while (p != &head) { + cnt++; + auto q = p->next; + delete p; + p = q; + } + + d2ds_assert_eq(cnt, 10); + + D2DS_WAIT + + return 0; +} \ No newline at end of file diff --git a/tests/embedded-list/embedded-slist.2.cpp b/tests/embedded-list/embedded-slist.2.cpp new file mode 100644 index 0000000..1dbb42e --- /dev/null +++ b/tests/embedded-list/embedded-slist.2.cpp @@ -0,0 +1,47 @@ +// embedded-slist.2.cpp - readonly +// +// 描述: +// 定义嵌入式单链表的删除操作 +// +// 目标/要求: +// - 使用SinglyLink的remove操作 +// - 在exercises/linked-list/EmbeddedList.hpp中完成你的代码设计 +// - 通过所有编译器检测 和 断言 +// + +#include "common/common.hpp" + +#include "exercises/linked-list/EmbeddedList.hpp" + +using namespace d2ds; + +int main() { + + SinglyLink head; + + for (int i = 0; i < 10; i++) { + auto linkPtr = new SinglyLink(); + SinglyLink::insert(&head, linkPtr); + } + + for (int i = 0; i < 5; i++) { + auto linkPtr = head.next; + SinglyLink::remove(&head, linkPtr); + delete linkPtr; + } + + int cnt = 0; + SinglyLink *p = head.next; + while (p != &head) { + cnt++; + auto q = p->next; + delete p; + p = q; + } + + d2ds_assert_eq(cnt, 5); + + D2DS_WAIT + + return 0; +} \ No newline at end of file diff --git a/tests/embedded-list/embedded-slist.3.cpp b/tests/embedded-list/embedded-slist.3.cpp new file mode 100644 index 0000000..eafead3 --- /dev/null +++ b/tests/embedded-list/embedded-slist.3.cpp @@ -0,0 +1,50 @@ +// embedded-slist.3.cpp - write +// +// 描述: +// 嵌入式表链-插入操作练习 +// +// 目标/要求: +// - 创建5个IntNode节点并初始化为0 ~ 4插入到list中 +// - 在本文件中完成你的代码设计 +// - 通过所有编译器检测 和 断言 +// + +#include "common/common.hpp" + +#include "exercises/linked-list/EmbeddedList.hpp" + +using namespace d2ds; + +struct IntNode { + SinglyLink link; + int data; + + IntNode() : link(), data { 0 } {} +}; + +int main() { + + SinglyLink list; + + // create & insert + for (int i = 0; i < 5; i++) { + // show your code (use `new` to create node) + + } + + // release all + int sum = 0; + SinglyLink *p = list.next; + while (p != &list) { + sum += reinterpret_cast(p)->data; + auto q = p->next; + delete p; + p = q; + } + + d2ds_assert_eq(sum, 0 + 1 + 2 + 3 + 4); + + D2DS_WAIT + + return 0; +} \ No newline at end of file diff --git a/tests/embedded-list/embedded-slist.4.cpp b/tests/embedded-list/embedded-slist.4.cpp new file mode 100644 index 0000000..ccef568 --- /dev/null +++ b/tests/embedded-list/embedded-slist.4.cpp @@ -0,0 +1,55 @@ +// embedded-slist.4.cpp - write +// +// 描述: +// 嵌入式表链-remove操作练习 +// +// 目标/要求: +// - 删除list中数值为偶数的节点 +// - 在本文件中完成你的代码设计 +// - 通过所有编译器检测 和 断言 +// + +#include "common/common.hpp" + +#include "exercises/linked-list/EmbeddedList.hpp" + +using namespace d2ds; + +struct IntNode : SinglyLink { + int data; +}; + +int main() { + + SinglyLink list; + + // insert int node + for (int i = 1; i <= 10; i++) { + auto nodePtr = new IntNode(); + nodePtr->data = i; + SinglyLink::insert(&list, nodePtr); + } + + for (auto it = &list; it->next != &list; it = it->next) { + // show your code + + } + + // release all + int sum = 0; + SinglyLink *p = list.next; + while (p != &list) { + + sum += reinterpret_cast(p)->data; + + auto q = p->next; + delete p; + p = q; + } + + d2ds_assert_eq(sum, 1 + 3 + 5 + 7 + 9); + + D2DS_WAIT + + return 0; +} \ No newline at end of file diff --git a/xmake.lua b/xmake.lua index 7508402..9800652 100644 --- a/xmake.lua +++ b/xmake.lua @@ -122,6 +122,26 @@ target("4.vector-5") set_kind("binary") add_files("tests/vector/vector.5.cpp") +target("5.embedded-slist-0") + set_kind("binary") + add_files("tests/embedded-list/embedded-slist.0.cpp") + +target("5.embedded-slist-1") + set_kind("binary") + add_files("tests/embedded-list/embedded-slist.1.cpp") + +target("5.embedded-slist-2") + set_kind("binary") + add_files("tests/embedded-list/embedded-slist.2.cpp") + +target("5.embedded-slist-3") + set_kind("binary") + add_files("tests/embedded-list/embedded-slist.3.cpp") + +target("5.embedded-slist-4") + set_kind("binary") + add_files("tests/embedded-list/embedded-slist.4.cpp") + add_moduledirs("tools") task("d2ds") @@ -176,6 +196,11 @@ task("dslings") ["4.vector-3-all"] = "exercises/array/Vector.hpp", ["4.vector-4"] = "exercises/array/Vector.hpp", ["4.vector-5"] = "exercises/array/Vector.hpp", + ["5.embedded-slist-0"] = "exercises/linked-list/EmbeddedList.hpp", + ["5.embedded-slist-1"] = "exercises/linked-list/EmbeddedList.hpp", + ["5.embedded-slist-2"] = "exercises/linked-list/EmbeddedList.hpp", + ["5.embedded-slist-3"] = "exercises/linked-list/EmbeddedList.hpp", + ["5.embedded-slist-4"] = "exercises/linked-list/EmbeddedList.hpp", } local function get_len(pairs_type)