C++ Part2-核心知识-QA

1. C++内存分区模型的理解

在C++程序运行时,内存被划分为几个不同的区域,每个区域都有其特定的作用和生命周期。以下是常见的C++内存分区:

  1. 栈(Stack)

    • 作用:栈用于存储函数调用的局部变量、函数参数、函数返回地址等信息。
    • 特点:栈是一种后进先出(LIFO)的数据结构,函数的调用和返回都在栈上进行。
    • 生命周期:局部变量的生命周期与所在函数的执行周期相同,当函数返回时,其局部变量被自动销毁。
  2. 堆(Heap)

    • 作用:堆用于动态内存分配,存储程序运行期间动态创建的对象和数据。
    • 特点:堆是一块大的连续内存区域,程序可以通过 newdelete 关键字在堆上进行内存的分配和释放
    • 生命周期:堆上分配的内存需要手动管理,直到使用 delete 关键字显式释放内存,否则会导致内存泄漏。
  3. 全局/静态存储区(Global/Static Storage Area)

    • 作用:全局变量静态变量存储在这个区域中。
    • 特点:全局/静态变量在程序运行期间都存在,它们的内存空间在程序启动时被分配,在程序结束时被释放
    • 生命周期:全局变量的生命周期与程序的生命周期相同,静态变量的生命周期与所在函数的生命周期相同。
  4. 常量区(Constant Area)

    • 作用:存储常量字符串全局常量
    • 特点:常量区是只读的,存储在常量区的数据是不可修改的。
    • 生命周期:常量字符串和全局常量在程序运行期间都存在,它们的内存空间在程序启动时被分配,在程序结束时被释放
  5. 代码区(Code Area)

    • 作用:存储程序的机器代码
    • 特点:代码区是只读的、共享的,存储在代码区的指令在程序运行期间不可修改。
    • 生命周期:代码区的生命周期与程序的生命周期相同,存储的指令在程序启动时被加载到内存中在程序结束时被卸载

2. 为什么不要返回局部变量引用?

不建议返回局部变量的引用是因为局部变量的生命周期与函数的执行周期相关联,一旦函数执行结束,局部变量就会被销毁,而返回了局部变量的引用后,该引用指向的内存空间已经无效,可能会导致未定义的行为

下面是一个例子来说明这个问题:

#include <iostream>
using namespace std;

int& getLocalVariableReference() {
    int x = 10;
    return x; // 返回局部变量 x 的引用
}

int main() {
    int& ref = getLocalVariableReference();
    cout << "Reference to local variable: " << ref << endl; // 输出引用指向的值
    return 0;
}

在这个例子中,函数 getLocalVariableReference() 返回了一个指向局部变量 x 的引用。然而,一旦函数返回后,x 的生命周期就结束了,它的内存空间被释放。当 main() 函数中尝试输出引用 ref 指向的值时,由于 x 已经被销毁,ref 指向的内存空间已经无效,这将导致未定义的行为。

要避免这种问题,应该始终返回指向有效内存空间的引用,而不是局部变量的引用。如果需要返回一个变量的引用,应该确保该变量的生命周期超出了函数的执行范围,或者使用动态内存分配(如 new 关键字)来分配内存,以确保内存空间的有效性。

 

3. public、private 和 protected 等几个权限访问关键字的理解和区分

public:

  • 概念: public关键字用于声明的类成员在类的内部和外部都是可访问的。
  • 通俗解释: 就像公共汽车上的座位一样,任何人都可以坐在公共座位上,无论是内部的乘客还是外部的行人。
  • 用法示例: 在类中声明的公共成员可以被类的对象和外部代码访问。
class Example {
public:
    int publicVar;
    void publicFunc() {
        // 可以访问公共成员和私有成员
        privateVar = 10;
    }
private:
    int privateVar;
};

int main() {
    Example obj;
    obj.publicVar = 5;  // 公共成员可以在外部访问
    obj.publicFunc();   // 公共函数可以在外部调用
    return 0;
}

2. private:

  • 概念: private关键字用于声明的类成员只能在类的内部访问,外部无法访问。
  • 通俗解释: 就像家里的私人空间一样,只有家庭成员可以在家里自由活动,外面的人无法进入家里的私人区域。
  • 用法示例: 在类中声明的私有成员只能在类的成员函数中访问,外部无法直接访问。
class Example {
private:
    int privateVar;
public:
    void setPrivateVar(int value) {
        privateVar = value;  // 私有成员可以在类的成员函数中访问和修改
    }
};

int main() {
    Example obj;
    obj.setPrivateVar(5);  // 私有成员只能通过公共接口间接访问
    // obj.privateVar = 10;  // 错误:私有成员无法在外部直接访问
    return 0;
}

3. protected:

  • 概念: protected关键字用于声明的类成员只能在类的内部访问,以及在派生类中访问。
  • 通俗解释: 就像家里的子女专属区域一样,只有家庭成员可以进入,外面的人无法进入,但如果有其他家庭成员的子女也可以进入。
  • 用法示例: 在类中声明的保护成员只能在类的成员函数和派生类中访问,外部无法直接访问。
class Base {
protected:
    int protectedVar;
};

