这章节,我们来集中学习类和对象中的构造函数方面的相关知识
首先我们来简单介绍一下C++中的类和对象:
介绍类和对象
当谈到C++中的类和对象时,通常涉及以下几个方面的介绍:
概念与定义:
- 类(Class):类是C++中的一种用户定义的数据类型,用于封装数据以及与数据相关的操作。它是一种蓝图或模板,描述了对象的属性(数据成员)和行为(成员函数)。
- 对象(Object):对象是类的实例化,是类的具体实体,具有实际的内存分配。可以通过对象来访问类的成员。
成员变量与成员函数:
- 成员变量:也称为数据成员或属性,是类中用于存储数据的变量。
- 成员函数:也称为方法或操作,是类中用于操作数据的函数。它们可以访问和修改成员变量,也可以执行其他操作。
访问控制:
- C++中提供了三种访问控制权限:
public
、private
和protected
。public
成员可以在类的外部访问,private
成员只能在类的内部访问,protected
成员在继承关系中起作用,允许派生类访问。构造函数与析构函数:
- 构造函数:用于在对象创建时初始化对象的数据成员。构造函数的名称与类名称相同,没有返回类型,可以有参数,可以重载。
- 析构函数:用于在对象被销毁时清理对象使用的资源。析构函数的名称是在类名称前面加上波浪号(~),没有返回类型,不接受参数,不能被重载。
特殊成员函数:
- C++中还有一些特殊的成员函数,例如拷贝构造函数、移动构造函数、拷贝赋值运算符和移动赋值运算符等。它们在对象的拷贝和移动过程中起重要作用。
类的封装性:
- 类提供了封装的特性,即将数据和操作封装在一个单元内,隐藏了内部的实现细节,只暴露必要的接口。这种封装性有助于实现数据的安全性和模块化设计。
类的继承性:
- 继承是面向对象编程中的重要概念,允许一个类(子类/派生类)继承另一个类(父类/基类)的属性和方法。通过继承,可以实现代码的重用和层次化设计。
类的多态性:
- 多态是面向对象编程的核心概念之一,指同一个函数调用可以根据对象类型的不同而表现出不同的行为。C++通过虚函数和函数重写来实现多态性。
以上是关于C++中类和对象的综合介绍,涵盖了其基本概念、特性和相关概念。
这里的this并不显式给出,但可以直接使用 ,并且要注意的是:
当你在类中重载赋值运算符
operator=
时,this
指针确实会自动占用第一个参数的位置。这是因为赋值运算符operator=
是一个特殊的成员函数,它有一个隐含的参数,即赋值运算符左侧的对象。示例:
class MyClass { public: int value; // 赋值运算符重载函数 MyClass& operator=(const MyClass& other) { std::cout << "Assignment operator called." << std::endl; this->value = other.value; return *this; } };
结构
类是 C++ 的核心特性,通常被称为用户定义的类型。
类用于指定对象的形式,是一种用户自定义的数据类型,它是一种封装了数据和函数的组合。类中的数据称为成员变量,函数称为成员函数。
类可以被看作是一种模板,可以用来创建具有相同属性和行为的多个对象。
6个默认成员函数及功能
访问限定符
说明:
1. public修饰的成员在类外可以直接被访问
2. protected和private修饰的成员在类外不能被直接访问(此处protected和private是类似的)
3. 访问权限作用域从该访问限定符出现的位置开始直到下一个访问限定符出现时为止
4. 如果后面没有访问限定符,作用域就到 } ,即类结束
5. class 的默认访问权限为private,struct为public(因为struct要兼容C)
构造函数
默认构造函数
总体来说,默认构造就是无参构造。
默认构造函数在 C++ 中有两种情况:
没有显式提供默认构造函数:
如果用户未提供任何构造函数,编译器会为该类生成一个默认的构造函数。这个默认构造函数会初始化类中的成员变量。对于内置类型,这意味着它们的值将是未定义的(即随机值)。而对于自定义类型,这个默认构造函数会调用每个成员变量的默认构造函数,如果没有显式定义,默认构造函数会初始化它们的值为默认值。用户显式提供的默认构造函数:
如果用户显式地提供了一个无参数的构造函数,该构造函数就被称为默认构造函数。在这种情况下,用户可以在构造函数中初始化成员变量,包括内置类型和自定义类型的成员变量。
自定义构造函数
#include <iostream> class MyClass { private: int data; public: // 构造函数 MyClass(int value = 0) { // 不使用初始化列表 data = value; // 在函数体内进行成员变量的赋值操作 } // 成员函数 void display() { std::cout << "Data: " << data << std::endl; } }; int main() { MyClass obj1; // 调用构造函数,使用默认参数0进行初始化 obj1.display(); // 输出: Data: 0 MyClass obj2(42); // 调用构造函数,使用参数42进行初始化 obj2.display(); // 输出: Data: 42 return 0; }
初始化列表
- 初始化列表允许在构造函数中对成员变量进行初始化,而不是在函数体中赋值。
- 通过初始化列表进行初始化可以提高效率,并且对于某些成员变量(例如const成员变量和引用成员变量)是必须使用初始化列表进行初始化的。
- 语法:在构造函数的参数列表后使用冒号(:),然后按照成员变量的定义顺序,列出每个成员变量并提供其初始值。
在使用初始化列表时,初始化的顺序应该与成员变量在类中的声明顺序一致。这是因为初始化列表的执行顺序与成员变量在类中的声明顺序有关,以避免潜在的问题。
#include <iostream> class MyClass { private: int x; int y; public: // 构造函数使用初始化列表 MyClass(int a) : x(a), y(x) { // 构造函数的函数体 } // 显示 x 和 y 的值 void display() { std::cout << "x: " << x << ", y: " << y << std::endl; } }; int main() { // 实例化对象时使用初始化列表 MyClass obj(10); obj.display(); return 0; }
如果写成这样呢?private: int y; int x;
因为初始化列表的定义顺序是按照生命顺序来依次定义的,因此y的值是随机的。
相比于构造函数中的函数体赋值初始化和使用初始化列表的性能差异
让我们通过一个示例来说明在对象构造时使用初始化列表可以提高代码执行效率的情况,尤其是对于非内置类型的成员变量或存在继承关系的情况。
#include <iostream> #include <string> // 基类 Animal class Animal { public: Animal() { std::cout << "Animal default constructor called." << std::endl; } }; // 派生类 Person class Person : public Animal { private: std::string name; public: // 构造函数 Person(const std::string& n) : name(n) { std::cout << "Person constructor called." << std::endl; } }; 现在,我们创建一个 Person 对象,并观察构造函数的调用: int main() { Person person("Riven"); return 0; }
使用初始化列表的构造函数会直接在对象构造时对成员变量进行初始化,避免了额外的默认构造函数和赋值操作,从而提高了代码的执行效率。特别是对于非内置类型的成员变量
name
和存在继承关系的情况,初始化列表的优势更加明显。当我们运行上述代码时,输出为:
Animal default constructor called. Person constructor called.
可以看到,使用初始化列表的构造函数只调用了一次基类
Animal
的默认构造函数,而不需要额外的默认构造函数和赋值操作来初始化成员变量name
。这样可以提高代码的执行效率,尤其在对象创建时存在多个成员变量或继承关系时。
缺省参数
- 构造函数可以具有默认参数,这些参数在调用构造函数时可以省略。
- 当构造函数调用时未提供默认参数的值,则使用默认值来初始化相应的参数。
- 默认参数通常在函数原型中声明,而不是在类定义中。
这里建议用全缺省参数,使得成员变量都有默认值,这样在某些不同场景下被调用,而不必提供所有的参数。
#include <iostream> void printMessage(const char* message, int times = 1) { for (int i = 0; i < times; ++i) { std::cout << message << std::endl; } } int main() { // 调用带有缺省参数的函数,只提供一个参数 printMessage("Hello"); // 这里将会打印一次 "Hello" // 调用带有缺省参数的函数,提供两个参数 printMessage("World", 3); // 这里将会打印三次 "World" return 0; }
私有成员变量的初始化要求
- 私有成员变量的初始化应该考虑它们的生命周期和依赖关系。
- 对于const成员变量和引用成员变量,必须在构造函数的初始化列表中进行初始化,因为它们只能在初始化时被赋值,而不能在函数体中赋值。
- 对于非const成员变量,通常在初始化列表或函数体中都可以进行赋值操作,但使用初始化列表可以提高效率,因为在函数体中使用赋值构造会调用成员变量自身的构造函数。
析构函数
在C++中,析构函数通常用于释放动态分配的资源、关闭文件、释放锁等清理工作。
语法:析构函数的语法为
~ClassName()
,其中ClassName
是类的名称。调用时机:析构函数在对象生命周期结束时自动调用,即当对象超出作用域、delete 操作符删除动态分配的对象、容器对象被销毁时,都会调用对象的析构函数。
清理工作:析构函数通常用于释放资源,如动态分配的内存、关闭打开的文件、释放锁等。确保对象销毁时不会造成资源泄漏和内存泄漏。
使用场景:
释放动态分配的资源:如果对象在生命周期内动态分配了内存,需要在对象销毁时释放这些资源,可以在析构函数中完成这个清理工作。
关闭文件:如果对象在生命周期内打开了文件,需要在对象销毁时关闭文件,可以在析构函数中调用关闭文件的操作。
释放锁:如果对象在生命周期内获取了锁,需要在对象销毁时释放锁,可以在析构函数中调用释放锁的操作。
要避免的问题:
多次调用:析构函数不应该显式调用,它会在对象生命周期结束时自动调用。显式调用析构函数可能导致未定义的行为和内存泄漏。
资源泄漏:如果析构函数没有正确释放动态分配的资源,会导致资源泄漏和内存泄漏。因此,在析构函数中要确保释放所有在对象生命周期内分配的资源。
异常处理:析构函数不应该抛出异常,因为异常从析构函数中抛出将会导致未定义的行为。如果在析构函数中有可能发生异常,应该在析构函数内部进行适当的异常处理。
虚析构函数:如果类可能会被继承并在子类中作为基类使用,析构函数应该声明为虚函数,以确保在删除基类指针时能够正确调用子类的析构函数,从而避免内存泄漏。
总的来说,析构函数在对象销毁时执行清理工作,通常用于释放动态分配的资源、关闭文件、释放锁等。在编写析构函数时,要确保正确释放资源,避免多次调用、资源泄漏、异常处理不当等问题。
拷贝构造
在讲拷贝构造之前,我们先来了解 深、浅拷贝
浅拷贝
浅拷贝(Shallow Copy):
浅拷贝是指将一个对象的数据成员的值复制到另一个对象,而不复制数据成员所指向的内存。这意味着,如果对象的数据成员包含指针,那么浅拷贝只会复制指针的值,而不会复制指针所指向的内存。
特点和功能:
- 只复制对象的表面数据,而不涉及到对象内部数据的复制。
- 如果对象中有指针成员变量,两个对象将共享同一块内存区域,可能会导致潜在的问题,如释放同一块内存区域两次。
- 浅拷贝的速度比深拷贝快,因为它只是简单地复制对象的数据成员。
- 示例:
#include <iostream> class ShallowCopy { private: int* data; public: ShallowCopy(int val) { data = new int(val); } // 浅拷贝构造函数 ShallowCopy(const ShallowCopy& other) { data = other.data; // 浅拷贝只复制指针的值 } void display() { std::cout << "Data: " << *data << std::endl; } ~ShallowCopy() { delete data; } }; int main() { ShallowCopy obj1(5); ShallowCopy obj2 = obj1; // 浅拷贝 obj1.display(); // 输出: Data: 5 obj2.display(); // 输出: Data: 5 return 0; }
深拷贝
深拷贝(Deep Copy):
深拷贝是指将一个对象的数据成员的值复制到另一个对象,并且复制数据成员所指向的内存。这意味着,如果对象的数据成员包含指针,深拷贝将会为每个指针创建新的内存空间,并将原指针指向的数据复制到新的内存空间中。
特点和功能:
- 完全复制对象的数据成员,包括数据成员所指向的内存。
- 每个对象都拥有自己独立的内存空间,不存在指针共享内存的问题。
- 深拷贝的速度比浅拷贝慢,因为它需要额外的内存分配和数据复制操作。
- 示例:
#include <iostream> class DeepCopy { private: int* data; public: DeepCopy(int val) { data = new int(val); } // 深拷贝构造函数 DeepCopy(const DeepCopy& other) { data = new int(*other.data); // 深拷贝复制数据成员的值和指向的内存 } void display() { std::cout << "Data: " << *data << std::endl; } ~DeepCopy() { delete data; } }; int main() { DeepCopy obj1(5); DeepCopy obj2 = obj1; // 深拷贝 obj1.display(); // 输出: Data: 5 obj2.display(); // 输出: Data: 5 return 0; }
不同类型的拷贝构造函数
对于内置类型的成员变量,默认初始化意味着它们可能会被赋予未定义的值(在某些编译器中可能是随机值)。对于自定义类型的成员变量,默认初始化会调用自定义类型的默认构造函数进行初始化,如果没有自定义默认构造函数,则成员变量的值也可能是未定义的。
类中嵌套类(也称为内部类)是一个很好的例子。
1.使用默认拷贝构造函数: 如果没有显式定义拷贝构造函数,编译器会提供默认的拷贝构造函数。默认的拷贝构造函数执行逐个成员变量的拷贝。
class MyClass { public: int data; }; MyClass obj1; obj1.data = 10; MyClass obj2 = obj1; // 使用默认拷贝构造函数创建obj2的副本
2.自定义拷贝构造函数: 用户可以定义自己的拷贝构造函数,以实现特定的拷贝行为。例如,深拷贝动态分配的资源。
class MyString { private: char* buffer; public: MyString(const MyString& source) { // 自定义拷贝构造函数 buffer = new char[strlen(source.buffer) + 1]; strcpy(buffer, source.buffer); } // 其他成员函数和析构函数等... }; MyString str1("Hello"); MyString str2 = str1; // 使用自定义拷贝构造函数创建str2的副本
3.使用移动语义(C++11及以后版本): 当对象在其生命周期内不再被使用时,可以使用移动语义将资源(例如内存)从一个对象转移到另一个对象,避免不必要的拷贝操作。
class MyResource { private: int* data; public: MyResource(int val) : data(new int(val)) {} MyResource(const MyResource& source) : data(new int(*source.data)) {} MyResource(MyResource&& source) noexcept : data(source.data) { source.data = nullptr; } ~MyResource() { delete data; } // 其他成员函数... }; MyResource res1(100); MyResource res2 = std::move(res1); // 使用移动构造函数创建res2的副本
隐式转换
什么是隐式转换?
隐式转换是指在某些情况下,编译器会自动将一种类型转换为另一种类型,而无需显式地使用类型转换运算符。
并且,编译器会根据类中更适合的参数调用不同的构造函数,比如:
基本类型之间的隐式类型转换通常是从“更窄”的类型向“更宽”的类型进行的。因此,你无法直接使用
double
类型的参数来隐式地构造一个参数类型为int
的构造函数。这是因为从double
到int
的隐式类型转换可能会丢失精度,所以编译器不会自动执行这种转换。但是,你可以使用
int
类型的参数来隐式地构造一个double
类型的对象,因为从int
到double
的类型转换是安全的,不会导致数据丢失。
代码示例 :
#include <iostream>
class MyClass {
public:
int intValue;
double doubleValue;
char charValue;
// 默认构造函数
MyClass() : intValue(0), doubleValue(0.0), charValue('\0') {}
// 带参构造函数1:接受一个整数参数
MyClass(int intValue) : intValue(intValue), doubleValue(0.0), charValue('\0') {}
// 带参构造函数2:接受一个整数参数和一个双精度浮点数参数
MyClass(int intValue, double doubleValue) : intValue(intValue), doubleValue(doubleValue), charValue('\0') {}
// 带参构造函数3:接受一个整数参数、一个双精度浮点数参数和一个字符参数
MyClass(int intValue, double doubleValue, char charValue) : intValue(intValue), doubleValue(doubleValue), charValue(charValue) {}
};
int main() {
MyClass obj1; // 使用默认构造函数
MyClass obj2(10); // 使用带参构造函数1
MyClass obj3(10, 3.14); // 使用带参构造函数2
MyClass obj4(10, 3.14, 'A'); // 使用带参构造函数3
std::cout << "obj1: " << obj1.intValue << ", " << obj1.doubleValue << ", " << obj1.charValue << std::endl;
std::cout << "obj2: " << obj2.intValue << ", " << obj2.doubleValue << ", " << obj2.charValue << std::endl;
std::cout << "obj3: " << obj3.intValue << ", " << obj3.doubleValue << ", " << obj3.charValue << std::endl;
std::cout << "obj4: " << obj4.intValue << ", " << obj4.doubleValue << ", " << obj4.charValue << std::endl;
return 0;
}
在对象实例化中,隐式转换通常发生在以下几种情况:
1. 单参数构造函数的隐式转换:如果类中有一个单参数的构造函数,并且该构造函数不是 explicit
关键字修饰的,那么编译器在某些情况下会隐式地调用这个构造函数进行类型转换。
#include <iostream>
class MyClass {
public:
// 单参数构造函数
MyClass(int x) : value(x) {}
// 成员函数
void display() {
std::cout << "Value: " << value << std::endl;
}
private:
int value;
};
int main() {
// 隐式转换:将整数转换为 MyClass 类型
MyClass obj = 42;
obj.display(); // 输出: Value: 42
return 0;
}
2. 派生类到基类的隐式转换:如果派生类对象被赋值给基类对象或者作为基类对象的参数传递,编译器会隐式地进行派生类到基类的类型转换。
#include <iostream>
class Base {
public:
void display() {
std::cout << "Base" << std::endl;
}
};
class Derived : public Base {
public:
void display() {
std::cout << "Derived" << std::endl;
}
};
int main() {
Derived derivedObj;
Base baseObj = derivedObj; // 隐式转换:派生类对象转换为基类对象
baseObj.display(); // 输出: Base
return 0;
}
这两种情况下,编译器会自动调用适当的构造函数或进行类型转换,从而实现了对象实例化时的隐式转换。
对象的传递
当对象作为参数传递给函数时,拷贝构造函数会被调用。这使得函数可以获得对象的副本,而不影响原始对象。
class MyClass {
// ...
};
void processObject(MyClass obj); // 函数声明
int main() {
MyClass obj;
processObject(obj); // 将对象作为参数传递给函数,调用拷贝构造函数
return 0;
}
由于值传递也会引起不必要的拷贝,我们尽量使用引用传递,特别是在对象的内存大的时候,同时为了防止原对象被修改可以用const修饰:
Myclass CreateMyclass(const Myclass& other)
{
// ...
}
对象的返回
当函数返回一个对象时,拷贝构造函数会被调用来创建返回值的副本。这使得函数可以返回临时对象或局部对象的副本,而不会影响原始对象。
class MyClass {
// ...
};
MyClass createObject() {
MyClass obj;
return obj; // 返回对象,调用拷贝构造函数
}
int main() {
MyClass newObj = createObject(); // 调用拷贝构造函数,将返回值赋给新对象
return 0;
}
如果考虑到对象的声明周期在执行完函数后结束,我们可以返回对象的副本 ,如:
Myclass CreateMyclass(const Myclass& other)
{
Myclass myclass = other;
return myclass;//这里要注意返回的不是引用,因为超出了myclass生命周期对象会被销毁
}
对象的实例化
1.直接实例化:使用类名直接实例化对象
MyClass obj;
2.使用 new 关键字动态分配内存:使用
new
关键字来在堆上动态分配内存,并返回指向新对象的指针MyClass* ptr = new MyClass();
3.初始化对象时传递参数:在实例化对象时,可以通过构造函数的参数列表传递参数
MyClass obj(42);
4.使用拷贝构造函数进行实例化:使用已有对象来创建一个新对象,通过调用拷贝构造函数
MyClass newObj(existingObj);
5.使用赋值运算符:将已有对象的值赋给新对象
MyClass newObj = existingObj;
6.使用隐式转换,间接创建对象,但此时编译器可能存在优化:
Myclass C = 10;//隐式转换-> ……
运行后,我们看到36行的代码,这种语法会创建一个临时的
Myclass
对象,然后使用该对象来初始化 C。如果Myclass
有一个接受一个int
类型参数的构造函数,那么编译器会使用这个构造函数来将整数10
转换为Myclass
类型的对象。然后,根据需要,编译器可能会使用拷贝构造函数将临时对象的值复制给 C。编译器做的优化:
然而,这里是否会调用拷贝构造函数取决于编译器的优化。比如我现在的编译器环境是VS2022,很明显做了很大的优化,省略了后者用临时对象的拷贝构造,而是直接在隐式转换后直接构造对象,而没有临时对象的创建。
这种优化通常称为“返回值优化”,这种优化可以大大减少临时对象的创建和销毁,从而提高程序的性能。
7.数组形式的对象实例化: 创建对象数组时,可以指定数组的大小并初始化每个元素
MyClass array[5]; // 创建一个包含5个MyClass对象的数组
这样因为没有带参构造,会调用5个对象各自的默认构造函数。
如果类中有带参数的构造函数也可以用以下写法:
MyClass array[5] = {MyClass(1), MyClass(2), MyClass(3), MyClass(4), MyClass(5)};
这将会创建一个包含了5个
MyClass
对象的数组,每个对象都会调用带有参数的构造函数进行初始化。8.使用初始化列表: 在对象实例化时,使用初始化列表对成员变量进行初始化
MyClass obj {42}; // 使用初始化列表初始化成员变量
9.使用 make_shared 创建智能指针:
auto sharedPtr = std::make_shared<MyClass>();
这些是常见的对象实例化方式,每种方式都有其适用的场景和用法。
对象的引用
使用引用的前提,我们要先了解const关键字,
- const 关键字用于声明常量,它修饰变量或对象的值保证了不被修改
(前面我写的《深入理解指针》中有提到过“不同的const用法”),
- 可以理解为权限的缩小(因为它限制了变量或对象的修改权限,但并不影响其他操作,比如读取和使用。),然而我们不能对权限进行放大。
- 如果引用的类型是 const,则它可以绑定到非 const 或 const 对象。
Myclass& D = A; const Myclass& E = A; const Myclass& F = 1;//这里要注意,因为临时对象具有常量性,不能被非const对象引用
这里要记住:临时变量的引用要先创建临时变量,同时临时变量的生命周期取决于其绑定的引用的生命周期
以上就是本期的内容,感谢观众哥哥姐姐们的耐心观看,我们下期再见🌹~