avatar

目录
302 - 对象数组复制陷阱

好的,这是您对话的标题: 对象数组复制陷阱

🧑‍💻 User

In this lecture.We’re going to see that if we try and pass objects in an array, these objects are going to be copied.And we are seeing that exactly here, because now we can prove this with copy constructors.So here is a simple example.We have a few person objects S1 all the way to S3 and we are using these person objects to put datain an array which is called student.It is an array of person objects and we’re going to put our person objects inside and we’re going tosee what happens.So what is going to happen?What we have inside the array are going to be copies of the objects that we put in here, and we’regoing to see copy constructors called as this array is being set up in our C plus plus program.This is what I want you to see here.Putting objects in an array is going to create copies and you need to be aware of that.Another place where we’re going to be making copies is if we use a range based for loop like we do here.So we are basically having this array and we are looping around it trying to set the first name to whateverwe want.So what this is going to do at each iteration, it’s going to create a copy of the person object itis at from this array here.So this copy is going to be scoped to our for loop, the body of the for loop.And when the iteration is done, the local copy is going to be destroyed.When we hit the next iteration, a new copy for the new element is going to be created and we’re goingto keep going until we hit the end of the range based for loop.So this is something you need to be aware of.If you want to avoid copies, you can use references in your range based for loop, and this is notgoing to create a copy and you’re going to see that the copy constructor is not being called as yourloop execute.But you should know that regular loops don’t make any copies.So if you set up something like this, you’re going to be using the actual elements that are storedin the array.You won’t be making copies in the for loop here.Okay, Now that you know this, let’s head over to Visual Studio code and play with this a little more.Okay.Here we are in our working folder.The current project is Objects in Arrays are copies.What a good title.We’re going to grab the template files here and we’re going to paste them in place and we’re going tograb the person class from the last lecture because we don’t want to redo this.We already have done this so we can reuse this.We’re going to put that in our current project and then we’re going to open this in Visual Studio code.Let’s do that.This is going to open our folder with our files and we see the main CPP file here.We can clean this up a little bit and we have our person class, which has some things we don’t reallyneed, so we just need the copy constructor here.So let’s put this in.So we’re going to delete all the other things we don’t need here to keep this class clean and nice.And we’re going to head over to the person CPP file and we’re going to remove all the dirty things thatare not used here.Everything that is commented out, we’re going to remove.Let’s do that and we’re going to keep the copy constructor that is dedicated to the other constructorhere.This is going to work.Okay, so now what we want to do is to set up an array of person objects.But for that to work in main, we need to include the person class in our main file here.So let’s do person dot H and we are going to create a few person objects.We’re going to say person 1P1 and we’re going to pass in John and we’re going to say snow And we’regoing to say age is 25.Why not?We’re going to do person two and we’re going to say, let’s say Johnny Drill is five here.We can do this so we can go down here and create an array of person objects.So let’s do that.We’re going to say person and we’re going to say students and we’re going to make this an array andwe’re going to initialize this with data that we have in the objects here.So we’re going to just put in P one, P, two and P three.Let’s see what happens if we build and run this code.Let’s put a little separator here to be able to separate what we get from the constructors here, fromwhat we get from the array setup here.We can build our program.We’re going to use GCC.You’re going to see that the world is good.We’re going to bring up a terminal and we are going to go up and clear.Let’s clear and run Rooster.You’re going to see that it’s saying copy constructor called copy constructor called copy constructorcalled.So what this is going to do after we set up our person objects, It’s going to copy each of these guysand keep a local copy in the array here.This is what is happening and I wanted you to see this with your own eyes.We can even make the message in our copy constructor.Are more descriptive so we can say copy constructor for the name and say the last name for example.So we can say code for and we can say the last name here.I think we can say that last name.We have access to this and the person object we just constructed so we can print this out.Let’s put a space here so that we can really see this.So we’re going to build with GCC again for these changes to be taken into account and we’re going toclear and run.Rooster, You see that the copy constructor is going to be called for John because John is the firstelement we copy into the array P one here.The copy constructor is going to be called for Sam because that’s the second person object.And we have John who is going to be copied in the first position here.And what we have inside our array are going to be copies from the outside here.I think we can really prove this, that they are going to be living at different memory locations sowe can go on top here and say P one print info can print the information and we’re going to say P twoprint info and P three print info and we can loop through this array and print at each iteration andprint the information and really prove that what we have is a copy.So let’s do that.We’re going to use a regular loop.We’re going to say for size t I and we’re going to initialize this to zero.We’re going to keep looping as long as I is less than STD size and we’re going to increment here inour incrementation part.And inside what we want to do is just print the information from the current element in the array.So we’re going to say students, we’re going to access the elements in the array, we’re going to saystudent and we’re going to say print info and we’re going to print information from the inside.So let’s do that.We’re going to build with GCC and the build is good.We can clear and run.Rooster Let’s run Rooster And you’re going to see that before we set up the array.This is going to be the information.So the person object is going to live at this address.P one.So that’s P one here.P two is going to live at this address.P three is going to live at this address because we are printing this information here.But once we hit the body of the loop, we’re going to see that John Snow is living at this address here.And he was living at this address from the outside of the array.So you see that what we have inside the array are going to be copies.It’s not going to be the original stuff.And you need to be aware of these copies.Okay.The second place where we’re going to have unintended copies is if we use a range based for loop.Okay.So what we’re going to do to really prove that we’re going to put another separator and we’re goingto put a few dashes in here, how many did we use there?Let’s say it’s four.I think this is four.So we’re going to use the same thing here and we’re going to set up a range based for loop, which isgoing to loop through this array here, printing the information.It’s going to do the same thing we do here, but it’s not going to be using a regular loop like we didon top here.I just want you to see that that’s going to create copies and you need to be aware of them.So let’s do that.We’re going to say for person P in our array of students and we’re going to grab this P person and printthe information.This is how easy a range based for loop can really be and it is really cool.But we’re going to see that after this dashes.We’re going to be calling the copy constructors.Let’s build and really prove this.So we’re going to build with GCC.Okay, The build is good.We can clear and run Rooster.We’re going to see that.We’re going to call the copy constructor for John and Sam and Johnny when we set up the array.That’s what we see when we set up this array here.And after we print each of the elements in the array, we’re going to print the second dashes, whichis here, and we see that we have a couple of copy constructor calls.We’re going to see the copy constructor called for John and we’re going to print the information aboutJohn.We’re going to see the copy constructor called for Sam.We’re going to print the information about Sam.We’re going to see the copy constructor called for Johnny and we’re going to print information for Johnny.What this is really doing at each iteration, we’re going to set up a copy of the current element weare at in this array here, and this is what we see and you need to be aware of these copies.Again, you need to know this because sometimes you don’t want copies and you need to do something moreto avoid those copies.In this case, in our range based loop, it is really easy.We can pass the current iteration element by reference.So for example, we can say person reference P here and this is going to be a reference.It’s no longer going to be a copy.If we build it now, we’re going to see that the copy constructors are no longer being called becausewhat we have inside the loop is going to be a reference to the original data that we.Half in the array here.Okay, so this is what we have.Let’s try and build and really prove that this is not going to create copies.You see, the build is good.With our reference, we’re going to clear and run Rooster.Now, you see that after the second pair of dashes, we’re not using the copy constructors anymore becausewhat we have are real references to the data we have in the array.And if you look here, you see that the address in memory are the same.Okay, so this is really cool.If we go back and look at what we printed earlier before this run here, you’ll see that the data wehave in our and it should be gone.It’s gone.We don’t have this, but we can take out the reference here and build again and show you that the datawe have in the array is going to be different from the data we have in the loop here, because whatwe have in the loop is a copy.So they are going to be living at different memory addresses.Let’s run and really prove this to you.You need to see this.So the build is good.We can clear and run Rooster.So look at what we have in our range based for loop.The person object is going to be living at this address.This is John Snow.He’s going to be living at an address that ends in A70, but in the array.John Snow is living at an address that ends in a zero.So this two things are different copies we have and we are making a copy in the range based for loophere or to be exact, we are copying whatever we have in the array when we loop through this range basedfor loop here.But we can avoid these copies again using references.We can do this and if we run the test to build with GCC, we’re going to see that we’re not going tocall the copy constructor, but we are also not going to be making copies, which is really cool.So we’re going to run Rooster and we see that now the person we have in the range based for loop isthe same person we have in the array because the addresses are the same.You can see this right here and this is really cool.So one thing you should really be careful about is these copies.You can avoid them with range based for loops using references, and you have to know that with regularloops like we did on top here, we’re not going to be making any copies.We basically going to be referencing exactly the same thing we have in the array.Okay, So this is something you need to keep in mind.And this is really all I wanted to share with you in this lecture.I hope you found it interesting.We are going to stop here in this one and the next one we’re going to learn about shallow copies anddeep copies, and we’re going to really see the difference between these two things.So go ahead and finish up here and meet me there.