class Derived : public Base {
public:
    void setProtectedVar(int value) {
        protectedVar = value;  // 保护成员可以在派生类中访问和修改
    }
};

int main() {
    Derived obj;
    obj.setProtectedVar(5);  // 保护成员可以通过派生类的公共接口间接访问
    // obj.protectedVar = 10;  // 错误:保护成员无法在外部直接访问
    return 0;
}

在使用public、private和protected关键字时,我们可以根据需要控制类成员的访问权限,从而增强程序的封装性和安全性。

 

4. C++的IO为什么平常用的更多是cout<< "**" << endl; 这种,而不是printf()这种?

在C++中,cout 是标准输出流对象,而 printf() 是C语言中的一个输出函数。虽然它们都可以用于输出数据,但在C++中更常用 cout 的几个原因如下:

  1. 类型安全cout 提供了类型安全的输出,它能够根据数据类型自动选择合适的输出方式,而 printf() 则需要使用格式化字符串来指定输出格式,容易因为格式不匹配而导致错误。

  2. 可读性高: 使用 cout 的输出语句通常更直观和易读,尤其是在输出复杂数据类型时,可以直接使用 << 运算符连接多个输出项,使得代码更加清晰易懂。

  3. 面向对象特性cout 是C++中的一个标准库对象,它是基于面向对象的设计,与C++的面向对象特性更加契合,而 printf() 是一个函数调用,与C++的面向对象设计不太一致。

  4. 更强大的功能cout 提供了丰富的输出功能,例如可以输出自定义的数据类型、控制输出格式、控制输出精度等,而 printf() 的功能相对较少,需要使用格式化字符串来实现复杂的输出。

  5. 与C++标准库兼容性cout 是C++标准库的一部分,与其他C++标准库函数和类的使用方式一致,可以更好地与现代C++代码风格和规范相匹配。

总的来说,虽然 printf() 在C语言中广泛使用且功能强大,但在C++中,由于 cout 提供了更多的优势和便利,因此更常用于日常编程中。

 

5. C++中左值、右值的概念

在C++中,左值(lvalue)和右值(rvalue)是用于描述表达式的两种不同类型。

1. 左值(lvalue):

  • 左值是指表达式结束后仍然存在的对象或标识符,可以出现在赋值运算符的左边
  • 具体来说,左值是一个标识符(如变量名)或表达式它代表一个具体的内存位置,可以对其进行赋值操作。
  • 例如,变量、数组元素、类成员、返回左值引用的函数的结果等都是左值。
int x = 5; // x 是左值
int arr[5]; // arr[5] 是左值

2.右值(rvalue):

  • 右值是指表达式结束后不再存在的临时对象,通常是在表达式求值后生成的临时值。
  • 具体来说,右值是一个常量字面量临时对象它代表一个不具体的内存位置,通常不能对其进行赋值操作。
  • 例如,常量、字面量、返回右值的函数的结果、表达式的计算结果等都是右值。
int result = 2 + 3; // 2 + 3 是右值

在C++11及更高版本中,引入了右值引用(rvalue reference)的概念,允许我们对右值进行引用,并且可以通过移动语义来提高性能和效率。

int&& rvalueRef = 5; // 5 是右值,可以被右值引用绑定

总的来说,左值表示一个具体的内存位置,可以被引用并且可以对其进行赋值操作右值表示一个临时的值,通常不能被引用或修改。左值和右值的区分在C++中很重要,特别是在理解赋值语句、引用和移动语义等方面。

 

6. 如何理解: 函数的返回值不可以作为函数重载的条件?

在 C++ 中,函数的返回值类型不能作为函数重载的条件。函数重载是指在同一个作用域内,可以定义多个名称相同但参数列表不同的函数。函数重载的原则是根据函数的参数列表来确定调用哪个函数,而不是根据函数的返回值类型。

例如,以下代码是合法的函数重载:

int add(int a, int b) {
    return a + b;
}

double add(double a, double b) {
    return a + b;
}

在这个例子中,存在两个名为 add 的函数,它们的参数列表不同,一个接受两个整数参数,另一个接受两个双精度浮点数参数。这样的定义是合法的,因为它们的参数列表不同,编译器可以根据调用时传入的参数类型来决定调用哪个函数。

但是,如果我们试图根据返回值类型来重载函数,就会导致编译错误:

int add(int a, int b) {
    return a + b;
}

double add(int a, int b) {
    return a + b;
}

在这个例子中,我们试图定义两个具有相同参数列表的函数,唯一的区别是它们的返回值类型。这是不允许的,因为函数重载的条件是函数的参数列表不同,而不是返回值类型不同。因此,这样的定义会导致编译错误。

 

7. C++中的"作用域"的理解,以及两个冒号::代表什么含义?

理解"作用域"在编程中的含义是很重要的,特别是在 C++ 这样的语言中。作用域指的是变量、函数或其他命名实体的可见性和有效范围。在 C++ 中,作用域可以由大括号 {}、命名空间、类、函数等限定。在特定的作用域内,可以访问和使用定义在该作用域内的变量、函数等。

作用域的重要性在于确保变量和函数名的唯一性,避免名称冲突,并且在不同的作用域内可以定义具有相同名称的变量和函数而不会产生歧义。

