如何在 7 天内掌握C++?

大家好,我是小康,今天我们来聊下如何快速学习 C++ 语言。

本篇文章适合于有 C 语言编程基础的小伙伴们,如果还没有学习过 C,请看这篇文章先入个门:C语言快速入门

引言:

C++,作为一门集面向过程和面向对象编程于一体的强大语言,既保留了 C 语言的高效性,又引入了类、继承、多态、模板等现代编程概念,是学习计算机编程不可或缺的一环。本文旨在为初学者提供一个清晰的 C++ 学习路径,帮助你快速入门并掌握这门语言。

大家可以先浏览下本篇文章要讲解的 C++ 知识图谱:

C++的基础语法我就不讲解了,包括变量和常量的定义标识符和关键字语句等,这些和 C 的一样,如果你还没有学习过 C 语言,可以看我之前的文章:「如何快速学习 C 语言 ?」

数据类型

基本数据类型

C++的基本数据类型包括 字符型整型浮点型和布尔型

字符型 (char):用于存储单个字符。

c 复制代码
char letter = 'A';

整型 (int, short, long, long long):用于存储整数, 以及它们的 unsigned 变体(unsigned int, unsigned short,unsigned long,unsigned long long)。

c 复制代码
int age = 30;
long var = 1000000;

unsigned int bigInt = 1000000;

浮点型 (float, double):用于存储小数。

c 复制代码
float temperature = 36.6;
double distance = 384400.0; // 从地球到月亮的距离,单位千米

布尔型 (bool):用于存储真(true)或假(false)。

c 复制代码
bool isRainy = false;

枚举类型

枚举类型允许定义一个变量,它可以在几个预定义的值之间进行选择。

c 复制代码
enum Color { RED, GREEN, BLUE };
Color my_color = RED;

复合数据类型

复合数据类型(也称为复杂数据类型)允许你将多个不同类型的数据项组合成一个单一的实体。这种类型的典型代表是结构体(struct)、共用体(union)和(class)。

结构体 (struct)

结构体允许将多个不同类型的数据项组合成一个单一的复合类型。

c 复制代码
// 定义结构体类型
struct Person {
    std::string name;
    int age;
};

// 定义结构体类型变量并初始化
Person person = {"Alice", 30};

联合体(union)

联合体是一个用于优化内存使用的特殊数据类型,允许在同一内存位置存储不同的数据类型,但任一时刻只能使用其中一个成员。联合体变量使用关键字 union 来定义。

c 复制代码
union Data {
    int i;
    float f;
    char str[20];
};
union Data data;

类 (class)

类是C++的核心,是支持面向对象编程的基础。

c 复制代码
// 定义 Book 类
class Book {
public:
    std::string title;
    std::string author;
    void read() {
        std::cout << "Reading " << title << " by " << author << std::endl;
    }
};
// 定义 book 对象
Book book = {"The C++ Programming Language", "Bjarne Stroustrup"};
// 调用 book 对象的 read 方法
book.read();

看不懂代码没关系,这里只需要了解 是 C++ 的一种特有数据类型。关于类的讲解下文会提及。

派生数据类型

派生数据类型是通过对已有的数据类型(基本类型、复合类型)进行某种形式的"扩展"或"派生"而得到的。典型的派生数据类型包括数组和指针。

数组

数组用来存储固定大小的相同类型元素序列。

c 复制代码
int numbers[5] = {1, 2, 3, 4, 5};

指针:

指针用来存储变量的内存地址。

c++ 复制代码
int var = 10;
int* ptr = &var;
std::cout << "Value of var: " << *ptr << std::endl;

这里我只简单提下。在上一篇文章如何快速学习 C 语言中关于数组和指针有过详细的讲解,不太了解的可以去看那片文章。C++ 的数组和指针和C的用法一样。只不过 C++ 多了一种比较特殊的类型-引用。

引用:

C++中的引用是一种给已存在的变量起一个新名字(或别名)的机制。一旦一个引用被初始化为指向一个变量,它就一直指向那个变量:你对引用所做的任何操作都会影响到原始变量。

引用的基本用法:

引用在定义时必须被初始化,并且一旦被绑定到一个变量上,就不能再绑定到另一个变量上。引用的语法是在变量类型后面加上 & 符号。

c++ 复制代码
int a = 10;
int& refA = a; // refA是变量a的引用
引用的特性:
  • 引用必须在定义时被初始化,并且一旦被初始化绑定到一个变量,就不能再指向其他变量。
  • 引用不占用任何内存空间(引用只是变量的一个别名)。
  • 不存在null引用。引用必须连接到一块合法的内存。
引用的用途:

引用主要用于以下几个方面:

1. 函数参数传递:通过传递引用给函数,可以让函数直接修改外部变量的值,而不是拷贝其值。这样做可以提高效率(尤其是对于大型对象)。

c 复制代码
// 通过使用引用作为函数参数,可以使得函数能够修改调用者提供的参数。
void increment(int& value) {
    value += 1; // 直接修改传入的参数
}

int main() {
    int x = 5;
    increment(x); // x被修改为6
    std::cout << "x after increment: " << x << std::endl;
    return 0;
}

在 C++ 中,函数参数传递时,参数是类对象比较常见。

2. 函数返回值:函数可以返回一个引用,从而允许对函数返回值直接赋值。这在操作重载运算符时尤其有用。

c 复制代码
#include <iostream>
int myNumber = 10; // 全局变量

// 返回全局变量myNumber的引用
int& getMyNumberRef() {
    return myNumber;
}

int main() {
    std::cout << "Original myNumber: " << myNumber << std::endl; // 输出: 10
    // 通过函数返回的引用直接修改myNumber的值
    getMyNumberRef() = 20;
    std::cout << "Modified myNumber: " << myNumber << std::endl; // 输出: 20
    return 0;
}

对于拷贝代价较大的对象(比如大型的类实例),通过引用传递或返回可以避免拷贝,提高程序效率。

函数

C++ 中的函数是一组一起执行一个任务的语句。函数允许你定义一次代码块并多次调用它,这有助于代码的重用和模块化。

函数定义

一个 C++ 函数定义包括以下几个主要部分:

  • 返回类型:函数可以返回一个值。返回类型是函数返回值的数据类型。如果函数不返回值,则使用void类型。
  • 函数名称:标识函数的唯一名称。
  • 参数列表:括号内的参数,用于从函数调用者向函数传递信息。如果函数不接受任何参数,则括号为空。
  • 函数体:大括号内的一系列语句,定义了函数的执行任务。
c 复制代码
// 函数定义示例
int add(int a, int b) {
    return a + b;
}

函数声明(函数原型)

为了在定义函数之前调用函数,你需要在调用点之前声明函数原型。函数声明(或称为函数原型)仅需要指定函数返回类型、函数名和参数类型,不需要函数体。

c 复制代码
int add(int a, int b); // 函数声明

函数调用

定义函数后,你可以通过提供函数名和所需的参数(如果有的话)来调用函数。

函数调用方式

函数名(参数1,参数2,...)

c 复制代码
int main() {
    int result = add(5, 3); // 函数调用
    std::cout << "Result: " << result << std::endl; // 输出:Result: 8
    return 0;
}

参数传递

C++支持几种参数传递方式:

  • 按值传递:调用函数时,实参的值被拷贝给形参。在函数内对形参的修改不会影响实参。
  • 按引用传递:允许函数修改调用者的变量。这通过将形参定义为引用类型实现。
  • 按指针传递:意味着将变量地址作为参数传递给函数,函数通过这个指针直接访问和修改原始变量的值。

代码示例:

c 复制代码
#include <iostream>
// 按值传递
void byValue(int value) {
    value = 10; // 只修改形参的值,对实参无影响
}
// 按引用传递
void byReference(int& value) {
    value = 20; // 修改了实参的值
}
// 按指针传递
void byPointer(int* value) {
    *value = 30; // 通过解引用修改了实参的值
}

int main() {
    int a = 1, b = 1, c = 1;
    byValue(a);
    std::cout << "After byValue: " << a << std::endl; // 输出 1
    byReference(b);
    std::cout << "After byReference: " << b << std::endl; // 输出 20 
    byPointer(&c);
    std::cout << "After byPointer: " << c << std::endl; // 输出 30
    return 0;
}

函数重载

C++中的函数重载(Function Overloading)是指允许在同一作用域内声明多个具有相同名称的函数,只要它们的参数列表(参数的类型、数量或顺序)不同即可。编译器根据函数调用时提供的参数类型和数量来决定具体调用哪个函数。

代码示例:

c 复制代码
// 以下两个 print 函数构成重载
void print(int i) {
    std::cout << "Printing int: " << i << std::endl;
}
void print(double f) {
    std::cout << "Printing float: " << f << std::endl;
}

int main(){
  print(10);
  print(10.5);
  return 0;
}

特殊函数

成员函数

在 C++ 的类中定义的函数称为成员函数(Member Functions)。成员函数可以访问类的私有(private)、保护(protected)和公有(public)成员。

代码示例:

c 复制代码
class MyClass {
public:
    void myFunction() {
        // 成员函数实现
    }
};

常量成员函数

常量成员函数是 C++ 中的一种特殊的成员函数,它保证在函数执行过程中不会修改对象的任何成员变量 。这种函数通过在成员函数声明的末尾添加 const 关键字来定义。常量成员函数可以被任何类型的对象调用,包括常量对象。

在类的实现中,常量成员函数对类内部的状态(成员变量)只能进行只读操作,不能进行修改。这为类的使用提供了额外的安全保证,确保了不会意外改变对象状态的函数逻辑。

代码示例:

c 复制代码
// 定义 MyClass 类
class MyClass {
private:
    int value;
public:
    MyClass(int v) : value(v) {} // 构造函数,初始化value
    // 常量成员函数声明,使用const关键字
    int getValue() const {
        return value; // 这里只是返回成员变量的值,不会修改它
    }
    
    // 尝试在常量成员函数中修改成员变量将导致编译错误
    void tryToModify() const {
        // value = 100; // 错误:不能在常量成员函数中修改成员变量
    }
};

int main() {
    MyClass obj(42);
    std::cout << "The value is: " << obj.getValue() << std::endl; // 输出:The value is: 42

    const MyClass constObj(55);
    std::cout << "The value is: " << constObj.getValue() << std::endl; // 输出:The value is: 55
    // constObj.tryToModify(); // 错误:不能在常量对象上调用非常量成员函数
    return 0;
}