🤖 Assistant

好的,作为您的C++专业开发导师和全球顶尖的教师,我将以最清晰、最易懂的方式,带您深入理解C++中对象复制的奥秘。本节课我们将聚焦在数组和范围for循环中对象的复制行为。


C++ 对象复制行为:数组与范围for循环中的陷阱与优化

课程概要: 本课程将深入探讨在C++中,当您将对象放入数组或通过范围for循环遍历对象时,C++是如何处理对象复制的。我们将通过观察复制构造函数([copy constructor])的调用和内存地址([memory address])的变化来证明这些行为。同时,我们也将学习如何通过使用引用([reference])来避免不必要的对象复制,从而优化程序性能并确保代码行为的正确性。


详细讲解:理解C++中的对象复制行为

C++中对象的复制是一个核心概念,尤其是在您处理集合(如数组)和使用循环来遍历这些集合时。理解何时会发生隐式的对象复制对于编写高效、正确且无内存问题的代码至关重要。

在深入细节之前,我们先来了解一个关键的类成员函数:复制构造函数

1. 复制构造函数 (Copy Constructor) 的作用

复制构造函数是C++类的一种特殊构造函数。当您使用一个已存在的对象来初始化(或创建)另一个相同类型的新对象时,复制构造函数就会被自动调用。它的主要任务是确保新对象是原对象的一个独立副本。