关于 A::foo 中的双冒号 ::,它是C++中的作用域解析操作符。这个操作符用于访问命名空间、类的静态成员、类的内部类或命名空间的成员。在 A::foo 中,A 是命名空间,foo 是命名空间 A 中的函数。这样的写法表示我们要访问命名空间 A 中的 foo 函数。

举例来说,假设我们有一个命名空间 A,其中定义了一个函数 foo,可以使用 A::foo 来表示访问这个函数。如果没有双冒号 ::,而只是 A.foo,那可能会被误解为 A 对象中的成员函数调用,而不是访问命名空间 A 中的函数。因此,双冒号 :: 的作用是明确指定作用域。

 

7.1 那双冒号:: 和 句号.的区别是?

双冒号 :: 和句号 . 在 C++ 中有着不同的用途,主要体现在作用域解析和成员访问方面。

  1. 句号 .: 在 C++ 中,句号 . 用于访问对象的成员,包括类的成员变量和成员函数。当你有一个类的对象时,你可以使用句号 . 来访问对象的成员。例如:

class MyClass {
public:
    int x;
    void func() {
        cout << "Hello, World!" << endl;
    }
};

int main() {
    MyClass obj;
    obj.x = 10; // 访问成员变量
    obj.func(); // 调用成员函数
    return 0;
}

在上面的例子中,obj.xobj.func() 中的句号 . 用于访问 MyClass 对象 obj 的成员变量 x 和成员函数 func()

 

  2. 双冒号 ::: 双冒号 :: 用于作用域解析,主要用于访问命名空间、类的静态成员、类的内部类或命名空间的成员。双冒号 :: 后面跟随的是命名空间、类名或类的别名,用于指定所要访问的作用域。例如:

namespace NS {
    int x = 5;
}

int main() {
    cout << NS::x << endl; // 访问命名空间 NS 中的变量 x
    return 0;
}

在上面的例子中,NS::x 中的双冒号 :: 用于指定访问命名空间 NS 中的变量 x

综上所述,句号 . 主要用于访问对象的成员,而双冒号 :: 主要用于作用域解析。两者的主要区别在于用途不同。

 

8. 拷贝构造函数概念的理解【多反复理解】

(1)拷贝构造函数是构造函数的一种,符合构造函数的一般性规则

(2)拷贝构造函数的引入是为了让对象在初始化时能够像简单变量一样的被直接用=来赋值

(3)拷贝构造函数不需要重载,他的参数列表固定为const classname& xx

(4)拷贝构造函数很合适用初始化列表来实现

参考:什么是拷贝构造函数

 

 9. 为什么如下代码打印不出拷贝构造函数?

 

#include <iostream>
using namespace std;


class Person {

public:
    //无参(默认)构造函数
    Person() {
        cout << "无参构造函数!" << endl;
    } 

    //有参构造函数
        Person(int a) {
        age = a;
        cout << "有参构造函数!" << endl;
    }

    //拷贝构造函数
        Person(const Person & p) {
        age = p.age;
        cout << "拷贝构造函数!" << endl;
    }

    //析构函数
        ~Person() {
        cout << "析构函数!" << endl;
    }

public:
    int age;
};


void test01()
{
    Person p;
}


void test02()
{    
    //Person p1(10);
    //Person p2 = Person(10); // 由于编译器进行了优化,它会直接将临时对象的值直接移动(move)到 p2 对象中,而不是调用拷贝构造函数来复制对象。这种优化被称为移动语义(move semantics)
    Person tmp(10);  // 要拆分成如下2步来写,才能打印出拷贝构造函数
    Person p2 = tmp;
}


int main()
{

    //test01();
    test02();

    system("pause");
    return 0;
}

Person p2 = Person(10); 这行代码实际上并没有调用拷贝构造函数。这是因为在C++中,编译器对某些情况下的临时对象进行了优化,避免了不必要的拷贝构造函数的调用。

Person p2 = Person(10); 这行代码中,Person(10) 创建了一个临时对象,并将其值传递给 p2 对象。然而,由于编译器进行了优化,它会直接将临时对象的值直接移动(move)到 p2 对象中,而不是调用拷贝构造函数来复制对象。这种优化被称为移动语义(move semantics),通常在以下情况下发生:

  1. 当临时对象是一个纯右值(例如,一个临时创建的对象)时。
  2. 当临时对象是一个具名的右值引用(例如,通过 std::move 函数转换的对象)时。

因此,在你的代码中,Person(10) 创建的临时对象是一个纯右值,因此编译器选择了移动语义而不是调用拷贝构造函数。因此,你看不到拷贝构造函数被调用的输出。

如果你希望强制调用拷贝构造函数,可以通过以下两种方式之一:

  1. 显式地将临时对象转换为具名的右值引用,例如 Person &&tmp = Person(10);,然后再将其传递给 p2 对象。
  2. 使用 Person p1(10); 的方式直接初始化 p2 对象,这种情况下会调用拷贝构造函数。

 在代码中明确地调用拷贝构造函数

void test02()
{
    Person tmp(10);  // 创建一个临时对象 tmp,并初始化其值为 10
    Person p2 = tmp; // 使用拷贝构造函数将临时对象 tmp 的值复制给 p2 对象
}

 

