C++ 类的内存分配是怎么样的?

dynamic_memory

首先通过一段代码来引入动态内存分配的主题。一个名为StringBad的类以及一个功能更强大的String类。

#include<iostream>
#ifndef STRNGBAD_H_
#define STRNGBAD_H_

class StringBad {
private:
	char* str;
	int len;
	static int num_strings;
public:
	StringBad(const char* s);
	StringBad();
	~StringBad();
	friend std::ostream& operator<<(std::ostream& os,
		const StringBad& st);
};

介绍一下这些定义,第一个char指针来表示一段字符串,这就意味着类声明没有为字符串本身分配存储空间。而是要在构造函数里通过new来为字符串分配空间,这就避免了在类声明里面预先定义字符串长度。

num_strings这个东西被声明为了静态存储类,静态成员有一个特点:无论创建了多少对象,程序都只创建一个静态类变量副本。也就是说,类的所有对象共享一个静态成员,具体解释可以看到下图的代码运行结果。

image-20240320221019713image-20240320221204441

这对于所有类对象都具有相同值的类私有数据都是非常方便的,比如num_strings可以记录所有创建的对象数目。

image-20240321133016710

#include<iostream>
#include<cstring>
/*#include <cstring>:
这是 C 语言标准库中的头文件,提供了一系列操作 C 字符串(字符数组)的函数。
在 C++ 中,<cstring> 头文件中的函数都被放在 std 命名空间中,并且可以使用 C 风格的字符串处理函数,比如 strcpy, strcat, strlen 等。
示例用法:#include <cstring> 可以用来进行基于字符数组的字符串操作,如拷贝、连接、比较等。
#include <string>:
这是 C++ 标准库中的头文件,提供了 std::string 类及相关操作,是 C++ 中用来处理字符串的首选方式。
<string> 头文件中定义了字符串类 std::string,提供了丰富的字符串操作方法,比如字符串拼接、查找、替换等。
示例用法:#include <string> 可以用来定义和操作 C++ 标准库中的字符串对象,避免了使用 C 风格的字符数组所带来的问题。*/
#include "strngbad.h"
using std::cout;
int StringBad::num_strings = 0;

StringBad::StringBad(const char* s) {
	len = std::strlen(s);
	str = new char[len + 1];
	std::strcpy(str, s);//使用strcpy函数时,它会从源地址开始拷贝字符,
	//直到遇到字符串结尾的null字符\0为止,所以可以通过指向字符串的指针来操作字符串
	num_strings++;
	cout << num_strings << ": \"" << str
		<< "\" object created\n";
}
StringBad::StringBad() {
	len = 4;
	str = new char[4];
	std::strcpy(str, "c++");
     num_strings++;
	cout << num_strings << ": \"" << str
		<< "\" object created\n";
}
StringBad::~StringBad() {
	cout << "\"" << str << "\" object deleted";
	cout << num_strings << " left \n";
	delete[]str;
}
std::ostream& operator<<(std::ostream& os, const StringBad& st) {
	os << st.str;
	return os;
}

这段代码就是对模板文件的方法进行了定义,首先不能在类声明中初始化静态成员变量,因为声明描述了如何分配内存,但并不分配内存。对于静态类成员,可以在类外单独初始化,因为静态类成员是单独存储的,而不是对象的组成部分。

第一个构造函数里,类成员是指针,所以构造函数必须提供内存来存储字符串。然后将字符串复制到内存里。要理解这种方法,必须知道字符串并不保存在对象里,字符串单独保存在堆内存里面,对象仅仅保存了指出到哪里去查找字符串的信息。比如str = s这种语句只保存了地址,而没有创建副本。

在析构函数里面,str指向的是new分配的内存,当stringbad对象过期时,str指针也会过期。但是指向的内存仍然被分配,删除对象可以释放对象本身占用的内存,但并不能自动释放属于对象成员的指针指向的内存。因此必须使用析构函数。

最后一个重载<<函数就是把 StringBad 对象中的 str 成员变量输出到给定的输出流 os 中,然后返回输出流对象本身。这样做的目的是为了支持链式输出。

#define  _CRT_SECURE_NO_WARNINGS

#include<iostream>
#include "strngbad.h"
using std::cout;
void callme1(StringBad&);
void callme2(StringBad);

