C++基础语法(11~16)

157 阅读20分钟

一、define 和 const的区别

#defineconst 是 C/C++ 中用于定义常量的两种方式,但它们在实现机制、作用域、类型检查等方面有显著区别。以下是它们的详细对比:


1. #define

#define 是 C/C++ 中的预处理指令,用于定义宏(Macro)。它在编译之前由预处理器处理。

特点

  1. 无类型
    • #define 定义的宏没有类型信息,只是简单的文本替换。
  2. 作用域
    • 宏的作用域从定义处开始,直到文件结束或使用 #undef 取消定义。
  3. 无内存分配
    • 宏在预处理阶段被替换为文本,不会占用内存。(宏本身不会占用内存,但替换后的值会占用内存)
  4. 调试困难
    • 宏在编译时被替换,调试时无法看到宏的名称,只能看到替换后的值。
  5. 无类型检查
    • 宏没有类型检查,可能导致潜在的错误。
  6. 灵活性
    • 宏可以定义常量、函数式宏、条件编译等。

示例

#include <stdio.h>

#define PI 3.14159
#define SQUARE(x) ((x) * (x))

int main() {
    printf("PI: %f\n", PI); // 输出 PI: 3.14159
    printf("Square of 5: %d\n", SQUARE(5)); // 输出 Square of 5: 25
    return 0;
}

2. const

const 是 C/C++ 中的关键字,用于定义常量变量。它在编译时处理,具有类型信息。

特点

  1. 有类型
    • const 定义的常量有明确的类型信息。
  2. 作用域
    • const 常量的作用域遵循变量作用域规则(如局部变量、全局变量)。
  3. 内存分配
    • const 常量会占用内存,但值不可修改。
  4. 调试方便
    • const 常量在调试时可以看到变量名和值。
  5. 类型检查
    • const 常量有类型检查,可以避免潜在的错误。
  6. 灵活性
    • const 只能定义常量,不能定义函数式宏或条件编译。

示例

#include <stdio.h>

const double PI = 3.14159;

int main() {
    printf("PI: %f\n", PI); // 输出 PI: 3.14159
    // PI = 3.14; // 错误:PI 是常量,不可修改
    return 0;
}

3. 对比总结

特性#defineconst
类型无类型,文本替换有类型,常量变量
作用域从定义处到文件结束或 #undef遵循变量作用域规则
内存分配无内存分配占用内存
调试调试时看不到宏名称,只能看到替换后的值调试时可以看到变量名和值
类型检查无类型检查,可能导致错误有类型检查,更安全
灵活性可以定义常量、函数式宏、条件编译等只能定义常量
适用场景简单常量、函数式宏、条件编译类型安全的常量、需要调试的常量

4. 使用建议

  1. 优先使用 const
    • const 具有类型检查和调试优势,更适合定义常量。
  2. 使用 #define 的场景
    • 需要定义函数式宏或条件编译时。
    • 需要跨文件使用的常量(如头文件中的常量)。
  3. 避免滥用 #define
    • #define 没有类型检查,容易引入错误,应谨慎使用。

5. 示例对比

#define 的缺点

#include <stdio.h>

#define PI 3.14159
#define SQUARE(x) ((x) * (x))

int main() {
    double result = SQUARE(PI + 1); // 预期: (3.14159 + 1) * (3.14159 + 1)
    printf("Result: %f\n", result); // 输出 Result: 17.1599
    return 0;
}

const 的优点

#include <stdio.h>

const double PI = 3.14159;

double square(double x) {
    return x * x;
}

int main() {
    double result = square(PI + 1); // 预期: (3.14159 + 1) * (3.14159 + 1)
    printf("Result: %f\n", result); // 输出 Result: 17.1599
    return 0;
}

6. 总结

  • #define 是预处理指令,适合定义简单常量、函数式宏和条件编译。
  • const 是类型安全的常量定义方式,适合需要调试和类型检查的场景。
  • 在现代 C/C++ 编程中,优先使用 const,避免滥用 #define

二、函数名、数组名和指针的区别

函数名、数组名和指针在 C/C++ 中都是与内存地址相关的概念,但它们在语义、用法和行为上有显著区别。以下是对它们的详细对比和解释。


1. 函数名