10. 在如下代码中,为什么int Person::m_A = 100; 需要用 :: 而不能用 句号 . ?

class Person {
public:
    static int m_A;
};

int Person::m_A = 100;

Person::m_A 表示访问 Person 类中的静态成员变量 m_A,因此需要使用作用域解析运算符 ::。如果使用句号.,则表示在对象上进行访问,而静态成员变量不属于对象的一部分,因此不能通过对象来访问。

  • 对象的方法(成员函数)和属性非静态成员变量)需要通过对象来访问,因此使用句号.来调用对象的成员。
  • 类的静态成员变量和静态成员函数属于类本身,不依赖于特定的对象,因此使用作用域解析运算符::来访问类的静态成员。

 

11. 如下返回类型 为什么要是&Person, return语句却又是 *this?当我把返回类型改为Person后,累加就失效了 ?

    Person& PersonAddAge(Person& p)
    {
        this->m_age += p.m_age;
        return *this;  // *this返回的就是调用对象本身,比如 Person p; p.PersonAddAge(); 这个时候this指针就指向p, *this就是p本体
    }

PersonAddAge 函数返回类型为 Person&,这意味着它返回的是对象的引用,而不是新的对象。这样做的目的是允许您在使用该函数时连续对同一个对象进行操作,而不是创建新的对象副本。这种连续操作的方式通常被称为“流式接口”或“方法链”。

当在函数内部使用 return *this; 语句时,您实际上返回的是当前对象的引用。使用 *this 表示当前对象,而 return *this; 语句将当前对象的引用返回给调用者。

因此,PersonAddAge 函数的设计允许您像下面这样连续调用它:

Person p1;
p1.PersonAddAge(p2).PersonAddAge(p3);

在这个例子中,PersonAddAge(p2) 调用将 p2 的年龄添加到 p1 中,并返回 p1 的引用。接着,.PersonAddAge(p3) 调用将 p3 的年龄再次添加到 p1 中。

如果将返回类型改为 Person,则每次调用 PersonAddAge 都会创建一个新的 Person 对象,而不是对同一个对象进行操作,因此连续的累加操作将失效。

 

12. 指针和引用的异同理解。

指针和引用是 C++ 中两种重要的机制,它们都用于处理变量的间接访问。它们的相同点和区别点如下:

相同点:

  1. 都用于间接访问变量:指针和引用都可以用来访问变量的值,而不直接使用变量本身。
  2. 可以作为函数参数传递:指针和引用都可以作为函数的参数传递,使得函数能够修改传入的参数的值。

区别点:

  1. 语法和符号不同:指针使用*&来声明和操作,而引用则使用&来声明,不需要使用*操作符。
  2. 指向对象的方式不同:指针可以指向任意的内存地址,包括空指针(nullptr),而引用必须在声明时初始化,并且不能改变引用的目标。
  3. 指针可以重新赋值:指针可以在运行时重新赋值,指向不同的对象,而引用一旦绑定到了某个对象,就不能再改变。

下面分别举例说明两者的使用:

指针的使用示例:

#include <iostream>
using namespace std;

int main() {
    int x = 10;
    int* ptr = &x; // 定义一个指针,并指向变量x
    *ptr = 20; // 通过指针修改变量x的值
    cout << "x: " << x << endl; // 输出修改后的值
    return 0;
}

引用的使用示例:

#include <iostream>
using namespace std;

int main() {
    int x = 10;
    int& ref = x; // 定义一个引用,绑定到变量x
    ref = 20; // 通过引用修改变量x的值
    cout << "x: " << x << endl; // 输出修改后的值
    return 0;
}

为什么有了指针还要搞出引用的概念?这是因为引用提供了一种更加简洁、易读的方式来操作变量,特别是在函数参数传递和返回值方面,引用更加直观和安全。此外,引用可以更好地与面向对象编程结合使用,使代码更加清晰易懂。虽然在某些情况下指针和引用可以互换使用,但它们各有优势,而且在某些场景下,引用更适合使用。因此,C++ 中同时存在指针和引用,以满足不同的编程需求。

 

13. 如何理解Person PersonAddAge(Person& p) 和Person& PersonAddAge(Person& p) 两种返回方式的结果差异?

#include <iostream>
using namespace std;

class Person
{
public:
    int m_age;

    Person(int age)
    {
        this->m_age = age;
    }

    Person PersonAddAge(Person& p)
    {
        this->m_age += p.m_age;
        return *this;
    }
};


void test01()
{
    Person p1(18);
    cout << "p1的年龄为: " << p1.m_age << endl;

}

void test02()
{
    Person p1(10);
    
    Person p2(10);

    //p2.PersonAddAge(p1).PersonAddAge(p1).PersonAddAge(p1); // 如果函数签名为 Person PersonAddAge(Person& p)的值返回,则p2.m_age = 20, 
                                                             // 如果是引用返回 Person& PersonAddAge(Person& p),则p2.m_age = 40. 
    
    cout << "p2.PersonAddAge(p1).PersonAddAge(p1)年龄为: " << p2.PersonAddAge(p1).PersonAddAge(p1).PersonAddAge(p1).m_age << endl; // 结果返回40, 这两行更加帮助理解 值返回 会拷贝构造新对象,而不是返回引用p2
    cout << "p2 的年龄为: " << p2.m_age << endl; //结果返回20
}