代码看不懂不要紧,这里只要了解常量成员函数的基本概念,以及如何声明即可。看完下文类和对象的讲解,再回过头来看代码就可以理解。

面向对象编程

C++的面向对象编程(OOP)是一种编程范式,它使用"对象"来设计软件。对象可以包含数据(称为属性或成员变量)和代码(称为方法或成员函数)。C++的 OOP 建立在几个核心概念之上:类、封装、继承、多态。让我们一一详细讲解这些知识点。

类和对象

类的定义:

类是创建对象的蓝图。它定义了对象的属性(成员变量)和行为(成员函数或方法),在C++中,使用关键字class 来定义类。

c 复制代码
// 定义 MyClass 类
class MyClass {

}

成员的访问权限:

在C++中,类的成员(包括变量和函数)可以具有三种不同的访问权限:publicprivateprotected。这些访问权限控制了类外部的代码对类成员的访问级别,从而实现了封装和数据隐藏。

  • public(公有成员):可以被任何其他代码访问,无论是类的内部还是外部。如果类的成员声明为public,那么在类的实例化对象外部也可以直接访问这些成员。

  • private(私有成员):只能被该类的成员函数、友元函数和该类的其他实例访问。如果类的成员声明为private,那么这些成员只能在类的内部被访问。这是默认的访问级别,如果没有指定访问权限,则成员默认为private。

  • protected(受保护成员):可以被该类的成员函数、友元函数、该类的派生类中的成员访问。如果类的成员声明为protected,那么这些成员既可以在类的内部被访问,也可以在派生类中被访问,但不能直接通过类的实例在类的外部被访问。

c 复制代码
class MyClass {
public:
    int publicVar;  // 公有成员变量,任何地方都可访问
protected:
    int protectedVar;  // 受保护成员变量,类内部和派生类可访问
private:
    int privateVar;  // 私有成员变量,仅类内部可访问
    
    void privateMethod() {
        // 私有成员函数,仅类内部可访问
    }
public:
    void publicMethod() {
        // 公有成员函数,任何地方都可访问
        privateVar = 0; // 可以访问私有成员
        privateMethod(); // 可以调用私有成员函数
    }
};

成员访问权限有什么用?

  • 封装:通过将成员设为私有或受保护,类可以隐藏其实现细节,仅通过公有接口与外界交互。这样做可以在不影响外部代码的情况下自由修改类的内部实现。
  • 维护性:限制对成员的访问可以减少因错误使用类成员而产生的bug,使得代码更加可维护。
  • 扩展性:合理使用访问权限可以在不破坏原有类的基础上进行扩展,增加新的功能。

成员变量和成员函数

类中定义的变量称为成员变量,类中定义的函数称为成员函数。它们定义了类的属性和行为。

成员变量初始化有以下两种方式:

1. 构造函数初始化列表:使用构造函数的初始化列表直接初始化成员变量,这是最常用且推荐的初始化成员变量的方式,特别是对于常量成员和引用成员。

c 复制代码
class Example {
private:
    int data;
public:
    Example(int d) : data(d) {} // 构造函数初始化列表
};

2. 在构造函数体内赋值:在函数体内对成员进行初始化。

c 复制代码
class Example {
private:
    int data;
public:
    Example(int d) {
        data = d; // 在构造函数体内赋值
    }
};

构造函数和析构函数

构造函数:构造函数的名称与类名相同,可以有参数,也可以重载(即定义多个构造函数,每个构造函数有不同的参数列表)。如果你不提供任何构造函数,C++编译器会自动生成一个默认的无参构造函数。

构造函数在创建对象时自动调用,用于初始化对象。

c 复制代码
class Car {
public:
    Car() { // 默认构造函数
        cout << "Car object created." << endl;
    }
    
    Car(string brand) { // 带有参数的构造函数
        cout << brand << " car object created." << endl;
    }
};

析构函数 :析构函数的名称是类名前加上波浪符号~,它不能带参数,因此一个类只能有一个析构函数。析构函数用于执行对象销毁前的清理工作,比如释放分配的资源等。

c 复制代码
class Car {
public:
    ~Car() { // 析构函数
        cout << "Car object destroyed." << endl;
    }
};

通过合理定义和使用构造函数和析构函数,我们可以确保对象在创建和销毁时维持合理的状态,以及有效地管理资源。

一个简单的 Car 类定义示例:

该 Car 类包含构造函数和析构函数,成员变量和成员函数等基本成员。

c 复制代码
// 定义 Car 类
class Car {
public:
    string brand;  // 汽车的品牌
    // 构造函数,使用初始化列表来初始化成员变量。
    Car(string b) : brand(b) {
        cout << brand << " car is created." << endl;
    }
    // 析构函数
    ~Car() {
        cout << brand << " car is destroyed." << endl;
    }
    // 成员函数
    void drive() {
        cout << "Driving " << brand << endl;
    }
};
int main() {
    Car myCar("Ford");  // 创建一个Car对象,品牌为"Ford"
    myCar.drive();      // 调用drive成员函数
    return 0;
}

对象的创建:

对象是类的实例。通过类,我们可以创建对象,并使用其属性和方法。对象可以通过成员访问运算符.访问其成员变量和成员函数。

c 复制代码
Car myCar("Ford"); // 福特汽车
myCar.drive();     // 访问 myCar 对象的成员方法

this指针

在C++中,this指针是一个特殊的指针,它指向当前对象。每个非静态成员函数(包括构造函数、析构函数以及其他成员函数)都有一个this指针作为其隐式参数,这使得成员函数能够访问调用它的对象的成员。this指针在成员函数内部使用,特别是在需要引用调用函数的当前对象时。

当我们在类的成员函数中需要引用对象本身时,就会用到this指针。这在以下几种情况中尤其有用:

  • 当参数名称与成员变量名称相同时,用以区分成员变量和参数。
  • 在实现链式调用时返回对象的引用。
  • 当需要返回对象本身的指针时。

示例代码:

1. 区分成员变量和参数

c 复制代码
class Box {
public:
    int width;
    Box(int width) {
        // 使用this指针区分成员变量和构造函数参数
        this->width = width;
    }
    void displayWidth() {
        cout << "Width: " << width << endl;  // 直接访问width,实际上是this->width
    }
};

在这个例子中,构造函数的参数width与类的成员变量width同名。通过使用this->width,我们明确指出了左边的width是对象的成员变量,而不是参数。

2. 实现链式调用

链式调用是一种编程风格,通过在成员函数末尾返回对象本身,可以连续调用多个成员函数。

示例代码:

c 复制代码
class Box {
private:
    int width;
public:
    Box& setWidth(int width) {
        this->width = width;
        return *this;  // 返回当前对象的引用
    }
    void displayWidth() {
        cout << "Width: " << width << endl;
    }
};

int main() {
    Box box;
    box.setWidth(10).displayWidth();  // 链式调用
    return 0;
}

在setWidth函数中,返回*this允许链式调用,即连续调用对象的成员函数。

3. 返回对象本身的指针

有时候,我们可能需要在成员函数中返回指向当前对象的指针。

示例代码:

c 复制代码
class Box {
public:
    Box* getPointer() {
        return this;  // 返回指向当前对象的指针
    }
};

在getPointer函数中,通过返回this,我们得到了一个指向当前对象的指针。

this指针是 C++ 中一个强大的工具,它提供了一个自引用的机制。通过 this 指针,类的成员函数可以访问调用它们的对象的其他成员。理解 this 指针对于深入学习C++面向对象编程非常重要。

而理解 this 指针,关键是要了解它的底层原理

在C++中,this指针的底层实现其实非常直观。当一个非静态成员函数被调用时,编译器隐式地将当前对象的地址作为一个参数传递给函数。这个隐式参数就是this指针。因此,每个非静态成员函数在内部都有一个名为this的额外参数,指向调用该函数的对象。

底层实现
  • 对于一个类ClassType中的非静态成员函数memberFunction,调用形式object.memberFunction(args...),实际上在底层被编译器处理为ClassType::memberFunction(&object, args...),其中&object就是this指针

  • 因此,即使你在成员函数定义中没有显式地看到this参数,编译器仍然按照每个非静态成员函数都有一个类型为ClassType*this指针作为其第一个参数的方式来处理。

考虑以下类定义:

c 复制代码
class MyClass {
public:
    int a;
    void myFunction() {
        std::cout << "Value of a: " << this->a << std::endl;
    }
};

当你创建一个MyClass对象并调用其成员函数myFunction时:

c 复制代码
MyClass obj;
obj.a = 10;
obj.myFunction();

在调用 obj.myFunction() 时,实际上编译器在底层将其转换为类似以下形式的调用(这是一种简化的表达,实际转换会依赖于具体的编译器):

css 复制代码
MyClass::myFunction(&obj);

这里,&obj 是对象 obj 的地址,它被隐式地作为 this 指针传递给 myFunction。所以,在 myFunction 内部,当你访问 this->a 时,实际上就是通过 obj 的地址来访问它的成员变量 a。

封装

封装在 C++ 面向对象编程中是一种将数据(属性)和行为(方法)捆绑在一起的机制,同时对外隐藏内部实现的细节,仅通过定义好的接口与外界交互。

简单来说:封装实质上是关于数据隐藏和接口暴露的。在定义一个类时,你会将某些数据成员标记为 private,这意味着它们只能被类的内部成员函数访问,对类的使用者来说,这些细节被隐藏了。然而,你也会提供public的成员函数作为操作这些数据的接口,这样类的使用者可以在不知道内部实现细节的情况下,通过这些接口来操作对象。

封装的实现

在C++中,封装通过实现,类中可以定义三种类型的成员:public(公有成员)、private(私有成员)和protected(受保护成员)。这些访问修饰符定义了成员的访问范围:

  • private 成员只能由同一类的成员函数访问。
  • public 成员可以由任何可以访问类对象的代码访问。
  • protected 成员可以被基类和派生类中的成员函数访问。

通过精心设计公有接口,类的设计者可以控制外部代码对内部数据的访问方式,保护对象的状态不被非法操作破坏。

