12.1、动态内存和类
在 C++ 中,我们可以使用动态内存分配来为类对象动态分配内存。动态内存分配指的是在程序运行的过程中,根据需要动态地分配内存空间。与之相对应的是静态内存分配,即在程序编译时就就已经确定了内存的分配情况,无法根据程序运行的需要进行调整。
动态内存分配的过程需要使用到两个运算符:new
和 delete
。其中,new
运算符用于向操作系统申请内存空间,返回指向该空间的指针;delete
运算符用于释放之前申请的动态内存空间,使该空间变为可用状态。
下面是一个使用动态内存的类示例:
#include <iostream>
class MyString {
public:
MyString(const char* str) { // 构造函数
if (str != nullptr) {
size_ = strlen(str);
buf_ = new char[size_ + 1];
strcpy(buf_, str);
} else {
size_ = 0;
buf_ = new char[1];
buf_[0] = '\0';
}
}
MyString(const MyString& other) { // 拷贝构造函数
size_ = other.size_;
buf_ = new char[size_ + 1];
strcpy(buf_, other.buf_);
}
~MyString() { // 析构函数
delete[] buf_;
}
MyString& operator=(const MyString& other) { // 赋值运算符重载
if (this != &other) {
delete[] buf_;
size_ = other.size_;
buf_ = new char[size_ + 1];
strcpy(buf_, other.buf_);
}
return *this;
}
friend std::ostream& operator<<(std::ostream& os, const MyString& str);
private:
char* buf_;
size_t size_;
};
std::ostream& operator<<(std::ostream& os, const MyString& str) {
os << str.buf_;
return os;
}
int main() {
MyString str1("hello");
MyString str2(str1); // 调用拷贝构造函数
MyString str3 = str2; // 调用拷贝构造函数
MyString str4(nullptr);
std::cout << str1 << ", " << str2 << ", " << str3 << ", " << str4 << std::endl;
str3 = "world";
std::cout << str3 << std::endl;
return 0;
}
上述代码中,我们定义了一个名为 MyString
的类,其中包含了指向字符数组的指针 buf_
和字符数组的长度 size_
。在构造函数中,我们使用 new
运算符向操作系统申请内存,将字符数组拷贝到动态申请的内存空间中。在拷贝构造函数和赋值运算符重载函数中,我们同样使用 new
和 delete
运算符来进行内存的动态分配与释放。最后,我们通过重载 <<
运算符来方便地输出类对象的内容。
在 main
函数中,我们创建了四个 MyString
类对象,并使用重载的 <<
运算符将其输出到控制台上。然后,我们修改了其中一个对象的值,并再次输出到控制台上。
需要注意的是,在使用动态内存分配时,应该时刻注意内存泄漏的问题。在拷贝构造函数和赋值运算符重载函数中,必须正确地管理动态分配的内存空间,避免重复分配或多次释放。
12.1.1、复习示例和静态类成员
在 C++ 中,静态类成员是属于整个类而不是属于类的具体实例的变量或函数。我们可以通过在类的内部声明静态成员,并在类的外部定义并初始化该成员来定义静态成员。与普通成员不同的是,静态成员可以在不创建类实例的情况下被访问。
下面是一个增加了静态成员的 MyString
类示例:
#include <iostream>
class MyString {
public:
MyString(const char* str) {
if (str != nullptr) {
size_ = strlen(str);
buf_ = new char[size_ + 1];
strcpy(buf_, str);
} else {
size_ = 0;
buf_ = new char[1];
buf_[0] = '\0';
}
++count_; // 每次创建 MyString 对象时,count_ 自增一
}
MyString(const MyString& other) {
size_ = other.size_;
buf_ = new char[size_ + 1];
strcpy(buf_, other.buf_);
++count_;
}
~MyString() {
delete[] buf_;
--count_; // 每次销毁 MyString 对象时,count_ 自减一
}
MyString& operator=(const MyString& other) {
if (this != &other) {
delete[] buf_;
size_ = other.size_;
buf_ = new char[size_ + 1];
strcpy(buf_, other.buf_);
}
return *this;
}
friend std::ostream& operator<<(std::ostream& os, const MyString& str);
static int getCount() { // 获取已创建的 MyString 对象数量
return count_;
}
private:
char* buf_;
size_t size_;
static int count_; // 静态成员变量,用于记录已创建的 MyString 对象数量
};
std::ostream& operator<<(std::ostream& os, const MyString& str) {
os << str.buf_;
return os;
}
int MyString::count_ = 0; // 静态成员变量需要在类的外部进行定义并初始化
int main() {
MyString str1("hello");
MyString str2(str1);
MyString str3 = str2;
MyString str4(nullptr);
std::cout << str1 << ", " << str2 << ", " << str3 << ", " << str4 << std::endl;
std::cout << "number of MyString objects created: " << MyString::getCount() << std::endl;
return 0;
}
上述代码中,我们新增加了一个静态成员变量 count_
,用于记录已创建的 MyString
对象数量。在类的构造函数和析构函数中,我们向 count_
中添加或减少已创建的对象数量。同时,我们实现了静态成员函数 getCount()
,用于获取已创建的 MyString
类对象的数量。
在 main
函数中,我们创建了四个 MyString
类对象,并输出它们的内容。然后,我们使用静态成员函数 getCount()
获取已创建的 MyString
类对象的数量,并输出到控制台上。
需要注意的是,静态成员变量只有一个副本,无论类的实例数量有多少个,只占用类的内存空间一份。而静态成员函数可以访问类的静态成员变量,但不能访问类的非静态成员变量。静态成员函数可以通过类名来调用,也可以通过类的实例来调用。
12.1.2、特殊成员函数
在 C++ 中,有几种特殊的成员函数,它们是由编译器自动生成的,分别为默认构造函数、析构函数、拷贝构造函数、移动构造函数、拷贝赋值运算符和移动赋值运算符。
默认构造函数是不带参数的构造函数,如果没有手动实现构造函数,编译器会默认生成一个默认构造函数。它主要用于在创建对象时进行对象的初始化。如果类中有其他的构造函数,则需要显示地定义并实现默认构造函数,否则编译器不会生成默认构造函数。
析构函数与构造函数相对应,用于在对象生命周期结束时执行清理操作。它的函数名为 ~类名()
,并且没有参数和返回值。如果类中没有手动实现析构函数,则编译器会默认生成一个析构函数。
拷贝构造函数用于创建一个新对象,并使用另一个同类型的已有对象进行初始化。它的形式为 类名(const 类名& other)
,它必须是 const 引用。如果类中没有手动实现拷贝构造函数,则编译器会默认生成一个拷贝构造函数。
移动构造函数与拷贝构造函数类似,也是用于创建一个新对象,但它使用右值引用参数(如 类名(类名&& other)
)来接收对象,并在创建新对象时“窃取”已有对象的资源而不复制。移动构造函数常见于实现高效的“移动语义”。
拷贝赋值运算符与拷贝构造函数相似,但它是在已有对象的内容被覆盖或重新使用时使用的。它的形式为 类名& operator=(const 类名& other)
,返回一个类的引用。如果类中没有手动实现拷贝赋值运算符,则编译器会默认生成一个拷贝赋值运算符。
移动赋值运算符与拷贝赋值运算符类似,但它使用右值引用参数(如 类名& operator=(类名&& other)
)来接收对象,并在重新使用已有对象时“窃取”已有对象的资源而不进行复制操作。移动赋值运算符也常见于实现高效的“移动语义”。
需要注意的是,如果类中定义了任何一种特殊成员函数,则在需要调用其它特殊成员函数时,也需要将其进行显式的定义和实现。这是因为编译器只会在需要时自动生成缺失的特殊成员函数,而如果特殊成员函数不是默认形式的(比如带有参数或调用了其它函数),则编译器无法自动为其生成默认实现。
12.1.3、回到StringBad:复制构造函数的哪里处理问题
在 StringBad
类中,复制构造函数的目的是创建一个新的 StringBad
对象,并使用一个已有的 StringBad
对象进行初始化。这个过程不仅要复制已有对象的数据成员(即字符数组),还需要为新的 StringBad
对象分配一段新的内存空间,避免与原有对象共用同一块内存造成意外的后果。
因此,StringBad
类的复制构造函数需要完成如下几个主要步骤:
-
在新对象中分配一段新的内存空间,用于存储从已有对象复制而来的字符数组内容。可以使用
new
运算符来完成内存分配操作。 -
将已有对象中的字符数组内容复制到新对象的字符数组中。可以使用标准库函数
strcpy()
或memcpy()
来完成字符数组的复制操作。在复制时,需要注意确保新对象分配的内存空间足够存储字符数组内容,并在复制完成后正确添加空字符\0
。 -
如果已有对象中还有其它数据成员或指针等需要复制的内容,也需要在复制构造函数中进行相应的复制操作。
下面是一个简单的 StringBad
类复制构造函数的实现代码示例:
// StringBad复制构造函数
StringBad::StringBad(const StringBad & st)
{
len = st.len; // 复制长度
str = new char[len + 1]; // 分配内存
strcpy(str, st.str); // 复制字符数组
}
在上面的代码中,构造函数首先复制了已有对象的字符数组长度 len
,然后调用 new
运算符分配了一段新的内存空间,并将该指针存储在新对象的 str
数据成员中。最后,使用 strcpy()
函数将已有对象的字符数组复制到新对象的字符数组中。
需要注意的是,在完成复制操作之后,新对象的字符数组指针 str
指向的是一段新的内存空间,与原有对象指针 str
指向的内存空间是不同的,这样才能确保各自的内存不相互干扰。
12.1.4、StringBad的其他问题:赋值运算符
除了拷贝构造函数,还有赋值运算符(operator=
)也需要在 StringBad
类中进行处理,以确保对象之间进行赋值操作时不会造成内存泄漏或被修改的问题。
同样地,赋值运算符需要为目标对象分配一段新的内存空间,并将已有对象的数据复制到目标对象的空间中。不同的是,赋值运算符需要在进行数据复制之前,释放目标对象已有的内存空间,避免造成内存泄漏。因此,赋值运算符需要完成以下主要步骤:
-
检查是否是自我赋值,如果是则直接返回当前对象。
-
释放目标对象已有的内存空间。可以使用
delete[]
运算符来释放字符数组所占用的内存空间。 -
在目标对象中分配一段新的内存空间,用于存储从已有对象复制而来的字符数组内容。
-
将已有对象中的字符数组内容复制到目标对象的字符数组中。可以使用标准库函数
strcpy()
或memcpy()
来完成字符数组的复制操作。 -
返回目标对象的引用。
下面是一个简单的 StringBad
类赋值运算符的实现代码示例:
// StringBad赋值运算符
StringBad & StringBad::operator=(const StringBad & st)
{
// 检查是否是自我赋值
if (this == &st)
return *this;
// 释放目标对象已有的内存空间
delete[] str;
// 分配新的内存空间并复制数据
len = st.len;
str = new char[len + 1];
strcpy(str, st.str);
// 返回目标对象的引用
return *this;
}
在上面的代码中,运算符首先检查了是否是自我赋值,如果是则直接返回当前对象的引用。如果不是自我赋值,则利用 delete[]
运算符释放了目标对象 this
已有的内存空间,然后分配新的内存空间,并将已有对象 st
的字符数组复制到目标对象的字符数组中。
需要注意的是,在完成数据复制操作之后,赋值运算符返回的是目标对象的引用,这个引用可以用于链式赋值操作和其他需要返回对象自身的操作。同时,赋值运算符还需要处理异常安全问题,确保在发生异常时不会造成资源泄漏的问题。
12.2、改进后的新String类
经过对 StringBad
类的分析和改进,我们可以创建一个新的 String
类,它能够更好地管理字符数组的内存,同时提供了一些实用的接口和运算符重载。
下面是一个简单的 String
类的实现,它包括了拷贝构造函数、赋值运算符、析构函数、<<
运算符重载、+
运算符重载以及 []
运算符重载。
#ifndef STRING_H_
#define STRING_H_
#include <iostream>
#include <cstring>
class String {
private:
char * str; // 字符数组指针
int len; // 字符数组长度
public:
// 默认构造函数
String();
// 拷贝构造函数
String(const String & st);
// 析构函数
~String();
// 获取字符串长度
int length() const { return len; }
// 重载赋值运算符
String & operator=(const String & st);
// 重载 + 运算符
String operator+(const String & st) const;
// 重载 [] 运算符
char & operator[](int i);
// 重载 [] 运算符 (常量版本)
const char & operator[](int i) const;
// 重载 << 运算符
friend std::ostream & operator<<(std::ostream & os, const String & st);
};
String::String()
{
len = 0;
str = new char[1];
str[0] = '\0';
}
String::String(const String & st)
{
len = st.len;
str = new char[len + 1];
std::strcpy(str, st.str);
}
String::~String()
{
delete [] str;
}
String & String::operator=(const String & st)
{
if (this == &st)
return *this;
delete [] str;
len = st.len;
str = new char[len + 1];
std::strcpy(str, st.str);
return *this;
}
String String::operator+(const String & st) const
{
String result;
result.len = len + st.len;
result.str = new char[result.len + 1];
std::strcpy(result.str, str);
std::strcat(result.str, st.str);
return result;
}
char & String::operator[](int i)
{
return str[i];
}
const char & String::operator[](int i) const
{
return str[i];
}
std::ostream & operator<<(std::ostream & os, const String & st)
{
os << st.str;
return os;
}
#endif // STRING_H_
在 String
类的实现中,我们使用了动态内存分配来管理字符数组的空间。在默认构造函数和拷贝构造函数中,我们分别为字符数组指针分配了一个含有一个空字符的动态内存空间,并在析构函数中释放了这段内存空间。同时,在赋值运算符中,我们重载了 =
运算符,利用 new[]
运算符为字符数组指针分配了新的内存空间,并使用标准库函数 strcpy()
和 strcat()
复制和拼接了已有字符数组中的内容和新字符串中的内容。
在 +
运算符和 []
运算符的重载中,我们返回了一个新的 String
对象和字符数组引用,方便进行字符串拼接和访问特定位置的字符。同时,在 <<
运算符的重载中,我们友元函数的形式将字符串输出到流中,方便进行字符串输出操作。
需要注意的是,我们在 String
类中使用 delete []
运算符和 new[]
运算符进行内存管理时,需要考虑异常安全问题。在发生异常时,需要确保在释放已有内存之前,已经成功地为新内存分配了空间,避免出现资源泄漏的问题。
12.2.1、修订后的默认构造函数
在对 StringBad
类的分析中,我们发现默认构造函数没有为字符数组分配任何的内存空间,这会导致程序在对字符数组进行赋值或操作时出现访问非法内存的错误。因此,在 String
类中,我们需要修订默认构造函数,为字符数组分配一定的内存空间,同时设置一个空字符。
修订后的默认构造函数代码如下:
String::String()
{
len = 0;
str = new char[1]; // 分配一个字符的空间,用于存储空字符
str[0] = '\0'; // 将字符数组第一个元素设置为空字符
}
在修订后的默认构造函数中,我们仍然为字符数组分配了一个字符的空间,但是将第一个元素的值设置为空字符,避免出现访问非法内存的错误。同时,我们在进行字符数组赋值或操作时,需要确保字符数组中至少有一个空字符,保证程序能够正确访问和输出字符数组的内容。
12.2.2、比较成员函数
在 String
类中,我们可以定义比较成员函数,用于比较两个 String
对象的大小关系。比较成员函数可以有多种实现方式,下面分别介绍两种常见的实现方式。
- 重载运算符
<
我们可以通过重载运算符 <
来实现比较成员函数。在该实现方式中,我们定义一个重载运算符 <
的成员函数,用于比较当前对象和另外一个 String
对象的大小关系。这个成员函数实现起来非常简单,只需要比较两个对象的字符数组的字典序大小即可。
下面是重载运算符 <
的实现代码:
bool String::operator<(const String& st) const
{
return (std::strcmp(str, st.str) < 0);
}
在该实现方式中,我们使用了 std::strcmp
函数来比较两个字符数组的字典序大小。如果当前对象的字符数组的字典序小于另外一个对象的字符数组的字典序,则返回 true
,否则返回 false
。
使用重载运算符 <
来比较两个 String
对象的大小关系的示例代码如下:
String s1("abc");
String s2("def");
if (s1 < s2)
{
std::cout << "s1 < s2" << std::endl;
}
else
{
std::cout << "s1 >= s2" << std::endl;
}
在上面的示例代码中,我们分别创建了两个 String
对象 s1
和 s2
,并使用 <
运算符比较它们的大小关系。因为 abc
的字典序小于 def
的字典序,所以输出结果为 s1 < s2
。
- 使用
std::strcmp
函数
除了使用运算符重载的方式比较两个 String
对象的大小关系外,我们也可以直接使用 std::strcmp
函数来实现比较成员函数。在该实现方式中,我们定义一个成员函数 compare
,用于比较当前对象和另外一个 String
对象的大小关系。这个成员函数实现起来也非常简单,只需要将当前对象的字符数组和另外一个对象的字符数组传递给 std::strcmp
函数进行比较即可。
下面是 compare
成员函数的实现代码:
int String::compare(const String& st) const
{
return std::strcmp(str, st.str);
}
在该实现方式中,我们使用了 std::strcmp
函数来比较两个字符数组的字典序大小。如果当前对象的字符数组的字典序小于另外一个对象的字符数组的字典序,则返回负整数;如果当前对象的字符数组的字典序等于另外一个对象的字符数组的字典序,则返回 0;如果当前对象的字符数组的字典序大于另外一个对象的字符数组的字典序,则返回正整数。
使用 compare
成员函数来比较两个 String
对象的大小关系的示例代码如下:
String s1("abc");
String s2("def");
if (s1.compare(s2) < 0)
{
std::cout << "s1 < s2" << std::endl;
}
else
{
std::cout << "s1 >= s2" << std::endl;
}
在上面的示例代码中,我们分别创建了两个 String
对象 s1
和 s2
,并使用 compare
成员函数比较它们的大小关系。因为 abc
的字典序小于 def
的字典序,所以输出结果为 s1 < s2
。
注意:使用 std::strcmp
函数比较字符串大小时,需要保证字符串中不包含空字符(即 '\0'
)以外的字符。因为 std::strcmp
函数会认为空字符为字符串的结束标志,如果字符串中包含了空字符以外的字符,则比较结果不可预期。
12.2.3、使用括号表示法访问字符
在 String
类中,我们可以使用括号表示法来访问 String
对象中的某个字符。使用括号表示法可以通过下标来指定所访问的字符在字符串中的位置。
下面是使用括号表示法访问字符的示例代码:
String s("hello");
std::cout << s[0] << std::endl; // 输出 'h'
std::cout << s[1] << std::endl; // 输出 'e'
std::cout << s[2] << std::endl; // 输出 'l'
std::cout << s[3] << std::endl; // 输出 'l'
std::cout << s[4] << std::endl; // 输出 'o'
在上面的示例代码中,我们创建了一个 String
对象 s
,并使用括号表示法访问该对象中的某个字符。第一次访问时,我们使用下标 0 来访问字符串中的第一个字符,即字符 ‘h’;第二次访问时,我们使用下标 1 来访问字符串中的第二个字符,即字符 ‘e’;以此类推。
需要注意的是,使用括号表示法访问字符时,需要保证所访问的字符在字符串范围内。如果访问了字符串范围外的字符,则程序可能会出现不可预期的错误。
下面是使用括号表示法访问字符的实现代码:
char& String::operator[](int i)
{
return str[i];
}
const char& String::operator[](int i) const
{
return str[i];
}
在该实现中,我们重载了 []
运算符,同时定义了两个版本的运算符函数。第一个版本的运算符函数返回一个左值引用,用于允许我们修改字符串中的字符;第二个版本的运算符函数返回一个常量左值引用,用于防止我们修改字符串中的字符。
使用括号表示法访问字符时,如果当前对象是一个常量对象,则会调用第二个版本的运算符函数,否则会调用第一个版本的运算符函数。
12.2.4、静态类成员函数
静态类成员函数是不依赖于类的实例而存在的函数。它们与类的作用域相关,但是不需要类的实例来调用。静态类成员函数可以访问类的静态成员变量和其他静态类成员函数,不能访问类的非静态成员变量和非静态成员函数。
在 C++ 中,我们可以使用 static
关键字来声明静态成员函数。静态成员函数的定义也需要使用 static
关键字。
下面是一个简单的示例,展示如何定义和使用静态类成员函数:
class MyClass
{
public:
static void printMessage()
{
std::cout << "Hello, World!" << std::endl;
}
};
int main()
{
MyClass::printMessage();
return 0;
}
在上面的示例中,我们定义了一个名为 printMessage
的静态类成员函数,并在 main
函数中调用了该函数。注意,我们使用了作用域运算符 ::
来访问静态类成员函数。
需要注意的是,静态类成员函数不能访问非静态成员变量和非静态成员函数。如果需要在静态类成员函数中访问非静态成员变量或非静态成员函数,我们需要将它们声明为静态成员。
下面是一个示例,展示如何在静态类成员函数中访问静态成员变量和静态成员函数:
class MyClass
{
private:
static int count;
public:
static void printCount()
{
std::cout << "The count is: " << count << std::endl;
}
static void incrementCount()
{
count++;
}
};
int MyClass::count = 0;
int main()
{
MyClass::incrementCount();
MyClass::incrementCount();
MyClass::printCount();
return 0;
}
在上面的示例中,我们定义了一个名为 count
的静态成员变量,并在静态成员函数 incrementCount
中对其进行了修改。我们还定义了另一个静态成员函数 printCount
,用于输出当前计数器的值。在 main
函数中,我们两次调用 incrementCount
函数来增加计数器的值,然后调用 printCount
函数输出计数器的值。
12.2.5、进一步重载赋值运算符
在 C++ 中,赋值运算符可以重载,以便我们可以使用自定义方式来赋值一个对象。默认情况下,C++ 提供了一个成员函数 operator=
来执行赋值操作,但是该函数只进行简单的浅复制。如果需要在赋值操作中进行深复制或实现其他自定义逻辑,我们需要重载赋值运算符。
赋值运算符需要返回一个指向当前类对象的引用。重载赋值运算符的一般形式如下:
class MyClass
{
public:
MyClass& operator=(const MyClass& rhs)
{
// 进行赋值操作
return *this;
}
};
其中,operator=
是赋值运算符的重载函数名称,const MyClass& rhs
是一个右值引用,指向另一个同类型的对象。我们需要在重载函数中完成新对象向旧对象的赋值操作,并返回当前对象的引用。
下面是一个示例,展示如何重载赋值运算符:
#include <iostream>
#include <cstring>
class MyString
{
private:
char* str;
public:
MyString(const char* s = "")
{
str = new char[std::strlen(s) + 1];
std::strcpy(str, s);
}
// 重载赋值运算符
MyString& operator=(const MyString& rhs)
{
if (this != &rhs) // 避免自我赋值
{
delete[] str; // 释放原有的内存
str = new char[std::strlen(rhs.str) + 1]; // 分配新的内存
std::strcpy(str, rhs.str); // 复制数据
}
return *this;
}
const char* getString() const
{
return str;
}
~MyString()
{
delete[] str;
}
};
int main()
{
MyString s1("Hello");
MyString s2("World");
std::cout << s1.getString() << " " << s2.getString() << std::endl;
s1 = s2;
std::cout << s1.getString() << " " << s2.getString() << std::endl;
return 0;
}
在上面的示例中,我们定义了一个名为 MyString
的类,该类包含一个字符串成员变量 str
和一组构造函数、析构函数、取值函数。我们重载了赋值运算符 operator=
,在该函数中,我们在不发生自我赋值的情况下,将 rhs
的字符串复制到新内存中,并释放旧内存,然后返回当前对象的引用。
在 main
函数中,我们创建了两个 MyString
对象 s1
和 s2
,并输出它们的值。然后我们将 s2
赋值给 s1
,再次输出它们的值以验证赋值的正确性。我们可以发现,赋值运算符能够正确地复制对象,并避免内存泄漏等问题。
12.3、在构造中使用new时应注意的事项
在 C++ 中,我们可以在构造函数中使用 new
运算符来动态分配内存空间,创建对象的实例。这种方式适用于需要在运行时根据条件创建对象或需要自定义内存管理的情况。然而,在构造函数中使用 new
时,需要注意以下几个问题:
-
内存泄漏问题:在使用
new
分配内存之后,需要在适当的时候使用delete
释放内存,否则可能会造成内存泄漏。一般来说,我们需要在析构函数中释放在构造函数中动态分配的内存。 -
异常安全问题:在构造函数中使用
new
进行内存分配时,如果在分配内存后抛出异常,可能会导致内存泄漏。一般来说,我们可以使用 RAII 技术来保证内存的释放。例如,可以使用智能指针std::unique_ptr
或std::shared_ptr
来管理动态分配的内存,这样即使出现异常,智能指针也会负责释放内存。 -
拷贝构造函数和赋值运算符重载:在一个类中,如果有动态分配的成员变量,需要重载拷贝构造函数和赋值运算符。否则,浅拷贝可能会导致多个指针指向同一块内存,进而造成内存泄漏和错误修改数据的风险。以下是一个示例代码,演示了在类中使用
new
的注意事项:
#include <iostream>
#include <memory>
using namespace std;
class MyClass {
public:
MyClass() {
p_data = new int(0);
}
// 拷贝构造函数
MyClass(const MyClass& rhs) {
p_data = new int(*rhs.p_data);
}
// 赋值运算符重载
MyClass& operator=(const MyClass& rhs) {
if (this != &rhs) {
*p_data = *rhs.p_data;
}
return *this;
}
virtual ~MyClass() {
delete p_data;
}
private:
int* p_data;
};
int main() {
MyClass obj1;
MyClass obj2(obj1);
MyClass obj3 = obj2;
MyClass obj4;
obj4 = obj3;
// 使用智能指针管理动态分配的内存
unique_ptr<MyClass> uptr(new MyClass);
shared_ptr<MyClass> sptr = make_shared<MyClass>();
return 0;
}
在上面的示例中,我们定义了一个名为 MyClass
的类,该类包含一个动态分配的 int
类型成员变量 p_data
,并且在构造函数中对其进行了初始化。我们重载了拷贝构造函数和赋值运算符,以便正确复制和赋值对象。析构函数中释放了动态分配的内存,避免了内存泄漏问题。
在 main
函数中,我们创建了四个 MyClass
对象,分别使用构造函数、拷贝构造函数、赋值运算符来初始化和赋值对象。我们还使用智能指针 std::unique_ptr
和 std::shared_ptr
来管理动态分配的内存,保证内存的自动释放。
12.3.1、应该和不应该
以下是在构造函数中使用 new
时,应该和不应该做的事项:
应该做的事项:
- 在析构函数中释放在构造函数中动态分配的内存;
- 确保分配的内存已成功地被释放;
- 使用 RAII 技术,例如智能指针,管理动态分配的内存;
- 重载拷贝构造函数和赋值运算符,以正确复制和赋值对象。
不应该做的事项:
- 不要在构造函数中分配过多的内存空间,过多的内存分配可能会导致性能问题;
- 不要在构造函数中频繁使用
new
运算符,过多的new
运算符可能会导致内存碎片问题; - 不要在构造函数中分配数组空间,应该优先使用标准库提供的容器类,如
std::vector
、std::array
、std::map
等; - 不要在构造函数中抛出异常,以免造成内存泄漏和其他异常安全问题。如果必须抛出异常,则应该使用智能指针、堆栈等来确保内存的释放。
12.3.2、包含类成员的类的逐成员复制
当一个类包含其他类类型作为其成员时,如果需要进行复制或移动操作,通常需要使用编写逐成员复制函数或使用编译器生成的默认逐成员复制函数。
默认逐成员复制函数使用逐成员复制方式对每个成员进行复制。对于包含其他类类型作为其成员的类,逐成员复制函数将递归调用其他类的逐成员复制函数。
默认逐成员复制函数仅能保证成员变量内存的拷贝,但可能无法保证其他资源的正确拷贝。因此,在处理动态分配的资源时,最好根据需要手动实现复制构造函数和赋值运算符重载函数,例如使用深拷贝或浅拷贝等策略来确保正确的拷贝行为。
在使用逐成员复制函数时,需要确保每个成员变量正确地支持复制操作,包括复制构造函数、赋值运算符重载函数以及逐成员复制函数,否则会出现编译错误或运行时错误。
此外,在包含类成员的类的移动操作中,还需要确保已将被移动的对象的成员变量置于“有效但未定义”的状态,防止重复释放析构的资源。
12.4、有关返回对象的指针
在 C++ 中,函数可以返回指向对象的指针。在有些情况下,返回对象指针可以确保对象的生命周期与指针的生命期一致,并提高程序的效率。然而,需要注意以下几个问题:
-
对象的生命期:返回对象指针时,需要确保被指向的对象在指针的生命期内保持有效。如果返回指向局部对象的指针,则该指针会指向无效的内存,程序行为未定义。为了避免这种情况,可以使用
new
运算符动态分配内存来创建对象,然后在适当的时间使用delete
运算符释放内存,或者使用智能指针等 RAII 技术来自动管理内存。 -
对象的所有权:在函数中返回对象指针时,需要考虑对象的所有权问题。如果对象的所有权已经转移给了返回的指针,那么调用者需要负责释放内存。如果函数只是返回对象的引用或指针,并不释放内存,那么调用者应该遵守 C++ 对象生命周期的原则,确保对象在使用之前一直有效。
-
指针的类型和生命期:在返回对象指针时,需要考虑指针的类型和生命期。如果指针的生命期超过了指向的对象的生命期,那么指针会指向无效的内存。为了避免这种情况,可以使用智能指针等 RAII 技术来管理指针的生命期。
下面是一个示例代码,演示了在函数中返回对象指针的用法和注意事项:
#include <iostream>
#include <memory>
using namespace std;
class MyClass {
public:
MyClass() {
cout << "MyClass constructor called." << endl;
}
virtual ~MyClass() {
cout << "MyClass destructor called." << endl;
}
void doSomething() {
cout << "Do something." << endl;
}
};
// 返回指向动态分配的 MyClass 对象的指针
MyClass* createMyClass() {
return new MyClass();
}
// 返回智能指针,用于管理动态分配的 MyClass 对象
unique_ptr<MyClass> createMyClass2() {
return make_unique<MyClass>();
}
int main() {
// 使用指针返回 MyClass 对象
MyClass* pObj1 = createMyClass();
pObj1->doSomething();
// 使用智能指针管理 MyClass 对象
unique_ptr<MyClass> pObj2 = createMyClass2();
pObj2->doSomething();
// 释放指针指向的资源
delete pObj1;
pObj1 = nullptr;
return 0;
}
在上面的示例中,我们定义了一个名为 MyClass
的类,该类包含默认构造函数和析构函数,并具有一个成员函数 doSomething
。我们定义了一个函数 createMyClass
,该函数使用 new
运算符动态分配内存来创建 MyClass
对象,并将对象的指针返回给调用者。同时,我们还定义了一个函数 createMyClass2
,该函数使用智能指针 std::unique_ptr
等 RAII 技术来管理动态分配的 MyClass
对象,并将对象的指针返回给调用者。
在 main
函数中,我们首先调用 createMyClass
函数来创建 MyClass
对象,并将对象的指针存储在变量 pObj1
中。然后,我们调用对象的成员函数 doSomething
来执行任务。接下来,我们使用智能指针 std::unique_ptr
调用函数 createMyClass2
,来创建 MyClass
对象,并在它的作用域结束时释放其动态分配的内存。
最后,我们释放指针 pObj1
指向的内存,避免内存泄漏。可以看到,在函数中返回对象指针能够提高程序的效率和可读性,但需要谨慎处理对象的生命期和所有权问题,避免出现不必要的错误和异常。
12.4.1、返回指向const对象的引用
在 C++ 中,函数可以返回指向 const 对象的引用。这种方式可以方便地允许调用者访问函数内部的对象,但不允许对其进行修改。返回 const 引用可以有效地提高程序的效率,因为它避免了对象的复制和内存的分配,但需要注意以下问题:
-
对象的生命周期:返回 const 引用时,需要确保被引用的对象在引用的生命期内保持有效。如果返回指向局部对象的引用,则该引用会指向无效的内存,程序行为未定义。为了避免这种情况,可以使用 static 关键字来实现静态对象或使用全局变量等避免对象的生命周期问题。
-
引用的类型和生命期:在返回 const 引用时,需要考虑引用的类型和生命期。如果引用的生命期超过了引用的对象的生命期,则程序会出现未定义行为。为了避免这种情况,可以使用对象池等技术来管理对象的生命期,并确保函数返回的引用绑定到有效的对象上。
以下是一个示例代码,演示了在函数中返回 const 引用的用法和注意事项:
#include <iostream>
#include <string>
using namespace std;
// 定义一个名为 Person 的类
class Person {
public:
Person(const string& name, int age)
: name_(name), age_(age) {}
// 返回引用,用于访问 name_ 成员变量
const string& getName() const {
return name_;
}
// 返回常量引用,用于访问 age_ 成员变量
const int& getAge() const {
return age_;
}
private:
string name_;
int age_;
};
// 返回常量引用,用于访问静态对象 person
const Person& getPerson() {
static Person person("Tom", 20);
return person;
}
int main() {
// 访问返回引用的成员函数
const Person& p1 = getPerson();
cout << "Name: " << p1.getName() << ", Age: " << p1.getAge() << endl;
// 访问返回常量引用的成员函数
const int& age = getPerson().getAge();
cout << "Age: " << age << endl;
return 0;
}
在上面的示例中,我们定义了一个名为 Person
的类,该类具有两个数据成员 name_
和 age_
,并定义了两个成员函数 getName
和 getAge
,分别返回 string
和 int
类型的常量引用。然后,我们通过函数 getPerson
定义了一个静态 Person
对象,用于演示返回 const 引用的用法。
在 main
函数中,我们首先通过调用 getPerson
函数来访问静态对象,并将其返回的常量引用存储在变量 p1
中。然后,我们调用对象的成员函数 getName
和 getAge
分别访问 name_
和 age_
成员变量,并输出其值。接下来,我们通过调用 getPerson
函数来访问静态对象,并调用对象的成员函数 getAge
,将其返回的常量引用存储在变量 age
中。最后,我们输出变量 age
的值。
可以看到,在函数中返回 const 引用可以方便地访问对象的成员变量,并提高程序的效率,但需要确保对象在返回引用的生命期内保持有效。
12.4.2、返回指向非const对象的引用
在 C++ 中,函数可以返回指向非 const 对象的引用。这允许函数改变已存在的对象的值。通常用于已存在的对象的更新、对象的成员的访问和设置等场景。
然而,使用非 const 引用存在一些注意事项:
-
生命周期:返回非 const 引用时,必须保证被引用的对象在引用的生命周期内是有效的,并且确保该对象不会在引用结束之前销毁。如果返回指向局部对象的非 const 引用,则该引用会指向无效的内存,程序行为未定义。
-
空引用问题:在函数返回类型为引用时,必须始终确保该函数始终返回一个对象的引用。如果没有返回,或在某些条件下不返回引用,则程序行为未定义。可以使用默认值来处理这种情况,并使其返回一个未定义的引用。
以下是一个示例代码,演示了在函数中返回非 const 引用的用法和注意事项:
#include <iostream>
#include <vector>
using namespace std;
// 定义一个名为 Person 的类
class Person {
public:
Person(const string& name, int age)
: name_(name), age_(age) {}
// 返回引用,用于访问 name_ 成员变量
string& getName() {
return name_;
}
// 返回引用,用于访问 age_ 成员变量
int& getAge() {
return age_;
}
private:
string name_;
int age_;
};
// 返回引用,用于访问静态 vector 的元素
int& getElement(vector<int>& v, size_t index) {
return v[index];
}
int main() {
// 返回非 const 引用,用于修改对象的值
Person p("Tom", 20);
p.getName() = "Jack";
p.getAge() = 21;
cout << "Name: " << p.getName() << ", Age: " << p.getAge() << endl;
// 返回引用,用于访问 vector 的元素
vector<int> v = {1, 2, 3};
getElement(v, 1) = 4;
cout << "Vector: ";
for (int i : v) {
cout << i << " ";
}
cout << endl;
return 0;
}
在上面的示例中,我们定义了一个名为 Person
的类,该类具有两个数据成员 name_
和 age_
,并定义了两个成员函数 getName
和 getAge
,分别返回 string
和 int
类型的引用。然后,我们通过调用对象的成员函数 getName
和 getAge
,分别访问 name_
和 age_
成员变量,并修改了它们的值。
接下来,我们定义了一个函数 getElement
,它使用非 const 引用返回了 vector 的元素。然后,我们在 main
函数中获取了一个 vector,并将其第二个元素赋值为 4,然后输出整个 vector。
可以看到,在函数中返回非 const 引用可以方便地访问和更新对象的值,但必须确保被引用的对象在引用的生命周期内是有效的,并且使用非 const 引用时必须小心并且确保遵守 C++ 的语言规范。
12.4.3、返回对象
在 C++ 中,函数可以返回对象。这种技术可以用于许多情况,例如:
-
由于对象可以分配在堆上,因此可以返回动态分配的对象,使得对象的生命周期从函数调用延伸到超出调用函数的时间。
-
可以使用函数返回具有成员变量或成员函数的匿名临时对象。这些临时对象通常使用于简单的计算或返回类型为类对象的表达式完全成为可能。
-
可以使用函数返回窗口或迭代器,用于遍历某些容器或另一个数据结构。
以下是返回对象的示例:
#include <iostream>
#include <string>
using namespace std;
// 定义一个名为 Person 的类
class Person {
public:
Person(const string& name, int age)
: name_(name), age_(age) {}
// 成员函数,返回一个新分配的 Person 对象
static Person createPerson(const string& name, int age) {
return Person(name, age);
}
// 成员函数,返回一个匿名的 Person 对象
static Person getAnonymousPerson() {
return Person("Anonymous", -1);
}
// 成员函数,返回 name_ 成员变量的引用
string& getName() {
return name_;
}
// 成员函数,返回 age_ 成员变量的值
int getAge() const {
return age_;
}
private:
string name_;
int age_;
};
int main() {
// 使用 createPerson 函数返回一个新的 Person 对象
Person p1 = Person::createPerson("Tom", 20);
cout << "Name: " << p1.getName() << ", Age: " << p1.getAge() << endl;
// 使用 getAnonymousPerson 函数返回一个匿名 Person 对象
Person p2 = Person::getAnonymousPerson();
cout << "Name: " << p2.getName() << ", Age: " << p2.getAge() << endl;
return 0;
}
在上面的示例中,我们定义了一个名为 Person
的类,并使用静态成员函数 createPerson
和 getAnonymousPerson
返回一个新分配的 Person
对象和一个匿名的 Person
对象,分别演示了返回动态分配的对象和返回具有成员变量或成员函数的匿名临时对象的用法。然后,我们通过调用 getName
和 getAge
成员函数访问对应对象的成员变量。
还可以使用返回类型为非指针的函数和返回基类类型的函数,这些都需要确保对象的正确管理和虚函数的正确分发。
总之,使用函数返回对象可以让我们实现更好的代码组织和更高效的内存管理。
12.4.4、返回const对象
在 C++ 中,函数可以返回 const 对象。这种技术可以用于许多情况,例如:
-
避免返回的对象被修改:当函数返回值应该是只读时,可以返回 const 对象以防止用户错误地修改该对象。
-
将对象转换为只读:当不希望用户修改对象时,可以返回 const 对象,以便将其视为只读对象。
以下是返回 const 对象的示例:
#include <iostream>
#include <string>
using namespace std;
// 定义一个名为 Person 的类
class Person {
public:
Person(const string& name, int age)
: name_(name), age_(age) {}
// 成员函数,返回 name_ 成员变量的值
const string& getName() const {
return name_;
}
// 成员函数,返回 age_ 成员变量的值
int getAge() const {
return age_;
}
private:
string name_;
int age_;
};
// 函数返回 const 对象
const Person getPerson() {
return Person("Tom", 20);
}
// 函数使用 const 对象作为参数
void printPerson(const Person& person) {
cout << "Name: " << person.getName() << ", Age: " << person.getAge() << endl;
}
int main() {
// 调用 getPerson 函数,返回 const 对象
const Person p1 = getPerson();
printPerson(p1);
return 0;
}
在上面的示例中,我们定义了一个 const 成员函数 getName
,以防止函数返回的对象被修改。然后,我们定义了一个函数 getPerson
,该函数返回一个 const 对象 Person
。最后,我们在 main
函数中调用 getPerson
函数,返回 const 对象并将其传递给使用 const 引用的函数 printPerson
。
需要注意的是,如果返回类型是 const 对象,则不能通过其返回值修改对象的状态。因此,只有当不希望函数的调用者对返回的对象进行更改时,才应该返回 const 对象。
12.5、使用指向对象的指针
指向对象的指针是指一个指针变量,可以存储对象的地址,因此可以通过该指针访问对象的成员。指针变量使用前要初始化,可以使用 new 运算符在堆上动态分配对象,或者使用取地址运算符 & 获取栈上对象的地址。下面是一个简单的示例:
#include <iostream>
#include <string>
using namespace std;
class Person {
public:
Person(const string& name, int age)
: name_(name), age_(age) {}
void printInfo() const {
cout << "Name: " << name_ << ", Age: " << age_ << endl;
}
private:
string name_;
int age_;
};
int main() {
// 动态分配一个 Person 对象并初始化
Person* p1 = new Person("Tom", 20);
// 使用指针访问对象的成员
cout << p1->printInfo();
// 释放动态分配的对象
delete p1;
// 在栈上分配一个 Person 对象并初始化
Person p2("Jerry", 25);
// 使用指向对象的指针访问对象的成员
Person* p3 = &p2;
cout << p3->printInfo();
return 0;
}
在上面的示例中,首先使用 new 运算符在堆上动态分配一个 Person
对象,然后使用指向对象的指针变量 p1
存储该对象的地址,以便访问其成员函数。使用 ->
运算符可以根据指针访问该对象的成员函数。在程序的末尾,使用 delete 运算符释放动态分配的对象。
然后,我们使用栈上的 Person
对象,并使用 &
运算符获取该对象的地址。使用指向对象的指针变量 p3
存储该地址,以便访问其成员函数。使用 ->
运算符可以根据指针访问该对象的成员函数。注意,在此示例中,我们不需要显式释放指针 p3
指向的对象,因为该对象在函数结束时会自动释放。
需要注意的是,在使用指向对象的指针时,必须确保指针不为空,以避免访问空指针导致的运行时错误。
12.5.1、再谈new和delete
在 C++ 中,使用 new
运算符可以在堆上动态分配内存,创建对象,并返回指向该对象的指针。指针可以通过 delete
运算符释放,从而释放分配给对象的内存,如下所示:
ClassName* p = new ClassName;
// 使用 p 指针操作对象
delete p;
其中,ClassName
为类名,p
是指向 ClassName
类型对象的指针。
需要强调的是,在使用 new
运算符时,必须分配足够的内存来容纳对象。如果分配的内存不足,可能会导致未定义的行为。在使用 delete
运算符时,必须确保指针不为 nullptr
,否则会导致运行时错误。
在 C++11 中,还引入了 new
运算符的另一种语法,名为“统一的初始化语法”,可以使用 {}
来初始化动态分配的对象,如下所示:
ClassName* p = new ClassName{};
这个初始化语法可以避免一些由于默认构造函数没有正确初始化对象导致的错误。另外,还可以使用 new[]
运算符来动态分配数组,并使用 delete[]
运算符来释放数组。例如:
int* arr = new int[10];
// 使用 arr 操作数组
delete[] arr;
在使用动态内存分配时,需要遵循一些规则。例如,应该始终确保分配的内存被正确释放,不要在分配内存之后跨越作用域;同时要避免使用不安全的指针操作,例如对已释放的内存进行访问。
12.5.2、指针和对象小结
在 C++ 中,指针是一种非常重要的数据类型,可以用来存储内存地址并访问该地址处的数据。指针可以用于访问动态分配的内存、传递参数以及在数据结构中引用对象等。
对象是类的实例,是 C++ 程序中的核心概念之一。对象可以封装数据和方法,使程序模块化并提供更好的抽象性。在 C++ 中,可以使用 .
运算符来访问对象成员,也可以使用 ->
运算符来访问指向对象的指针成员。
C++ 中有许多与指针和对象相关的概念和操作,例如引用、内存管理、析构函数等。对这些概念的深入理解和正确使用是编写高质量 C++ 程序的关键。同时,由于指针和对象的灵活性和危险性,也需要注意在使用时遵循规范并谨慎操作,以避免出现一些常见的错误,例如空指针引用、野指针、内存泄漏等。
12.5.3、再谈定位new运算符
C++ 也提供了另外一种方式来在指定位置上创建对象,即使用定位 new
运算符。
定位 new
运算符可以让我们在一个指定的地址上创建对象,而不是在默认的堆上动态分配内存。其语法形式为:new (指针地址) 类型名(构造函数参数)
。其中,指针地址
就是我们希望在其上创建一个新对象的地址。
使用定位 new
运算符时需要注意以下几点:
指针地址
必须是一个已经分配过的、未初始化的内存块的地址。- 必须在指定的位置上调用对象的构造函数来执行对象的初始化。
- 使用定位
new
运算符必须手动调用对象的析构函数,并在合适的时候手动释放分配给对象的内存。
下面是一个使用定位 new
运算符的简单示例:
#include <iostream>
using namespace std;
class MyClass {
public:
MyClass(int value) : value_(value) {
cout << "Constructing MyClass object" << endl;
}
~MyClass() {
cout << "Destructing MyClass object" << endl;
}
void printValue() {
cout << "Value: " << value_ << endl;
}
private:
int value_;
};
int main() {
// 定义一个指针,并分配 24 字节的内存
char* buffer = new char[24];
int* p = reinterpret_cast<int*>(buffer);
// 在指针地址上创建 MyClass 对象,并执行初始化
MyClass* object = new (p) MyClass(42);
// 使用定位 new 运算符创建的对象不能使用 delete 关键字释放内存,
// 而需要显式调用析构函数来释放资源,然后再手动释放内存
object->~MyClass();
delete[] buffer;
return 0;
}
在上述示例中,我们首先使用 new
运算符在堆上分配了 24 字节的内存,并将其转换成了 int
类型指针 p
。然后,我们使用定位 new
运算符在指针地址上创建了一个 MyClass
对象,并执行了初始化。使用定位 new
运算符创建的对象不能使用 delete
关键字释放内存,而需要显式调用析构函数来释放资源,然后再手动释放内存。
12.6、复习各种技术
12.6.1、重载<<运算符
C++ 中的 <<
运算符可以用于将数据输出到流中。我们可以使用重载 <<
运算符来扩展其功能,以便能够输出自定义类型的对象。重载 <<
运算符的一般形式如下所示:
ostream& operator<<(ostream& os, const MyClass& obj) {
// 输出 obj 中的数据到 os
return os;
}
其中,os
是一个输出流对象,obj
是一个 MyClass
类型的对象或引用。
我们需要在函数体中编写将对象输出到流的代码,例如:
ostream& operator<<(ostream& os, const MyClass& obj) {
os << "MyClass: " << obj.getVal();
return os;
}
在上面的示例中,getVal()
是 MyClass
类的一个成员函数,用于获取对象的值。重载 <<
运算符使得我们可以像输出其他类型的数据一样输出 MyClass
类型对象。
下面是一个完整的示例:
#include <iostream>
using namespace std;
class MyClass {
public:
MyClass(int val) : val_(val) {}
int getVal() const { return val_; }
private:
int val_;
};
ostream& operator<<(ostream& os, const MyClass& obj) {
os << "MyClass: " << obj.getVal();
return os;
}
int main() {
MyClass obj(42);
cout << obj << endl;
return 0;
}
在上述示例中,我们定义了一个 MyClass
类,并在其中添加了一个成员函数 getVal()
,用于获取对象的值。然后,我们重载了 <<
运算符,使得可以像输出其他类型一样输出 MyClass
对象。在 main()
函数中,我们创建了一个 MyClass
对象,并将其输出到标准输出流中。可以看到,输出的信息包括 MyClass
类型及其值。
需要注意的是,重载的 <<
运算符通常返回一个引用,以便支持链式输出,例如, cout << obj1 << obj2 << obj3
。为了支持链式输出,通常需要在函数体中返回第一个参数 os
的引用。
12.6.2、转换函数
C++ 中的转换函数是一种特殊成员函数,其功能是将一个对象转换为另外一种类型。转换函数可以用于将用户自定义类型转换为原生类型或其他用户自定义类型,可以大大提高编程的灵活性和可读性。
转换函数有几个要点:
- 转换函数是类的成员函数,其名称必须是
operator 转换类型()
,其中转换类型
可以是任意类型,包括普通类型、指针类型、引用类型、类类型等。 - 转换函数必须返回
转换类型
的值或引用,否则会导致编译器错误。 - 转换函数可以是
const
成员函数或非const
成员函数,取决于是否需要修改对象的状态。 - 转换函数可以有任意数量和类型的参数,只要不与其他函数造成歧义即可。
- 转换函数在函数名前加上
explicit
关键字时,该函数只能被显式调用,不能隐式调用。
下面是一个简单的示例,演示了如何将一个自定义类型转换为 int
类型:
#include <iostream>
using namespace std;
class MyInt {
public:
MyInt(int value = 0) : value_(value) {}
operator int() const { return value_; }
private:
int value_;
};
int main() {
MyInt obj(42);
int val = obj; // 自动调用转换函数将 obj 转换为 int 类型
cout << "Value: " << val << endl;
return 0;
}
12.6.3、其他构造函数使用new的类
有些类的对象在创建时使用了 new
运算符,这些类需要注意内存管理的问题,以避免出现内存泄漏或重复释放等问题。
通常情况下,这些类需要至少实现默认构造函数(不接受参数的构造函数)和一个析构函数,以便正确分配和释放内存。
另外,如果类需要使用动态分配的内存,则必须实现一个复制构造函数和一个赋值运算符重载函数,以避免浅拷贝导致的内存问题。
下面是一个简单的示例,演示了一个使用 new
运算符创建对象的类的实现:
#include <iostream>
using namespace std;
class MyClass {
public:
MyClass(int value) : value_(value) {
cout << "Constructing MyClass object" << endl;
}
~MyClass() {
cout << "Destructing MyClass object" << endl;
}
void printValue() {
cout << "Value: " << value_ << endl;
}
private:
int value_;
};
int main() {
MyClass* obj = new MyClass(42);
obj->printValue();
delete obj;
return 0;
}
在上述示例中,我们定义了一个 MyClass
类,并在其中实现了默认构造函数、析构函数和一个 printValue()
成员函数。在 main()
函数中,我们使用 new
运算符创建了一个 MyClass
对象并进行了初始化,在使用完对象后,还需要手动调用 delete
运算符释放分配的内存。
在实际开发中,当涉及到动态分配内存的类时,我们需要特别注意内存管理的问题,以避免发生内存泄漏、野指针等问题。
12.7、队列模拟
队列是一种常见的数据结构,它具有先进先出(FIFO)的特性,比如打印任务等场景。
在 C++ 中,我们可以使用标准库 queue
来实现队列的模拟。但是,这里我们将手写一个简单的队列实现,以便更好地了解队列的基本原理和操作。
下面是一个简单的队列模拟实现:
#include <iostream>
using namespace std;
const int MAXSIZE = 100; // 最大长度
class Queue {
public:
Queue() : head_(0), tail_(0), size_(0) {}
void enqueue(int val) {
if (size_ == MAXSIZE) {
cout << "Queue is full" << endl;
return;
}
data_[tail_] = val;
tail_ = (tail_ + 1) % MAXSIZE;
size_++;
}
void dequeue() {
if (size_ == 0) {
cout << "queue is empty" << endl;
return;
}
head_ = (head_ + 1) % MAXSIZE;
size_--;
}
bool isEmpty() const {
return size_ == 0;
}
bool isFull() const {
return size_ == MAXSIZE;
}
int front() const {
if (size_ == 0) {
cout << "queue is empty" << endl;
return -1;
}
return data_[head_];
}
private:
int data_[MAXSIZE];
int head_;
int tail_;
int size_;
};
int main() {
Queue q;
q.enqueue(1);
q.enqueue(2);
q.enqueue(3);
cout << "Queue front: " << q.front() << endl;
q.dequeue();
cout << "Queue front: " << q.front() << endl;
q.enqueue(4);
q.enqueue(5);
q.enqueue(6);
q.enqueue(7);
q.enqueue(8);
q.enqueue(9);
q.enqueue(10);
q.enqueue(11);
q.enqueue(12);
q.enqueue(13);
q.dequeue();
q.enqueue(14);
while (!q.isEmpty()) {
cout << q.front() << " ";
q.dequeue();
}
cout << endl;
return 0;
}
在上述示例中,我们定义了一个 Queue
类,使用一个数组 data_
来保存队列中的元素,使用 head_
和 tail_
分别记录队列的头部和尾部位置,使用 size_
记录队列中元素的个数。在 enqueue()
函数中,我们向队列尾部添加一个元素,并更新 tail_
和 size_
;在 dequeue()
函数中,我们从队列头部移除一个元素,并更新 head_
和 size_
。front()
函数用于返回队列头部的元素,如果队列为空则返回 -1
,isEmpty()
和 isFull()
函数分别判断队列是否为空和是否已满。在 main()
函数中,我们演示了如何使用队列模拟一个简单的打印任务的场景。
需要注意的是,我们在实现队列时使用了数组来存储元素。这种实现方式的缺点在于,如果队列中的元素被不断地出队和入队,那么数组中未被访问过的位置也会越来越少,最终导致空间的浪费。为了解决这个问题,我们可以使用链表等动态数据结构来实现队列,从而避免这种浪费。
12.7.1、队列类
队列是一种常见的数据结构,它具有先进先出(FIFO)的特性,比如打印任务等场景。在 C++ 中,我们可以使用标准库 queue
来实现队列的模拟。但是,这里我们将手写一个简单的队列实现,以便更好地了解队列的基本原理和操作。
下面是一个基本实现例子:
#include <iostream>
using namespace std;
const int MAXSIZE = 100; // 队列最大长度
class Queue {
public:
Queue() : head_(0), tail_(0), size_(0) {}
// 入队
void enqueue(int val) {
if (size_ == MAXSIZE) {
cout << "Queue is full" << endl;
return;
}
data_[tail_] = val;
tail_ = (tail_ + 1) % MAXSIZE;
size_++;
}
// 出队
void dequeue() {
if (size_ == 0) {
cout << "queue is empty" << endl;
return;
}
head_ = (head_ + 1) % MAXSIZE;
size_--;
}
// 返回队首元素
int front() const {
if (size_ == 0) {
cout << "queue is empty" << endl;
return -1;
}
return data_[head_];
}
// 判断队列是否为空
bool isEmpty() const {
return size_ == 0;
}
// 判断队列是否已满
bool isFull() const {
return size_ == MAXSIZE;
}
private:
int data_[MAXSIZE]; // 队列元素
int head_; // 队首位置
int tail_; // 队尾位置
int size_; // 队列元素个数
};
在上述实现中,我们定义了一个 Queue
类,使用一个数组 data_
来保存队列中的元素,使用 head_
和 tail_
分别记录队列的头部和尾部的位置,使用 size_
记录队列中元素的个数。
在 enqueue()
函数中,如果队列已满则输出 “Queue is full”,否则将元素添加至队列尾部并更新 tail_
和 size_
的值。
在 dequeue()
函数中,如果队列为空则输出 “queue is empty”,否则通过将头部位置加 1 并更新 size_
的值,删除队首元素。
在 front()
函数中,如果队列为空则输出 “queue is empty”,否则返回队列元素中 head_
位置上的元素。
在 isEmpty()
函数中,如果队列中元素个数为 0,则返回 true
,否则返回 false
。
在 isFull()
函数中,如果队列中元素个数等于 MAXSIZE
,则返回 true
,否则返回 false
。
在实际使用中,我们可以通过调用上述成员函数对队列进行操作。例如:
int main() {
Queue q;
q.enqueue(1);
q.enqueue(2);
q.enqueue(3);
while (!q.isEmpty()) {
cout << q.front() << " ";
q.dequeue();
}
cout << endl;
q.enqueue(4);
q.enqueue(5);
cout << "Queue front: " << q.front() << endl;
q.dequeue();
cout << "Queue front: " << q.front() << endl;
return 0;
}
在上述例子中,我们首先向队列中添加了三个元素,并通过循环输出队列中元素的值。然后,我们删除队列中的元素,再向队列中添加两个元素,并分别输出队列首个元素的值。
12.7.2、Customer类
在模拟银行等业务场景中,我们常常需要使用到顾客(Customer)类。顾客类记录了顾客的一些基本信息,比如到达时间、业务办理时间、离开时间等等。下面是一个简单的顾客类实现:
#include <iostream>
#include <ctime>
using namespace std;
class Customer {
public:
Customer() {
arrv_time_ = time(nullptr); // 当前时间
trans_time_ = rand() % 3 + 1; // 1-3s随机时间
}
int getArrvTime() const {
return arrv_time_;
}
int getTransTime() const {
return trans_time_;
}
void setLeaveTime(int time) {
leave_time_ = time;
}
int getLeaveTime() const {
return leave_time_;
}
private:
int arrv_time_; // 到达时间
int trans_time_; // 业务办理时间
int leave_time_; // 离开时间
};
在上述实现中,我们定义了一个 Customer
类,包括到达时间 arrv_time_
,业务办理时间 trans_time_
和离开时间 leave_time_
。在构造函数中,我们使用 time(nullptr)
获取当前时间,作为顾客的到达时间,使用随机数生成 1 到 3 的整数,作为顾客的业务办理时间。在使用过程中,我们可以使用 getArrvTime()
、getTransTime()
、setLeaveTime()
和 getLeaveTime()
等成员函数获取和设置顾客的到达、办理和离开时间。
例如,我们可以这样使用 Customer
类:
int main() {
srand(static_cast<unsigned int>(time(nullptr)));
Customer c;
cout << "arrival time: " << c.getArrvTime() << endl;
cout << "transaction time: " << c.getTransTime() << endl;
c.setLeaveTime(123);
cout << "leave time: " << c.getLeaveTime() << endl;
return 0;
}
在上述例子中,我们先使用 srand()
设置随机数生成器种子,将当前时间作为种子,用于生成随机数。接着,我们创建了一个 Customer
对象 c
,并使用成员函数 getArrvTime()
和 getTransTime()
输出顾客到达时间和业务办理时间的值。然后,我们使用成员函数 setLeaveTime()
和 getLeaveTime()
设置和输出顾客的离开时间。
12.7.3、ATM模拟
在银行等业务场景中,使用ATM(自动取款机)可以为顾客提供方便快捷的服务。下面我们来模拟一下ATM的行为。我们使用一个双端队列来模拟ATM的用户等待队列。队列的前端代表当前正在使用ATM的用户,队列的后端代表等待ATM的用户。ATM的基本参数包含:最长等待时间、平均服务时间、正在服务客户的服务时间等等。
下面是一个简单的ATM模拟程序:
#include <iostream>
#include <cstdlib>
#include <ctime>
#include <deque>
#include "customer.h" // 将上一题的Customer类保存在customer.h文件中
using namespace std;
const int MIN_PER_HR = 60;
bool isNewCustomer(int);
int main()
{
srand((unsigned) time(nullptr));
cout << "ATM模拟程序开始运行..." << endl;
int hours = 100; // 总模拟时长
int perhour = 60; // 每小时平均服务客户数
int min_per_cust; // 平均每个客户服务时间
long crt_customers = 0; // 模拟期间进入的总顾客数
long served_customers = 0; // 模拟期间已服务的顾客数
long total_wait_time = 0; // 模拟期间顾客总等待时间
long total_trans_time = 0; // 模拟期间顾客总办理时间
deque<Customer> waiting_queue; // ATM等待队列
min_per_cust = MIN_PER_HR / perhour;
for (int cycle = 0; cycle < hours; cycle++)
{
if (isNewCustomer(min_per_cust))
{
crt_customers++;
Customer c;
waiting_queue.push_back(c);
}
if (!waiting_queue.empty())
{
Customer& c = waiting_queue.front();
if (c.getLeaveTime() == -1)
{
c.setLeaveTime(c.getArrvTime() + c.getTransTime());
}
c.setTransTime(c.getTransTime() - 1);
if (c.getTransTime() == 0)
{
waiting_queue.pop_front();
served_customers++;
total_wait_time += c.getLeaveTime() - c.getArrvTime() - c.getTransTime();
total_trans_time += c.getTransTime();
}
}
cout << "第 " << cycle << " 小时:" << endl;
cout << "ATM中的客户数: " << waiting_queue.size() << endl;
cout << "当前正在服务的客户: ";
if (!waiting_queue.empty())
{
Customer& c = waiting_queue.front();
cout << c.getArrvTime() << " 时刻进入,目前剩余服务时间为 " << c.getTransTime() << " 秒";
cout << endl;
}
else
{
cout << "无" << endl;
}
}
cout << "ATM模拟程序结果:" << endl;
cout << "总消耗时间:" << hours << " 小时" << endl;
cout << "进入顾客数:" << crt_customers << endl;
cout << "已服务顾客数:" << served_customers << endl;
cout << "未服务顾客数:" << crt_customers - served_customers << endl;
cout << "顾客平均等待时间:" << total_wait_time / served_customers << " 秒" << endl;
cout << "顾客平均办理时间:" << total_trans_time / served_customers << " 秒" << endl;
return 0;
}
bool isNewCustomer(int per_time)
{
return rand() % per_time == 0;
}
在主函数中,我们首先定义了ATM的基本参数:总模拟时长、每小时平均服务客户数、平均每个客户服务时间等等。然后使用一个for循环,模拟每一个小时内ATM的行为。
在循环体内,我们使用 isNewCustomer()
函数来判断是否有新的顾客到来。如果有,我们就创建一个新的顾客对象,并添加到ATM等待队列的后端。
如果等待队列不为空,则表示当前有客户正在使用ATM。我们使用 front()
方法获取队列的前端元素,即当前正在服务的客户。如果该客户的离开时间为空,我们就计算出他的离开时间,并将其存储在对象中。然后,我们将当前客户的办理时间减1,模拟时间的流逝。如果办理时间为0,说明该客户已经完成了所有业务,我们就将其移除队列,统计相关数据。
在循环体内,我们使用cout语句输出了当前 ATB 中的客户数、正在服务的客户情况等信息。循环结束后,我们输出了模拟结果,包括:进入顾客数、已服务顾客数、未服务顾客数、顾客平均等待时间、顾客平均办理时间等。