int main()
{
    //test01();
    test02();
    
    system("pause");
    return 0;
}
理解:如果函数签名为 Person PersonAddAge(Person& p)的值返回,则p2.m_age = 20, 如果是引用返回 Person& PersonAddAge(Person& p),则p2.m_age = 40. 

这是因为,Person&返回的一直是p2, 而Person返回每次都会调用拷贝构造返回一个匿名新对象,

第一次新对象为 p2.PersonAddAge(p1),这个匿名对象值为20,返回并建立这个新匿名对象后(而不是把它像引用那样绑定给p2,从这以后就和p2无关了),

再次调用.PersonAddAge(p1), 就是20+10 = 30,又返回并创建一个新的匿名对象p2.PersonAddAge(p1).PersonAddAge(p1),这个匿名对象值为30,

然后再次对该新匿名对象调用.PersonAddAge(p1),就是30 + 10 = 40,最后返回并创建一个新的匿名对象 p2.PersonAddAge(p1).PersonAddAge(p1).PersonAddAge(p1) 值为40,

这个对象也是一个Person对象,所以有成员属性m_age, 此时便很容易理解:p2.PersonAddAge(p1).PersonAddAge(p1).PersonAddAge(p1).m_age = 40 就是这样来的了。

如果返回值类型为Person&, 细微的差别就在于每次并没有返回创建新的匿名对象,而是不停把累加的值重复的都绑定到p2这个对象上。

综上所述,这就是该函数两种不同返回类型(返回值和返回引用)的细节差异,一定要理解吃透!!

 

 14. 如下++运算符的重载代码中,为什么

重载<<部分的函数中,第二个参数写成myinteger &myint时, cout << num++就报错,但 cout << ++num却不报错;
然后,写成myinteger myint时, 不论是cout << num++还是cout << ++num 两者都是不报错的。
#include <string>
#include <iostream>
using namespace std;


class myinteger {

    friend ostream& operator<<(ostream& out, myinteger myint); // myinteger &myint就报错,而myinteger myint就不报错呢?

public:
    myinteger() {
        m_num = 0;
    }

    //前置++
    myinteger& operator++() {
        m_num++;
        return *this; //这里返回的是一个左值,而非临时对象。也就是说,++num返回的是num对象本身的引用,而不是一个临时对象。
    }

    //后置++
    myinteger operator++(int) {
        myinteger temp = *this;
        m_num++;
        return temp; // 这是个临时对象,函数调用完毕后就会被立马销毁。如果传递【临时对象的引用】,则很危险,会导致未定义的行为(群龙无首)。如果传递对象本身,编译器会创建这个临时对象的一个拷贝。并将该拷贝传递给函数。
    }

private:
    int m_num;
}; ostream
& operator<<(ostream& out, myinteger myint) // myinteger& myint就 cout << num++ << endl; 就报错。myinteger& myint就不报错。 { out << myint.m_num; return out; } void test01() { myinteger num; cout << ++num << endl; cout << num << endl; } void test02() { myinteger num; cout << num++ << endl; cout << num << endl; } int main() { test01(); test02(); system("pause"); return 0; }

<<运算符重载函数的第二个参数从myinteger&改为myinteger就可以解决编译错误。

原因在于C++对于函数参数传递的规则。当将一个对象作为参数传递给函数时,编译器会根据参数类型决定使用值传递还是引用传递:

  1. 如果参数类型是非引用类型(如本例中的myinteger),编译器会创建该对象的一个临时拷贝,并将该拷贝传递给函数。这种情况下,函数内部对参数的任何修改都不会影响到原始对象。
  2. 如果参数类型是引用类型(如本例中原始的myinteger&),编译器会直接传递原始对象的引用给函数,在函数内部对该引用的任何修改都会反映到原始对象上。

cout << num++ << endl;这一行中,num++表达式会返回一个临时的myinteger对象(参考我之前的解释)。当尝试将这个临时对象传递给<<运算符重载函数时:

  • 如果第二个参数类型是myinteger&,编译器会试图将这个临时对象的引用传递给函数,但由于临时对象的生命周期非常短暂,这会导致未定义的行为,因此编译器拒绝编译
  • 如果第二个参数类型是myinteger,编译器会创建这个临时对象的一个拷贝,并将该拷贝传递给函数这样做就避免了对临时对象的引用,从而规避了潜在的未定义行为。

因此,将参数类型改为myinteger可以让cout << num++这种表达式正确编译,因为它使用了对象的拷贝作为参数,而不是试图传递临时对象的引用。

总的来说,这种解决方案的本质是通过值传递规避了对临时对象引用的问题,从而使cout << num++这种语句可以正确编译和执行。

 

追问:那为什么返回类型为myinteger&时,cout << ++num << endl;却不报错呢?

原因在于++numnum++表达式的区别。

当使用前置自增运算符++num时:

  1. 首先对num执行自增操作,num的值增加1
  2. 将自增后的num对象作为一个左值返回,而非临时对象

因此,++num返回的是num对象本身的引用,而不是一个临时对象。

而在<<运算符重载函数中:

ostream& operator<<(ostream& out, myinteger &myint)