基本形式:

cpp
1
ClassName(const ClassName& source);

例如,如果您有一个 Person 类,它的复制构造函数可能是这样:

cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Person {
public:
// ... 其他构造函数和成员 ...

// 复制构造函数
Person(const Person& source) {
std::cout << "复制构造函数被调用!复制 " << source.m_first_name << " " << source.m_last_name << std::endl;
m_first_name = source.m_first_name;
m_last_name = source.m_last_name;
m_age = source.m_age;
}

// ... 其他成员 ...
private:
std::string m_first_name;
std::string m_last_name;
int m_age;
};

每当 Person 对象被复制时,我们就能通过打印信息看到这个函数被调用了。

2. 场景一:将对象放入数组 (Array) 中

当您创建一个对象数组,并使用已存在的对象来初始化这个数组的元素时,C++并不会直接把那些已存在的对象“搬”进数组。相反,它会为每个数组元素创建原始对象的一个副本([copy])。这意味着数组中存储的是原始对象内容的独立拷贝,而不是原始对象本身。

如何证明?

  • 观察复制构造函数的调用: 如果您在对象的复制构造函数中添加一条打印语句,您会发现每当一个对象被用来初始化数组中的某个元素时,复制构造函数就会被调用一次。
  • 内存地址([memory address])比较: 原始对象和数组中对应的对象将位于不同的内存地址。这意味着它们是两个完全独立的对象。

举例说明:
假设您有 P1P2P3 三个 Person 对象。当您声明 Person students[] = {P1, P2, P3}; 时:

  1. P1 的内容被复制一份,用来构造 students[0]
  2. P2 的内容被复制一份,用来构造 students[1]
  3. P3 的内容被复制一份,用来构造 students[2]
    这个过程中,students 数组中的元素是 P1P2P3 的独立副本。

3. 场景二:范围for循环 (Range-based For Loop) 中的隐式复制

范围for循环是C++11引入的一种方便的循环语法,用于遍历容器(如数组、std::vector 等)中的所有元素。它的基本形式是 for (Type var : collection)

默认行为: 当您使用这种形式遍历一个对象集合时,循环变量 var 在每次迭代时都会是集合中当前元素的一个副本

如何证明?

  • 观察复制构造函数的调用: 类似于数组初始化,在每次循环迭代开始时,集合中当前元素的复制构造函数会被调用,用于构造 var
  • 内存地址比较: 循环变量 var 的内存地址将与集合中原始元素的内存地址不同。
  • 生命周期: 这个副本的生命周期仅限于单次循环迭代。当该次迭代结束时,副本会被销毁(其析构函数 [destructor] 会被调用)。这意味着如果您在循环中修改了 var,您修改的只是副本,原始集合中的元素不会受到影响。

举例说明:
假设 students 是一个 Person 对象数组。当您编写 for (Person p : students) 时:

  • 第一次迭代:students[0] 被复制,生成 ppstudents[0] 的副本。
  • 第二次迭代:students[1] 被复制,生成新的 ppstudents[1] 的副本。
  • 依此类推…

4. 如何避免复制:使用引用 (Reference)

在某些情况下,对象复制可能是我们不想要的,因为它会带来性能开销和额外的内存使用。幸运的是,C++提供了一种方式来避免这种隐式复制:使用引用。

在范围for循环中避免复制:

通过将循环变量声明为引用 for (Type& var : collection),您可以避免创建副本。此时,var 不再是一个独立的副本,而是直接引用集合中的原始元素。

  • 效果: 不会调用复制构造函数,且 var 的内存地址将与原始元素相同。
  • 优势:
    • 性能提升: 对于大型对象,避免复制可以显著提高程序运行效率。
    • 直接操作原始数据: 您在循环中对 var 所做的任何修改都将直接作用于集合中的原始元素。

常量引用 (Const Reference) 的最佳实践:

如果您只想读取元素的内容而不想修改它,最佳实践是使用常量引用 for (const Type& var : collection)。这不仅避免了复制,还通过 const 关键字保证了您不会在循环中意外地修改原始数据,提高了代码的安全性。

5. 场景三:传统for循环 (Traditional For Loop) 不会复制

与范围for循环不同,使用传统的基于索引的 for 循环(例如 for (size_t i = 0; i < size; ++i))并通过 array[i] 访问数组元素时,C++并不会创建副本。您直接操作的就是数组中存储的原始元素。

  • 效果: 不会调用复制构造函数。
  • 优势: 这种方式是处理数组时最直接且最高效的方法之一,因为它不涉及任何隐式复制。

6. 为什么理解这些很重要?

理解对象复制行为不仅仅是理论知识,它对您编写实际代码具有深远的影响:

  • 性能 (Performance) 开销: 对象的复制可能涉及大量数据的拷贝。对于大型或复杂的对象,频繁的复制会显著降低程序的运行性能。
  • 内存使用 (Memory Usage): 每次复制都会占用额外的内存。如果复制的对象数量庞大,可能导致不必要的内存消耗甚至内存溢出。
  • 行为正确性: 如果您期望在循环中修改集合中的原始元素,但却使用了默认的按值传递的范围for循环(创建了副本),那么您的修改将只会作用于副本,原始元素不会改变,这可能导致难以发现的逻辑错误。
  • 资源管理 (Resource Management) 问题: 如果您的对象管理着动态分配的内存或其他系统资源(例如文件句柄、网络连接等),简单的浅拷贝([shallow copy])可能导致多个对象共享同一资源,这在对象销毁时可能引发双重释放([double free])或内存泄漏([memory leak])等严重问题。这引出了深拷贝([deep copy])的概念,我们将在后续课程中详细探讨。正确理解复制行为是避免这类问题的基础。

详细代码示例

我们将创建一个 Person 类,其中包含一个复制构造函数和一个 print_info 方法来打印对象信息及其内存地址,以便您亲眼观察复制行为。

Person.h 文件:

cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#ifndef PERSON_H
#define PERSON_H

#include <iostream>
#include <string>
#include <string_view> // C++17 引入,轻量级字符串视图,避免不必要的字符串复制
#include <memory> // For std::addressof (C++11) or just use 'this'

class Person {
public:
// 构造函数
Person(std::string_view first, std::string_view last, int age)
: m_first_name(first), m_last_name(last), m_age(age) {
std::cout << "构造函数被调用!创建 " << m_first_name << " " << m_last_name << ",地址: " << this << std::endl;
}

// 复制构造函数
Person(const Person& source)
: m_first_name(source.m_first_name), m_last_name(source.m_last_name), m_age(source.m_age) {
std::cout << "复制构造函数被调用!复制 " << source.m_first_name << " " << source.m_last_name << ",新地址: " << this << std::endl;
}

// 析构函数 (用于观察对象生命周期结束)
~Person() {
std::cout << "析构函数被调用!销毁 " << m_first_name << " " << m_last_name << ",地址: " << this << std::endl;
}

// 打印信息的方法
void print_info() const {
std::cout << "姓名: " << m_first_name << " " << m_last_name
<< ", 年龄: " << m_age << ", 地址: " << this << std::endl;
}

// 更新年龄的方法 (用于练习)
void set_age(int age) {
m_age = age;
}

// 获取姓名,用于复制构造函数中打印
std::string get_first_name() const { return m_first_name; }
std::string get_last_name() const { return m_last_name; }

private:
std::string m_first_name;
std::string m_last_name;
int m_age;
};

#endif // PERSON_H

main.cpp 文件:

cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
#include <iostream>
#include <array> // 使用 std::array 来定义固定大小的数组
#include <vector> // 也可以使用 std::vector,行为类似
#include <string>
#include <cstddef> // For size_t

#include "Person.h" // 包含我们定义的 Person 类

