• 【 C++ 】string类的模拟实现


    目录

    1、基本成员变量

    2、默认成员函数

            构造函数

            析构函数

            拷贝构造函数(深拷贝)

            赋值运算符重载(深拷贝)

    3、容量与大小相关函数

            size

            capacity

    4、字符串访问相关函数

            operator[ ]重载

            迭代器

    5、增加的相关函数接口

            reserve扩容

            resize

            push_back追加字符

            append追加字符串

            operator+=

            insert

    6、删除的相关函数接口

            erase

            clear清空数据

    7、查找的相关函数接口

            find

    8、c_str获取字符串

    9、swap交换函数

    10、非成员函数

            关系运算符函数重载

            <<流插入运算符重载

            >>流提取运算符重载

            getline函数

    11、源码链接


    1、基本成员变量

    1. namespace cpp
    2. {
    3. //使用命名空间防止定义的string类与库里的string类冲突
    4. class string
    5. {
    6. public:
    7. //……
    8. private:
    9. char* _str; //存储字符串
    10. size_t _size; //有效字符个数
    11. size_t _capacity; //实际存储有效字符的空间,不包含'\0'
    12. const static size_t npos;
    13. };
    14. const size_t string::npos = -1;
    15. }

    2、默认成员函数

    构造函数

    这里的构造函数最好写成全缺省函数,与标准库里的构造函数相一致

    1. //全缺省的默认构造函数
    2. string(const char* str = "")//标准库里string定义对象的默认值为空串""
    3. //按声明的顺序进行初始化
    4. :_size(strlen(str))
    5. , _capacity(_size)
    6. {
    7. _str = new char[_capacity + 1];//在堆上为_str开空间,+1是给'\0'留的
    8. strcpy(_str, str);//把常量字符串的内容拷贝过去
    9. }

    析构函数

    这里string类里的_str是动态开辟建立在堆中的,堆区的空间不能自动销毁因此需要我们手动去销毁。

    1. //析构函数
    2. ~string()
    3. {
    4. if (_str)
    5. {
    6. delete[] _str;
    7. _str = nullptr;
    8. _size = _capacity = 0;
    9. }
    10. }

    拷贝构造函数(深拷贝)

    首先,我们不写,编译器会默认生成一个拷贝构造函数,不过是值拷贝或者浅拷贝,按字节拷贝的。

    浅拷贝针对于日期类这种是非常适合的,不过对于string类这样_str是动态开辟到堆上的,如果使用值拷贝会导致1、析构两次 2、一个对象修改会影响另外一个。因此我们需要写深拷贝。

    深拷贝的核心要点在于我和你的有一样的值,但是使用的不是同一块空间

    深拷贝有两种写法:传统写法和现代写法。

    • 1、传统写法:

    传统写法就是先开辟一块能够容纳原字符串大小的空间,最后把拷贝的对象的字符串数据拷贝到新开的空间里头即可。

    1. //拷贝构造函数
    2. //不能用浅拷贝,原因如下:1、析构两次 2、一个对象修改会影响另外一个
    3. //传统写法
    4. //s2(s1);
    5. string(const string& s)
    6. :_size(strlen(s._str))
    7. ,_capacity(_size)
    8. {
    9. _str = new char[_capacity + 1];
    10. strcpy(_str, s._str);
    11. }
    • 2、现代方法:

    传统写法是本分的自己开空间,然后再拷贝数据,而现代方法就是剥削,要完成深拷贝,自己不想干活就安排别人干活,然后窃取别人的劳动成果。

    假如我拿s1去拷贝s2,现代方法就是我设定了一个tmp对象,拿s1._str的字符串作为参数去给tmp对象完成构造函数,再利用swap函数把tmp对象的_str、_size、_capacity全部与s1的交换即可完成现代方法的深拷贝。但是在这之前注意把s1的数据置空,避免交换后tmp调用析构函数出现析构随机值的错误现象。

    1. /*现代写法*/
    2. string(const string& s)
    3. :_str(nullptr)
    4. , _size(0)
    5. , _capacity(0)
    6. {
    7. string tmp(s._str);//调用构造函数,构造一个字符串作为s.c_str的对象
    8. swap(tmp);
    9. }

    赋值运算符重载

    这里和上述拷贝构造函数一样,我们不写编译器会自动生成,不过对于string类的_str来说,在堆上申请的空间需要自己去释放,否则会导致同一块空间析构两次。此深拷贝依旧有传统写法和现代写法。

    • 思路:

    如若我把s3赋值给s1,这里不能直接进行赋值。要考虑两个问题。

    1. 如若我s1的空间小于s3,此时直接拷贝过去会导致越界
    2. 如若我s1的空间过分大于s3的空间,又会导致直接拷贝后空间过渡浪费。只有在我s1和s3的空间差不多大时,才可以直接进行拷贝。

    综上:先把s1原先指向的空间delete释放掉,再把s1重新开辟和s3一样大的空间,记得多开一个字节,因为还有'\0'。再利用strcpy把s3的内容拷贝给s1即可。不过要避免一种特殊情况:自己给自己赋值,如若自己赋值给自己,直接返回,所以加个if条件判断即可。

    如果我new开空间失败了,那么就要抛异常,而先前我依旧释放了s1,此时就把s1给破坏了。为了避免这一点,我们可以先开空间再拷贝数据最后再释放从而进行优化,具体见下文。

    • 1、传统写法:
    1. //赋值运算符重载 --> 深拷贝
    2. //s1 = s3 s1.operator=(&s1, s3);
    3. string& operator=(const string& s)
    4. {
    5. //防止自己给自己赋值
    6. if (this != &s)
    7. {
    8. /*
    9. //法一:
    10. //先删除原先s1的所有空间,防止赋值后s1过大导致空间浪费,s1过小导致空间不够
    11. delete[] _str;
    12. //给s1开辟与s3等价的空间大小,要多开一字节给'\0'
    13. _str = new char[strlen(s._str) + 1];
    14. strcpy(_str, s._str);
    15. */
    16. //法二优化
    17. //先开辟空间
    18. char* tmp = new char[s._capacity + 1];
    19. strcpy(tmp, s._str);
    20. delete[] _str;
    21. _str = tmp;
    22. _size = s._size;
    23. _capacity = s._capacity;
    24. }
    25. return *this;
    26. }

    C语言的动态开辟内存malloc需要检查合法性,而C++的new不需要,new失败的话需要抛异常捕获:

    1. int main()
    2. {
    3. try
    4. {
    5. //C++new失败要抛异常捕获
    6. //test_string1();
    7. test_string2();
    8. }
    9. catch (const exception& e)
    10. {
    11. cout << e.what() << endl;
    12. }
    13. return 0;
    14. }
    • 2、现代写法:

    这里的现代方法和上文拷贝构造的现代方法没两样,只不过多了个返回值。具体操作如下:

    1. //现代写法1:
    2. string& operator=(const string& s)
    3. {
    4. if (this != &s)//避免自己给自己赋值
    5. {
    6. string tmp(s._str);
    7. swap(tmp);
    8. }
    9. return *this;
    10. }

    这里还有另一种更加简洁的现代方法,上述写法是引用传参,这里我们可以直接传值传参,让编译器自动调用拷贝构造函数,再把拷贝出来的对象作为右值与左值交换即可

    1. //法二:简洁版
    2. //s1 = s3;
    3. string& operator=(string s)//传值传参调用拷贝构造,s就是s3的深拷贝结果
    4. {
    5. swap(s);//交换这俩对象
    6. return *this;
    7. }

    不过这种简洁的版本无法避免自己给自己赋值,但很少会出现自己给自己赋值的行为,除非你有啥癖好。所以上述两种方法都可使用。


    3、容量与大小相关函数

    size

    直接返回隐含this指针指向的_size即为字符串长度

    1. //返回字符串的长度
    2. size_t size() const //不改变内部成员,最好加上const
    3. {
    4. return _size;
    5. }

    capacity

    直接返回隐含this指针指向的_capacity即可

    1. //返回字符串容量
    2. size_t capacity() const //不改变内部成员,最好加上const
    3. {
    4. return _capacity;
    5. }

    4、字符串访问相关函数

    operator[ ]重载

    有了operator[ ]运算符重载,便可以直接用下标+[ ]进行元素访问,不过这里还应提供一个const版本的operator[ ]运算符重载以便于普通对象和const对象均可调用而不会出现权限放大的问题

    1. //版本1:
    2. char& operator[](size_t pos)//引用返回,便于后续修改返回的字符
    3. {
    4. assert(pos < _size);//记得确保pos位置的合法性,不能超过字符串
    5. return _str[pos]; //返回pos位置字符的引用
    6. }
    7. //版本2:
    8. const char& operator[](size_t pos) const//引用返回,便于后续修改返回的字符
    9. {
    10. assert(pos < _size);
    11. return _str[pos]; //返回pos位置字符的引用
    12. }

    迭代器

    string类的迭代器就是像字符指针一样的东西

    1. begin函数的作用就是返回字符串中第一个字符的地址
    2. end函数的作用就是返回字符串最后一个字符的后一个位置的地址,即'\0'的地址
    1. //版本1:
    2. typedef char* iterator;
    3. iterator begin()
    4. {
    5. return _str;//返回第一个有效字符的指针
    6. }
    7. iterator end()
    8. {
    9. return _str + _size;//返回最后一个字符后一个位置的地址,即'\0'的地址
    10. }

    和上文的operator[ ]重载一样,这里也要写一个const版本的迭代器,以便于后续的const对象也能够调用。

    1. //版本2:只读,const对象可调用
    2. typedef const char* const_iterator;
    3. const_iterator begin() const
    4. {
    5. return _str;//返回第一个有效字符的指针
    6. }
    7. const_iterator end() const
    8. {
    9. return _str + _size;//返回最后一个字符后一个位置的地址,即'\0'的地址
    10. }

    这里还有一种基于迭代器的遍历方式:范围for

    范围for的底层实现原理和迭代器没两样,只不过写法看着很高端。

    1. void test_string()
    2. {
    3. cpp::string s1("hello world");
    4. //迭代器
    5. cpp::string::iterator it = s1.begin();
    6. while (it != s1.end())
    7. {
    8. cout << *it << " "; //h e l l o w o r l d
    9. it++;
    10. }
    11. cout << endl;
    12. //范围for
    13. for (auto& ch : s1) //加上引用,相当于是每个字符的别名,便于修改
    14. {
    15. ch -= 1;
    16. }
    17. for (auto& ch : s1)
    18. {
    19. cout << ch << " "; //g d k k n v n q k c
    20. }
    21. }

    5、增加的相关函数接口

    reserve扩容

    reserve扩容只影响_capacity空间,不影响_size,其有以下两点规则

    1. 当n大于对象当前的capacity时,将capacity扩大到n或大于n。
    2. 当n小于对象当前的capacity时,无需操作。
    1. //reserve扩容
    2. void reserve(size_t n)
    3. {
    4. if (n > _capacity)
    5. {
    6. char* tmp = new char[n + 1];//每次开空间一定要多给一个字节给'\0'
    7. strcpy(tmp, _str);
    8. //释放旧空间
    9. delete[] _str;
    10. //把新空间赋给_str
    11. _str = tmp;
    12. //更新容量_capacity
    13. _capacity = n;
    14. }
    15. }

    resize

    resize是将字符串调整为n个字符的长度,不仅会改变_size,还会改变_capacity空间。规则如下:

    1. 如果n小于当前的_size长度,将_size缩小到n
    2. 如果n大于当前的_size长度,将_size扩大到n,扩大的字符默认为'\0'

    1. //resize调整大小
    2. void resize(size_t n, char ch = '\0')
    3. {
    4. //如果n < _size,就保留前n个字符即可,把下标n置为'\0'
    5. if (n < _size)
    6. {
    7. _size = n;
    8. _str[_size] = '\0';
    9. }
    10. else
    11. {
    12. //如果n > _capacity,就要扩容了
    13. if (n > _capacity)
    14. {
    15. reserve(n);
    16. }
    17. for (size_t i = _size; i < n; i++)
    18. {
    19. //把剩余的字符置为ch
    20. _str[i] = ch;
    21. }
    22. _size = n;
    23. _str[_size] = '\0';
    24. }
    25. }

    push_back追加字符

    首先要考虑需不需要扩容,如若需要,直接复用reserve函数进行增容,追加字符后,记得把最后一个下标_size对应的值置为'\0'。

    1. //push_back
    2. void push_back(char ch)
    3. {
    4. /*法一*/
    5. //先检查是否需要扩容
    6. if (_size == _capacity)
    7. {
    8. //复用reserve进行扩容,如果一开始容量为0,记得处理,否则容量*2依旧为0
    9. reserve(_capacity == 0 ? 4 : _capacity * 2);
    10. }
    11. _str[_size] = ch;
    12. _size++;
    13. _str[_size] = '\0'; //注意最后一个值恒为'\0'以确保字符串的完整性
    14. }

    这里我们还可使用后文写好的insert尾插字符,因为当insert函数中的pos为_size时即为尾插:

    1. //push_back尾插字符
    2. void push_back(char ch)
    3. {
    4. //法二:复用insert尾插入字符
    5. insert(_size, ch);
    6. }

    append追加字符串

    使用append追加字符串首先要判断是否需要扩容,扩容后利用strcpy函数把追加的字符串拷贝到原字符串末尾即可,不需要额外处理'\0',因为strcpy默认把'\0'拷贝过去。

    1. //append
    2. void append(const char* str)
    3. {
    4. //统计追加字符串后的长度
    5. size_t len = _size + strlen(str);
    6. //判断是否需要扩容
    7. if (len > _capacity)
    8. {
    9. reserve(len);
    10. }
    11. //把字符串追加到末尾
    12. strcpy(_str + _size, str);
    13. _size = len;
    14. }

    这里也可以使用后文的insert追加字符串来完成,因为当pos为_size时,就是在尾部追加字符串。

    1. void append(const char* str)
    2. {
    3. //法二:复用insert函数
    4. insert(_size, str);
    5. }

    operator+=

    operator+=可以追加字符、字符串、对象。因此我们可以分开来讨论:

    • 追加字符:直接复用push_back
    1. //operator+=字符
    2. string& operator+=(char ch)
    3. {
    4. //复用push_back
    5. push_back(ch);
    6. return *this;
    7. }
    • 追加字符串:直接复用append
    1. //operator+=字符串
    2. string& operator+=(const char* str)
    3. {
    4. //复用append
    5. append(str);
    6. return *this;
    7. }

    insert

    insert的作用是在指定pos位置往后插入字符字符串

    • insert在pos位置插入字符

    这里首先要判断pos的合法性,接下来就要挪动数据了,这里我们优先考虑从最后一个'\0'位置的下一个位置(_size + 1)开始往前挪动。因此定义end指向'\0'后一个位置,当end挪到与pos位置重合时停止,最后把插入的字符ch挪到下标pos处。记得最后更新_size++。

    1. //insert插入字符
    2. string& insert(size_t pos, char ch)
    3. {
    4. assert(pos <= _size);
    5. if (_size == _capacity)
    6. {
    7. //复用reserve进行扩容,如果一开始容量为0,记得处理,否则容量*2依旧为0
    8. reserve(_capacity == 0 ? 4 : _capacity * 2);
    9. }
    10. size_t end = _size + 1; //最好把end放到_size + 1的位置,防止后续出现整型提升等问题
    11. while (end > pos)
    12. {
    13. _str[end] = _str[end - 1];
    14. end--;
    15. }
    16. //当end挪动到pos的位置时停止挪动,并把ch赋到pos的下标处
    17. _str[pos] = ch;
    18. _size += 1;
    19. return *this;
    20. }

    测试入下:

    1. void test_string()
    2. {
    3. cpp::string s("hello world");
    4. s.insert(6, '@');
    5. s += '@';
    6. cout << s.c_str() << endl; //hello @world@
    7. for (auto& ch : s)
    8. {
    9. cout << ch << " ";
    10. }
    11. cout << "#" << endl; //h e l l o @ w o r l d @ #
    12. s += '\0';
    13. for (auto& ch : s)
    14. {
    15. cout << ch << " ";
    16. }
    17. cout << "#" << endl; //h e l l o @ w o r l d @ #
    18. s.insert(0, '@');
    19. cout << s.c_str() << endl;//@hello @world@
    20. }
    • insert在pos位置插入字符串

    首先判断是否需要扩容,接下来挪动数据。定义变量end为_size + len的位置,把pos处往后的字符串整体往后挪动直至空出插入字符串的长度。利用循环+ _str[end] = _str[end - len];来完成。当end挪动到pos + len - 1时结束循环,再利用strncpy函数把插入的字符串拷贝过去即可。

    1. //insert插入字符串
    2. string& insert(size_t pos, const char* str)
    3. {
    4. assert(pos <= _size);
    5. size_t len = strlen(str);
    6. if (len == 0)
    7. {
    8. //如果传进来的字符串为空,直接返回即可
    9. return *this;
    10. }
    11. if (_size + len > _capacity)
    12. {
    13. //判断是否扩容
    14. reserve(_size + len);
    15. }
    16. size_t end = _size + len;
    17. //当end >= pos + len时都不结束循环
    18. while (end >= pos + len)
    19. {
    20. _str[end] = _str[end - len];
    21. end--;
    22. }
    23. //不能使用strcpy,因为会把\0也拷过去,就会出错
    24. strncpy(_str + pos, str, len);
    25. _size += len;
    26. return *this;
    27. }

    测试如下:

    1. void test_string()
    2. {
    3. cpp::string s("hello world");
    4. s.insert(0, "xxx");
    5. cout << s.c_str() << endl;//xxxhello world
    6. }

    6、删除的相关函数接口

    erase

    如果给定删除的长度len为npos无符号值,或者说len + pos的长度>=_size,那么直接把pos位置的值设定为'\0\即可,因为此时就是把pos后的所有数据全部删除。出去这种特殊情况,其余的就是从pos + len处开始先前挪动到_size + 1为止。pos后的数据往前覆盖即可。

    1. //erase删除
    2. void erase(size_t pos, size_t len = npos)
    3. {
    4. assert(pos < _size);
    5. if (len == npos || pos + len >= _size)
    6. {
    7. //这种情况是删除pos后的所有数据,直接把pos处设定为'\0'即可
    8. _str[pos] = '\0';
    9. _size = pos;
    10. }
    11. else
    12. {
    13. size_t begin = pos + len;
    14. while (begin <= _size)
    15. {
    16. _str[begin - len] = _str[begin];
    17. ++begin;
    18. }
    19. _size -= len;
    20. }
    21. }

    测试如下:

    1. void test_string9()
    2. {
    3. cpp::string s("hello world");
    4. s.insert(0, "xxx");
    5. cout << s.c_str() << endl;//xxxhello world
    6. s.erase(0, 3);
    7. cout << s.c_str() << endl;//hellow world
    8. }

    clear清除数据

    clear函数是用来清除原字符串的所有数据,并不是连空间一并清除了,所以我们只需要把下标0置为'\0',并把有效字符个数_size置为0即可。

    1. //clear清除数据
    2. void clear()
    3. {
    4. _str[0] = '\0';
    5. _size = 0;
    6. }

    7、查找的相关函数接口

    find

    find函数也分查找字符和字符串

    • find查找字符:

    直接遍历即可:

    1. //find查找字符
    2. size_t find(char ch, size_t pos = 0)
    3. {
    4. for (; pos < _size; pos++)
    5. {
    6. if (_str[pos] == ch)
    7. return pos;
    8. }
    9. //没找到就返回npos,-1
    10. return npos; //-1
    11. }
    • find查找字符串:

    这里可以直接复用C语言的strstr函数进行查找,不过该函数返回的是地址,想要获得最终的下标直接利用地址相减即可,p - _str

    1. size_t find(const char* str, size_t pos = 0)
    2. {
    3. //直接复用C语言库函数strstr即可,strstr函数返回的是地址
    4. const char* p = strstr(_str + pos, str);
    5. if (p == nullptr)
    6. {
    7. return npos;
    8. }
    9. else
    10. {
    11. //返回下标直接用p - str即可
    12. return p - _str;
    13. }
    14. }

    8、c_str获取字符串

    c_str用于获取c类型的字符串,直接返回字符串即可

    1. //c_str 获取c形式的字符串
    2. const char* c_str() const //最好加上const,便于普通及const对象均可调用
    3. {
    4. return _str;
    5. }

    9、swap交换函数

    swap函数用于交换两个对象的数据,我们可以通过复用库里的swap函数来完成,但是要在前面加上作用域限定符"::"。让编译器在全局域的库里调用swap函数。

    1. //swap交换函数
    2. void swap(string& s)
    3. {
    4. std::swap(_str, s._str);
    5. std::swap(_size, s._size);
    6. std::swap(_capacity, s._capacity);
    7. }

    10、非成员函数

    关系运算符函数重载

    关系运算符有==、!=、<、<=、>、>=这6类,先前的日期类已经讲解过类似的。这里关系运算符重载我们不把它放到成员函数里头。

    • 1、operator<

    直接借用库函数strcmp进行字符串大小比较即可。此外,和日期类一样,写好了<和==的重载,剩下的4个关系运算符直接复用即可。

    1. //1、operator<
    2. bool operator<(const string& s1, const string& s2)
    3. {
    4. return strcmp(s1.c_str(), s2.c_str()) < 0;
    5. }
    6. //2、operator==
    7. bool operator==(const string& s1, const string& s2)
    8. {
    9. return strcmp(s1.c_str(), s2.c_str()) == 0;
    10. }

    剩下4个关系运算符复用上面两个:

    1. //3、operator<=
    2. bool operator<=(const string& s1, const string& s2)
    3. {
    4. return s1 < s2 || s1 == s2;
    5. }
    6. //4、operator>
    7. bool operator>(const string& s1, const string& s2)
    8. {
    9. return !(s1 <= s2);
    10. }
    11. //5、operator>=
    12. bool operator>=(const string& s1, const string& s2)
    13. {
    14. return !(s1 < s2);
    15. }
    16. //6、operator!=
    17. bool operator!=(const string& s1, const string& s2)
    18. {
    19. return !(s1 == s2);
    20. }

    <<流插入运算符重载

    这里我们可以通过范围for来完成<<运算符的重载

    1. //<<流插入运算符重载
    2. ostream& operator<<(ostream& out, const string& s)
    3. {
    4. for (auto ch : s)
    5. {
    6. out << ch;
    7. }
    8. return out;
    9. }

    >>流提取运算符重载

    这里实现的过程种要注意当遇到空格或换行符时就要停止读取了。此外,在一开始要记得调用clear函数把原字符串的所有数据给清空,然后才能正常往后输入新的数据,否则新数据会累加到原数据后面,就不是>>预期的效果了。

    1. //>>流提取运算符重载
    2. istream& operator>>(istream& in, string& s)
    3. {
    4. 法一:
    5. //要先把原字符串的所有数据给清空才可以输入新的数据,否则会累加到原数据后面,出错
    6. s.clear();
    7. char ch;
    8. ch = in.get();//使用get()函数才能获取空格或者换行字符
    9. while (ch != ' ' && ch != '\n')
    10. {
    11. s += ch;
    12. ch = in.get();
    13. }
    14. return in;
    15. }

    这里有个缺陷,如若频繁输入大量字符,那么就会多次扩容,扩容也会在效率上有所损耗,因此我们可以提前开辟一个128字节大小的数组,把每次输进的字符放到数组里头,最后当遇到停止的符号时,+=到字符串s上,如若下标加到127,把数组的字符+=到字符串s上,并充值数组为'\0',更新下标为0即可。以此类推。

    1. //>>流提取运算符重载
    2. istream& operator>>(istream& in, string& s)
    3. {
    4. //法二:
    5. //要先把原字符串的所有数据给清空才可以输入新的数据,否则会累加到原数据后面,出错
    6. s.clear();
    7. char ch;
    8. ch = in.get();//使用get()函数才能获取空格或者换行字符
    9. char buff[128] = { '\0' };
    10. size_t i = 0;
    11. while (ch != ' ' && ch != '\n')
    12. {
    13. buff[i++] = ch;
    14. if (i == 127)
    15. {
    16. s += buff;
    17. memset(buff, '\0', 128);
    18. i = 0;
    19. }
    20. ch = in.get();
    21. }
    22. s += buff;
    23. return in;
    24. }

    getline函数

    getline函数与上述写的<<流提取运算符重载非常相似,唯一不同的地方在于getline只有在遇到换行符才停止读取,而<<在遇到换行符停止外,遇到空格也会停止读取,因此,在<<的基础上改变下if种的判断条件即可:

    1. //getline函数
    2. istream& getline(istream& in, string& s)
    3. {
    4. s.clear();
    5. char ch;
    6. ch = in.get();
    7. //getline函数只有在遇到换行符才会停止
    8. while (ch != '\n')
    9. {
    10. s += ch;
    11. ch = in.get();
    12. }
    13. return in;
    14. }

    测试如下:


    11、源码链接

    gitee仓库一键传送:string类的模拟实现完整版链接

  • 相关阅读:
    如果你还不懂区块链那就out了(一)--从货物交换到数字货币
    聊一聊 tcp/ip 在.NET故障分析的重要性
    论文浅尝 | KR-GCN: 知识感知推理的可解释推荐系统
    sql server笔记1(表的定义)
    SRC逻辑漏洞-忘记密码/邮箱密码找回/链接token时间戳参数可逆
    C语言:详细介绍了六种进程间通讯方式(还有一种socket在主页关于socket的介绍里面有详细介绍,欢迎观看)
    Java 异常处理通关指南
    RabbitMQ
    [开题报告]flask框架行走的历史文博课(python+程序+论文)
    【论文记录】Boosting Detection in Crowd Analysis via Underutilized Output Features
  • 原文地址:https://blog.csdn.net/bit_zyx/article/details/125619182