封装的优势

  • 数据安全:通过隐藏内部实现细节,减少了外部对内部数据的直接访问,降低了数据被误用或误修改的风险。
  • 接口清晰:用户只需关注类提供的公有接口,不必深究类的内部实现,使得类更加易于使用和理解。
  • 易于维护和扩展:类的内部实现可以自由修改,只要公有接口保持不变,就不会影响到使用该类的代码,提高了代码的可维护性和扩展性。

一个体现 C++ 封装的类的实现

c 复制代码
#include <iostream>
#include <string>
using namespace std;

class Person {
private:
    string name;  // 私有成员变量,存储人的姓名
    int age;      // 私有成员变量,存储人的年龄
public:
    // 构造函数,初始化姓名和年龄,使用初始化列表来初始化成员变量。
    Person(string n, int a) : name(n), age(a) {}
    // 公有成员函数,设置姓名
    void setName(string n) {
        name = n;
    }
    // 公有成员函数,获取姓名
    string getName() const {
        return name;
    }
    // 公有成员函数,设置年龄
    void setAge(int a) {
        if(a >= 0) { // 确保年龄是非负数
            age = a;
        }
    }
    // 公有成员函数,获取年龄
    int getAge() const {
        return age;
    }
    // 成员函数,打印Person信息
    void printInfo() const {
        cout << "Name: " << name << ", Age: " << age << endl;
    }
};

int main() {
    Person person("John Doe", 30); // 创建Person对象
    person.printInfo(); // 打印信息
    // 尝试修改Person的姓名和年龄
    person.setName("Jane Doe");
    person.setAge(25);
    // 再次打印修改后的信息
    person.printInfo(); // 打印信息
    return 0;
}

继承

继承的定义

继承允许新的类(派生类)继承现有类(基类)的属性和方法。它支持代码重用,并建立了类之间的层次关系。

继承的分类

单一继承

在单一继承中,一个派生类只继承自一个基类。这意味着派生类包含了基类的所有属性和方法,同时还可以添加自己的属性和方法,或者重写基类的方法。

定义方式

c 复制代码
class BaseClass {
    // 基类的成员
};

class DerivedClass : public BaseClass {
    // 派生类的成员
};

这里,DerivedClass 是通过单一继承从 BaseClass派生而来的。

示例:

c 复制代码
// 基类
class Animal {
public:
    void eat() {
        cout << "Eating." << endl;
    }
};
// 派生类
class Dog : public Animal {
public:
    void bark() {
        cout << "Barking." << endl;
    }
};
int main() {
    Dog myDog;
    myDog.eat(); // 调用基类的方法
    myDog.bark(); // 调用派生类的方法
    return 0;
}
多重继承

多重继承允许一个派生类同时从多个基类继承属性和方法。这种方式增加了灵活性,但也可能引入复杂性,例如需要处理潜在的命名冲突,以及著名的"菱形问题"。

定义方式

c 复制代码
class BaseClass1 {
    // 基类1的成员
};
class BaseClass2 {
    // 基类2的成员
};
class DerivedClass : public BaseClass1, public BaseClass2 {
    // 派生类的成员
};

在这个例子中,DerivedClass 同时从 BaseClass1 和BaseClass2 继承,成为它们的派生类。

示例:

c 复制代码
// 第一个基类
class Animal {
public:
    void eat() {
        cout << "Eating." << endl;
    }
};
// 第二个基类
class Bird {
public:
    void fly() {
        cout << "Flying." << endl;
    }
};

// 派生类(麻雀),继承自Animal和Bird
class Sparrow : public Animal, public Bird {
public:
    //发出声音,模拟麻雀叫声
    void chirp() {
        cout << "Chirping." << endl;
    }
};

int main() {
    Sparrow mySparrow;
    mySparrow.eat(); // 调用Animal基类的方法
    mySparrow.fly(); // 调用Bird基类的方法
    mySparrow.chirp(); // 调用派生类的方法
    return 0;
}

多重继承允许一个派生类同时继承自多个基类。这是C++提供的一种强大功能,它可以让派生类继承并实现多个基类定义的接口和属性。然而,在使用多重继承时,我们可能会遇到一种特殊情况:菱形继承(也称为钻石继承)问题。

菱形继承

假设有这样一个场景:我们有一个基类A,然后有两个类B和C分别继承自A,最后有一个类D同时继承自B和C。这样构成的继承结构形状像一个菱形,因此称为菱形继承。如下图:

c 复制代码
     A
    / \
   B   C
    \ /
     D

// 对应的代码示例:
class A {
public:
    int value;
};
class B : public A {
    // ...
};
class C : public A {
    // ...
};
class D : public B, public C {
    // D通过B和C继承了A,可能会导致A的成员在D中存在多份拷贝
};

菱形继承引入的问题是D通过B和C继承了两份A的成员,这导致了数据冗余和不一致性的风险。特别是当试图访问从A继承来的成员时,编译器会因为不知道应该通过B还是C的路径去访问而产生歧义。

解决菱形继承问题

C++中通过引入虚继承来解决菱形继承问题。在菱形继承的结构中,将B和C对A的继承声明为虚继承(使用virtual关键字),可以确保D中只有一份A的成员副本。

这样,无论是通过B还是C,访问到的都是同一份来自A的成员,解决了成员访问歧义的问题,并且保证了数据的一致性。

虚继承的声明方式

通过在派生类中使用 virtual 关键字进行继承。

使用虚继承来解决菱形继承问题示例:

c 复制代码
class A {
public:
    int value;
};

// B和C虚继承A
class B : virtual public A {};
class C : virtual public A {};

// D继承自B和C
class D : public B, public C {};

在这个例子中,通过将 B 和 C 对 A 的继承声明为虚继承,我们确保了在 D 中只有一份来自 A 的成员 value,无论是通过 B 还是 C 的路径访问 value,访问到的都是相同的成员。

友元

在C++中,友元(Friend)是一个允许某些外部函数或类访问另一个类的私有(private)和保护(protected)成员的特性。友元关系不受类之间的公有(public)、私有(private)和保护(protected)访问控制的约束,这使得某些特定的函数或类可以直接访问类的内部成员。

友元机制可以增强程序的灵活性,但同时也可能破坏对象的封装性。

友元可以是:

  • 友元函数
  • 友元类
  • 友元成员函数

友元函数

友元函数在C++中是一种特殊的函数,它虽然不是类的成员函数,但能够访问类的私有(private)和保护(protected)成员。这允许全局函数访问类的私有成员。

友元函数的定义包含两个主要步骤:

  • 在类内声明友元函数 :你需要在类定义内部使用 friend 关键字声明该函数为友元,这告诉编译器这个特定的函数可以访问类的私有和保护成员。

  • 定义友元函数:友元函数的定义与普通函数相同,但需要注意的是,友元函数本身不是类的成员函数,因此它不能通过对象或指针来调用,而是像普通函数那样直接调用。

代码示例:

c 复制代码
class MyClass {
private:
    int value;
public:
    MyClass(int val) : value(val) {} // 构造函数初始化value
    // 声明友元函数
    friend void friendFunction(MyClass& obj);
};
// 定义友元函数
void friendFunction(MyClass& obj) {
    // 友元函数可以访问MyClass的私有成员value
    std::cout << "Accessing private member value: " << obj.value << std::endl;
}

int main() {
    MyClass myObj(100);
    // 调用友元函数,并访问MyClass对象的私有数据
    friendFunction(myObj); // 输出: Accessing private member value: 100
    return 0;
}

友元类

当一个类被声明为另一个类的友元时,这个友元类的所有成员函数都可以访问另一个类的私有和保护成员。

示例代码:

c 复制代码
class Box {
    double width;
public:
    Box(double wid) : width(wid) {}

    friend class Printer; // 声明Printer为友元类
};

class Printer {
public:
    void printWidth(Box box) {
        cout << "Width of box : " << box.width << endl;
    }
};

int main() {
    Box box(10.0);
    Printer printer;
    printer.printWidth(box);
    return 0;
}

这个例子中,Printer 类是 Box 类的友元,因此 Printer 的成员函数 printWidth可以访问 Box 的私有成员 width。

友元成员函数

一个类的成员函数可以被声明为另一个类的友元。

示例代码:

c 复制代码
class Box {
    double width;
public:
    Box(double wid) : width(wid) {}

    friend void Printer::printWidth(Box box); // 前向声明
};

class Printer {
public:
    void printWidth(Box box) {
        cout << "Width of box : " << box.width << endl;
    }
};

int main() {
    Box box(10.0);
    Printer printer;
    printer.printWidth(box); // 使用Printer对象打印Box的宽度
    return 0;
}

在这个例子中,Printer类的成员函数printWidth是Box类的友元,因此它可以访问Box的私有成员width。

注意事项

  • 使用友元时应谨慎,因为它破坏了类的封装性。一个设计良好的类应该尽量隐藏其实现细节,只通过公共接口与外界交互。
  • 友元关系不能被继承。
  • 友元关系是单向的,即如果类A是类B的友元,类B不一定是类A的友元。

运算符重载

运算符重载是 C++ 中一个非常强大的特性,它允许开发者为自定义类型指定运算符操作的行为。这样,我们就可以对自定义类型使用标准的C++运算符,如+、-、<<等。这不仅可以提高代码的直观性,还可以使得自定义类型的操作更加自然。

定义

运算符重载允许开发者为自定义类型重新定义运算符的功能。它可以作为成员函数或非成员函数(友元函数)实现,但必须至少有一个操作数是用户定义的类型。

使用运算符重载时,需要遵循一些规则

  • 不能改变运算符的优先级。
  • 不能创造新的运算符。
  • 有些运算符不能被重载,如.::?:sizeof。其他的内置运算符都是可以重载的,比如常见的算术运算符、比较运算符、逻辑运算符等。
  • 大多数重载的运算符可以是成员函数,也可以是非成员函数,但有些必须是成员函数,如赋值运算符=

运算符重载的分类

根据运算符作用于的对象,运算符重载可以是成员函数或非成员函数。

成员函数运算符重载

当运算符重载作为成员函数时,它的第一个操作数隐式地成为了调用它的对象,这意味着你不能改变操作数的顺序。这通常用于二元运算符,比如加法运算符+,或一元运算符,比如递增运算符++。

我们先来看个成员函数运算符重载的例子,以重载+运算符为例,定义一个 Point 类,并为它重载 + 运算符。

