面试官让我写一个 string 类,半天憋不出来

想当年,有次 567去面试,面试官听完自我介绍后,说”既然你懂 C++,那就写一个 C++ 的 string 类吧”,题目越短,问题越难,567 抓耳捞腮写了半天,面试官说“时间差不多了,让我看看吧”,沉默了几分钟后,面试官来了句” 平时常用的是 C++ 吗?“ 然后就没有然后了…

为了让大家不再陷入面试的尴尬境地,把这个问题翻出来重新写一写?

首先,面试官让你写一个 string 类,考察的是什么?

  1. 对 C++ 类的考察,知识点包括构造函数、析构函数、成员变量和成员函数等。
  2. 对 C++ std::string 类的考察,知识点包括 string 类的常见特性和实现原理。
  3. 对 C++内存管理的考察,字符串处理过程中通常涉及动态内存的分配,例如 new 和 delete 关键字的使用,拷贝构造函数和赋值运算符的实现,注意内存泄漏和浅拷贝问题。
  4. 编程基础和代码质量的考察,考察代码的可读性、可维护性和效率,还有程序的健壮性,比如异常处理、边界条件的处理等等。

以下示例跟 std::string 类相比都是班门弄斧,仅供各位大佬参考,抛转引玉。

1. 基础版本

先来一个基础版本

#include <cstring>
#include <iostream>

class MyString {
private:
    char* data;
    size_t length;

    void copy_data(const char* s) {
        data = new char[length + 1];
        std::strcpy(data, s);
    }

public:
    // 构造函数
    MyString(const char* s = "") {
        length = std::strlen(s);
        copy_data(s);
    }

    // 拷贝构造函数
    MyString(const MyString& str) {
        length = str.length;
        copy_data(str.data);
    }

    // 赋值运算符重载
    MyString& operator=(const MyString& str) {
        if (this != &str) {
            delete[] data;
            length = str.length;
            copy_data(str.data);
        }
        return *this;
    }

    // 析构函数
    ~MyString() {
        delete[] data;
    }

    // 获取字符串长度
    size_t size() const {
        return length;
    }
};

这段代码是一个 C++ 实现的一个简单字符串类 MyString。它包括构造函数、拷贝构造函数、赋值运算符重载、析构函数、以及一些基本功能如获取字符串长度和打印字符串。

大家可以思考下,它有哪些可以优化的点?

2. 优化版本1

增加const修饰符:对于不改变成员变量的成员函数,增加 const 修饰符,这一点是优点,强调下。

下面将从以下一些方面来进行优化:

  1. 使用更安全的字符串拷贝函数:strcpy 函数不会检查目标数组的大小,因此可能会导致缓冲区溢出的安全问题,推荐使用更安全的字符串处理方法,可以使用 strncpy 来限制复制的最大字符数。

  2. 避免内存泄漏和多次分配:当前的赋值运算符重载在复制之前释放旧内存,这是好的,但如果新字符串和旧字符串长度相同,则无需释放和重新分配内存。可以先检查长度,仅当长度不同时才重新分配。

  3. 使用初始化列表:在构造函数中使用初始化列表可以更有效地初始化成员变量。

  4. 支持更多字符串操作:目前,MyString 类只支持基本操作。可以增加更多功能,如字符串拼接、查找、比较等。

最后,强化异常安全性,考虑 new char[] 分配失败的情况。

2.1 字符串拷贝优化

strcpy 不会检查目标数组的大小,因此可能会导致缓冲区溢出的安全问题,推荐使用更安全的字符串处理方法,可以使用 strncpy 来限制复制的最大字符数。strcpy 函数对应的函数头文件 #include <cstring>,它是 C 语言标准库中的一部分,在 C 中,则通常包含 string.h。在 C++ 中使用 strcpy 时,可以包含 C 语言的字符串处理头文件 cstring