函数名代表函数的入口地址,可以看作是一个指向函数的指针。

特点

  1. 类型
    • 函数名的类型是“函数指针”,指向函数的入口地址。
    • 例如,int func(int) 的函数名 func 的类型是 int (*)(int)
  2. 使用方式
    • 函数名可以直接调用函数,也可以通过函数指针调用。
  3. 内存地址
    • 函数名本身是一个常量指针,指向函数的代码段(.text 段)。
  4. 不可修改
    • 函数名是一个常量,不能修改其值。

示例

#include <stdio.h>

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

int main() {
    int (*func_ptr)(int, int) = add; // 函数指针指向 add
    printf("Result: %d\n", func_ptr(2, 3)); // 通过函数指针调用
    printf("Address of add: %p\n", (void*)add); // 输出函数地址
    return 0;
}

2. 数组名

数组名代表数组的首元素地址,可以看作是一个指向数组首元素的指针。

特点

  1. 类型
    • 数组名的类型是“指向数组元素类型的指针”。
    • 例如,int arr[10] 的数组名 arr 的类型是 int*
  2. 使用方式
    • 数组名可以直接访问数组元素,也可以作为指针使用。
  3. 内存地址
    • 数组名是一个常量指针,指向数组的首元素。
  4. 不可修改
    • 数组名是一个常量,不能修改其值。
  5. sizeof 行为
    • sizeof(arr) 返回整个数组的大小(字节数),而不是指针的大小。

示例

#include <stdio.h>

int main() {
    int arr[5] = {1, 2, 3, 4, 5};
    int* ptr = arr; // 数组名作为指针使用
    printf("First element: %d\n", *ptr); // 输出 1
    printf("Size of arr: %zu\n", sizeof(arr)); // 输出 20(5 * sizeof(int))
    return 0;
}

3. 指针

指针是一个变量,用于存储内存地址。

特点

  1. 类型
    • 指针的类型是“指向某种类型的指针”。
    • 例如,int* ptr 的类型是 int*
  2. 使用方式
    • 指针可以指向任何内存地址,包括变量、数组、函数等。
  3. 内存地址
    • 指针本身是一个变量,存储在栈或堆中。
  4. 可修改
    • 指针的值可以修改,指向不同的内存地址。
  5. sizeof 行为
    • sizeof(ptr) 返回指针的大小(通常为 8 字节,在 64 位系统上)。

示例

#include <stdio.h>

int main() {
    int a = 10;
    int* ptr = &a; // 指针指向变量 a
    printf("Value of a: %d\n", *ptr); // 输出 10
    printf("Size of ptr: %zu\n", sizeof(ptr)); // 输出 8(指针的大小)
    return 0;
}

4. 对比总结

