NDK 基础(二)—— C++ 语言基础与特性1

发布于:2024-04-28 ⋅ 阅读:(23) ⋅ 点赞:(0)

1、C++ 语言基础

本节主要还是了解一些 C++ 的基本用法以及与 C 语言的不同/改进之处。比如说,C++ 是面向对象的语言,C 是面向过程的语言。

1.1 Hello, C++

熟悉 C++ 的标准 IO 库、命名空间以及基本的输入输出方法:

// 导入 C++ 的标准输入输出库
#include <iostream>

// 声明使用 std 这个命名空间,类似于 Java 的内部类
using namespace std;

void sample1() {
    // CLion 模板给出的代码,在引入 std 命名空间后可以省略掉 std::
    std::cout << "Hello, World!" << std::endl;
    // << 是操作符重载,后续文章会介绍
    // endl 是换行,也可以在字符串中加入 \n 实现相同效果
    cout << "Hello, C++!\n";
}

C++ 的常量是真常量,而 C 的常量是“假”常量:

// C++ 与 C 常量区分,C++ 绝对无法修改常量的值,但 C 是可以的
void sample2() {
    // 如下代码在 .c 文件中是可以运行并修改 num 常量的
    // 但是在 C++ 中编译不通过,或者在部分编译器中编译通过但运行崩溃
    /*const int num = 100;
    int *p = &num;
    *p = 1000;*/
}

1.2 引用

C++ 的引用可以理解为变量的别名,因为引用的地址和变量的地址是一样的。

虽然我们说引用是变量的别名,但实际上,引用的本质是指针,只不过取址的操作由 C/C++ 编译器帮我们做了,所以当函数参数被声明为引用后,调用该函数时就无须传入变量地址(&value)而是直接传变量本身(value)即可。

概念与使用

在 C++ 中,引用符号 & 用于创建引用类型。引用是一个已存在对象的别名,它与原始变量共享相同的内存位置,提供了对原始对象的另一种访问方式。通过引用,可以使用不同的名称来访问同一个变量,而不需要进行复制或创建新的对象。

引用必须在定义时进行初始化,并且不能改变引用的绑定对象。对引用的操作实际上是对原始变量的操作。引用通常用于函数参数传递、函数返回值和简化对对象的访问。

下面是一个简单示例,说明引用是如何充当变量的别名:

int x = 10;    // 原始变量 x
int& ref = x;  // 引用 ref 是 x 的别名

ref = 20;      // 修改引用 ref 也会修改 x 的值
std::cout << x;  // 输出: 20

在上述示例中,refx 的引用,它被初始化为 x。当修改 ref 的值时,实际上是修改了 x 的值。因此,输出 x 的值也会显示为修改后的值。

我们再举一个更具实用性的例子,上面提到引用通常用于函数参数传递,我们就以这个为例。

先回忆一下,C 语言中想写一个交换两个 int 类型数值的函数应该怎样写?需要借助指针:

// 错误示范:
// 实参调用本函数时,会将实参分别赋值给形参的 num1 和
// num2,函数执行的内容只交换了形参数值,而没有交换实参
void exchangeWrong(int num1, int num2) {
    int temp = num1;
    num1 = num2;
    num2 = temp;
}

// 正确示范:用指针交换变量值
// 这样即便参数是值传递,由于传递的是实参变量的地址给形参,
// 形参与实参地址相同,达到了实参的值交换的目的
void exchange1(int *num1, int *num2) {
    int temp = *num1;
    *num1 = *num2;
    *num2 = temp;
}

在 C++ 中,可以将形参声明为引用:

// 使用引用交换变量值
void exchange2(int &num1, int &num2) {
    cout << "exchange2 参数地址:num1 = " << &num1 << ",num2 = %p" << &num2 << endl;
    int temp = num1 + num2;
    num1 = temp - num1;
    num2 = temp - num2;
}

/**
 * C++ 的引用,在变量前加 &,注意区分,与 C 中的 & 表示
 * 取变量地址完全不是一回事,这里我们还是以变量交换值为例
 * 观看注释上给出的 log 不难发现,变量与变量的引用地址相同,
 * 实际上,引用就是变量的别名
 */
void sample3() {
    int n1 = 100, n2 = 10000;
    // 变量原始地址:&n1 = 0xbf5f3ff96c,&n2 = 0xbf5f3ff968
    cout << "变量原始地址:&n1 = " << &n1 << ",&n2 = " << &n2 << endl;

    exchange1(&n1, &n2);
    cout << "exchange1 交换后的值:n1 = " << n1 << ",n2 = " << n2 << endl;

    int &reference_n1 = n1, &reference_n2 = n2;
    exchange2(reference_n1, reference_n2);
    // exchange2 参数地址:num1 = 0xbf5f3ff96c,num2 = %p0xbf5f3ff968
    cout << "exchange2 交换后的值:n1 = " << n1 << ",n2 = " << n2 << endl;
}