c 复制代码
#include <iostream>
class Point {
public:
    int x, y;
    Point(int x = 0, int y = 0) : x(x), y(y) {}
    // 重载+运算符
    Point operator+(const Point& rhs) const {
        return Point(x + rhs.x, y + rhs.y);
    }
};

int main() {
    Point p1(1, 2), p2(3, 4);
    Point p3 = p1 + p2;
    std::cout << "p3 = (" << p3.x << ", " << p3.y << ")" << std::endl;
    return 0;
}

在这个例子中,我们定义了 Point 类的对象可以通过+运算符相加,返回两点坐标的和。

非成员函数运算符重载

非成员函数运算符重载通常声明为类的友元,这样它们就可以访问类的私有成员。这种方式适用于操作符左侧的对象不是重载运算符所在类的实例的情况,比如输出流运算符<<

重载<<运算符

接下来,我们看下非成员函数运算符重载的例子,重载<<运算符以便能够直接打印 Point 对象。

由于<<运算符需要操作std::ostream类型的左操作数(如std::cout),它不能作为成员函数重载,而应该是非成员函数(友元函数)。通常,我们会将这样的函数声明为友元,以便它可以访问类的私有成员。

c 复制代码
#include <iostream>
class Point {
    friend std::ostream& operator<<(std::ostream& os, const Point& p);
public:
    int x, y;
    Point(int x = 0, int y = 0) : x(x), y(y) {}
};
// 重载<<运算符
std::ostream& operator<<(std::ostream& os, const Point& p) {
    os << "(" << p.x << ", " << p.y << ")";
    return os;
}

int main() {
    Point p1(1, 2);
    std::cout << p1 << std::endl; // 现在可以直接打印Point对象了
    return 0;
}

这里留个问题 :为什么<<运算符不能作为成员函数重载,而只能是非成员函数?

这个问题,在我开始学习运算符重载的时候就挺疑惑的,不过现在已经搞清楚了,接下来,我尽可能用易懂的文字及代码示例给大家讲解清楚:

在 C++ 中,当你使用如 std::cout << object; 的形式进行输出时,期望的行为是把object的内容发送到输出流std::cout。为了实现这个行为,我们需要重载<<运算符。但这里的挑战在于,std::cout是一个std::ostream类型的对象,而object是另一个用户自定义类型的类对象。

成员函数的限制 : 如果<<运算符是作为用户自定义类型的一个成员函数来重载,它的使用方式将变为object.operator<<(std::cout);。这意味着,从语法上讲,你正在尝试向object发送std::cout,而不是反过来。这与我们通常使用输出流的直觉相违背,因为我们希望std::cout在左边,object在右边,即:std::cout<<object

c 复制代码
class MyClass {
public:
    int value;
    MyClass(int v) : value(v) {}
    // 假设尝试将 << 作为成员函数重载
    void operator<<(std::ostream& os) {
        os << value;
    }
};

int main() {
    MyClass obj(10);
    std::cout << obj; // 这是我们想要的使用方式
    obj << std::cout; // 如果<<是成员函数,实际调用将会是这样,这显然不符合我们的预期
    return 0;
}

为什么用非成员函数?

为了让std::cout << object;按预期工作,我们需要把<<运算符重载为非成员函数,这样它就可以接受两个参数:左边的std::ostream对象和右边的用户自定义类型对象。这种方式符合我们直观的使用习惯。

使用友元函数

此外,由于重载的<<运算符通常需要访问用户自定义类型对象的内部数据(可能包括私有成员),我们一般会把这个重载函数声明为友元函数。这样,即使是非成员函数,它也能访问类的私有或受保护成员,从而可以输出对象的内部状态。

c 复制代码
class MyClass {
public:
    int value;
    MyClass(int val) : value(val) {}
    // 注意,这里没有作为成员函数重载<<
};

// 重载<<运算符作为全局函数,并声明为友元,以便可以访问MyClass的内部数据
std::ostream& operator<<(std::ostream& os, const MyClass& obj) {
    os << obj.value; // 假设我们要输出MyClass对象的value成员
    return os;
}

int main() {
    MyClass myObject(10);
    std::cout << myObject; // 正确地把myObject的内容输出到std::cout
}

多态

在讲解多态之前,我们先来回顾下继承,因为多态是建立在类的继承关系之上的。

简单来说: 继承允许我们基于一个已有的类(称为基类)来创建新的类(称为派生类)。派生类继承了基类的属性和方法,并且可以添加自己的属性和方法,或者重写继承来的方法。这为代码复用提供了一个强大的机制。

多态,字面意思是"多种形态"。在C++中,它允许我们通过一个共同的接口来操作不同的数据类型。这听起来可能有点抽象,不过别担心,让我们通过一个例子来简化它。

想象一下,你在动物园里,看到了各种各样的动物。虽然每种动物都有自己独特的叫声,但是你可以通过一个统一的行为"发出声音"来描述它们的共性。在C++中,我们可以将这种"发出声音"的行为抽象成一个共同的接口,然后让每种动物类根据自己的特性来实现这个接口。

多态的分类:

在C++中,多态主要以两种形式出现:编译时多态和运行时多态。

  • 编译时多态,也称为静态多态,主要是通过函数重载和模板实现的。函数重载允许你在同一个作用域内创建多个同名函数,只要它们的参数列表不同即可。编译器根据调用函数时提供的参数类型和数量,来决定调用哪个函数。

  • 运行时多态,也称为动态多态,是通过虚函数和继承实现的。这允许你在基类中定义一个接口,并在派生类中以不同的方式实现该接口。运行时多态的关键在于,你在代码运行时才确定调用哪个函数。

运行时多态的实现:

要实现C++的运行时多态,你需要掌握两个核心概念:虚函数和指针或引用

虚函数

虚函数是在基类中使用关键字 virtual 声明的函数,它可以在派生类中被重覆盖,覆盖指的是派生类被重写的函数和基类声明的虚函数具有相同的函数声明。这样当你通过基类的指针或引用调用虚函数时,C++会根据对象的实际类型来决定调用哪个版本的函数。

简单示例

让我们回到动物园的例子,如果"动物"是一个基类,"狗"和"猫"是派生类,那么即使我们有一个指向"动物"的指针,我们也可以用它来调用"狗"和"猫"特有的方法。

代码示例:

c 复制代码
class Animal {
public:
    virtual void speak() { 
      cout << "Some sound" << endl; 
    }
};

class Dog : public Animal {
public:
    void speak()  { 
          cout << "Woof" << endl; 
      }
};

class Cat : public Animal {
public:
    void speak()  { 
        cout << "Meow" << endl; 
    }
};
int main() {
    Animal* myAnimal = new Dog();
    myAnimal->speak();  // Outputs: Woof

    myAnimal = new Cat();
    myAnimal->speak();  // Outputs: Meow

    delete myAnimal; // Assuming myAnimal now points to Cat, delete the Cat object
    return 0;
}

在这个例子中,我们定义了一个基类 Animal 和两个派生类 Dog 和 Cat。每个类都有一个 speak 函数,但实现各不相同。通过基类指针调用 speak 时,C++运行时会根据对象的实际类型来决定调用 Dog 的 speak 还是 Cat 的 speak。

这里问个问题:myAnimal是基类指针,为什么调用的是派生类(Cat类和Dog类)的方法?

其实就是通过多态和虚函数机制来实现的。简单来说:

  • 虚函数:在基类中用virtual关键字声明的函数。派生类可以重写这些函数。

  • 虚表指针:每个包含虚函数的类对象都有一个指针(vptr),指向其类的虚表。

  • 虚表:每个包含虚函数的类都有一个虚函数表(简称vtable),里面存储了虚函数的地址。

注意:虚函数表(vtable)是在编译期间确定的,而虚表指针(vptr)是在每个对象被构造时创建并初始化的。(这个也是面试常考的点)

当通过基类指针调用虚函数时,程序会使用这个指针指向的对象的虚表来确定实际调用哪个函数(这个过程是在运行时做的)。这样,即便是通过基类指针,程序也能调用到派生类中重写的方法,实现了多态。

在这个例子中,Animal 类中的 speak 函数被声明为 virtual,这使得 Dog 和 Cat类能够提供自己的speak函数实现。当通过类型为 Animal* 的指针 myAnimal 调用 speak 函数时,C++ 运行时会检查 myAnimal 实际指向的对象类型(Dog或Cat),并调用那个类型的 speak 函数。

纯虚函数和抽象类

当我们希望定义一个通用接口,但又不想在基类中提供任何具体实现时,该怎么办。使用纯虚函数和抽象类即可实现。

纯虚函数

纯虚函数是一种特殊的虚函数,在基类中声明但不提供实现(不定义函数体),并且要求派生类必须提供具体的实现。这通过在函数声明的末尾加上= 0来实现。

纯虚函数的存在使得基类变成所谓的抽象类,这意味着它不能被直接实例化。这样,抽象类为派生类定义了一个或多个必须实现的接口,从而实现了一个完全抽象的概念层。

抽象类

抽象类是包含至少一个纯虚函数的类。它主要用作其他类的基类,定义了一组接口,派生类通过实现这些接口实现多态性。抽象类提供了一种强制派生类遵守特定设计契约的机制。

回到我们的动物园例子,假设我们想要强制每种动物都必须实现自己的"发出声音"的方法,但在"动物"这一概念层面,我们无法给出一个具体的实现。这就是纯虚函数和抽象类发挥作用的地方。

c 复制代码
// 抽象基类Animal
class Animal {
public:
    // 纯虚函数
    virtual void speak() const = 0;
    virtual ~Animal() {} // 虚析构函数,保证派生类的析构函数被调用
};

// Dog类继承自Animal并实现speak方法
class Dog : public Animal {
public:
    void speak() const  {
        cout << "Dog says: Woof!" << endl;
    }
};

// Cat类继承自Animal并实现speak方法
class Cat : public Animal {
public:
    void speak() const {
        cout << "Cat says: Meow!" << endl;
    }
};

void letAnimalSpeak(const Animal* animal) {
    animal->speak(); // 动态绑定speak方法
}

int main() {
    Dog dog;
    Cat cat;
    letAnimalSpeak(&dog);
    letAnimalSpeak(&cat);
    // Animal animal; // 错误:不能实例化抽象类
    return 0;
}

