想当年,有次 567去面试,面试官听完自我介绍后,说”既然你懂 C++,那就写一个 C++ 的 string 类吧”,题目越短,问题越难,567 抓耳捞腮写了半天,面试官说“时间差不多了,让我看看吧”,沉默了几分钟后,面试官来了句” 平时常用的是 C++ 吗?“ 然后就没有然后了…
为了让大家不再陷入面试的尴尬境地,把这个问题翻出来重新写一写?
首先,面试官让你写一个 string 类,考察的是什么?
- 对 C++ 类的考察,知识点包括构造函数、析构函数、成员变量和成员函数等。
- 对 C++ std::string 类的考察,知识点包括 string 类的常见特性和实现原理。
- 对 C++内存管理的考察,字符串处理过程中通常涉及动态内存的分配,例如 new 和 delete 关键字的使用,拷贝构造函数和赋值运算符的实现,注意内存泄漏和浅拷贝问题。
- 编程基础和代码质量的考察,考察代码的可读性、可维护性和效率,还有程序的健壮性,比如异常处理、边界条件的处理等等。
以下示例跟 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
修饰符,这一点是优点,强调下。
下面将从以下一些方面来进行优化:
-
使用更安全的字符串拷贝函数:strcpy 函数不会检查目标数组的大小,因此可能会导致缓冲区溢出的安全问题,推荐使用更安全的字符串处理方法,可以使用 strncpy 来限制复制的最大字符数。
-
避免内存泄漏和多次分配:当前的赋值运算符重载在复制之前释放旧内存,这是好的,但如果新字符串和旧字符串长度相同,则无需释放和重新分配内存。可以先检查长度,仅当长度不同时才重新分配。
-
使用初始化列表:在构造函数中使用初始化列表可以更有效地初始化成员变量。
-
支持更多字符串操作:目前,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
类,我们可以使用初始化列表来改进构造函数。这里是修改后的构造函数代码,包括默认构造函数和拷贝构造函数:
默认构造函数:初始化 data
为 nullptr
和 length
为 0,然后根据需要分配内存和复制内容。
拷贝构造函数:直接用参数 str
的成员初始化 length
和 data
。
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 实现
-
使用 std::move 实现移动构造函数和移动赋值运算符:这允许利用右值引用的优势,减少不必要的内存分配和复制操作,特别是在涉及临时对象时。
-
强化异常安全性:在赋值运算符重载中,如果 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、数据结构等面试考点,持续更新!