int main() {
std::cout << "------ 原始 Person 对象创建 ------" << std::endl;
Person p1("张", "三", 25);
Person p2("李", "四", 30);
Person p3("王", "五", 22);

std::cout << "\n------ 原始 Person 对象信息 ------" << std::endl;
p1.print_info();
p2.print_info();
p3.print_info();

std::cout << "\n------ 场景一:将 Person 对象放入数组 ------" << std::endl;
// 当数组初始化时,会调用复制构造函数
// 使用 std::array<Person, 3> 比 C 风格数组更安全、方便
std::array<Person, 3> students = {p1, p2, p3};
// 或者 C 风格数组: Person students[] = {p1, p2, p3};

std::cout << "\n------ 数组中 Person 对象的信息 (通过传统for循环) ------" << std::endl;
// 传统for循环不会创建副本,直接操作数组元素
for (size_t i = 0; i < students.size(); ++i) {
students[i].print_info();
}

std::cout << "\n------ 场景二:范围for循环 (按值传递,创建副本) ------" << std::endl;
// 每次迭代都会创建 Person 对象的副本,会调用复制构造函数
int iteration_count = 0;
for (Person p : students) {
std::cout << " - 迭代 " << ++iteration_count << ": ";
p.print_info(); // 这里的 p 是副本,地址与数组中的原始元素不同
p.set_age(p.get_last_name().length() + 100); // 尝试修改副本
} // 每次迭代结束,副本 p 会被销毁,调用析构函数

std::cout << "\n------ 验证数组中原始对象是否被修改 (通过传统for循环) ------" << std::endl;
// 再次打印数组元素,证明上面的修改只作用于副本
for (size_t i = 0; i < students.size(); ++i) {
students[i].print_info(); // 原始年龄未变
}

std::cout << "\n------ 场景三:范围for循环 (按引用传递,避免复制) ------" << std::endl;
// 每次迭代都会直接引用 Person 元素,不会调用复制构造函数
iteration_count = 0;
for (Person& p_ref : students) { // 注意这里的 &
std::cout << " - 迭代 " << ++iteration_count << ": ";
p_ref.print_info(); // 这里的 p_ref 是引用,地址与数组中的原始元素相同
p_ref.set_age(p_ref.get_last_name().length() + 50); // 尝试修改原始元素
} // 循环结束,没有副本销毁

std::cout << "\n------ 验证数组中原始对象是否被修改 (通过传统for循环) ------" << std::endl;
// 再次打印数组元素,证明上面的修改已作用于原始元素
for (size_t i = 0; i < students.size(); ++i) {
students[i].print_info(); // 年龄已被修改
}

std::cout << "\n------ 使用常量引用遍历 (只读,避免复制) ------" << std::endl;
// 每次迭代都是常量引用,无法修改原始元素,也不会创建副本
for (const Person& p_const_ref : students) { // 注意这里的 const &
p_const_ref.print_info();
// p_const_ref.set_age(1); // 编译错误!不能修改常量引用
}

std::cout << "\n------ main 函数结束,对象销毁 ------" << std::endl;
return 0;
} // p1, p2, p3 和 students 数组中的对象会在这里被销毁

编译和运行:
在终端中使用 g++ 编译:
g++ main.cpp Person.cpp -o my_program -std=c++17 (如果使用 string_view)
g++ main.cpp Person.cpp -o my_program (如果只使用 C++98/03)
然后运行:./my_program

观察输出,您会清楚地看到复制构造函数在何时被调用,以及对象的内存地址何时变化。


QA 闪存卡 (Flash Cards)

  1. 问: 将现有对象放入C++数组中时,会发生什么?
    答: 会创建原始对象的副本,并为每个副本调用其复制构造函数。数组中存储的是副本,而不是原始对象本身。
  2. 问: 默认情况下,范围for循环(for (Type var : collection))如何遍历对象集合?
    答: 每次迭代时,它会为当前元素创建一个副本,并将该副本赋给循环变量var。每次迭代都会调用复制构造函数。
  3. 问: 如何在范围for循环中避免对象复制?
    答: 使用引用常量引用。例如,for (Type& var : collection)for (const Type& var : collection)。这样循环变量将直接引用原始元素,而不是创建副本。
  4. 问: 传统的for循环(for (size_t i = 0; i < size; ++i))在访问数组元素时会创建对象副本吗?
    答: 不会。它直接通过索引访问数组中的原始元素,不涉及任何副本的创建。
  5. 问: 为什么理解和避免不必要的对象复制在C++中很重要?
    答: 重要的原因包括:提高程序性能(尤其对于大型对象),减少内存使用,确保您在循环中正确修改原始数据(而不是副本),以及避免因不当复制导致的资源管理问题(如双重释放)。