使用引用作为参数具有以下好处:

  1. 避免数据的拷贝:
    • 当函数以值传递(pass by value)方式调用时,会创建参数的副本。对于大型对象或数据结构,这可能会导致显著的性能开销和内存消耗。
    • 使用引用作为参数(pass by reference),可以避免数据的拷贝,直接通过引用操作原始数据,提高程序的效率和性能。
  2. 允许函数修改调用者传递的数据:
    • 引用参数允许函数对传递的数据进行修改,而不仅仅是使用其副本。
    • 这对于需要修改传递的对象或需要返回多个值的函数非常有用。
  3. 实现函数间的数据共享:
    • 通过引用参数,多个函数可以共享和访问相同的数据,而无需进行额外的数据传递。
    • 这使得代码更简洁、高效,并且可以避免数据同步或数据不一致的问题。
  4. 支持函数重载:
    • 引用参数的使用支持函数重载,即可以定义多个具有相同名称但参数类型不同的函数。
    • 这样可以根据传递的参数类型选择正确的函数进行调用,提高代码的灵活性和可读性。
  5. 可以作为返回值:
    • 函数可以返回引用类型,允许返回对函数内部创建的对象或数据的引用。
    • 这样可以避免返回大型对象的副本,同时允许函数返回一个可修改的对象。

常量引用

在引用前面加 const 就声明了一个常量引用,常量引用不能修改:

typedef struct {
    char name[10];
    int age;
} Student;

/**
 * 如果在参数 Student &student 前面加 const 就使得
 * student 成为一个常量引用,方法内的两种操作对普通引用
 * 是可行的,但是试图对常量引用进行修改会直接编译报错
 */
void sample4(const Student &student) {
    // 1.不能修改常量引用内的属性值
//    student.age = 16;

    // 2.不能让常量引用指向其他的对象
    Student newStudent = {"Tracy", 10};
//    student = newStudent;
}

1.3 函数重载

与 C 不同,C++ 支持函数重载:

int add(int num1, int num2) {
    return num1 + num2;
}

int add(int num1, int num2, int num3) {
    return num1 + num2 + num3;
}

// 右侧的形参才可以使用默认值
/*int add(int num1, int num2, int num3 = 0, int num4 = 0) {
    return num1 + num2 + num3 + num4;
}*/

// C++ 支持函数重载,并且支持形参默认参数
void sample5() {
    // 如果开启了使用形参默认值的 add(),如下两个调用都会报错,因为
    // 编译器不知道你是在调用哪一个方法,因为 4 个参数带默认值的 add
    // 也可以这样调用
    add(1, 2);
    add(1, 2, 3);
}