第二个参数myinteger &myint也是一个非临时的左值引用。所以在cout << ++num << endl;这一行中,++num返回的是一个非临时myinteger对象的引用,可以被安全地传递给<<运算符

但是,对于后置自增运算符num++来说,情况就不同了。num++表达式首先会返回num对象的一个临时拷贝,然后再对num执行自增操作。而由于这个临时拷贝仅存在于本条语句中,当试图将它传递给接受myinteger&参数的<<运算符时,编译器会报错(因为临时对象的生命周期过短)

因此,cout << ++num << endl;不会报错,因为前置自增运算符++num返回的是对象本身的左值引用,而不是一个临时对象但是cout << num++ << endl;在没有对<<重载函数进行修改的情况下会报错,因为后置自增运算符返回了一个临时对象

总结一下,导致这种差异的根本原因在于前置/后置自增运算符返回值的不同:前置返回左值,后置返回右值(临时对象)。根据不同的返回值类型,其与<<运算符的兼容性也不同,从而导致了不同的行为。

 

 

追问:由于++num的返回值是num对象本身(作为一个左值),所以它被隐式转换为一个myinteger临时对象。?为什么只是临时对象呢?怎么理解?

之前的解释中,将++num返回的num对象称为"被隐式转换为一个myinteger临时对象"的说法是不准确的,让我重新解释一下:

++num是前置自增运算符,它返回的是对num对象自增后的引用,而不是一个临时对象。更准确地说:

  1. ++numnum进行自增操作,得到自增后的num对象。
  2. ++num的返回值是对自增后num对象本身的引用,而不是临时对象。
  3. 当将这个引用传递给<<运算符重载函数时,由于参数类型是myinteger(非引用类型),编译器会对该引用进行一次按值拷贝,创建一个myinteger副本作为函数参数。

也就是说,++num返回的不是临时对象,而是对象本身的引用。只是在传递给<<运算符重载函数时,由于参数类型是非引用类型,编译器才会创建一个该对象的副本作为实参。

您指出的这个细节非常好,我的之前的解释确实存在不准确之处。应该这样说:

对于cout << ++num << endl;这一行,++num返回的是对自增后num对象的引用,而不是临时对象。当将这个引用传递给<<运算符重载函数时,由于参数类型为myinteger(非引用类型),编译器会对该引用进行按值拷贝,从而创建一个myinteger对象的副本作为函数参数。

 

15. 理解下C++ Vistual Studio Native Tools Command Prompt工具的返回结果

#include <iostream>
#include <string>
using namespace std;


class Animal
{
public:
    int m_Age;
};


class Sheep : public Animal {};
class Tuo: public Animal {};
class SheepTuo : public Sheep, public Tuo {};


void test()
{
    SheepTuo st;
    st.Sheep::m_Age = 18;
    st.Tuo::m_Age = 28;

    cout << "st.Sheep::m_Age =" << st.Sheep::m_Age << endl;
    cout << "st.Tuo::m_Age =" << st.Tuo::m_Age << endl;
    //cout << "st.m_Age = " << st.m_Age << endl;
}



int main()
{
    test();

    system("pause");
    return 0;
}

打开Native Tools command prompt,在文件所在目录下输入如下命令 cl /dl reportSingleClassLayout类名 文件名.cpp

D:\Visual Studio\project_files\CppPart2\CppPart2>cl /d1 reportSingleClassLayoutSheepTuo 4.6.8.cpp

用于 x64 的 Microsoft (R) C/C++ 优化编译器 19.39.33520 版

版权所有(C) Microsoft Corporation。保留所有权利。

4.6.8.cpp

class SheepTuo size(8):

+---

0 | +--- (base class Sheep)

0 | | +--- (base class Animal)

0 | | | m_Age

| | +---

| +---

4 | +--- (base class Tuo)

4 | | +--- (base class Animal)

4 | | | m_Age

| | +---

| +---

+---

这个输出是 Microsoft C++ 编译器的一个特性,用于显示类的内存布局和大小。

让我们逐步分析一下输出内容:

D:\Visual Studio\project_files\CppPart2\CppPart2>cl /d1 reportSingleClassLayoutSheepTuo 4.6.8.cpp

这一行表示你正在使用 Microsoft C++ 编译器 (cl.exe) 编译文件 4.6.8.cpp/d1 选项是用于请求编译器输出类的布局信息。

用于 x64 的 Microsoft (R) C/C++ 优化编译器 19.39.33520 版
版权所有(C) Microsoft Corporation。保留所有权利。

上面是编译器的版本信息。

4.6.8.cpp

上面是要编译的源文件名。

class SheepTuo size(8):
+---
0 | +--- (base class Sheep)
0 | | +--- (base class Animal)
0 | | | m_Age
| | +---
| +---
4 | +--- (base class Tuo)
4 | | +--- (base class Animal)
4 | | | m_Age
| | +---
| +---
+---

这是类 SheepTuo 的内存布局信息。

  • class SheepTuo size(8): 表示类 SheepTuo 的总大小为 8 字节。
  • 每一行的偏移量表示该成员在类中的起始位置(以字节为单位)。
  • +--- 表示当前成员的起始位置。
  • | +--- 表示嵌套的基类或成员的起始位置。
  • m_Age 表示名为 m_Age 的成员变量。