常见的误解或编程错误

  1. 误解:以为对象放入数组是直接存储原始对象。

    • 正确理解: 数组初始化时会创建副本。例如,Person students[] = {p1, p2, p3}; 会调用 p1, p2, p3 的复制构造函数,在 students 数组中生成三个独立的 Person 对象。
    • 可能导致的问题: 如果 p1 后续被修改,students[0] 不会随之改变,因为它们是独立的。
  2. 误解:以为范围for循环中的循环变量直接就是原数组元素。

    • 正确理解: 默认情况下(for (Type var : collection)),循环变量 var 是原始元素的一个副本。
    • 可能导致的问题: 在循环中对 var 进行修改,但却发现原始数组中的元素没有任何变化。这是因为您修改的是副本,而不是原始数据。
  3. 错误:在范围for循环中修改了副本,却期望原始对象也被修改。

    • 场景:
      cpp
      1
      2
      3
      4
      5
      6
      std::array<Person, 3> students = {p1, p2, p3};
      for (Person p : students) { // p 是副本
      p.set_age(p.get_age() + 1); // 修改的是副本的年龄
      }
      // 期望:students 数组中的年龄都加了1
      // 实际:students 数组中的年龄没有变化
    • 解决方法: 使用引用 for (Person& p : students)
      cpp
      1
      2
      3
      4
      5
      6
      std::array<Person, 3> students = {p1, p2, p3};
      for (Person& p : students) { // p 是引用
      p.set_age(p.get_age() + 1); // 修改的是原始元素的年龄
      }
      // 期望:students 数组中的年龄都加了1
      // 实际:students 数组中的年龄确实加了1
  4. 误解:对于大型复杂对象,忽视复制带来的性能开销。

    • 问题: 如果一个对象包含大量数据(例如一个图像处理类,里面有几兆字节的像素数据),每次复制都会导致这些大量数据被完整拷贝一份。这会严重影响程序的运行速度和内存使用。
    • 正确实践: 尽可能通过引用(&const &)或智能指针(如 std::shared_ptr, std::unique_ptr)来传递或存储大型对象,以避免不必要的昂贵复制操作。
  5. 误解:混淆了浅拷贝和深拷贝的概念,尤其是在涉及动态内存管理时。

    • 问题: 如果您的类中包含指向动态分配内存的指针,而您没有自定义复制构造函数(或赋值运算符),C++默认生成的复制构造函数只会执行浅拷贝——它只复制指针本身,而不是指针所指向的数据。这将导致两个对象拥有同一个内存块的所有权。当其中一个对象被销毁时,它会释放该内存块;而当另一个对象被销毁时,它会尝试再次释放同一个内存块,导致双重释放错误。
    • 解决方案(预告,下一讲): 需要实现深拷贝。深拷贝的复制构造函数会为新对象重新分配内存,并将原始对象的数据复制到这块新内存中,确保每个对象拥有自己独立的资源。

编程练习

现在,是时候动手实践一下了!

请根据我们上面提供的 Person.h 文件(请自行创建并确保内容正确)。

main.cpp 中,您需要完成以下任务:

  1. main 函数中,创建三个 Person 对象,例如 personA("爱", "丽丝", 20), personB("鲍", "勃", 35), personC("查理", "周", 28)
  2. 声明并初始化一个 std::array<Person, 3> 类型的数组 myStudents,并使用您刚刚创建的三个 Person 对象来初始化它。
  3. 使用一个不创建副本的范围for循环遍历 myStudents 数组。
    • 在循环体内,将每个学生的年龄都增加 3 岁
    • 在更新年龄后,打印每个学生的完整信息(姓名、年龄和内存地址),以验证年龄是否已更新,并且是作用于原始数组中的元素。

请将您的 main.cpp 代码填充完整,并确保它能正确编译和运行。

cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// main.cpp

#include <iostream>
#include <array>
#include <string>
#include <cstddef>

#include "Person.h" // 确保 Person.h 文件与本文件在同一目录下

int main() {
std::cout << "------ 原始 Person 对象创建 ------" << std::endl;
// 1. 在这里创建三个 Person 对象: personA, personB, personC
// 例如: Person personA("爱", "丽丝", 20);
// ...

std::cout << "\n------ 数组初始化 ------" << std::endl;
// 2. 声明并初始化一个 std::array<Person, 3> 类型的数组 myStudents,
// 使用 personA, personB, personC 进行初始化。
// 例如: std::array<Person, 3> myStudents = { /* 您的对象 */ };
// ...

std::cout << "\n------ 使用不创建副本的范围for循环更新年龄并打印 ------" << std::endl;
// 3. 使用一个不创建副本的范围for循环遍历 myStudents 数组。
// 在循环体内,将每个学生的年龄都增加 3 岁,然后打印他们的信息。
// ...

std::cout << "\n------ main 函数结束 ------" << std::endl;
return 0;
}