在这个示例中,Animal类成为了一个抽象基类,因为它包含了一个纯虚函数 speak。我们不能直接实例化 Animal 类,但我们可以通过它的派生类 Dog 和 Cat 来实例化对象,并且通过 Animal 类的引用或指针来调用它们各自的 speak 方法。

通过将 speak 方法定义为纯虚函数,我们确保了所有 Animal 的派生类都必须实现自己的 speak 方法,这样每种动物都有自己独特的发声方式。同时,这也展示了运行时多态的强大之处:即使是通过 Animal 类型的引用或指针,程序在运行时也能正确调用到派生类对象的 speak 方法。

引入纯虚函数和抽象类后,我们的代码设计变得更加清晰和严格。这种方式不仅强制派生类遵守一定的规则,也提供了一个明确的、可扩展的接口框架。

使用多态的好处: 多态的使用提供了几个优点:

  • 代码的可复用性:可以通过基类接口编写通用的代码,这些代码能够与任何派生类对象协同工作,从而减少代码重复。
  • 代码的可扩展性:新增派生类时,不需要修改现有的基类代码或其他派生类代码,只需覆盖基类的虚函数即可。
  • 接口的一致性:派生类可以有不同的实现,但是共享相同的基类接口,使得接口一致、清晰。

在讲解虚函数的时候,我们提到了覆盖,然而在C++中也存在另外一个相似的概念叫隐藏,这两者也是比较容易混淆的,接下来我们来看下覆盖和隐藏是什么?以及它们之间的区别?

覆盖和隐藏

在C++中,函数覆盖和函数隐藏是面向对象编程中的两个基本概念,它们都涉及到派生类(子类)与基类(父类)之间方法的关系。

函数覆盖(Function Overriding)

当派生类中的成员函数与基类中的一个虚函数具有相同的签名(即相同的函数名称、返回类型及参数列表)时,我们说派生类的函数覆盖了(overriding)基类的函数。函数覆盖是实现多态的关键机制之一。

  • 覆盖发生在派生类与基类之间的虚函数上。
  • 覆盖的目的是在派生类中提供一个特定实现,替换掉基类中的默认实现。

示例:

c 复制代码
class Base {
public:
    virtual void display() {
        std::cout << "Display of Base" << std::endl;
    }
};

class Derived : public Base {
public:
    void display() override { // 覆盖基类的display函数
        std::cout << "Display of Derived" << std::endl;
    }
};

int main() {
    Base* ptr = new Derived();
    ptr->display(); // 调用Derived类的display方法
    delete ptr;
    return 0;
}

在这个例子中,Derived 类的 display 函数覆盖了 Base 类的 display 函数。通过基类指针调用 display 时,实际上调用的是 Derived 类的实现。这其实就是所为的多态。

函数隐藏(Function Hiding)

当派生类中的函数与基类中的某个函数具有相同的名称,但是签名不同,则我们说派生类中的函数隐藏了(hiding)基类中的同名函数。

  • 隐藏与覆盖不同,它发生在所有同名函数上,无论它们是否为虚函数。
  • 隐藏的发生仅仅因为函数的名称相同。

示例:

c 复制代码
class Base {
public:
    void display() {
        std::cout << "Display of Base" << std::endl;
    }
};

class Derived : public Base {
public:
    void display(int) { // 隐藏了基类的display函数
        std::cout << "Display of Derived with parameter" << std::endl;
    }
};

int main() {
    Derived obj;
    obj.display(5); // 调用Derived类的display方法
    // obj.display(); // 错误:Base类的display方法被隐藏
    obj.Base::display(); // 明确调用Base类的display方法
    return 0;
}

在这个例子中,Derived 类的 display 函数隐藏了 Base 类的 display 函数,因为它们的签名不同。尝试直接调用没有参数的 display() 会导致编译错误,除非我们明确指定要调用 Base 类的版本。

类型转换

C++提供了四种类型转换运算符,用于在不同类型之间进行显式转换。这些转换方式比C语言中的传统转换提供了更好的类型安全性和可读性。

1. 静态类型转换(static_cast)

static_cast是用于类型之间转换的最常见形式,它在编译时检查转换的合法性。如果转换不合法,编译时会报错。它主要用于以下场景:

  • 基本数据类型的转换:如整型与浮点型之间的转换。
  • 类层次结构中的向上转换(从派生类到基类):这是安全的。
  • 类层次结构中的向下转换(从基类到派生类):可能不安全,因为基类指针可能并不真正指向一个派生类对象。
  • void指针的转换 :将void转换为具体类型的指针,或将具体类型的指针转换为void

基本数据类型的转换示例:

c 复制代码
double d = 10.5;
int i = static_cast<int>(d); // double转int

类层次结构中的向上转换、向下转换代码示例:

c 复制代码
class Base {
public:
    void baseMethod() { std::cout << "Base method\n"; }
};
class Derived : public Base {
public:
    void derivedMethod() { std::cout << "Derived method\n"; }
};

// 向上转换
Derived derivedObj;
Base* basePtr = static_cast<Base*>(&derivedObj); // 安全的向上转换
basePtr->baseMethod(); // 正常:可以访问基类方法

// 向下转换
Base baseObj;
Derived* derivedPtr = static_cast<Derived*>(&baseObj); // 不安全的向下转换
// derivedPtr->derivedMethod(); // 不安全:baseObj不是Derived的实例

2. 常量类型转换(const_cast)

const_cast主要用于修改类型的const属性,包括:去除const属性:允许修改原本被声明为const的变量。

代码示例:

c 复制代码
const int a = 10;
int& b = const_cast<int&>(a); // 去除const属性
b = 20; // 修改成功,但修改const变量是未定义行为
cout << a<<endl;  // 10
cout << b<<endl;  // 20

3. 动态类型转换(dynamic_cast)

dynamic_cast是C++中用于在类的继承体系内进行类型转换的操作符,特别适用于执行安全的向下转换。向下转换是指将基类的指针(或引用)转换为派生类的指针(或引用)。这种转换在运行时检查对象的实际类型,以确保转换的合法性和安全性.

dynamic_cast向下转换代码示例:

假设有一个基类 Base 和一个从 Base 派生的类 Derived:

c 复制代码
class Base {
public:
    virtual void print() {
        cout << "Base class" << endl;
    }
    virtual ~Base() {}
};

class Derived : public Base {
public:
    void print() override {
        cout << "Derived class" << endl;
    }
    void specificFunction() {
        cout << "Derived class specific function" << endl;
    }
};

现在,如果我们想安全地将基类 Base 的指针转换为派生类 Derived 的指针,并调用派生类的特定函数,我们可以使用dynamic_cast:

c 复制代码
int main() {
    Base* basePtr = new Derived();
    basePtr->print();  // 输出: Derived class

    // 安全的向下转换
    Derived* derivedPtr = dynamic_cast<Derived*>(basePtr);
    if (derivedPtr != nullptr) {
        derivedPtr->specificFunction();  // 输出: Derived class specific function
    } else {
        cout << "Conversion failed." << endl;
    }
    delete basePtr;
    return 0;
}

在这个示例中,basePtr 实际上指向一个 Derived 类的对象,因此使用 dynamic_cast 将 basePtr 转换为 Derived* 类型是安全的,并且转换成功。这允许我们安全地调用 Derived 类的 specificFunction 方法。

dynamic_cast类型转换如何使用讲完了,接下来我们来看下 dynamic_cast类型转换的具体过程是怎样的?

dynamic_cast的工作原理

dynamic_cast利用 C++ 的运行时类型信息(RTTI)机制来检查转换的安全性。它在运行时检查对象的实际类型,以确保所执行的转换是合法的。这种检查使得dynamic_cast比其他类型转换操作符(如static_cast或reinterpret_cast)更安全,但也带来了一定的性能开销。

RTTI 是什么?

C++的RTTI(Runtime Type Information,运行时类型信息)是一种机制,它允许C++程序在运行时查询和操作对象的类型信息。这种能力使得dynamic_cast能够在执行类型转换前,检查转换是否安全,从而确保类型转换的正确性和安全性。

dynamic_cast类型转换的具体过程?

1. 确定对象的实际类型

访问虚函数表(vtable):在C++中,每个具有虚函数的类的对象都会有一个隐藏的指针(称为虚表指针vptr),指向一个静态的虚函数表(vtable)。vtable主要用于支持多态性,即在运行时决定调用哪个虚函数。

类型信息(RTTI)在vtable中:除了虚函数的地址外,vtable还包含了指向特定的类型信息的指针,这里说的类型信息就是RTTI。RTTI的核心是type_info类的对象,它为每个类提供了唯一的类型标识。

2. 利用RTTI确定实际类型

  • 当使用dynamic_cast进行类型转换时,C++运行时会查找原对象的vtable,通过其中的RTTI信息(即指向type_info对象的指针)来获取对象的实际类型。
  • 一旦获得了对象的实际类型信息,dynamic_cast接着检查这个类型与目标类型的关系。对于向下转换(基类指针转换为派生类指针),它验证目标派生类是否确实是源对象实际类型的派生类或相同类型。

在多态的使用场景中,上面提到的原对象指的是一个指向基类的指针或引用指向的对象。 目标派生类指的是我们希望将原对象的基类指针或引用转换到的目标类。

c 复制代码
Base* basePtr = new Derived();
Derived* derivedPtr = dynamic_cast<Derived*>(basePtr);

对于上面的代码示例:原对象指的就是 basePtr 基类指针指向的 Derived 对象。目标派生类指的是 Derived 类。

3.验证转换的合法性并执行转换或失败处理

  • 如果转换合法,dynamic_cast修改源指针或引用,使其指向正确的目标类型的对象。
  • 如果转换不合法:对于指针类型,dynamic_cast返回nullptr,表示转换失败。 对于引用类型,dynamic_cast 抛出 std::bad_cast 异常,因为引用不能为 nullptr。

4.重新解释类型转换 reinterpret_cast

reinterpret_cast是C++中一种强大但需谨慎使用的类型转换操作符。它允许开发者在几乎任何指针类型之间进行转换,也支持指针与足够大的整数类型之间的转换。其基本作用是重新解释数据的位模式,但不改变数据本身。

由于 reinterpret_cast 不进行类型检查和转换安全性保证,使用时需要特别注意,以防止未定义行为的发生。