void copy_data(const char* s) {
    data = new char[length + 1];
    std::strncpy(data, s, length);
    data[length] = '\0'; // 确保字符串以空字符结束
}

由于 std::strncpy 不保证在复制的字符串末尾添加空字符,我们需要手动添加 data[length] = '\0' 以确保字符串正确结束。

这样修改后,即使源字符串 s 长度大于 length,也不会发生缓冲区溢出,因为我们只复制了 length 个字符,并且确保了字符串以空字符结束。

2.2 避免内存泄漏和多次分配

当前的实现在每次赋值时都会释放旧内存并分配新内存,但如果新字符串和旧字符串长度相同,则无需释放和重新分配内存。优化方式是仅在长度不同时重新分配内存,这可以通过比较当前字符串和赋值来源字符串的长度来实现。

检查 this != &str 以避免自赋值(这可能导致不必要的操作和潜在错误)。

比较 length != str.length 来确定是否需要重新分配内存。只有当长度不同时,才删除旧数据并分配新内存。

最后,使用 std::strncpy 复制字符串内存。std::strncpy 不会自动在字符串末尾添加空字符(\0),如果源字符串长度大于指定的复制长度。因此,我们需要手动添加空字符以确保字符串正确终止。

MyString& operator=(const MyString& str) {
    if (this != &str) {
        // 只有在长度不同的情况下才重新分配内存
        if (length != str.length) {
            delete[] data;
            length = str.length;
            data = new char[length + 1];
        }
        std::strncpy(data, str.data, length);
        data[length] = '\0'; // 确保字符串以空字符结束
    }
    return *this;
}

这种方法减少了不必要的内存分配,提高了效率。但需要注意的是,如果内存分配失败(如由于内存不足),new 将抛出异常。根据您的应用场景,您可能需要考虑异常处理策略。

2.3 使用初始化列表

在 C++ 中,使用初始化列表(member initializer list)来初始化类的成员变量通常比在构造函数体内赋值更高效,特别是对于基础数据类型和对象的构造。初始化列表直接初始化成员变量,而不是先默认初始化然后赋值。

对于MyString 类,我们可以使用初始化列表来改进构造函数。这里是修改后的构造函数代码,包括默认构造函数和拷贝构造函数:

默认构造函数:初始化 datanullptrlength 为 0,然后根据需要分配内存和复制内容。

拷贝构造函数:直接用参数 str 的成员初始化 lengthdata

private:
    void strncpy_s(char* dest, const char* src, size_t n) {
        std::strncpy(dest, src, n);
        dest[n] = '\0'; // 确保字符串以空字符结束
    }

public:
    // 主要构造函数
    MyString(const char* s) : length(std::strlen(s)), data(new char[length + 1]) {
        strncpy_s(data, s, length);
    }

    // 委托构造函数(默认构造函数)
    MyString() : MyString("") {}

    // 拷贝构造函数
    MyString(const MyString& str) : length(str.length), data(str.length ? new char[length + 1] : nullptr) {
        if (data) {
            strncpy_s(data, str.data, length);
        }
    }
  • 默认构造函数现在委托给主要构造函数,使用空字符串 "" 作为参数。
  • 在拷贝构造函数中,通过判断 str.length 是否为零,我们可以避免在源字符串为空时进行不必要的内存分配。这不仅节省了资源,还使得类的行为在处理空字符串时更加清晰。

需要注意的是,在使用初始化列表时,成员变量的初始化顺序与它们在类定义中的声明顺序一致,而不是初始化列表中的顺序。因此,成员变量的声明顺序应该仔细考虑,以避免潜在的问题。

2.4 支持更多字符串操作

目前,MyString 类只支持基本操作。可以增加更多功能,如字符串拼接、查找、比较等。

为了更高效和安全地实现 MyString 类中的字符串操作(如拼接、查找、比较),我们需要考虑几个关键因素:避免不必要的内存分配,确保内存操作安全,以及提高异常安全性。以下是优化后的函数实现:

字符串拼接

为了避免多次分配和复制,我们可以一次性分配足够的内存来存储两个字符串的合并结果,并检查内存分配的成功与否。

// 字符串拼接
void append(const MyString& str) {
    size_t newLength = length + str.length;
    char* newData = new char[newLength + 1];

    std::strcpy(newData, data);
    std::strcat(newData, str.data);

    delete[] data;
    data = newData;
    length = newLength;
}

在内存操作涉及到新的分配时,如 append 方法,我们采用了 new (std::nothrow) char[] 来避免抛出异常。如果内存分配失败,我们可以选择合适的错误处理策略,例如返回错误代码或抛出异常。这种处理方式增加了代码的鲁棒性和异常安全性。

void append(const MyString& str) {
    size_t newLength = length + str.length;
    char* newData = new (std::nothrow) char[newLength + 1];

    if (newData) {
        std::strcpy(newData, data);
        std::strcat(newData, str.data);

        delete[] data;
        data = newData;
        length = newLength;
    } else {
        // 处理内存分配失败的情况
        // 可以抛出异常或采取其他错误处理方式
    }
}

字符串查找

查找操作本身是安全的,不过返回的是索引,需要确保索引在字符串范围内。

size_t find(const char* substr) const {
    const char* found = std::strstr(data, substr);
    if (found) {
        size_t pos = found - data;
        return pos <= length ? pos : std::string::npos;
    }
    return std::string::npos;
}

字符串比较

比较操作本身是安全的,不需要特别优化。

int compare(const MyString& str) const {
    return std::strcmp(data, str.data);
}

此外,对于查找操作,我们确保返回的位置不会超出字符串实际长度。这些细节的处理有助于提高代码的健壮性和安全性。

#include <cstring>
#include <iostream>
#include <stdexcept>

class MyString {
private:
    char* data;
    size_t length;

private:
    void strncpy_s(char* dest, const char* src, size_t n) {
        std::strncpy(dest, src, n);
        dest[n] = '\0'; // 确保字符串以空字符结束
    }  

public:

    // 主要构造函数
    MyString(const char* s) : length(std::strlen(s)), data(new (std::nothrow) char[length + 1]) {
        if (data) {
            strncpy_s(data, s, length);
        } else {
            throw std::bad_alloc();
        }
    }

    // 委托构造函数(默认构造函数)
    MyString() : MyString("") {}

    // 拷贝构造函数
    MyString(const MyString& str) : length(str.length), data(new (std::nothrow) char[length + 1]) {
        if (data) {
            strncpy_s(data, str.data, length);
        } else {
            throw std::bad_alloc();
        }
    }

    // 移动构造函数
    MyString(MyString&& str) noexcept : data(str.data), length(str.length) {
        str.data = nullptr;
        str.length = 0;
    }

    // 移动赋值运算符
    MyString& operator=(const MyString& str) {
        if (this != &str) {
            // 只有在长度不同的情况下才重新分配内存
            if (length != str.length) {
                delete[] data;
                length = str.length;
                data = new char[length + 1];
            }
            strncpy_s(data, str.data, length);
        }
        return *this;
    }


    // 析构函数
    ~MyString() {
        delete[] data;
    }

    // 获取字符串长度
    size_t size() const {
        return length;
    }

    // 打印字符串
    void print() const {
        std::cout << data << std::endl;
    }

    // 字符串拼接
    void append(const MyString& str) {
        size_t newLength = length + str.length;
        char* newData = new (std::nothrow) char[newLength + 1];

        if (newData) {
            std::strcpy(newData, data);
            std::strcat(newData, str.data);

            delete[] data;
            data = newData;
            length = newLength;
        } else {
            throw std::bad_alloc();
        }
    }