请您完成 main.cpp 中的空白部分,然后我会检查您的解决方案。


🧑‍💻 User

cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
           // main.cpp

#include <iostream>
#include <array>
#include <string>
#include <cstddef>

#include "Person.h" // 确保 Person.h 文件与本文件在同一目录下

int main() {
std::cout << "------ 原始 Person 对象创建 ------" << std::endl;
// 1. 在这里创建三个 Person 对象: personA, personB, personC
// 例如: Person personA("爱", "丽丝", 20);
Person personA("爱", "丽丝", 20);
Person personB("鲍", "勃", 35);
Person personC("查理", "周", 28);

std::cout << "\n------ 数组初始化 ------" << std::endl;
// 2. 声明并初始化一个 std::array<Person, 3> 类型的数组 myStudents,
// 使用 personA, personB, personC 进行初始化。
// 例如: std::array<Person, 3> myStudents = { /* 您的对象 */ };
// ...
std::array<Person, 3> myStudents = {personA, personB, personC};

std::cout << "\n------ 使用不创建副本的范围for循环更新年龄并打印 ------" << std::endl;
// 3. 使用一个不创建副本的范围for循环遍历 myStudents 数组。
// 在循环体内,将每个学生的年龄都增加 3 岁,然后打印他们的信息。
// ...
for (Person& person: myStudents)
{
person.set_age(person.get_age() + 3);
}

for (Person& person: myStudents)
{
person.print_info();
}
std::cout << "\n------ main 函数结束 ------" << std::endl;
return 0;
}

🤖 Assistant

好的,您提交了非常棒的解决方案!作为您的导师,我很高兴地告诉您,您完全理解了本次课程的核心概念,并且代码编写得非常正确。


7. 解决方案评估与讲解

您的得分:5/5