特性函数名数组名指针
类型函数指针(如 int (*)(int)指向数组元素类型的指针(如 int*指向某种类型的指针(如 int*
使用方式调用函数或作为函数指针使用访问数组元素或作为指针使用指向内存地址,访问数据
内存地址指向函数的代码段(.text 段)指向数组的首元素指向任意内存地址
可修改性不可修改不可修改可修改
sizeof 行为返回函数指针的大小(通常为 8 字节)返回整个数组的大小(字节数)返回指针的大小(通常为 8 字节)

5. 示例对比

函数名

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

int (*func_ptr)(int, int) = add; // 函数指针指向 add
func_ptr(2, 3); // 通过函数指针调用

数组名

int arr[5] = {1, 2, 3, 4, 5};
int* ptr = arr; // 数组名作为指针使用
printf("%d\n", *ptr); // 输出 1

指针

int a = 10;
int* ptr = &a; // 指针指向变量 a
printf("%d\n", *ptr); // 输出 10

6. 总结

  • 函数名:指向函数的入口地址,类型为函数指针,不可修改。
  • 数组名:指向数组的首元素地址,类型为指向数组元素类型的指针,不可修改。
  • 指针:指向任意内存地址,类型为指向某种类型的指针,可修改。

通过理解函数名、数组名和指针的区别,可以更好地掌握 C/C++ 中的内存管理和地址操作。


三、声明、初始化和赋值

在编程中,声明初始化赋值是三个基本但非常重要的概念。它们在 C/C++ 中有着明确的定义和用法,以下是它们的详细介绍和对比。


1. 声明(Declaration)

声明是指告诉编译器某个变量、函数或类型的名称和类型,但并不分配内存或定义具体值。

特点

  1. 作用
    • 声明引入一个标识符(如变量名、函数名、类型名),并指定其类型。
  2. 内存分配
    • 声明本身不会分配内存(变量声明除外)。
  3. 语法
    • 变量声明:int a;
    • 函数声明:int add(int, int);
    • 类型声明:typedef int MyInt;

示例

#include <stdio.h>

int add(int, int); // 函数声明

int main() {
    int a; // 变量声明
    printf("a: %d\n", a); // 未初始化,值不确定
    return 0;
}

2. 初始化(Initialization)

初始化是指在声明变量的同时为其赋予一个初始值。

特点

  1. 作用
    • 在声明变量的同时为其赋值,确保变量有一个明确的初始值。
  2. 内存分配
    • 初始化会分配内存,并将初始值存储到内存中。
  3. 语法
    • 变量初始化:int a = 10;
    • 数组初始化:int arr[3] = {1, 2, 3};
    • 结构体初始化:struct Point { int x; int y; } p = {1, 2};

示例

#include <stdio.h>

int main() {
    int a = 10; // 变量初始化
    int arr[3] = {1, 2, 3}; // 数组初始化
    printf("a: %d\n", a); // 输出 10
    printf("arr[0]: %d\n", arr[0]); // 输出 1
    return 0;
}

3. 赋值(Assignment)

赋值是指在变量已经声明或初始化后,为其赋予一个新的值。

特点

  1. 作用
    • 修改变量的值。
  2. 内存分配
    • 赋值不会分配内存,只会修改已分配内存中的值。
  3. 语法
    • 变量赋值:a = 20;
    • 数组元素赋值:arr[0] = 10;
    • 结构体成员赋值:p.x = 5;

示例

#include <stdio.h>

int main() {
    int a = 10; // 初始化
    a = 20; // 赋值
    printf("a: %d\n", a); // 输出 20
    return 0;
}

4. 对比总结

特性声明(Declaration)初始化(Initialization)赋值(Assignment)
作用引入标识符并指定类型在声明变量的同时赋予初始值修改变量的值
内存分配变量声明会分配内存,其他声明不会分配内存并存储初始值不分配内存,只修改已分配内存中的值
语法int a;int a = 10;a = 20;
示例int add(int, int);int arr[3] = {1, 2, 3};arr[0] = 10;

5. 注意事项

  1. 未初始化的变量
    • 在 C/C++ 中,未初始化的局部变量的值是未定义的(垃圾值),使用它们可能导致未定义行为。
    int a;
    printf("%d\n", a); // 未定义行为
    
  2. 全局变量的初始化
    • 未初始化的全局变量会被默认初始化为 0。
    int global_var;
    printf("%d\n", global_var); // 输出 0
    
  3. 多次初始化
    • 变量只能初始化一次,多次初始化会导致编译错误。
    int a = 10;
    int a = 20; // 错误:重复初始化
    
  4. 赋值与初始化的区别
    • 初始化是在声明时赋值,而赋值是在变量已经存在的情况下修改其值。

6. 示例代码

以下代码展示了声明、初始化和赋值的用法:

#include <stdio.h>

int global_var; // 全局变量声明(默认初始化为 0)

int main() {
    int a; // 变量声明
    int b = 10; // 变量初始化
    a = 20; // 变量赋值

    printf("global_var: %d\n", global_var); // 输出 0
    printf("a: %d\n", a); // 输出 20
    printf("b: %d\n", b); // 输出 10

    return 0;
}

7. 总结

  • 声明:引入标识符并指定类型,变量声明会分配内存。
  • 初始化:在声明变量的同时赋予初始值,分配内存并存储初始值。
  • 赋值:修改变量的值,不分配内存。

通过理解声明、初始化和赋值的区别,可以更好地编写正确和高效的代码。


四、未正确回收的指针

在 C/C++ 中,不正常的指针通常被称为“野指针”(Wild Pointer)或“悬空指针”(Dangling Pointer)。这些指针可能会导致程序崩溃、数据损坏或安全漏洞。以下是对这些指针的详细说明:


1. 野指针(Wild Pointer)

野指针是指未初始化或指向无效内存地址的指针。

特点

  1. 未初始化
    • 指针变量声明后未赋值,其值是随机的(垃圾值)。
  2. 指向无效地址
    • 指针指向的内存地址可能不属于当前进程的地址空间。

示例

#include <stdio.h>

int main() {
    int* ptr; // 野指针,未初始化
    printf("%d\n", *ptr); // 未定义行为
    return 0;
}

后果

  • 访问野指针会导致未定义行为,可能导致程序崩溃或数据损坏。

解决方法

  • 在声明指针时初始化为 NULL,并在使用前检查是否为 NULL
    int* ptr = NULL;
    if (ptr != NULL) {
        *ptr = 10;
    }
    

2. 悬空指针(Dangling Pointer)

悬空指针是指指向已经被释放的内存地址的指针。

特点

  1. 指向已释放内存
    • 指针指向的内存已经被 freedelete 释放。
  2. 访问无效内存
    • 访问悬空指针会导致未定义行为。

示例

#include <stdlib.h>

int main() {
    int* ptr = (int*)malloc(sizeof(int));
    *ptr = 10;
    free(ptr); // 释放内存
    printf("%d\n", *ptr); // 悬空指针,未定义行为
    return 0;
}

后果

  • 访问悬空指针可能导致程序崩溃、数据损坏或安全漏洞。

解决方法

  • 在释放内存后将指针置为 NULL,并在使用前检查是否为 NULL
    int* ptr = (int*)malloc(sizeof(int));
    *ptr = 10;
    free(ptr);
    ptr = NULL; // 置为 NULL
    if (ptr != NULL) {
        *ptr = 20;
    }
    

3. 其他不正常的指针

除了野指针和悬空指针,还有一些其他不正常的指针情况:

1. 未对齐指针(Unaligned Pointer)

  • 指针指向的内存地址未对齐到特定边界(如 4 字节或 8 字节)。
  • 在某些硬件架构上,访问未对齐指针会导致性能下降或硬件异常。

2. 越界指针(Out-of-Bounds Pointer)

  • 指针指向的内存地址超出了合法范围(如数组越界)。
  • 访问越界指针会导致未定义行为。

3. 类型不匹配指针(Type-Mismatched Pointer)

  • 指针的类型与指向的内存类型不匹配。
  • 例如,将 int* 强制转换为 float* 并访问。

4. 总结

名称描述示例解决方法
野指针未初始化或指向无效内存地址的指针int* ptr;初始化为 NULL,使用前检查
悬空指针指向已经被释放的内存地址的指针free(ptr); *ptr = 10;释放后置为 NULL,使用前检查
未对齐指针指向未对齐内存地址的指针int* ptr = (int*)(char*)buffer + 1;确保内存对齐
越界指针指向超出合法范围的内存地址的指针int arr[10]; int* ptr = &arr[10];检查指针范围
类型不匹配指针指针类型与指向的内存类型不匹配float* ptr = (float*)&int_var;避免强制类型转换

5. 最佳实践

  1. 初始化指针
    • 在声明指针时初始化为 NULL
    int* ptr = NULL;
    
  2. 检查指针有效性
    • 在使用指针前检查是否为 NULL
    if (ptr != NULL) {
        *ptr = 10;
    }
    
  3. 释放后置空
    • 在释放内存后将指针置为 NULL
    free(ptr);
    ptr = NULL;
    
  4. 避免强制类型转换
    • 尽量避免不必要的指针类型转换。

通过遵循这些最佳实践,可以有效避免不正常的指针问题,提高程序的稳定性和安全性。


五、C++中的重载、重写(覆盖)和隐藏的区别

在 C++ 中,重载(Overloading)重写(Overriding,也称为覆盖)隐藏(Hiding) 是三个容易混淆的概念。它们都与函数的行为有关,但它们的机制和适用场景不同。以下是它们的详细对比和解释。


1. 重载(Overloading)

重载是指在同一个作用域内定义多个同名函数,但这些函数的参数列表(参数类型、数量或顺序)不同。

特点

  1. 作用域
    • 重载发生在同一个作用域内(如同一个类或同一个命名空间)。
  2. 函数签名
    • 重载函数的函数名相同,但参数列表必须不同。
  3. 返回类型
    • 返回类型可以相同或不同,但不能仅通过返回类型区分重载函数。
  4. 调用规则
    • 编译器根据调用时提供的参数选择最匹配的函数。

示例

#include <iostream>

void print(int a) {
    std::cout << "Integer: " << a << std::endl;
}

void print(double a) {
    std::cout << "Double: " << a << std::endl;
}

int main() {
    print(10);    // 调用 void print(int)
    print(3.14);  // 调用 void print(double)
    return 0;
}

2. 重写(Overriding,覆盖)

重写是指派生类中定义与基类中虚函数(virtual)同名且参数列表相同的函数,从而覆盖基类的实现。

特点

  1. 作用域
    • 重写发生在派生类和基类之间。
  2. 函数签名
    • 重写函数的函数名、参数列表和返回类型必须与基类的虚函数完全相同。
  3. 虚函数
    • 基类的函数必须声明为 virtual
  4. 调用规则
    • 通过基类指针或引用调用时,实际调用的是派生类的重写函数(动态绑定)。

示例

#include <iostream>

class Base {
public:
    virtual void show() {
        std::cout << "Base class" << std::endl;
    }
};

class Derived : public Base {
public:
    void show() override { // 重写基类的虚函数
        std::cout << "Derived class" << std::endl;
    }
};

int main() {
    Base* ptr = new Derived();
    ptr->show(); // 调用 Derived::show()
    delete ptr;
    return 0;
}

3. 隐藏(Hiding)

隐藏是指派生类中定义与基类中同名(但参数列表可以不同)的函数,从而隐藏基类的同名函数。

特点

  1. 作用域
    • 隐藏发生在派生类和基类之间。
  2. 函数签名
    • 派生类的函数名与基类的函数名相同,但参数列表可以不同。
  3. 非虚函数
    • 基类的函数不需要声明为 virtual
  4. 调用规则
    • 通过派生类对象调用时,基类的同名函数被隐藏,只能调用派生类的函数。

示例

#include <iostream>

class Base {
public:
    void show() {
        std::cout << "Base class" << std::endl;
    }
};

class Derived : public Base {
public:
    void show() { // 隐藏基类的同名函数
        std::cout << "Derived class" << std::endl;
    }
};

int main() {
    Derived obj;
    obj.show(); // 调用 Derived::show()
    return 0;
}

4. 对比总结

特性重载(Overloading)重写(Overriding)隐藏(Hiding)
作用域同一个作用域(如类或命名空间)派生类和基类之间派生类和基类之间
函数签名函数名相同,参数列表不同函数名、参数列表和返回类型相同函数名相同,参数列表可以不同
虚函数不需要基类函数必须声明为 virtual不需要
调用规则根据参数选择最匹配的函数通过基类指针或引用调用派生类函数通过派生类对象调用派生类函数
示例void print(int); void print(double);virtual void show(); void show();void show(); void show();

5. 注意事项

  1. 重载与隐藏的区别
    • 重载发生在同一个作用域内,而隐藏发生在派生类和基类之间。
  2. 重写与隐藏的区别
    • 重写要求基类函数是虚函数,且函数签名完全相同;隐藏不要求基类函数是虚函数,且函数签名可以不同。
  3. 使用 override 关键字
    • 在 C++11 及更高版本中,可以使用 override 关键字明确表示重写基类的虚函数,避免错误。
    void show() override {
        std::cout << "Derived class" << std::endl;
    }
    

6. 示例代码

以下代码展示了重载、重写和隐藏的区别:

#include <iostream>

class Base {
public:
    void show() {
        std::cout << "Base class" << std::endl;
    }
    virtual void print(int a) {
        std::cout << "Base::print(int): " << a << std::endl;
    }
};

class Derived : public Base {
public:
    void show() { // 隐藏基类的 show 函数
        std::cout << "Derived class" << std::endl;
    }
    void print(double a) { // 隐藏基类的 print 函数
        std::cout << "Derived::print(double): " << a << std::endl;
    }
    void print(int a) override { // 重写基类的 print 函数
        std::cout << "Derived::print(int): " << a << std::endl;
    }
};

int main() {
    Derived obj;
    obj.show(); // 调用 Derived::show(隐藏)
    obj.print(10); // 调用 Derived::print(int)(重写)
    obj.print(3.14); // 调用 Derived::print(double)(隐藏)

    Base* ptr = &obj;
    ptr->print(10); // 调用 Derived::print(int)(重写)
    return 0;
}

7. 总结

  • 重载:同一作用域内,函数名相同,参数列表不同。
  • 重写:派生类中重写基类的虚函数,函数签名相同。
  • 隐藏:派生类中定义与基类同名的函数,隐藏基类的同名函数。

六、内联函数和宏的区别

内联函数(Inline Function)宏(Macro) 都是用于提高代码执行效率的工具,但它们在实现机制、类型检查、调试和安全性等方面有显著区别。以下是它们的详细对比和解释。


1. 内联函数(Inline Function)

内联函数是 C++ 中的一种函数优化机制,通过在编译时将函数体直接插入调用处来减少函数调用的开销。

特点

  1. 实现机制
    • 在编译时,编译器将内联函数的函数体直接插入调用处,而不是生成函数调用指令。
  2. 类型检查
    • 内联函数是真正的函数,支持类型检查和参数验证。
  3. 调试
    • 内联函数可以调试,调试器可以跟踪到内联函数的内部。
  4. 作用域
    • 内联函数遵循函数作用域规则,可以访问类的成员变量和函数。
  5. 安全性
    • 内联函数更安全,避免了宏可能带来的副作用。
  6. 编译器控制
    • inline 关键字只是对编译器的建议,编译器可以选择忽略内联请求。

示例

#include <iostream>

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

int main() {
    int result = add(2, 3); // 编译时可能被替换为 int result = 2 + 3;
    std::cout << "Result: " << result << std::endl;
    return 0;
}

2. 宏(Macro)

宏是 C/C++ 中的预处理指令,通过文本替换的方式在编译前展开代码。

特点

  1. 实现机制
    • 在预处理阶段,宏被直接替换为定义的文本。
  2. 类型检查
    • 宏没有类型检查,只是简单的文本替换,可能导致潜在的错误。
  3. 调试
    • 宏在调试时不可见,调试器只能看到宏展开后的代码。
  4. 作用域
    • 宏没有作用域概念,是全局的。
  5. 安全性
    • 宏可能带来副作用,尤其是在参数是表达式时。
  6. 强制展开
    • 宏一定会被展开,没有编译器的控制权。

示例

#include <iostream>

#define ADD(a, b) (a + b)

int main() {
    int result = ADD(2, 3); // 预处理时被替换为 int result = (2 + 3);
    std::cout << "Result: " << result << std::endl;
    return 0;
}

3. 对比总结

特性内联函数(Inline Function)宏(Macro)
实现机制编译时函数体插入调用处预处理时文本替换
类型检查支持类型检查无类型检查
调试可以调试不可调试
作用域遵循函数作用域规则无作用域概念,全局
安全性更安全,避免副作用可能带来副作用
编译器控制编译器可以忽略内联请求宏一定会被展开
适用场景小型函数,频繁调用的函数简单代码替换,条件编译

4. 宏的副作用示例

宏的文本替换机制可能导致副作用,尤其是在参数是表达式时。

示例

#include <iostream>

#define SQUARE(x) (x * x)

int main() {
    int a = 5;
    int result = SQUARE(a + 1); // 预期: (a + 1) * (a + 1)
    std::cout << "Result: " << result << std::endl; // 输出 11,而不是 36
    return 0;
}

在预处理阶段,SQUARE(a + 1) 被替换为 (a + 1 * a + 1),导致错误的结果。

解决方法

使用内联函数:

inline int square(int x) {
    return x * x;
}

5. 内联函数的限制

  1. 函数体过大
    • 如果内联函数的函数体过大,编译器可能会忽略内联请求。
  2. 递归函数
    • 递归函数不能内联。
  3. 虚函数
    • 虚函数不能内联,因为虚函数需要在运行时动态绑定。

6. 总结

场景使用内联函数使用宏
小型函数
频繁调用的函数
简单代码替换
条件编译

在现代 C++ 编程中,优先使用内联函数,避免使用宏,以提高代码的安全性、可读性和可维护性。