从输出可以看出:

  • SheepTuo 继承自 Sheep 和 Tuo
  • Sheep 和 Tuo 都继承自 Animal
  • Animal 包含一个名为 m_Age 的成员变量。
  • SheepTuo 的总大小为 8 字节,因为它包含了两个基类 Sheep 和 Tuo,每个基类都包含了 Animal 的 m_Age 成员(大小为 4 字节)。

这种输出可以帮助开发人员了解类的内存布局,优化内存使用,并排查潜在的内存相关问题。

 

 

16. 当使用虚拟继承vitual后,便可解决菱形继承问题,代码和返回结果如下

#include <iostream>
#include <string>
using namespace std;


class Animal
{
public:
    int m_Age;
};


class Sheep : virtual public Animal {};
class Tuo: virtual public Animal {};
class SheepTuo : public Sheep, public Tuo {};


void test()
{
    SheepTuo st;
    st.Sheep::m_Age = 18;
    st.Tuo::m_Age = 28;

    cout << "st.Sheep::m_Age =" << st.Sheep::m_Age << endl;
    cout << "st.Tuo::m_Age =" << st.Tuo::m_Age << endl;
    cout << "st.m_Age = " << st.m_Age << endl;
}



int main()
{
    test();

    system("pause");
    return 0;
}

使用Native Tools Commands Prompt工具结果如下

D:\Visual Studio\project_files\CppPart2\CppPart2>cl /d1 reportSingleClassLayoutSheepTuo 4.6.8.cpp

用于 x64 的 Microsoft (R) C/C++ 优化编译器 19.39.33520 版

版权所有(C) Microsoft Corporation。保留所有权利。

4.6.8.cpp

class SheepTuo size(24):

+---

0 | +--- (base class Sheep)

0 | | {vbptr}

| | <alignment member> (size=4)

| +---

8 | +--- (base class Tuo)

8 | | {vbptr}

| | <alignment member> (size=4)

| +---

| <alignment member> (size=4)

+---

+--- (virtual base Animal)

16 | m_Age

+---

SheepTuo::$vbtable@Sheep@:

0 | 0

1 | 16 (SheepTuod(Sheep+0)Animal)

SheepTuo::$vbtable@Tuo@:

0 | 0

1 | 8 (SheepTuod(Tuo+0)Animal)

vbi: class offset o.vbptr o.vbte fVtorDisp

Animal 16 0 4 0

下面详细解释一下这个输出结果:

+---
0 | +--- (base class Sheep)
0 | | {vbptr}
| | <alignment member> (size=4)
| +---
8 | +--- (base class Tuo)
8 | | {vbptr}
| | <alignment member> (size=4)
| +---
| <alignment member> (size=4)
+---

这部分显示了 SheepTuo 类的内存布局。

  • (base class Sheep) 和 (base class Tuo) 表示它们是 SheepTuo 的基类。
  • {vbptr} 表示虚基类表指针(virtual base table pointer)的存储位置。
  • <alignment member> 是编译器插入的填充字节,用于保证正确的内存对齐
+--- (virtual base Animal)
16 | m_Age
+---

这部分表示虚基类 Animal 及其数据成员 m_Age 的存储位置。由于 Animal 是虚基类,所以它只存在一个实例,位于偏移 16 处。

SheepTuo::$vbtable@Sheep@:
0 | 0
1 | 16 (SheepTuod(Sheep+0)Animal)

SheepTuo::$vbtable@Tuo@:
0 | 0 
1 | 8 (SheepTuod(Tuo+0)Animal)

这部分显示了两个虚基类表(virtual base table)的内容。

  • 第一个表是基类 Sheep 的虚基类表,第二项 16 表示从 SheepTuo 对象的起始地址偏移 16 字节就可以找到虚基类 Animal
  • 第二个表是基类 Tuo 的虚基类表,第二项 8 表示从 SheepTuo 对象的起始地址偏移 8 字节就可以找到虚基类 Animal
vbi: class offset o.vbptr o.vbte fVtorDisp
Animal 16 0 4 0

这部分是一个表,显示了虚基类相关的信息:

  • class 是虚基类的名称。
  • offset 是虚基类相对于派生类对象的偏移量,这里是 16。
  • o.vbptr 是虚基类表指针在虚基类中的偏移量,这里是 0。
  • o.vbte 是虚基类表入口在虚基类中的偏移量,这里是 4。
  • fVtorDisp 是一个标志,表示虚函数表的位置,这里是 0,表示位于虚基类自身。

总的来说,这个输出详细地描述了 SheepTuo 类的内存布局,包括基类、虚基类、虚基类表等信息。这对于了解类的内存布局、优化内存使用以及排查相关问题非常有帮助。

 

17. 为什么多态使用时,如果子类中有属性开辟到堆区,那么父类指针在释放时无法调用到子类的析构代码?

在C++中,如果子类拥有在堆上动态分配的资源,而基类指针指向了子类对象,并通过该指针进行了资源的释放,这可能会导致资源泄漏或未定义行为的问题。