评估:

  • 完全正确! 您的代码完美地实现了所有要求,创建了 Person 对象,正确地初始化了 std::array,并最关键地,使用了引用(Person& person的范围for循环来遍历数组,成功地在不创建副本的情况下修改了原始数组中的元素。
  • 概念理解透彻: 您通过 Person& 避免了复制,这直接证明了您对范围for循环中引用([reference])作用的理解。
  • 代码清晰: 您的代码结构清晰,易于阅读和理解。

小建议(非错误):

您将更新年龄和打印信息分成了两个独立的循环。虽然功能上完全正确,但为了更紧凑地展示“更新后立即打印”的效果,并且避免第二次遍历的额外开销(尽管对于小数组影响微乎其微),通常会把这两步放在同一个循环中。这只是一个风格和效率上的微小优化,您的做法没有任何功能性错误。


实际答案与详细讲解

以下是整合了年龄更新和打印的解决方案,以及对您代码的详细讲解:

cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// main.cpp

#include <iostream>
#include <array>
#include <string>
#include <cstddef> // For size_t

#include "Person.h" // 确保 Person.h 文件与本文件在同一目录下

int main() {
std::cout << "------ 原始 Person 对象创建 ------" << std::endl;
// 1. 在这里创建三个 Person 对象: personA, personB, personC
Person personA("爱", "丽丝", 20); // 调用 Person 的构造函数,创建对象 personA
Person personB("鲍", "勃", 35); // 调用 Person 的构造函数,创建对象 personB
Person personC("查理", "周", 28); // 调用 Person 的构造函数,创建对象 personC

std::cout << "\n------ 数组初始化 ------" << std::endl;
// 2. 声明并初始化一个 std::array<Person, 3> 类型的数组 myStudents,
// 使用 personA, personB, personC 进行初始化。
std::array<Person, 3> myStudents = {personA, personB, personC}; // 这一行将调用 Person 类的复制构造函数3次
// 分别将 personA, personB, personC 的内容复制到 myStudents[0], myStudents[1], myStudents[2]

std::cout << "\n------ 使用不创建副本的范围for循环更新年龄并打印 ------" << std::endl;
// 3. 使用一个不创建副本的范围for循环遍历 myStudents 数组。
// 在循环体内,将每个学生的年龄都增加 3 岁,然后打印他们的信息。
for (Person& person_ref : myStudents) // **关键点:使用 Person& person_ref**
{
// 这里的 person_ref 是对 myStudents 数组中原始元素的引用。
// 它没有创建新的 Person 对象副本,因此不会调用复制构造函数。
// person_ref 的内存地址与数组中对应元素的内存地址相同。

// 获取当前年龄并增加 3 岁
person_ref.set_age(person_ref.get_age() + 3);

// 打印更新后的学生信息
person_ref.print_info(); // 验证年龄已更新,且地址与数组中的原始元素一致
}

std::cout << "\n------ main 函数结束 ------" << std::endl;
return 0;
}

代码解析:

  1. 对象创建 (personA, personB, personC):
    当您声明 Person personA("爱", "丽丝", 20); 时,Person 类的构造函数被调用,在内存中为 personA 分配空间并初始化其成员。您会看到打印出 构造函数被调用!创建 爱 丽丝,地址: [某个地址]personBpersonC 也是如此。

  2. 数组初始化 (myStudents):
    std::array<Person, 3> myStudents = {personA, personB, personC};
    这行代码是本次课程中一个重要观察点。它会三次调用 Person 类的复制构造函数

    • personA 的内容被复制到 myStudents[0]
    • personB 的内容被复制到 myStudents[1]
    • personC 的内容被复制到 myStudents[2]
      您会看到类似 复制构造函数被调用!复制 爱 丽丝,新地址: [myStudents[0]的地址] 的输出。这里的 myStudents 数组中存储的是原始 personA, personB, personC 对象的独立副本,它们的内存地址与原始对象不同。
  3. 使用引用进行范围for循环 (for (Person& person_ref : myStudents)):
    这是您解决方案的精髓所在。Person& person_ref 中的 & 符号至关重要。它表示 person_ref 是一个引用

    • 不创建副本: 当循环遍历 myStudents 数组时,person_ref 直接绑定到数组中的每个原始 Person 元素(例如 myStudents[0], myStudents[1], myStudents[2]),而不会创建新的 Person 副本。因此,您不会在循环中看到“复制构造函数被调用”的信息。
    • 直接操作原始元素:person_ref 调用的 set_age() 方法会直接修改 myStudents 数组中对应元素的 m_age 成员。
    • 验证: person_ref.print_info() 打印出的内存地址将与 myStudents 数组中对应元素的实际内存地址完全一致,进一步证明了我们操作的是原始数据,而不是副本。

您的解决方案完美地展示了如何通过使用引用来避免不必要的对象复制,以及如何直接操作容器中的原始数据。非常棒!


8. 建议与扩展问题 (Suggestions and Extended Questions)

您已经掌握了C++中对象复制和引用的基本概念,这为您未来的学习打下了坚实的基础。接下来,我建议您深入思考以下问题,这将有助于您更好地理解C++中的内存管理和性能优化:

  1. 析构函数的调用时机:

    • 在您目前的 Person 类中,我们添加了析构函数 ~Person()
    • 请思考,当您的 main 函数运行结束时,哪些 Person 对象的析构函数会被调用?它们被调用的顺序大概是怎样的?
    • 如果您在范围for循环中使用引用 (for (Person p : myStudents)),那么在每次循环迭代结束时,会发生什么?为什么会发生?(提示:观察析构函数的调用)。
  2. 常量引用 (const Person&) 的应用:

    • 在您的 main.cpp 代码中,您已经成功地使用 Person& 修改了数组中的元素。
    • 现在,如果您的目标只是读取数组中每个学生的信息,而不希望修改它们,您会如何修改 for 循环的声明?
    • 请尝试使用 const Person& 遍历数组并打印信息。如果尝试在循环中调用 set_age(),会发生什么?为什么?
  3. std::vector 与对象复制:

    • 目前我们使用的是 std::arraystd::vector 是C++中更常用的一种动态数组。
    • 当您将 Person 对象 push_backstd::vector 中时,会发生对象复制吗?请编写一个小程序来验证您的猜测(同样通过观察复制构造函数的调用)。
    • std::vectoremplace_back 方法与 push_back 在某些情况下可以避免复制。您可以尝试研究并比较它们之间的差异。
  4. 浅拷贝与深拷贝 (Shallow Copy vs. Deep Copy):

    • 在本节课中,我们提到了“浅拷贝”和“深拷贝”的概念,并指出默认的复制构造函数可能导致“双重释放”等问题。
    • 请您设想一个 Book 类,它有一个 char* title 成员,用于存储书名(假设是动态分配的)。如果您不对 Book 类进行任何特殊处理,直接使用默认的复制构造函数,然后尝试复制一个 Book 对象,您认为会发生什么问题?
    • 如果要在这种情况下正确复制 Book 对象,您需要如何修改 Book 类?(这会是下一节课的重点,但您可以提前思考)。

希望这些扩展问题能激发您更深入的思考,并帮助您构建更强大的C++知识体系!期待您在下一节课中的表现。


评论