如上所示,C++ 也支持函数形参的默认值,以下是形参默认值相关的知识点:

  1. 默认参数值:函数声明中可以为一个或多个形参指定默认值。这样,在调用函数时,如果相应的实参被省略,则将使用默认值。默认参数值必须在函数声明中的右侧指定,并且只能在函数声明中定义一次:

    void printInfo(int age, std::string name = "Unknown");
    ```
    
  2. 默认参数的顺序:当一个函数有多个形参具有默认值时,调用函数时可以省略任意数量的尾部实参。省略的实参将按照声明中的顺序使用相应的默认参数值。如果要省略中间的实参,必须显式指定后续实参的名称:

    void printInfo(std::string name, int age = 0, bool married = false);
    
    // 以下调用方式都是有效的
    printInfo("John");
    printInfo("Jane", 25);
    printInfo("Mike", 30, true);
    ```
    
  3. 默认参数与函数重载:函数可以重载,即在同一作用域内有多个同名函数,但它们的参数列表不同。默认参数值可以用于函数重载中,以提供更多的灵活性和方便性:

    void printInfo(int age);
    void printInfo(std::string name);
    void printInfo(std::string name, int age = 0);
    ```
    
  4. 默认参数的注意事项:

    • 默认参数应该在函数声明中指定,而不是在函数定义中
    • 如果函数声明中指定了默认参数值,而函数定义中没有提供相应的默认参数值,那么默认参数值是在函数声明中指定的
    • 当默认参数值发生变化时,只有重新编译调用该函数的代码,才会使用新的默认参数值

使用默认参数可以使函数的调用更简洁,并为函数提供更大的灵活性。但在使用时需要注意默认参数的顺序和声明/定义的一致性,以避免潜在的问题。

如果将 4 个参数的 add() 的注释打开,那么在调用 add() 时就只能调用 4 个参数的 add(),否则都会因为二义性而编译报错。或者也可以在设计 add() 时,不要让使用默认值的参数在多个方法中出现。只要能够避免二义性,就可以通过编译。

1.4 省略函数形参名字

// 函数省略形参名称
void uploadLogToServer(char *log, int) {
    /**
     * 初期开发功能时可能就上传到某一个服务器上就行,但是可以预见
     * 的是未来可能需要上传到其他服务器,或者同时上传多个服务器,这
     * 需要调用方传入参数来具体区分。
     * 但是当前可能做不了这个功能,所以就预留出来一个 int 作为上传
     * 模式,以后要做这个功能时再把参数名称填上,再写具体的功能代码。
     * JNI 中有很多没写形参名称的函数
     */
}

void sample6() {
    char log[] = "xxxxx";
    uploadLogToServer(log, 0);
    uploadLogToServer(log, 1);
    uploadLogToServer(log, 2);
}

1.5 面向对象

创建与使用对象

在 student.h 中定义类并声明成员变量与成员函数:

#ifndef BASIC2_STUDENT_H
#define BASIC2_STUDENT_H

#include <iostream>

using namespace std;

class Student {

private:
    char *name;
    int age;

public:
    void setAge(int age);

    void setName(char *age);

    int getAge();

    char *getName();
};

#endif //BASIC2_STUDENT_H

在 student.cpp 源文件中实现头文件中声明的函数:

#include "student.h"

/**
 * ::是 C++ 的作用域解析运算符,用来表示函数或变量属于
 * 哪个类。Student::setAge() 表示 setAge 是 Student
 * 类的成员函数,以便编译器能正确识别并关联该函数与 Student
 * 类。如果不加 Student::,那么 setAge 就是当前文件内的
 * 一个全局函数。
 */
void Student::setAge(int age) {
    // C++对象指向的是一个指针
    // -> 调用一级指针的成员
    this->age = age;
}

void Student::setName(char *name) {
    this->name = name;
}

int Student::getAge() {
    return this->age;
}

char *Student::getName() {
    return this->name;
}

最后使用 Student 类:

// 使用 Student 类
void sample7() {
    // 1.栈区新建一个 Student 对象
    Student student1;
    student1.setName("Tracy");
    student1.setAge(25);
    cout << "name = " << student1.getName() << ", age = " << student1.getAge() << endl;

    // 2.堆区新建 Student 对象
    Student *student2 = new Student();
    student2->setName("James");
    student2->setAge(20);
    cout << "name = " << student2->getName() << ", age = " << student2->getAge() << endl;
    // 使用完毕后要回收对象,使用 delete 而不是 free
    if (student2) {
        delete student2;
        student2 = nullptr;
    }
}

需要注意,声明与定义是要分开的,声明(Declaration)要写在头文件中,定义(Definition)写在源文件中。有时可能会把定义说成实现,其实是不准确的,虽然确实在源文件中填写头文件中声明的函数体这种行为是一种实现行为,但 C++ 的术语就将其称之为定义,还是根据规范来吧。

C++ 中声明一个类对象通常有如下方式:

  1. 在栈上分配:
    • 如果 Student 声明了无参构造函数,则 Student student; 即可创建一个 Student 对象
    • 如果 Student 声明了有参构造函数,比如两个参数 name 和 age,那么 Student student2("John", 20); 即可创建一个 Student 对象
    • 前两种属于直接创建对象,还可以通过拷贝构造函数,使用 Student student2 = new Student("John", 20); 这种形式,先创建出一个 Student 的临时变量,再通过拷贝构造函数将临时变量的成员属性拷贝到 student2 上
    • 通过拷贝赋值运算符 =,Student s3;s3 = s1;,将 s1 的属性值拷贝给 s3
  2. 在堆上分配:堆上的动态内存分配使用 new 关键字,比如 Student* student3 = new Student();

在栈上创建的对象,当函数执行完毕弹栈后会自动回收,无须手动回收;而在堆上创建的对象,使用完毕后要通过 delete 回收该对象。

注意:C++ 中创建对象与回收对象要使用 new - delete,前者调用构造函数,后者调用析构函数。而在 C 中才是使用 malloc - free 这一对,malloc 不会调用构造函数,free 也不会调用析构函数。

头文件和源文件的职责与规范

在 C++ 中,通常使用.h(头文件)和.cpp(源文件)两种文件扩展名来组织代码。这种分离的方式有助于提高代码的可维护性和可重用性。以下是它们各自的职责和一些编写规范:

头文件(.h/.hpp 文件)的职责:

  • 声明类、结构体、函数和变量的接口。
  • 包含类型定义、函数原型、常量等。
  • 提供对外的公共接口,以便其他源文件可以引用和使用。
  • 通常不包含函数的实现,只包含声明。

头文件的编写规范:

  • 使用预处理指令(#ifndef, #define, #endif)来防止头文件的多重包含。
  • 只包含必要的头文件,避免引入不必要的依赖关系。
  • 使用命名空间(namespace)来避免命名冲突。

源文件(.cpp 文件)的职责:

  • 实现头文件中声明的类、函数和变量。
  • 包含函数的实现细节,提供算法和逻辑。
  • 通常包含 main() 函数,作为程序的入口点。

源文件的编写规范:

  • 包含对应头文件的引用,以便可以访问接口和声明。
  • 保持良好的代码结构和组织,使用适当的函数和类来划分代码块。
  • 遵循代码风格和命名规范,以增加代码的可读性。
  • 使用注释来解释函数和类的作用、参数、返回值等。

2、C++ 重要函数原理

2.1 命名空间

C++ 的命名空间(namespace)用于组织代码,防止命名冲突,并提供了代码模块化和分组的方式。通过将相关的类、函数、变量等放置在同一个命名空间下,可以将它们从其他命名空间隔离开来,使得代码更清晰、易读和易于维护。

命名空间的使用方式如下:

  1. 声明命名空间:

    namespace MyNamespace {
        // 代码放置在命名空间内
        // 可以包含类、函数、变量等
        class MyClass {
            // ...
        };
    
        void MyFunction() {
            // ...
        }
    
        int myVariable = 42;
    }
    
  2. 使用命名空间中的成员:

    // 可以使用命名空间中的类、函数、变量等
    MyNamespace::MyClass obj;
    MyNamespace::MyFunction();
    int value = MyNamespace::myVariable;
    
  3. 使用 using 声明简化命名空间的使用:

    using namespace MyNamespace;
    
    // 现在可以直接使用命名空间中的成员
    MyClass obj;
    MyFunction();
    int value = myVariable;
    

需要注意的是,过度使用 using namespace 可能导致命名冲突或不明确的引用。因此,推荐在函数内部或局部范围使用 using 声明,而避免在全局命名空间中使用。

此外,C++ 还提供了匿名命名空间(unnamed namespace),它是一个隐式命名空间,用于限定只在当前文件中可见的符号。匿名命名空间的特点是在文件内部使用,不需要给命名空间起名字,如下所示:

namespace {
    // 代码放置在匿名命名空间内
    // 只在当前文件中可见
}

命名空间还可以嵌套:

namespace MyNamespace {
    namespace SubNamespace {
        namespace DescendantsNamespace {
            int value = 666;
        }
    }
}

嵌套的命名空间的内容可以使用作用域解析运算符 :: 来访问:

MyNamespace::SubNamespace::DescendantsNamespace::value

2.2 构造函数

创建类对象时会调用构造函数,一般在头文件的类中声明构造函数:

class Student {

public:
    Student();

    Student(char *name);

    Student(char *name, int age);
    
private:
    char *name;
    int age;
}

在源文件中实现构造函数:

#include "student.h"

Student::Student() {}

/**
 * : Student(name, 30) 会调用双参的构造函数,而且会先调用双参
 * 构造函数然后再调用这个单参构造函数
 */
Student::Student(char *name) : Student(name, 30) {
    cout << "单参构造函数" << endl;
}

/**
 * : name(name) 相当于执行了 this -> name = name;
 */
Student::Student(char *name, int age) : name(name), age(age) {
    cout << "双参构造函数" << endl;
}

解释以上三个构造函数:

  1. 空参构造函数是一个类默认的构造函数:
    • 假如类内没有显式声明任何一个构造函数,那么编译器就会为这个类生成一个隐式的空参的构造函数,对类的变量进行默认初始化(如 int 型初始化为 0,float 型初始化为 0.0,类类型的成员则调用它们的默认构造函数进行初始化)
    • 如果在类内显式定义了其他构造函数,编译器就不会提供上述的隐式构造函数。在这种情况下,如果需要无参构造函数,需要显式声明
  2. 单参构造函数声明的末尾通过 : Student(name, 30) 调用了双参构造函数,执行顺序上也是先执行双参构造函数,然后再执行单参构造函数
  3. 双参构造函数上使用成员初始化列表 : name(name), age(age) 为 Student 的 name 和 age 属性赋值,相当于执行了 this -> name = name;this -> age= age; 多个属性之间用逗号隔开。使用成员初始化列表的好处包括:
    • 性能优化:成员初始化列表可以直接初始化属性,而不是在构造函数体内进行赋值操作,可以提高效率
    • 对常量和引用成员的必要性:对于某些属性,只能在构造函数初始化列表中进行赋值,例如常量成员和引用成员
    • 初始化顺序控制:成员初始化列表可以控制属性的初始化顺序,与它们在类中的声明顺序无关

2.3 析构函数

C++ 中的析构函数

当一个对象被销毁之前会调用析构函数,用来进行对象的清理和资源的释放,例如释放动态分配的内存、关闭打开的文件等。

具体说来,析构函数在以下情形中会被调用:

  1. 对象的生命周期结束:通常发生在对象超出其作用域时,例如当对象是局部变量时,当函数执行完毕弹栈并离开该变量的作用域时,对象的析构函数会被自动调用
  2. 动态分配的对象销毁:动态分配的对象需要手动调用 delete 运算符销毁该对象,在调用 delete 时会调用析构函数释放对象占用的内存
  3. 容器被销毁时:如果对象放在容器(如数组、容器类等)内,当容器本身被销毁时,其中的元素对象的析构函数会被调用

如果在类中没有显式定义析构函数,编译器会提供一个默认的析构函数,它会执行一些默认的清理操作,例如释放对象的成员变量所占用的内存。但是,如果类中有动态分配的资源或需要进行其他特定的清理操作,那么通常需要显式定义析构函数来完成这些任务。

析构函数也要在头文件中声明:

class Student {

public:
    ~Student();
}

然后在源文件中实现:

Student::~Student() {
    cout << "析构函数,name = " << name << endl;

    // 必须释放堆区开启的成员,假设我修改了 name 的创建方式为在堆区创建:
    // this->name = (char *) (malloc(sizeof(char *) * 10));
    // 那么在析构函数中就要释放掉这个 name:
    /*if (this->name) {
        free(this->name);
        this->name = nullptr;
    }*/
}

析构函数是没有参数的。

Java 与 Kotlin 中的“析构函数”

Java 与 Kotlin 并没有真正的析构函数,只有类似的:

  • Java 对象在被 JVM 添加到回收队列时(注意不是回收哦,只是添加到队列中,具体的回收时机不可控)会调用 Object 类的 finalize()

  • Kotlin 则连 finalize() 这样的机制都没有,只能间接的借助 finalize()。具体做法是先创建一个 Java 的帮助类,需要实现 java.io.Closeable:

    public class JavaHelp implements Closeable {
    
        @Override
        protected void finalize() throws Throwable {
            super.finalize();
            // 调用 close()
            close();
        }
    
        @Override
        public void close() throws IOException {
    
        }
    }
    

    然后在 Kotlin 类中继承 JavaHelp,重写 close() 就可以了:

    class Student : JavaHelp() {
        override fun close() {
            super.close()
    
            println("KT 我被添加到回收队列了");
        }
    }
    

2.4 拷贝构造函数

概念

通过一个例子引入拷贝构造函数的概念:

void testCopyConstructor() {
    Student s1("s1", 26);
    Student s2 = s1;
    cout << "s2 信息:" << s2.getName() << "," << s2.getAge() << endl;
    cout << "s1 地址:" << &s1 << ",s2 地址:" << &s2 << endl;
}

输出结果为:

s2 信息:s1,26
s1 地址:0x121c9ffc20,s2 地址:0x121c9ffc10

这说明 s2 与 s1 不是同一个对象,但是 s2 拷贝了 s1 的属性值。这就是通过拷贝构造函数实现的。

与普通的构造函数类似,如果一个类没有显式的声明拷贝构造函数,那么编译器会提供一个隐式的,其具有如下特点:

  1. 参数:默认的拷贝构造函数接受一个同类型的对象作为参数。
  2. 功能:默认的拷贝构造函数会将传入的对象的成员变量值逐个复制到新创建的对象中。对于指针类型的成员变量,只会复制指针的值,而不会复制指向的对象。
  3. 浅拷贝:默认的拷贝构造函数执行的是浅拷贝,即简单地复制成员变量的值。如果类中存在动态分配的资源(如堆内存),默认的拷贝构造函数只会复制指针的值,而不会复制资源本身。这可能导致多个对象共享同一份资源,从而引发潜在的问题。

当类中存在指针类型的成员变量或资源管理的情况时,通常需要显式地定义拷贝构造函数,以确保进行适当的深拷贝或资源管理。否则,默认的浅拷贝可能导致意外的行为,例如资源重复释放或内存泄漏。

显式定义

当我们显式定义拷贝构造函数后,默认的会被覆盖掉。比如说:

	// 使用常量引用作为参数,避免修改原对象
	Student(const Student & student) { 
        cout << "拷贝构造函数" << endl;

        // 我们自己赋值,age - 10 是为了演示效果,不是常规逻辑
        this->name = student.name;
        this->age = student.age - 10;

        cout << "自定义拷贝构造函数 内存地址 " << &student << endl;
    }

然后再次运行 testCopyConstructor(),输出结果如下:

双参构造函数
拷贝构造函数
自定义拷贝构造函数 内存地址 0x8b073ff760
s2 信息:s1,16
s1 地址:0x8b073ff760,s2 地址:0x8b073ff750

说明确实调用了显式定义的拷贝构造函数。

区分

下面来区分一下什么时候会调用拷贝构造函数,什么时候不会:

void testCopyConstructor() {
    // 1.调用拷贝构造函数
    Student s1("s1", 26);
    Student s2 = s1;
    cout << "s2 信息:" << s2.getName() << "," << s2.getAge() << endl;
    cout << "s1 地址:" << &s1 << ",s2 地址:" << &s2 << endl;

    // 2.先创建了对象再指向另一个对象,不会调用拷贝构造函数
    Student s3;
    s3 = s1;

    // 3.通过指针也不会调用拷贝构造函数
    Student *s4 = new Student("s4", 40);
    Student *s5 = s4;
}

上述三种情况只有第一种会调用,后两种不会,原因是:

  • 第二种是先调用了 Student 的空参构造方法声创建了 s3,然后用拷贝赋值运算符 = 将 s1 的属性值拷贝给 s3,这个拷贝赋值运算符与拷贝构造函数不是一回事,下面详解
  • 第三种是在堆上创建了 Student 对象,栈中的 s4 指向该对象,而 s5 这个指针保存的地址与 s4 相同,同样指向堆上的 Student,这个过程中也没有用到拷贝构造函数

下面来区分一下拷贝赋值运算符与拷贝构造函数:

拷贝赋值运算符(copy assignment operator)和拷贝构造函数(copy constructor)是用于对象之间的拷贝操作的两个特殊成员函数。

拷贝构造函数用于创建一个新对象,并将其初始化为另一个同类型对象的副本。它在以下情况下被调用:

  1. 通过直接初始化创建新对象,例如 Student s2(s1);
  2. 通过值传递方式将对象作为参数传递给函数。
  3. 在函数返回时,将对象作为返回值返回。

拷贝赋值运算符用于将一个已经存在的对象的值赋给另一个已存在的对象。它在以下情况下被调用:

  1. 在赋值操作中,例如 s3 = s1;
  2. 在函数中将一个已存在的对象赋值给另一个对象。

拷贝构造函数和拷贝赋值运算符的目的都是实现对象之间的拷贝,但它们的调用时机和使用方式略有不同。

当进行拷贝操作时,编译器会根据需要自动生成默认的拷贝构造函数和拷贝赋值运算符。默认的拷贝构造函数执行逐个成员的拷贝初始化,而默认的拷贝赋值运算符执行逐个成员的赋值操作。

如果你需要特定的拷贝行为,例如深拷贝或资源管理,你可以自定义拷贝构造函数和拷贝赋值运算符来实现所需的行为。自定义的拷贝构造函数和拷贝赋值运算符可以根据你的需求执行更复杂的对象拷贝操作。

需要注意的是,如果你自定义了拷贝构造函数或拷贝赋值运算符,通常也需要同时实现析构函数和移动操作(移动构造函数和移动赋值运算符),以遵循所谓的“Rule of Three”或“Rule of Five”规则,确保正确的对象管理和资源释放。

2.5 对象的创建方式

总结一下 C++ 中声明一个对象的常用方式:

  1. 在栈上分配:
    • 如果 Student 声明了无参构造函数,则 Student student; 即可创建一个 Student 对象
    • 如果 Student 声明了有参构造函数,比如两个参数 name 和 age,那么 Student student2("John", 20); 即可创建一个 Student 对象
    • 前两种属于直接创建对象,还可以通过拷贝构造函数,使用 Student student2 = new Student("John", 20); 这种形式,先创建出一个 Student 的临时变量,再通过拷贝构造函数将临时变量的成员属性拷贝到 student2 上
    • 通过拷贝赋值运算符 =,Student s3;s3 = s1;,将 s1 的属性值拷贝给 s3
  2. 在堆上分配:堆上的动态内存分配使用 new 关键字,比如 Student* student3 = new Student();

在栈上创建的对象,当函数执行完毕弹栈后会自动回收,无须手动回收;而在堆上创建的对象,使用完毕后要通过 delete 回收该对象。

注意:C++ 中创建对象与回收对象要使用 new - delete,前者调用构造函数,后者调用析构函数。而在 C 中才是使用 malloc - free 这一对,malloc 不会调用构造函数,free 也不会调用析构函数。

以 Student 为例:

int main() {
    // 创建对象的方式分为两大类,在栈上申请和在堆上动态申请,
    // 不论哪种方式都要通过构造函数创建对象,前两种在栈上,后面在堆
    // 1.由于 Student 有无参构造函数,因此可以直接省略后面的无参
    // 构造函数调用
    Student stu1;
    stu1.setName("stu1");
    stu1.setAge(16);
    cout << "stu1:" << stu1.getName() << "," << stu1.getAge() << endl;

    // 2.直接在栈上通过双参构造函数创建
    Student stu2("stu2", 20);
    stu2.setAge(20);
    cout << "stu2:" << stu2.getName() << "," << stu2.getAge() << endl;

    // 3.使用拷贝构造函数创建 Student
    Student stu3 = Student("stu3");
    cout << "stu3:" << stu3.getName() << "," << stu3.getAge() << endl;

    // 4.使用拷贝赋值运算符 =
    Student stu4;
    stu4 = stu1;
    cout << "stu4:" << stu4.getName() << "," << stu4.getAge() << endl;

    // 5.在堆上动态创建 Student
    Student *stu5 = new Student("stu3", 26);
    cout << "stu5:" << stu5->getName() << "," << stu5->getAge() << endl;
    // new-delete 是一套,前者调用构造函数,后者调用析构函数
    delete stu5;

    // 注意不要用 malloc 为对象分配空间,因为这样没有调用构造函数,如下方式不可取
    Student *stu6 = (Student *) malloc(sizeof(Student));
    cout << "stu6:" << stu6->getName() << "," << stu6->getAge() << endl;
    free(stu6);

    return 0;
}

2.6 常量指针与指针常量

指针常量(pointer to a constant)和常量指针(constant pointer)是两个不同的概念,它们涉及指针和常量之间的关系。

通过一个例子来区分它们:

void testPointer() {
    int number = 9;
    int number2 = 8;

    // 1.常量指针:指向常量(不一定是常量,你看 number 不就是变量么,只不过是不能用这个
    // 指针修改它指向的值)的指针,代码先声明常量 const,然后声明指针 int*
    const int *numberP1 = &number;
//     *numberP1 = 100; // 报错,不允许用 numberP1 修改【常量指针】存放地址所对应的值
    numberP1 = &number2; // OK,允许重新指向【常量指针】存放的地址
    
    // 但是可以用其他指针修改 number 的值
    int *p = &number;
    *p = 60;

    // 2.指针常量:指针本身是常量,代码先声明指针 int*,后生明常量 const
    int *const numberP2 = &number;
    *numberP2 = 100; // OK,允许去修改【指针常量】存放地址所对应的值
    // numberP2 = &number2; // 报错,不允许重新指向【指针常量】存放的地址

    // 3.常量指针常量
    const int *const numberP3 = &number;
    // *numberP3 = 100; // 报错,不允许去修改【常量指针常量】存放地址所对应的值
    // numberP3 = &number2; // 报错,不允许重新指向【常量指针常量】存放的地址
}

指针常量(pointer to a constant)与常量指针(constant pointer)都是指针,区别在于:

  • 指针常量的指针是一个常量,也就是说,指针不能修改,但指针指向的对象可以修改
  • 常量指针是一个指向常量的指针,指针可以修改,但是指针指向的对象不可通过该指针修改(可以通过别的指针修改,因为 number 并不是一个常量,其他指针可以修改 number)

其实通过上面的例子能发现,两种指针指向的对象是不是常量都没关系,number 和 number2 声明时都不是常量,而是普通变量。

在选择使用指针常量还是常量指针时,取决于你的需求和设计。如果你希望保护指针指向的值不被修改,可以使用指针常量;如果你希望保护指针本身不被修改,可以使用常量指针

3、浅拷贝与深拷贝

本节接着上一节的拷贝构造函数进行延伸,介绍 C++ 中的浅拷贝与深拷贝。

3.1 概念

什么是浅拷贝(Shallow Copy),什么是深拷贝(Deep Copy)?在不同的编程语言中,它们的具体定义和含义可能有所不同:

  1. 在 C++ 中:
    • 浅拷贝:指简单地复制对象的位模式,包括指针成员变量。这意味着拷贝后的对象与原始对象共享相同的指针,指向相同的内存资源。因此,对其中一个对象的修改可能会影响到其他对象。默认的拷贝构造函数和拷贝赋值运算符执行的是浅拷贝。
    • 深拷贝:指创建一个新对象,并复制源对象的所有内容,包括动态分配的内存资源。这意味着每个对象都有自己独立的资源副本,彼此之间不共享。深拷贝通常需要自定义拷贝构造函数和拷贝赋值运算符,以确保正确地复制对象及其成员。
  2. 在 Java 中:Java 中没有直接的浅拷贝和深拷贝的概念。对象的拷贝是通过引用进行的,而不是通过位模式的复制。
    • 非直接的浅拷贝:当使用赋值操作符或传递对象作为参数时,实际上是将引用复制给了新的变量或参数。这意味着新变量和原变量引用相同的对象,对其中一个对象的修改会影响到另一个对象。
    • 非直接的深拷贝:可以通过实现 Cloneable 接口和重写 clone() 方法来实现对象的拷贝。这种拷贝方式称为克隆(Clone),默认情况下执行的是浅拷贝。如果需要进行深拷贝,需要在 clone() 方法中创建新的对象,并复制所有的成员变量,包括引用类型的成员变量。

也就是说,在 C++ 中,如果一个类内定义的成员变量是指针类型的时候,使用默认的拷贝构造函数进行浅拷贝,就会使得多个对象的指针类型成员指向同一个对象,埋下隐患。这也是我们要引入深拷贝的根本原因

3.2 浅拷贝可能会造成的问题

在 C++ 中,浅拷贝可能会导致以下问题:

  1. 对象共享资源:如果一个类包含指针成员变量,并且使用默认的浅拷贝方式进行对象拷贝,那么拷贝后的对象和原始对象将共享相同的指针,指向相同的内存资源。这可能导致问题,因为当其中一个对象释放资源或修改资源时,会影响到其他对象,可能导致内存错误、悬挂指针或未定义行为。
  2. 资源泄漏:如果类中包含动态分配的内存(例如通过 new 分配的内存),并且在析构函数中没有正确释放这些资源,浅拷贝会导致多个对象共享相同的指针,从而导致资源泄漏,因为析构函数可能会多次释放相同的内存。
  3. 对象生命周期问题:浅拷贝可能会导致对象的生命周期管理不正确。当一个对象被释放,而其他对象仍然持有指向同一资源的指针时,这可能导致访问无效的内存或悬挂指针。

为了解决上述问题,通常需要使用深拷贝(Deep Copy)。深拷贝会创建一个新的对象,并复制源对象的所有内容,包括指针指向的资源。这样,每个对象都有自己的独立资源副本,彼此之间不会相互影响。

为了实现深拷贝,可以自定义拷贝构造函数和拷贝赋值运算符,确保对指针成员变量执行独立的资源分配和复制操作,而不仅仅是简单地复制指针本身。

下面我们用一个简单的 Demo 来说明浅拷贝会造成的问题,以及解决方案。

假设有一个 Student 类,头文件如下:

#include <iostream>
#include <cstring>

using namespace std;

class Student {

public:

    Student(char *name, int age);

    ~Student();

private:
    char *name;
    int age;

public:
    char *getName();

    void setName(char *name);

    int getAge();

    void setAge(int age);
};

可以看到没有显式声明拷贝构造函数,隐患已经埋下。

再看源文件的实现:

Student::Student(char *name, int age) : age(age) {
    this->name = (char *) malloc(sizeof(char) * strlen(name));
    strcpy(this->name, name);
}

Student::~Student() {
    cout << "析构函数,释放对象 " << this << endl;
    if (name) {
        free(name);
        name = nullptr;
    }
}

// setters and getters 实现省略...

name 属性是在堆上开辟的空间,在析构函数中会释放 name。

然后我们来进行测试:

int main() {
    // 在栈上创建 student1
    Student student1("Tracy", 20);
    // 使用拷贝构造函数创建 student2,属性值从 student1 中拷贝
    Student student2 = student1;

    return 0;
}

创建 student2 时会通过默认的拷贝构造函数,将 student1 的成员浅拷贝到 student2 中。接着,main() 执行完毕弹栈,先后弹出 student2 和 student1,执行它们的析构函数,这时候问题就来了。先执行析构函数的 student2 可以顺利的释放掉 name 指向的堆内存,但是由于 student1 的 name 指针也指向相同的堆内存,并且这个 name 还没有指向 nullptr,所以它会继续走释放流程,再次 free 相同的堆内存,造成内存的重复释放而抛出异常:

析构函数,释放对象 0x3d52dffaf0
析构函数,释放对象 0x3d52dffb00

Process finished with exit code -1073740940 (0xC0000374)

3.3 解决方案

那怎么解决呢?前面我们提到,发生问题的根本原因是默认的拷贝构造函数做的是浅拷贝,那么我们显式定义拷贝构造函数,让 name 指针做深拷贝,复制出一个不同的对象就可以了。

头文件中声明拷贝构造函数:

class Student {
    
public:

    // 显式定义拷贝构造函数,进行深拷贝
    Student(const Student&student);
}

源文件实现:

Student::Student(const Student &student) {
    // 对 name 属性进行深拷贝,在堆上申请一个新的内存空间存放 student.name
    this->name = (char *) malloc(sizeof(char *) * strlen(student.name));
    strcpy(this->name, student.name);
    this->age = student.age;
}

这样再次运行就正常了:

析构函数,释放对象 0xe1345ff850
析构函数,释放对象 0xe1345ff860

Process finished with exit code 0

网站公告

今日签到

点亮在社区的每一天
去签到