    // 字符串查找
    size_t find(const char* substr) const {
        const char* found = std::strstr(data, substr);
        if (found) {
            size_t pos = found - data;
            return pos <= length ? pos : std::string::npos;
        }
        return std::string::npos;
    }

    // 字符串比较
    int compare(const MyString& str) const {
        return std::strcmp(data, str.data);
    }
};

int main() {
    try {
        MyString s1("Hello");
        MyString s2 = s1; // 拷贝构造

        s1.print(); // 输出 Hello
        s2.print(); // 输出 Hello

        MyString s3;
        s3 = s1; // 赋值运算
        s3.print(); // 输出 Hello

        s3.append(MyString("World"));
        s3.print(); // 输出 Hello World
    } catch (const std::bad_alloc&) {
        std::cerr << "内存分配失败。" << std::endl;
        return 1;
    }

    return 0;
}

3. 优化版本2

该版本主要是在前面的优化基础上再增加两点

3.1 使用 std::move 实现

  1. 使用 std::move 实现移动构造函数和移动赋值运算符:这允许利用右值引用的优势,减少不必要的内存分配和复制操作,特别是在涉及临时对象时。

  2. 强化异常安全性:在赋值运算符重载中,如果 new char[] 分配失败,程序可能因未处理的异常而崩溃。一个更好的做法是先在局部变量中分配和复制数据,然后交换指针。这也称为“复制并交换”(copy-and-swap)技术,能提高代码的异常安全性。

使用 std::move 实现移动构造函数和移动赋值运算符: C++11 中引入的右值引用和 std::move 函数实现移动构造函数和移动赋值运算符可以显著提高性能,特别是在涉及大量数据或临时对象时。

移动构造函数:这个构造函数将接收一个右值引用,并从源对象那里“窃取”资源(例如,动态分配的内存),而不是复制它们,完成后,源对象处于有效但未定义的状态。

移动赋值运算符:这个运算符也接收一个右值引用,它释放接收方当前持有的资源,并“窃取”源对象的资源。

// 移动构造函数
MyString(MyString&& str) noexcept : data(str.data), length(str.length) {
    str.data = nullptr;  // 确保源对象的析构函数不会删除内存
    str.length = 0;
}

// 移动赋值运算符
MyString& operator=(MyString&& str) noexcept {
    if (this != &str) {
        delete[] data;    // 释放当前对象的资源

        data = str.data;  // 窃取资源
        length = str.length;

        str.data = nullptr;  // 确保源对象的析构函数不会删除内存
        str.length = 0;
    }
    return *this;
}

noexcept 关键字来标记这些函数不会抛出异常,这是因为移动构造函数和移动赋值运算符通常应该是无异常的,以便标准容器等能有效地使用它们。

3.2 Copy and Swap

Copy and Swap:在赋值运算符重载中,如果 new char[] 分配失败,程序可能因未处理的异常而崩溃,一个更好的做法是先在局部变量中分配和复制数据,然后交换指针。这也称为“复制并交换”(copy-and-swap)技术,能提高代码的异常安全性。

这种方法的核心思想是首先在局部变量中创建新实例的副本(从而可能触发异常),然后交换新旧实例的内容,这种做法保证了如果在复制过程中发生异常,原始对象不会被破坏,从而保持了异常安全性。

为此,我们首先需要实现一个交换函数(通常作为类的成员函数或友元函数),然后在赋值运算符中使用它。

实现交换函数

void swap(MyString& first, MyString& second) noexcept {
    using std::swap;
    swap(first.length, second.length);
    swap(first.data, second.data);
}

改进赋值运算符

MyString& operator=(MyString str) {  // 注意:这里是按值传递
    swap(*this, str);
    return *this;
}

在这里,赋值运算符通过值传递一个 MyString 对象,这意味着实际上调用了拷贝构造函数或移动构造函数来创建 str 的副本。然后,使用 swap 函数交换当前对象 (*this) 和这个副本的内容。由于副本是按值传递的,当赋值运算符的函数体结束时,副本(包含原始对象的旧数据)将被自动销毁。