这是因为基类的析构函数通常不是虚函数。当通过基类指针释放子类对象时,只会调用基类的析构函数,而不会调用子类的析构函数。因此,如果子类拥有动态分配的资源,子类的析构函数将无法被调用,从而导致资源泄漏。

为了解决这个问题,通常应该将基类的析构函数声明为虚函数。这样,在通过基类指针释放子类对象时,将会调用子类的析构函数,确保子类对象所分配的资源能够被正确释放。

示例代码如下:

#include <iostream>

class Base {
public:
    Base() {}
    virtual ~Base() {} // 基类析构函数声明为虚函数
};

class Derived : public Base {
public:
    Derived() {}
    ~Derived() {
        std::cout << "Derived destructor called" << std::endl;
    }
};

int main() {
    Base* ptr = new Derived(); // 基类指针指向子类对象
    delete ptr; // 通过基类指针释放子类对象,调用子类的析构函数
    return 0;
}

 

18. 【虚析构函数】和【纯虚析构函数】的异同有哪些点

虚析构函数和纯虚析构函数都是用于实现多态的重要机制,它们在实现继承和多态时起着不同的作用。

  1. 虚析构函数(Virtual Destructor):

    • 作用:允许通过基类指针删除派生类对象时正确调用派生类的析构函数,实现多态的析构。
    • 定义方式:在基类中定义一个虚析构函数,派生类可以选择性地覆盖它。通常形式为virtual ~Base() {}
    • 注意事项:基类的析构函数应该声明为虚函数,即使它不需要执行任何特定的析构操作。这样做是为了确保在通过基类指针删除派生类对象时,会调用到派生类的析构函数。
  2. 纯虚析构函数(Pure Virtual Destructor):

    • 作用:用于定义一个抽象基类,该类只负责接口的定义,不包含实际的数据或实现。它允许类被声明为抽象类,不能实例化对象。
    • 定义方式:在基类中声明一个纯虚析构函数,但不提供具体的实现。通常形式为virtual ~Base() = 0;
    • 注意事项:纯虚析构函数的存在使得该类成为抽象基类,不能直接创建对象。派生类必须实现纯虚析构函数,否则它们也会成为抽象类。

异同点:

  • 相同点:都是用于实现多态的关键机制,允许通过基类指针调用派生类的析构函数。
  • 不同点:虚析构函数可以有具体的实现,而纯虚析构函数没有实现,使得基类成为抽象类,不能实例化对象。

下面举例说明异同点

相同点:

假设有以下基类 Animal 和两个派生类 DogCat

#include <iostream>

class Animal {
public:
    virtual ~Animal() {
        std::cout << "Animal destructor called." << std::endl;
    }
};

class Dog : public Animal {
public:
    ~Dog() {
        std::cout << "Dog destructor called." << std::endl;
    }
};

class Cat : public Animal {
public:
    ~Cat() {
        std::cout << "Cat destructor called." << std::endl;
    }
};

int main() {
    Animal* ptr = new Dog();
    delete ptr;
    
    ptr = new Cat();
    delete ptr;

    return 0;
}

在这个示例中,无论是 Dog 还是 Cat 类型的对象,都可以通过基类指针进行多态析构。当通过 delete 操作删除指针时,会调用正确的析构函数。

不同点:

假设有以下基类 Shape 和派生类 Circle

#include <iostream>

class Shape {
public:
    // 纯虚析构函数
    virtual ~Shape() = 0;
    virtual void draw() const = 0;
};

// 纯虚析构函数的实现(需要在类外实现)
Shape::~Shape() {
    std::cout << "Shape destructor called." << std::endl;
}

class Circle : public Shape {
public:
    void draw() const override {
        std::cout << "Drawing a circle." << std::endl;
    }
};

int main() {
    // 无法实例化 Shape 对象,因为它是抽象类
    // Shape* ptr = new Shape(); // 编译错误

    Shape* ptr = new Circle();
    ptr->draw();
    delete ptr;

    return 0;
}

在这个示例中,Shape 类具有一个纯虚析构函数,使得它成为抽象类,无法实例化对象。派生类 Circle 实现了纯虚函数 draw(),并且可以正常地通过基类指针进行多态调用。

在不同点这个例子中:

  1. virtual void draw() const = 0; 中的 virtual void draw() const 定义了一个虚函数 draw(),并指定了它是一个常量成员函数(const member function)。关键字 const 表示该成员函数在其内部不会修改对象的状态,即不会修改任何成员变量的值。而 = 0 则表示这是一个纯虚函数,没有具体的实现,需要在派生类中进行实现。

  2. void draw() const override 是在派生类 Circle 中对基类的虚函数进行重写(override)。在此处,const 关键字表示该成员函数是一个常量成员函数,保证在派生类中也不会修改对象的状态。 override 关键字告诉编译器,此函数重写了基类中的虚函数,这样可以在编译时进行检查,确保正确地进行函数重写。

为什么要在虚函数声明和实现中加上 const 呢?

  • const 的加入可以确保常量对象也能调用这个成员函数,保证了对常量对象的操作的安全性。
  • 同时,const 也是一种良好的编程习惯,可以提高代码的清晰度和可读性,让代码更易于理解和维护。

 

posted @ 2024-03-09 20:02  AlphaGeek  阅读(22)  评论(0)    收藏  举报