指针类型转换

reinterpret_cast 可以用来将一个指针类型转换为另一个指针类型,即便这两个类型之间并无直接的关联。这种转换基本上是在告诉编译器:将内存地址当作另一种类型来解释,但不改变位模式本身。这种转换不会进行任何类型安全检查,因此非常危险且易于产生错误。因此在解引用转换后的指针之前,你需要确保转换是有意义的。

指针类型转换示例:

c 复制代码
char c = 'a';
char* cp = &c;
// 将char*转换为int*,虽然不安全,但可以编译通过
int* ip = reinterpret_cast<int*>(cp);
cout<<*ip<<endl; // 输出随机值
指针与整数类型之间的转换

reinterpret_cast也可以用于将指针转换为整数类型,或者相反。这在需要在整数和指针之间进行转换,例如,当与需要整数参数的底层系统调用交互时非常有用。为了安全地执行这种转换,整数类型必须足够大以存储指针值,通常使用 uintptr_t 或 intptr_t。

示例代码:

c 复制代码
#include <iostream>
#include <cstdint>  // 包含uintptr_t定义

int main() {
    int a = 42;
    // 将int指针转换为整数
    uintptr_t ptrAsInt = reinterpret_cast<uintptr_t>(&a);
    std::cout << "The pointer as integer: " << ptrAsInt << std::endl;

    // 将整数转换回int指针
    int* aPtrAgain = reinterpret_cast<int*>(ptrAsInt);
    std::cout << "The integer as pointer: " << *aPtrAgain << std::endl;
    return 0;
}

指针与整数类型之间的转换的使用场景

  • 系统级调用或API: 一些底层的系统调用或API可能要求使用整数类型的"句柄"来代表资源或对象。在这些情况下,如果资源或对象由C++管理,并通过指针访问,我们可以临时将指针转换为整数类型的句柄,进行调用,然后再转换回指针进行操作。

  • 回调函数与用户数据: 在使用回调函数时,通常需要提供一个指向用户数据的指针。如果回调函数的接口仅允许传递整数类型的用户数据,我们可以将指针转换为整数进行传递,然后在回调函数中再转换回指针,以访问实际的用户数据。

示例代码:使用回调函数

假设我们有一个C++库,该库提供了一个设置回调函数的API,但API要求回调函数的用户数据必须是uintptr_t类型:

c 复制代码
#include <iostream>
#include <cstdint>

void MyCallback(uintptr_t userData) {
    // 在回调中将整数还原回指针
    std::string* str = reinterpret_cast<std::string*>(userData);
    std::cout << *str << std::endl;
}

void RegisterCallback(void(*callback)(uintptr_t), std::string* userData) {
    // 调用回调函数,将指针作为整数传递
    callback(reinterpret_cast<uintptr_t>(userData));
}

int main() {
    std::string myData = "Hello, callback!";
    RegisterCallback(MyCallback, &myData);
    return 0;
}

模板

C++的模板是一种强大的编程特性,它允许程序员编写与类型无关的代码,也就是所谓的泛型编程。这使得我们可以编写一个通用的代码框架,它可以用于多种数据类型。使用模板可以大大提高代码的复用性和灵活性。

C++ 模板主要有两种形式:函数模板和类模板

在讲解函数模板和类模板之前,我们先来了解下 模板参数

在 C++ 模板编程中,模板参数是定义模板时指定的一种占位符,它在模板实例化时被具体的类型或值所替代。模板参数使模板具有泛型,能够适应不同的数据类型或值。C++ 中的模板参数主要分为两类:类型参数和非类型参数

类型参数

类型参数允许在模板定义时指定一些占位符 类型,这些类型在模板实例化时被具体的类型所替代。这意味着你可以编写一个通用的模板,然后用不同的类型来实例化它,生成针对那些类型的特化版本。

类型参数声明方式:使用关键字 typename 或 class 来声明。

C 复制代码
template <typename T>
T max(T x, T y) {
    // 函数实现
}

在上述示例中,T是一个类型参数,表示 max 函数的两个参数可以接受任何类型。

非类型参数

非类型参数允许你将一个或多个常量值 作为参数传递给模板。非类型参数必须是一个常量表达式,因为模板在编译时实例化。

下面来看类模板如何使用非类型参数:

c 复制代码
template <  T, size_t N>
class FixedArray {
    T data[N]; // N 是一个非类型参数
public:
    T& operator[](size_t index) { return data[index]; }
    const T& operator[](size_t index) const { return data[index]; }
    constexpr size_t size() const { return N; }
};

在这个示例中,N是一个非类型参数,它指定了FixedArray的大小。

非类型参数大多数使用在类模板中,虽然非类型参数在函数模板中的使用不如在类模板中那么频繁,但在某些情况下,它们仍然非常有用,特别是当你需要根据编译时常量来调整函数行为时。

c 复制代码
template <typename T, int increment>
T addIncrement(T value) {
    return value + increment;
}

在这个例子中,addIncrement 函数模板通过非类型参数 increment 允许在编译时确定增加的量,这可以在不同的调用中提供不同的增量值。

函数模板

函数模板允许我们创建一个函数原型,它可以用不同的数据类型来实例化。这意味着我们可以用一个函数模板来创建一系列执行相似操作的函数,而无需为每种数据类型编写重复的代码。

c 复制代码
template <typename T>
T max(T x, T y) {
    return x > y ? x : y;
}

调用函数模板

1. 自动类型推导::

c 复制代码
int a = 5, b = 10;
std::cout << "Max of " << a << " and " << b << " is " << max(a, b) << std::endl; 

double c = 3.5, d = 2.5;
std::cout << "Max of " << c << " and " << d << " is " << max(c, d) << std::endl;

// 或者这样调用:
max(3, 5);

2. 显式指定模板参数类型:

c 复制代码
std::cout << "Max of 2 and 3 is " << max<int>(2, 3) << std::endl;
std::cout << "Max of 2.5 and 3.5 is " << max<double>(2.5, 3.5) << std::endl;

上面整型的调用,函数模板实际上会被实例化为一个接受两个 int 类型参数的函数版本:

c 复制代码
int max(int x, int y) {
    return x > y ? x : y;
}

而对于double类型的调用,函数模板会被实例化为一个接受两个 double 类型参数的函数版本。

c 复制代码
double max(double x, double y) {
    return x > y ? x : y;
}

类模板

类模板与函数模板类似,允许我们定义一个类蓝图,用于生成处理不同数据类型的类。

c 复制代码
template <typename T>
class Box {
public:
    Box(T value) : value(value) {}
    T getValue() const { return value; }
private:
    T value;
};

// 创建类模板实例
Box<int> intBox(123);
std::cout << "Value in intBox: " << intBox.getValue() << std::endl;

Box<std::string> stringBox("Hello Templates");
std::cout << "Value in stringBox: " << stringBox.getValue() << std::endl;

通过以上例子,我们了解了模板的基本使用,以及它是如何提高代码的复用性的。

上面的max函数模板的通用性确保了它可以用于整数、浮点数,甚至是字符串等多种类型,展现了模板编程的灵活性。然而,这种通用性有时候也是一把双刃剑。以指针类型为例,如果我们使用上述max函数比较两个指针,它实际上会比较指针的地址,而不是指针所指向的值,这可能并不是我们期望的行为。

在这种情况下,C++提供了一种强大的机制来优化和定制模板行为------模板特化 。模板特化允许我们为特定的类型或值集合提供专门的实现,以此来处理那些需要特殊考虑的特定情况。模板特化分为两大类:全特化(Explicit Specialization)和偏特化(Partial Specialization)

全特化

全特化(Explicit Specialization)是为一个已有的模板定义提供一个特定版本的过程,这个特定版本适用于特定的类型或值。全特化意味着为模板的所有参数指定具体的类型或值。全特化不再是模板,而是对模板的一个特定实例提供了一个完全定制的实现。

类模板全特化:

当你想为一个特定类型提供一个完全不同的类模板实现时,可以使用全特化。

语法

c 复制代码
template<> // 空尖括号代表全特化的声明
class ClassName<Type> {
    // 特化的实现
};

示例:

c 复制代码
template<typename T>
class MyClass { // 通用模板
public:
    void function() {
        std::cout << "Generic MyClass\n";
    }
};

template<>
class MyClass<int> { // 全特化为int类型
public:
    void function() {
        std::cout << "Specialized MyClass for int\n";
    }
};
函数模板全特化:

与类模板相似,你也可以为特定类型提供特定的函数模板实现。

语法

c 复制代码
template<>
ReturnType functionName<SpecificType>(parameters) {
    // 特化的实现
}

举一个函数模板全特化的例子,比如对于前面的max函数,我们想要对指针类型进行特化,使其比较指针所指向的值而不是指针地址:

c 复制代码
template <typename T>
T max(T x, T y) { // 通用模板
    return x > y ? x : y;
}

template<>   //使用全特化
const char* max<const char*>(const char* a, const char* b) {
    return strcmp(a, b) > 0 ? a : b;
}

通过这个例子,展示如何为特定类型(在这里是const char*类型的字符串)提供特化实现。

偏特化

偏特化允许你为模板的一部分参数 提供具体的类型或值,而其余参数仍然保持泛型。偏特化仅适用于类模板,不能用于函数模板。通过偏特化,你可以对模板的部分参数施加约束,从而为特定的类型组合提供特定的实现。

语法示例

c 复制代码
template <typename T, typename U>
class MyClass {}; // 通用模板

template <typename U>
class MyClass<int, U> {}; // 对第一个参数为int的偏特化

代码示例:

c 复制代码
#include <iostream>

template<typename T, typename U>
class MyClass { // 原始模板
public:
    void function() {
        std::cout << "Generic MyClass<T, U>\n";
    }
};

template<typename U>
class MyClass<int, U> { // 偏特化其中一个参数为int
public:
    void function() {
        std::cout << "Partially Specialized MyClass<int, U>\n";
    }
};

int main() {
    MyClass<double, double> myClass1; // 将使用原始模板
    myClass1.function();
    MyClass<int, double> myClass2; // 将使用偏特化模板
    myClass2.function();
    return 0;
}

内存管理

在C++中,内存管理是一个非常重要的概念,它关系到程序的性能和稳定性。C++提供了多种内存管理方式,包括自动、静态、动态分配等。让我们一步一步来了解。