int main() {
	using std::endl;
	{
		cout << "Starting an inner block.\n" << endl;
		StringBad headline1("Regina in home");
		StringBad headline2("having sex");
		StringBad sport("Dance");
		cout << "headline1:" << headline1 << endl;
		cout << "headline2:" << headline2 << endl;
		cout << "sport:" << sport << endl;
		callme1(headline1);
		cout << "headline1:" << headline1 << endl;
		callme2(headline2);
		cout << "headline2:" << headline2 << endl;
		cout << "把一个对象初始化给另一个对象:\n";
		StringBad ss = sport;
		cout << "ss:" << ss << endl;
		cout << "用=号进行赋值:\n";
		StringBad regina;
		regina = headline1;
		cout << "regina:" << regina << endl;
	}
	cout << "Exit the main\n";
	return 0;
}
void callme1(StringBad & rsb) {
	cout << "通过引用传递的字符串\n";
	cout << "     \"" << rsb << " \"\n";
}
void callme2(StringBad sb) {
	cout << "通过值传递的字符串\n";
	cout << "     \"" << sb << " \"\n";
}
#define  _CRT_SECURE_NO_WARNINGS
#pragma warning(disable:4996)

首先说第一段代码是有残缺的,这些缺陷使得输出不确定,就像下面的输出红框一样。就是因为strcpy在 StringBad::StringBad(const char* s)StringBad::StringBad() 构造函数中,使用 strcpy 函数来将源字符串复制到 str 缓冲区中,如果源字符串长度超过了 str 缓冲区的大小(len + 1),就会导致缓冲区溢出,可能引起程序崩溃或数据损坏

image-20240323110419545

上述第一个红框里的乱码字符来自通过值传递的字符串的函数callme2,因此在函数执行完毕返回时,会销毁 callme2 函数内部的 headline2 对象,从而触发该对象的析构函数的调用。

通过引用传递参数给函数时,不会触发析构函数的原因在于引用本身并不拥有被引用对象的所有权,它只是对对象的一个别名或者引用。因此,当引用超出其作用域时,不会触发被引用对象的析构函数。

析构函数的调用主要与对象的生命周期和所有权有关。当一个对象的所有权转移或该对象的生命周期结束时,其析构函数会被调用以执行必要的清理操作。然而,通过引用传递并不改变对象的所有权,只是提供了对对象的访问方式,因此不会触发析构函数。

其实程序在运行时已经报错image-20240323111107130

这条消息通常意味着在使用堆内存(通过 newdelete 进行内存分配和释放)时出现了问题,可能是由于内存泄漏、重复释放已释放的内存或者其他与堆内存操作相关的错误导致的。

实际上最后的num_string的值是-2,在《C++ primer Plus》里面其实用Borland C++运行会成这样,我没有运行出来。

image-20240323131833684其实是因为StringBad ss = sports这句没有调用默认的构造函数,也没有调用有参数的构造函数,而是StringBad(const StringBad &)的一种复制构造函数,当用一个对象来初始化另一个对象时,编译器将自动生成上述构造函数,这个函数我们没有声明过,不知道要更新静态变量,所以实际有三次的已知构造函数+2次默认系统构造函数和5次析构函数。

新建一个对象并将其初始化为同类现有对象,复制构造函数都将被调用。最常见的将新对象显式地初始化为现有对象。有下列四种情况:

  1. StringBad regina(ivan);
  2. StringBad regina = ivan;
  3. StringBad regina = StringBad(ivan);
  4. StringBad * regina = new StringBad(ivan);

每当程序生成了对象副本时,编译器都将使用复制构造函数。当函数按值传递对象或函数返回对象时,都将使用复制构造函数。

所以如果按照刚刚的说法,我们显式的定义一下复制构造函数

StringBad::StringBad(const StringBad& st) {
	num_strings++;  // 增加对象计数
}

image-20240323132932473

会发现隐式复制构造函数是按值进行复制,所以原函数里的功能相当于ss.str = sport.str;这里复制的并不是字符串,而是一个指向该字符串的指针。我们重载了运算符<<,并且在析构函数中释放时,ss.str将会被释放,sport所对应的字符串的内存将会消失。但是因为两个对象指向了同一个位置的内存,第二次再调用delete的时候该内存已经没有了,就会导致不确定性,可能会释放掉其他位置。

所以我们将全面的显式定义一下。

StringBad::StringBad(const StringBad& st) {
	len = st.len;  // 复制长度
	str = new char[len + 1];  // 分配新的内存
	std::strcpy(str, st.str);  // 执行复制
	num_strings++;  // 增加对象计数
	std::cout << num_strings << ": \"" << str
		<< "\" object created by copy\n";  // 输出信息
}