这种方法的优点在于,如果 new char[] 在拷贝构造函数或移动构造函数中分配失败,会抛出异常,但不会影响到现有对象。一旦到达赋值运算符内部并交换了数据,就不会有任何异常发生,从而保证了强异常安全保证。

请注意,为了完全实现这种模式,您需要确保您的类有正确的拷贝构造函数和移动构造函数,以处理赋值运算符中的副本创建。

#include <cstring>
#include <iostream>
#include <stdexcept>

class MyString {
private:
    char* data;
    size_t length;

private:
    void strncpy_s(char* dest, const char* src, size_t n) {
        std::strncpy(dest, src, n);
        dest[n] = '\0'; // 确保字符串以空字符结束
    }

public:

    // 构造函数
    MyString(const char* s = "") : length(std::strlen(s)), data(new (std::nothrow) char[length + 1]) {
        if (data) {
            strncpy_s(data, s, length);
        } else {
            throw std::bad_alloc();
        }
    }

    // 拷贝构造函数
    MyString(const MyString& str) : length(str.length), data(new (std::nothrow) char[length + 1]) {
        if (data) {
            strncpy_s(data, str.data, length);
        } else {
            throw std::bad_alloc();
        }
    }

    // 移动构造函数
    MyString(MyString&& str) noexcept : data(str.data), length(str.length) {
        str.data = nullptr;
        str.length = 0;
    }

    // 赋值运算符
    MyString& operator=(MyString str) {
        swap(*this, str);
        return *this;
    }

    // 移动赋值运算符
    MyString& operator=(MyString&& str) noexcept {
        swap(*this, str);
        return *this;
    }

    // 交换函数
    friend void swap(MyString& first, MyString& second) noexcept {
        using std::swap;
        swap(first.length, second.length);
        swap(first.data, second.data);
    }

    // 析构函数
    ~MyString() {
        delete[] data;
    }

    // 获取字符串长度
    size_t size() const {
        return length;
    }

    // 打印字符串
    void print() const {
        std::cout << data << std::endl;
    }

    // 字符串拼接
    void append(const MyString& str) {
        size_t newLength = length + str.length;
        char* newData = new (std::nothrow) char[newLength + 1];

        if (newData) {
            std::strcpy(newData, data);
            std::strcat(newData, str.data);

            delete[] data;
            data = newData;
            length = newLength;
        } else {
            throw std::bad_alloc();
        }
    }

    // 字符串查找
    size_t find(const char* substr) const {
        const char* found = std::strstr(data, substr);
        if (found) {
            size_t pos = found - data;
            return pos <= length ? pos : std::string::npos;
        }
        return std::string::npos;
    }

    // 字符串比较
    int compare(const MyString& str) const {
        return std::strcmp(data, str.data);
    }
};

int main() {
    try {
        MyString s1("Hello");
        MyString s2 = s1; // 拷贝构造

        s1.print(); // 输出 Hello
        s2.print(); // 输出 Hello

        MyString s3;
        s3 = s1; // 赋值运算
        s3.print(); // 输出 Hello

        s3.append(MyString(" World"));
        s3.print(); // 输出 Hello World
    } catch (const std::bad_alloc&) {
        std::cerr << "内存分配失败。" << std::endl;
        return 1;
    }

    return 0;
}

同名公众号文章地址:https://mp.weixin.qq.com/s?__biz=MzU3OTgzNTMwNg==&mid=2247484004&idx=1&sn=1c81db3a4ecea479b3fba445641b64fb&chksm=fd6146a1ca16cfb7139cb8cbbc5536fba45dc4144a22b71f00b92029aed64aede1577600acb5#rd
欢迎来公众号交流,共同学习,共同进步,在这里有 C++、Golang、数据结构等面试考点,持续更新!
在这里插入图片描述

  • 41
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Coder567

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值