为了更好地理解这些内存管理方式及其应用场景,我们首先需要了解C/C++程序在运行时的内存布局。这里我介绍 Linux C/C++ 程序的内存布局,因为大多数 C/C++ 程序都是运行在 Linux 操作系统上,因此有必要了解下。

Linux C/C++ 程序的内存布局图示

上图中,从下往上,地址是增加的,0-3G属于用户空间,3G-4G 属于内核空间.

接下来,我们对上面图示的各个区(段)作个详细的说明:

内核空间(Kernel Space)

Kernel space(内核空间)指的是操作系统内核所占用的内存区域。这部分内存是保留给操作系统内核的,用于执行核心的系统任务和硬件操作。出于安全和稳定性的考虑,用户程序通常无法直接访问这部分内存。在多数操作系统中,内核空间位于内存地址的高区域。在32位Linux系统上,通常最高的1GB内存(如地址从0xC0000000到0xFFFFFFFF)被保留给内核空间,而剩下的下3GB内存用于用户空间。

用户空间(User Space)

  • 栈(Stack)

    栈用于存放函数的局部变量、函数参数和返回地址。栈有着LIFO(后进先出)的特性,每当进入一个新的函数调用时,就会在栈上为其分配空间,函数返回时则释放这些空间。栈的大小通常有限,并且由操作系统预先定义。

  • 内存映射段(Memory Mapping Segment)

    内存映射段是一块可以被用来映射文件内容到进程地址空间的内存区域。这不仅包括用于动态库(如libc.so等)的映射,还包括程序运行时可能使用的任何匿名映射或文件映射。

  • 堆(Heap)

    堆用于动态内存分配,由new和delete(或malloc和free)控制。不同于栈,堆上的内存分配和释放是不自动的,需要程序员手动管理。堆的大小相对更灵活,受限于系统的可用内存。

  • BSS段(Block Started by Symbol)

    BSS段,全称为"Block Started by Symbol",主要用于存储程序中未初始化的全局变量和静态变量。与数据段(存放已初始化的全局变量和静态变量)相对,BSS段的特点是在程序启动之前,操作系统会自动将其内容初始化为零。这意味着,如果你在程序中声明了一个未初始化的全局或静态变量,它会被放在BSS段。

  • 数据区

    这部分内存用于存放全局变量和静态变量。不同于栈和堆上的变量,全局/静态变量的生命周期贯穿整个程序运行期间,从程序开始执行时分配,到程序结束时才被释放。

  • 代码区

    存放程序的二进制代码,即编译后的机器指令。这部分内存是只读的。

接下来,我们详细来聊下C++的多种内存管理方式,包括自动、静态、动态分配

常见的内存管理方式

自动存储(Stack Allocation)

最简单的内存管理方式是自动存储,也就是在函数内部声明的局部变量。这些变量在函数被调用时自动分配内存(在栈上分配),并在函数返回时自动释放。

示例:

c 复制代码
void function() {
    int localVar = 5; // 自动存储,函数结束时自动销毁
    // ...
}
静态存储(Static Allocation)

静态存储用于全局变量和静态变量,其生命周期贯穿整个程序运行期间。静态变量只被初始化一次,在首次加载时分配内存。

示例:

c 复制代码
void function() {
    static int staticVar = 5; // 静态存储,整个程序运行期间持续存在
    // ...
}
动态存储(Dynamic Allocation)

动态存储是C++内存管理的核心,允许在运行时分配任意大小的内存。它使用newdelete操作符来手动管理内存。

使用new和delete

示例:

c 复制代码
int* ptr = new int; // 动态分配一个int
*ptr = 5; // 给分配的int赋值
std::cout << *ptr << std::endl; // 使用分配的内存
delete ptr; // 释放内存

使用new[]和delete[]管理数组

示例:

c 复制代码
int* array = new int[5]; // 动态分配一个有5个整数的数组
for (int i = 0; i < 5; ++i) {
    array[i] = i * i;
}
delete[] array; // 释放数组

现在大家已经了解了C++中的内存管理基础,接下来,我们将探讨如何更安全、更有效地管理资源。C++提供了一个强大的编程范式,称为RAII(资源获取即初始化)。

RAII

"Resource Acquisition Is Initialization" (RAII) 是一种在C++中管理资源(如内存、文件句柄等)的编程模式。在RAII模式下,资源的分配(获取)发生在构造函数中,资源的释放(归还)发生在析构函数中。这种方式利用了C++自动调用析构函数的特性,确保了资源总是被正确释放,即使在面对异常情况时也不例外。

智能指针

通过RAII的介绍,我们已经认识到构造函数和析构函数在资源管理中的重要性。然而,在现实的编程实践中,尤其是面对复杂的资源管理需求时,单靠构造函数和析构函数可能不足以保证资源的安全和高效管理。这时,智能指针的概念应运而生。

智能指针,实质上是一种行为类似于指针的对象,但它们包裹了原始指针,自动管理指向的资源。智能指针的核心理念正是基于RAII模式------通过对象的生命周期来管理资源。当智能指针的实例被创建时,它获取一个资源(比如动态分配的内存);当智能指针实例销毁时,它释放那个资源。

C++11 标准库中提供了几种智能指针,如std::unique_ptr、std::shared_ptr和std::weak_ptr,但是我这里不讲解C++11 标准库的智能指针,而是重点讲解boost库里的智能指针。这两者的实现原理类似。后续会出一篇专门讲解 C++11新特性的文章。

在C++11之前,C++社区已经有一套成熟的解决方案来处理资源管理问题,那就是boost库提供的智能指针。

boost库是C++标准的实验田,很多在boost中实现的功能后来都被纳入了C++标准库。例如,C++11标准中的智能指针(std::shared_ptr和std::unique_ptr)、基于范围的for循环、无序容器等,都是从Boost库中借鉴或直接采用的。因此,可以说Boost对C++标准的发展有着重要的贡献。

boost库智能指针有哪些?

1. boost::scoped_ptr

boost::scoped_ptr是一种简单的智能指针,用于管理在作用域内分配的对象。它不能传递所有权,即不能从一个scoped_ptr拷贝或赋值给另一个scoped_ptr。当scoped_ptr离开作用域时,它所管理的对象会被自动销毁。

使用示例:

c 复制代码
#include <boost/scoped_ptr.hpp>
#include <iostream>

class MyClass {
public:
    MyClass() { std::cout << "MyClass constructed\n"; }
    ~MyClass() { std::cout << "MyClass destroyed\n"; }
};

int main() {
    boost::scoped_ptr<MyClass> ptr(new MyClass);
    // ptr在这里可用
} // ptr离开作用域,自动销毁MyClass实例

2. boost::shared_ptr

boost::shared_ptr是一种引用计数的智能指针,也称共享型智能指针,允许多个shared_ptr实例共享同一个对象的所有权。当最后一个引用(shared_ptr实例)被销毁或重新指向另一个对象时,所管理的对象会被自动释放。

c 复制代码
#include <boost/shared_ptr.hpp>
#include <iostream>

class MyClass {
public:
    MyClass() { std::cout << "MyClass constructed\n"; }
    ~MyClass() { std::cout << "MyClass destroyed\n"; }
};

int main() {
    boost::shared_ptr<MyClass> ptr1(new MyClass);
    {
        boost::shared_ptr<MyClass> ptr2 = ptr1; // ptr1和ptr2共享对象
    } // ptr2离开作用域,对象不会被销毁,因为ptr1仍然存在,引用计数不为0
} // ptr1离开作用域,对象被销毁(引用计数为0)

对于上面提到的引用计数,大家可以简单理解为一个非负整型数值.

3. boost::weak_ptr

boost::weak_ptr专门设计用于与boost::shared_ptr协同工作,解决潜在的循环引用问题。循环引用发生在两个或多个对象通过shared_ptr相互引用时,导致它们的引用计数永远不会归零,进而导致内存泄漏。weak_ptr提供了一种机制,允许对这些对象进行观察,而不增加引用计数。

weak_ptr的几个特性

  • 观察者:boost::weak_ptr是对boost::shared_ptr所管理对象的非拥有性引用(观察者)。它允许你访问对象,但不会延长对象的生命周期。
  • 临时升级:虽然weak_ptr本身不能直接访问对象,但它可以被临时升级为一个shared_ptr(如果对象仍然存在),以安全地访问对象。
  • 解决循环引用:在使用shared_ptr管理相互引用的对象时,容易产生循环引用,导致对象无法被释放。weak_ptr不参与引用计数,因此可以打破这种循环,避免内存泄漏。

weak_ptr基本用法

1. 创建weak_ptr

weak_ptr通常通过与一个shared_ptr关联来创建:

c 复制代码
boost::shared_ptr<int> sp(new int(42)); // 创建shared_ptr
boost::weak_ptr<int> wp(sp);            // 通过shared_ptr创建weak_ptr

2. 使用weak_ptr

要访问 weak_ptr 所观察的对象,需要将它临时升级为shared_ptr,这可以通过调用weak_ptr的lock方法实现:

c 复制代码
boost::shared_ptr<int> sp = wp.lock(); // 尝试将weak_ptr升级为shared_ptr
if (sp) {
    std::cout << *sp << std::endl; // 安全使用
} else {
    std::cout << "对象已被销毁" << std::endl;
}

3. 解决循环引用示例

考虑两个类ClassA和ClassB,它们通过shared_ptr相互持有对方:

c 复制代码
#include <boost/shared_ptr.hpp>
#include <iostream>

class ClassB;

class ClassA {
public:
    boost::shared_ptr<ClassB> bPtr;
    ~ClassA() {
        std::cout << "ClassA destroyed\n";
    }
};

class ClassB {
public:
    boost::shared_ptr<ClassA> aPtr;
    ~ClassB() {
        std::cout << "ClassB destroyed\n";
    }
};

int main() {
    boost::shared_ptr<ClassA> a(new ClassA());
    boost::shared_ptr<ClassB> b(new ClassB());

    a->bPtr = b; // a持有b的shared_ptr
    b->aPtr = a; // b持有a的shared_ptr,形成循环引用

    return 0;
}