代码里还有一个地方是StringBad regina; regina = headline1;这里也调用了复制构造函数,赋值运算符的隐式实现也对成员进行逐个复制,如果成员本身就是对象,则程序将使用为这个类定义的赋值运算符来复制该成员。但是这样做还有问题!也是和上面的问题一样,regina和headline1指向同一个内存位置的str,不能无故删除两次。我们还需要对=符号进行一个重载。

StringBad& StringBad::operator=(const StringBad& st) {
	if (this == &st) {
		return *this;
		/*在赋值操作符重载函数中,
		返回*this意味着返回当前对象的引用。
		这样做的目的是为了支持连续赋值操作,
		比如a = b = c。通过返回对象自身的引用,
		可以实现多重赋值操作的链式调用。*/
	}
	delete[] str;
	len = st.len;
	str = new char[len + 1];  // 分配新的内存
	std::strcpy(str, st.str);  // 执行复制
	return *this;
}

这段代码里面的语法首先是检查自我复制,这个对比的是地址,如果相同就返回本身的引用。如果不相同就和之前的复制操作一样了。

image-20240323140210209

改进后的类

class NewString {
private:
	char* str;
	int len;
	static int num_strings;
	static const int CINLIM = 80;
public:
	NewString(const char* s);
	NewString();
	NewString(const NewString&);
	~NewString();
	int length() const { return len; }

	NewString& operator=(const NewString&);
	NewString& operator=(const char *);
	char& operator[](int i);
	const char& operator[](int i) const;
	friend bool operator<(const NewString& s1, const NewString& s2);
	friend bool operator>(const NewString& s1, const NewString& s2);
	friend bool operator==(const NewString& s1, const NewString& s2);
	friend ostream& operator<< (ostream & os, const NewString & ns);
	friend ostream& operator>> (ostream& is, NewString& ns);
};

这就是我们修改之后的新的类定义。我们可以看到新增了很多的方法以及符号重载,下列我会依次介绍每一种用法。

首先是默认的无参数构造函数

NewString::NewString() {
	len = 0;
	str = new char[1];
	str[0] = '\0';
    num_strings++;
}

为什么不再是之前的写法了,而是要开辟一个数组空间。其实不加[1]两种形式分配的内存量是相同的,区别在于前者和类析构函数兼容,而后者不兼容。析构函数为

NewString::~NewString() {
	--num_strings;
	delete[] str;
}

delete[]和 new[]初始化的指针和空指针都兼容,直接将str写成0 也代表了设置为空指针,和前面的代码功能相同,因为空指针通常用整数0来表示,因为0是一个特殊的地址值,代表着无效的内存地址。在C++11里面,str=nullptr;的写法也同样表示空指针。


friend bool operator<(const NewString& s1, const NewString& s2);
friend bool operator>(const NewString& s1, const NewString& s2);
friend bool operator==(const NewString& s1, const NewString& s2);

bool operator<(const NewString& s1, const NewString& s2) {
	return (std::strcmp(s1.str, s2.str) < 0);
}

bool operator>(const NewString& s1, const NewString& s2) {
	return (std::strcmp(s1.str, s2.str) > 0);
}

bool operator==(const NewString& s1, const NewString& s2) {
	return (std::strcmp(s1.str, s2.str) == 0);
}

把运算符重载写成友元函数,有助于将类对象和普通字符串进行比较。不光是可以访问类的私有静态成员,还可以进行比较,假设regina是一个对象,则

"love" == regina & operator==("love",regina) & operator==(NewString("love"),regina)

都是成立的。


char& operator[](int i);
const char& operator[](int i) const;

实际的需求就是希望获取字符串的某一位时简单一些,我们可以对[]进行重载。

char& NewString::operator[](int i) {
	return str[i];
}

这里使用的是char&,主要目的是允许通过该函数返回的引用来修改调用对象内部存储的字符数组中的元素。当函数返回一个 char& 类型时,它实际上返回的是一个指向字符数组中特定位置的引用。通过返回引用而不是值,可以直接在调用对象的字符数组中进行读写操作,具体区别如下图所示。

image-20240324135347878

image-20240324135421956

const char& operator[](int i) const;但是为什么还有这句话呢,因为在示例里的代码会用到const关键字使const A a("regina");里面的a变成了常量对象,这种对象是无法被修改的,这和我们重载运算符的目的相违背,所以需要特意的写一个常量的重载方法。

const char& NewString::operator[](int i) const{
	return str[i];
}
//第一个 const 关键字放在函数声明或定义的最后表示该函数是一个常量成员函数,即在该函数内部不能修改对象的成员变量。
//第二个 const 关键字放在函数返回类型 char& 前面表示该函数返回一个常量引用,即返回值不能被修改。