在这个示例中,main 函数中创建了两个shared_ptr对象a和b,分别指向 ClassA 和ClassB 的新实例。然后,我们通过 a->bPtr = b;和b->aPtr = a;让这两个实例相互持有对方,从而创建了循环引用。

由于存在循环引用,当 main 函数执行完毕,尽管a和b的作用域结束,它们应该被销毁,但 ClassA 和 ClassB 的实例的引用计数并没有降到零(因为它们相互引用),导致析构函数没有被调用,从而引发内存泄漏。

如何解决?使用weak_ptr可以解决这个问题:

可以将其中一个类的shared_ptr成员改为weak_ptr。这样做可以打破循环引用。

c 复制代码
class ClassB;

class ClassA {
public:
    // 使用weak_ptr代替shared_ptr
    boost::weak_ptr<ClassB> bPtr;
    ~ClassA() {
        std::cout << "ClassA destroyed\n";
    }
};

class ClassB {
public:
    boost::shared_ptr<ClassA> aPtr;
    ~ClassB() {
        std::cout << "ClassB destroyed\n";
    }
};
int main() {
    boost::shared_ptr<ClassA> a(new ClassA());
    boost::shared_ptr<ClassB> b(new ClassB());

    a->bPtr = b; // ClassA中持有ClassB的弱引用
    b->aPtr = a; // ClassB中持有ClassA的强引用,形成非对称的引用

    // 当main结束时,a和b的shared_ptr都会被销毁。
    // b的shared_ptr被销毁时,由于ClassA中持有的是ClassB的weak_ptr,不会阻止ClassB对象的销毁。
    // 因此,ClassB被销毁后,ClassA中的weak_ptr变为悬挂指针,但ClassA对象也会随之被安全销毁。
    return 0;
}

这里大家只需要掌握这几种智能指针的简单使用即可,后面笔者有计划写一篇关于智能指针实现原理的文章,从源码实现的角度来讲解。帮助大家更好的理解智能指针。

内存泄漏

在C++中,内存泄漏是指程序分配的内存没有被正确释放,导致程序不再能够使用那部分内存。内存泄漏在长时间运行的程序中尤为危险,因为它们会逐渐消耗掉所有可用的内存资源,可能导致程序崩溃或系统变得缓慢。

内存泄露的原因:

在C++中,内存泄露通常由以下几个原因引起:

  • 动态分配内存未释放:使用new或malloc等分配内存后,未使用delete或free释放。
  • 资源未释放:除了内存外,文件句柄、数据库连接等资源未被关闭或释放也会造成资源泄露。
  • 循环引用:使用智能指针(如std::shared_ptr)时,不当的循环引用会导致对象无法被自动销毁。
  • 异常安全性问题: 当函数或方法在执行过程中抛出异常,而这个函数或方法之前进行了资源分配(如动态内存分配),如果没有正确地处理异常(例如通过异常安全的智能指针或try/catch块来确保资源被释放),那么原本应该在函数结束时释放的资源可能会因为异常的抛出而遗漏。
内存泄露的检测:

检测内存泄露通常可以通过以下几种方式:

1. 代码审查:通过审查代码逻辑,检查每次new的内存分配是否都有对应的delete释放。

2. 运行时工具

  • Valgrind:Linux下一个强大的内存检测工具,能够检测出内存泄露、内存越界等问题。Valgrind的优点在于它不需要对程序进行重新编译,适用于几乎所有的二进制文件,但缺点是运行速度较慢,通常会让程序的执行速度降低10倍以上。

  • AddressSanitizer:一个快速的内存错误检测工具,能够检测出包括内存越界访问、使用后释放、堆栈缓冲区溢出等问题。与Valgrind相比,ASan的主要优点是执行速度快(一般只会让程序变慢2倍左右)和提供精确的错误信息,但它需要对程序进行重新编译并链接,并且增加了程序的内存需求。

如何避免内存泄漏?

1. 限制动态内存的使用

尽量减少动态内存分配的使用。许多情况下,可以通过使用栈分配的变量或标准容器来代替动态分配的内存。这不仅可以减少内存泄漏的风险,还可以提高程序的性能。

2. 使用智能指针 :尽量避免在代码中直接使用裸指针管理动态分配的内存。裸指针很容易导致内存泄漏,因为它们不会自动释放所指向的内存。如果确实需要使用指针,考虑使用智能指针来代替。

3. 使用容器类:C++标准库提供了多种容器,如std::vector、std::list等,这些容器在内部管理内存,可以减少直接使用动态内存分配的需要。

4. 使用对象池: 对于频繁创建和销毁的小对象,使用对象池可以是一个有效的解决方案。对象池预先分配一定数量的对象,并在需要时重用它们,从而避免了频繁的动态内存分配和释放。

5. 定期检查和测试:使用内存检测工具定期检查程序,及早发现并修复内存泄漏问题。

异常处理

C++中的异常处理是通过trycatchthrow关键字实现的,旨在处理程序运行时可能出现的错误和异常情况。使用异常处理可以使错误处理代码和正常业务逻辑分离,使程序结构更清晰,更易于维护。

基本概念

  • throw:当检测到错误条件时,程序可以通过throw关键字抛出一个异常。抛出的异常可以是预定义的数据类型,也可以是自定义类型。
  • try:try块包含可能抛出异常的代码。如果在try块中的代码抛出了异常,执行将跳转到相应的catch块。
  • catch:catch块用于捕获和处理异常。可以定义多个catch块来捕获不同类型的异常。

示例代码

下面是一个简单的示例,演示了如何使用C++的异常处理机制:

c 复制代码
#include <iostream>
using namespace std;

int divide(int a, int b) {
    if (b == 0) {
        throw "Division by zero error"; // 抛出异常
    }
    return a / b;
}

int main() {
    try {
        cout << divide(10, 2) << endl; // 正常情况
        cout << divide(10, 0) << endl; // 这里将抛出异常
    } catch (const char* msg) {
        cerr << "Error: " << msg << endl; // 捕获并处理异常
    }

    return 0;
}

在上面的示例中,divide函数在除数为零时抛出一个异常,main函数中的 try 块捕获并处理了这个异常。

自定义异常

除了使用预定义类型作为异常外,C++还允许定义自定义异常类。通过继承标准的exception类来创建自定义异常更为方便:

c 复制代码
#include <iostream>
#include <exception>
using namespace std;

// 自定义异常类
class MyException : public exception {
public:
    const char* what() const throw() {
        return "Custom error occurred";
    }
};

int main() {
    try {
        throw MyException();
    } catch (MyException& e) {
        cout << "MyException caught" << endl;
        cout << e.what() << endl;
    } catch (exception& e) {
        // 其他所有的异常
    }

    return 0;
}

在这个示例中,我们定义了一个名为MyException的自定义异常类,并在main函数中抛出和捕获了这个异常。自定义异常类通过覆写 what 方法提供了异常的描述信息。

通过合理使用C++的异常处理机制,可以有效地管理程序中的错误情况,提高程序的健壮性和可读性。

总结

本篇文章旨在提供一个关于C++语言学习的指南,以帮助初学者系统地掌握C++编程的关键技能和概念。通过深入浅出的方式,我们逐步解析了C++开发的各个方面,从基础的数据类型、函数使用,到高级的面向对象编程技术,如类和对象的操作、封装、继承、以及多态等。

  • 数据类型和函数:我们探讨了C++的基本数据类型、枚举、复合以及派生数据类型,这为理解C++提供了坚实的基础。同时,函数的定义、声明、调用以及参数传递等知识点,构建了函数编程的框架。

  • 面向对象编程:详细讨论了类和对象的定义、成员变量和函数、构造函数和析构函数等概念,强调了封装、继承和多态这三大面向对象编程的核心特性。特别地,通过this指针、友元、运算符重载的讲解,进一步拓展了面向对象的编程思维。

  • 高级特性:深入到模板编程,介绍了类型参数、非类型参数、函数模板、类模板以及模板的特化,这些内容展现了C++泛型编程的强大能力。同时,对C++中的内存管理、异常处理进行了探讨,了解了怎样编写高效且安全的C++代码。

这篇文章主要是给大家提供一个如何快速学习 C++ 的指南,不知道怎样学习 C++ 的朋友可以 按照我列的知识点去看书,去实践,掌握 C++ 语言应该会很快的。记住:一定要多敲代码,多实践!!

最后

如果你单纯去学习C、C++语言是干不了任何事情的,作为与硬件和操作系统打交道的计算机底层语言,要想掌握 C和C++,你还得学习这几门课程:计算机组成原理、操作系统、数据结构。甚至还得了解汇编语言。除此之外,还需要学习 Linux 系统编程以及网络编程相关知识。

如果你想学习 Linux 编程 ,包括系统编程和网络编程 相关的内容或者想了解计算机原理 相关的知识,那欢迎关注我的公众号「跟着小康学编程 」,微信搜索即可,这里会定时更新计算机编程相关的技术文章,感兴趣的读者可以关注一下。具体可访问:关注小康微信公众号

另外大家在阅读这篇文章的时候,如果觉得有问题的或者有不理解的知识点,欢迎大家评论区询问,我看到就会回复。

大家觉得讲的不错的话,记得帮我点个赞呦. 也期待你们的关注!

本文由mdnice多平台发布

相关推荐
Estar.Lee2 小时前
查手机号归属地免费API接口教程
android·网络·后端·网络协议·tcp/ip·oneapi
2401_857610034 小时前
SpringBoot社团管理:安全与维护
spring boot·后端·安全
凌冰_5 小时前
IDEA2023 SpringBoot整合MyBatis(三)
spring boot·后端·mybatis
码农飞飞5 小时前
深入理解Rust的模式匹配
开发语言·后端·rust·模式匹配·解构·结构体和枚举
一个小坑货5 小时前
Rust 的简介
开发语言·后端·rust
monkey_meng5 小时前
【遵守孤儿规则的External trait pattern】
开发语言·后端·rust
Estar.Lee5 小时前
时间操作[计算时间差]免费API接口教程
android·网络·后端·网络协议·tcp/ip
新知图书6 小时前
Rust编程与项目实战-模块std::thread(之一)
开发语言·后端·rust
盛夏绽放7 小时前
Node.js 和 Socket.IO 实现实时通信
前端·后端·websocket·node.js
Ares-Wang7 小时前
Asp.net Core Hosted Service(托管服务) Timer (定时任务)
后端·asp.net