可以将成员函数也声明为静态的(函数声明必须包含static关键字,但如果函数定义是独立的,则其中不能包含static)。因为首先不能通过对象调用静态函数,实际上静态成员函数甚至都不能调用this指针。如果静态成员函数在公有部分声明,则可以用类名和作用域解析符调用。(完整代码看最后)

其次由于静态成员函数不与特定的对象相关联,因此只能使用静态数据成员。所以代码里的Howmany函数只能使用num_strings。其他的私有成员都不能访问。

静态成员函数属于类本身而不是类的实例,因此它们在不依赖于特定对象状态的情况下执行。由于静态成员函数不会自动获取任何类实例的指针或引用,所以它们无法直接访问非静态数据成员或调用非静态成员函数,这些成员和函数都是特定于类的对象的。


image-20240324213152875

这段代码里面有一个delete操作,这个可以不加,但是由于目标对象可能引用了以前分配的数据,一般情况我们需要先释放掉这个引用对象的str指向的内存,来为新字符串分配足够的内存。

代码

newString.h
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
using namespace std;
#ifndef NEWSTRING_H_
#define NEWSTRING_H_

class NewString {
private:
	char* str;
	int len;
	static int num_strings;
	static const int CINLIM = 80;
public:
	NewString(const char* s);
	NewString();
	NewString(const NewString&);
	~NewString();
	int length() const { return len; }

	NewString& operator=(const NewString&);
	NewString& operator=(const char *);
	char& operator[](int i);
	const char& operator[](int i) const;
	friend bool operator<(const NewString& s1, const NewString& s2);
	friend bool operator>(const NewString& s1, const NewString& s2);
	friend bool operator==(const NewString& s1, const NewString& s2);
	friend ostream& operator<< (ostream & os, const NewString & ns);
	friend istream& operator>> (istream& is, NewString& ns);

	static int HowMany();
};
#endif // !
#Newstring.cpp
#include "newString.h"
#include<cstring>

int NewString::num_strings = 0;
NewString::NewString() {
	len = 0;
	str = new char[1];
	str[0] = '\0';
	num_strings++;
}
NewString::~NewString() {
	--num_strings;
	delete[] str;
}

bool operator<(const NewString& s1, const NewString& s2) {
	return (std::strcmp(s1.str, s2.str) < 0);
}

bool operator>(const NewString& s1, const NewString& s2) {
	return (std::strcmp(s1.str, s2.str) > 0);
}

bool operator==(const NewString& s1, const NewString& s2) {
	return (std::strcmp(s1.str, s2.str) == 0);
}

char& NewString::operator[](int i) {
	return str[i];
}

const char& NewString::operator[](int i) const{
	return str[i];
}

int NewString::HowMany() {
	return num_strings;
}

NewString::NewString(const char* s) {
	len = std::strlen(s);
	str = new char[len + 1];
	std::strcpy(str, s);//使用strcpy函数时,它会从源地址开始拷贝字符,
	//直到遇到字符串结尾的null字符\0为止,所以可以通过指向字符串的指针来操作字符串
	num_strings++;
}

NewString::NewString(const NewString& ns) {
	/*区分用=赋值的两个实例指向同一个内存地址*/
	len = ns.len;
	str = new char[len + 1];
	std::strcpy(str, ns.str);
	num_strings++;
}

NewString& NewString::operator=(const NewString& ns) {
	if (this == &ns) {
		return *this;
	}
	delete[] str;
	len = ns.len;
	str = new char[len + 1];
	std::strcpy(str, ns.str);
}

NewString& NewString::operator=(const char * s) {
	/*NewString name;
	char tmp[40];
	cin.getline(tmp,40);
	name = tmp;这个过程会一直创建一个临时对象,
	然后再调用析构函数删除该对象,很低效*/
	delete[] str;
	len = std::strlen(s);
	str = new char[len + 1];
	std::strcpy(str, s);
    return *this;
}

ostream& operator<<(ostream& os, const NewString& ns) {
	os << ns.str;
	return os;
}

istream& operator>>(istream& is, NewString& ns) {
	char tmp[NewString::CINLIM];
	is.get(tmp, NewString::CINLIM);
	if (is) {
		ns = tmp;
	}
	while (is && is.get() != '\n') {
		continue;
	}
    /*NewString name; cin >> name; 直接可以实现输入为str*/
}

这样代码就完成了对于基本赋值和一些基础运算符的优化,具体实现可以参照《C++ primer plus》里面445页的代码进行实现。

posted @ 2024-03-24 22:12  ivanlee717  阅读(17)  评论(0编辑  收藏  举报