C++ 基础语法(1~5)

170 阅读20分钟

一、 指针和引用的区别

指针和引用的区别

指针和引用是 C++ 中两种重要的概念,它们都可以用于间接访问对象,但在语法、语义和使用场景上有显著的区别。以下是详细的对比:


1. 定义与初始化

指针

  • 指针是一个变量,存储的是另一个对象的内存地址。
  • 指针可以不初始化(但未初始化的指针是危险的,可能导致未定义行为)。
  • 指针可以重新赋值,指向不同的对象。
int a = 10;
int* ptr = &a; // ptr 指向 a
ptr = nullptr; // ptr 可以重新赋值

引用

  • 引用是一个对象的别名,本质上是对另一个对象的绑定。
  • 引用必须在定义时初始化,且不能重新绑定到其他对象。
  • 引用没有自己的内存地址,它只是原对象的另一个名字。
int a = 10;
int& ref = a; // ref 是 a 的引用
// int& ref2; // 错误:引用必须初始化

2. 语法与操作

指针

  • 使用 * 解引用指针以访问其指向的对象。
  • 使用 & 获取对象的地址。
  • 指针可以进行算术运算(如 ++--+-)。

指针的算术运算基于指针所指向的数据类型的大小。

int a = 10;
int* ptr = &a;
*ptr = 20; // 修改 a 的值
ptr++;     // 指针算术运算

引用

  • 引用直接使用原对象的名称,无需解引用。
  • 引用没有算术运算的概念。
  • 引用不能获取其自身的地址(因为引用没有独立的内存空间)。
int a = 10;
int& ref = a;
ref = 20; // 修改 a 的值
// ref++; // 错误:引用不能进行算术运算

3. 空值(Nullability)

指针

  • 指针可以设置为 nullptr,表示不指向任何对象。
  • 检查指针是否为空是常见的操作。
int* ptr = nullptr;
if (ptr == nullptr) {
    std::cout << "Pointer is null" << std::endl;
}

引用

  • 引用必须绑定到一个有效的对象,不能为 nullptr
  • 引用不存在“空引用”的概念。
// int& ref = nullptr; // 错误:引用不能为空

4. 内存管理

指针

  • 指针可以指向动态分配的内存(如使用 newdelete)。
  • 指针需要显式管理内存,避免内存泄漏或悬空指针。
int* ptr = new int(10);
delete ptr; // 必须手动释放内存

使用sizeof计算所占内存大小时,sizeof(ptr)则返回的是指针本身的大小,作用于引用则返回的引用绑定对象的大小。

引用

  • 引用通常用于绑定到已存在的对象,不涉及内存管理。
  • 引用不能绑定到动态分配的内存(除非通过指针间接绑定)。
int a = 10;
int& ref = a;
// int& ref = new int(10); // 错误:引用不能绑定到动态内存

5. 多级间接访问

指针

  • 指针可以有多级间接访问(如指向指针的指针)。
int a = 10;
int* ptr = &a;
int** ptr2 = &ptr; // 指向指针的指针

引用

  • 引用没有多级间接访问的概念。
  • 不能定义引用的引用。
int a = 10;
int& ref = a;
// int& & ref2 = ref; // 错误:不能定义引用的引用

6. 函数参数传递

指针

  • 指针可以作为函数参数传递,允许函数修改指针指向的对象。
  • 指针参数可以传递 nullptr,表示可选参数。
void func(int* ptr) {
    if (ptr) {
        *ptr = 20;
    }
}

int a = 10;
func(&a); // 传递指针

引用

  • 引用作为函数参数传递时,语法更简洁,且不需要检查空值。
  • 引用参数必须绑定到一个有效的对象。
void func(int& ref) {
    ref = 20; // 直接修改原对象
}

int a = 10;
func(a); // 传递引用

7. 使用场景

指针

  • 需要动态内存管理时(如 newdelete)。
  • 需要可选参数时(如传递 nullptr)。
  • 需要多级间接访问时(如指向指针的指针)。

引用

  • 需要简化语法时(如函数参数传递)。
  • 需要确保参数不为空时。
  • 需要实现运算符重载时。

8. 性能

  • 指针和引用在性能上没有显著区别,因为它们本质上都是通过内存地址访问对象。
  • 引用的语法更简洁,减少了代码的复杂性。

总结

特性指针引用
定义与初始化可以不初始化,可以重新赋值必须初始化,不能重新绑定
语法与操作需要解引用,支持算术运算直接使用,不支持算术运算
空值可以为 nullptr不能为空
内存管理需要显式管理不涉及内存管理
多级间接访问支持多级间接访问不支持多级间接访问
函数参数传递可以传递 nullptr必须绑定到有效对象
使用场景动态内存管理、可选参数简化语法、确保参数不为空
性能与引用相当与指针相当

指针和引用各有优缺点,应根据具体需求选择使用。在 C++ 中,引用通常用于简化代码和确保安全性,而指针则用于更灵活的场景(如动态内存管理)。


二、 在C++中传递参数有哪几种方式,如何选择呢?

在 C++ 中,传递参数的方式主要有以下几种:

  1. 按值传递(Pass by Value)
  2. 按引用传递(Pass by Reference)
  3. 按指针传递(Pass by Pointer)
  4. 按右值引用传递(Pass by Rvalue Reference)(C++11 及以后)

每种方式都有其特点和适用场景。以下详细介绍每种方式,并举例说明如何选择。


1. 按值传递(Pass by Value)

按值传递是将实参的值复制一份传递给形参。函数内部对形参的修改不会影响实参。

语法

void func(类型 形参);

示例

#include <iostream>

void increment(int x) {
    x++;
    std::cout << "Inside function: " << x << std::endl;
}

int main() {
    int a = 10;
    increment(a);
    std::cout << "Outside function: " << a << std::endl;
    return 0;
}

输出:

Inside function: 11
Outside function: 10

适用场景

  • 当函数不需要修改实参时。
  • 当实参是基本数据类型(如 intdouble)且复制开销较小时。

2. 按引用传递(Pass by Reference)

按引用传递是将实参的引用传递给形参。函数内部对形参的修改会影响实参。

语法

void func(类型& 形参);

示例

#include <iostream>

void increment(int& x) {
    x++;
    std::cout << "Inside function: " << x << std::endl;
}

int main() {
    int a = 10;
    increment(a);
    std::cout << "Outside function: " << a << std::endl;
    return 0;
}

输出:

Inside function: 11
Outside function: 11

适用场景

  • 当函数需要修改实参时。
  • 当实参是复杂类型(如结构体、类)且复制开销较大时。

3. 按指针传递(Pass by Pointer)

按指针传递是将实参的地址传递给形参。函数内部通过指针修改实参。

语法

void func(类型* 形参);

示例

#include <iostream>

void increment(int* x) {
    (*x)++;
    std::cout << "Inside function: " << *x << std::endl;
}

int main() {
    int a = 10;
    increment(&a);
    std::cout << "Outside function: " << a << std::endl;
    return 0;
}

输出:

Inside function: 11
Outside function: 11

适用场景

  • 当函数需要修改实参时。
  • 当实参可能是 nullptr(可选参数)时。
  • 当需要传递动态分配的内存时。

4. 按右值引用传递(Pass by Rvalue Reference)(C++11 及以后)

按右值引用传递是将实参的右值引用传递给形参。通常用于实现移动语义,避免不必要的复制。

语法

void func(类型&& 形参);

示例

#include <iostream>
#include <string>

void process(std::string&& str) {
    std::cout << "Inside function: " << str << std::endl;
    str = "Modified";
}

int main() {
    std::string s = "Hello";
    process(std::move(s)); // 使用 std::move 将 s 转为右值
    std::cout << "Outside function: " << s << std::endl;
    return 0;
}

输出:

Inside function: Hello
Outside function: Modified

适用场景

  • 当需要实现移动语义,避免不必要的复制时。
  • 当实参是临时对象或可以安全“移动”的对象时。

5. 如何选择传递参数的方式

传递方式特点适用场景
按值传递复制实参的值,函数内部修改不影响实参基本数据类型、复制开销小的场景
按引用传递传递实参的引用,函数内部修改影响实参需要修改实参、复杂类型且复制开销大的场景
按指针传递传递实参的地址,函数内部通过指针修改实参需要修改实参、可选参数(nullptr)、动态内存管理
按右值引用传递传递实参的右值引用,通常用于移动语义,避免不必要的复制实现移动语义、临时对象或可以安全“移动”的对象

6. 综合示例

以下示例展示了不同传递方式的使用场景:

#include <iostream>
#include <string>

// 按值传递
void printValue(int x) {
    std::cout << "Value: " << x << std::endl;
}

// 按引用传递
void increment(int& x) {
    x++;
}

// 按指针传递
void setToZero(int* x) {
    if (x) {
        *x = 0;
    }
}

// 按右值引用传递
void process(std::string&& str) {
    std::cout << "Processing: " << str << std::endl;
    str = "Modified";
}

int main() {
    int a = 10;
    printValue(a); // 按值传递
    increment(a);  // 按引用传递
    std::cout << "After increment: " << a << std::endl;

    setToZero(&a); // 按指针传递
    std::cout << "After setToZero: " << a << std::endl;

    std::string s = "Hello";
    process(std::move(s)); // 按右值引用传递
    std::cout << "After process: " << s << std::endl;

    return 0;
}

输出:

Value: 10
After increment: 11
After setToZero: 0
Processing: Hello
After process: Modified

7. 总结

  • 按值传递:适用于基本数据类型或复制开销小的场景。
  • 按引用传递:适用于需要修改实参或复杂类型的场景。
  • 按指针传递:适用于可选参数或动态内存管理的场景。
  • 按右值引用传递:适用于实现移动语义或处理临时对象的场景。

根据具体需求选择合适的传递方式,可以提高代码的效率和可读性。

三、 堆和栈的区别

在 C++ 程序运行过程中,堆(Heap)栈(Stack) 是两种重要的内存管理区域,它们在内存分配方式、速度、生命周期等方面有显著的区别。以下结合 C++ 程序运行过程,详细讲解堆和栈的特点。


1. 内存分配方式

  • 分配方式:由编译器自动分配和释放。
  • 特点
    • 内存分配和释放遵循“后进先出”(LIFO)原则。
    • 分配速度快,只需移动栈指针。
    • 内存大小固定,通常较小(默认大小为几 MB,具体取决于操作系统和编译器)。
  • 示例
    void func() {
        int a = 10; // a 分配在栈上
    } // 函数结束时,a 自动释放
    

  • 分配方式:由程序员手动分配和释放(如使用 newdeletemallocfree)。
  • 特点
    • 内存分配和释放没有固定顺序。
    • 分配速度较慢,需要查找合适的内存块。
    • 内存大小灵活,通常较大(受限于系统的可用内存)。
  • 示例
    void func() {
        int* ptr = new int(10); // ptr 指向堆上分配的内存
        delete ptr; // 手动释放内存
    }
    

2. 速度

  • 分配速度:非常快,只需移动栈指针。
  • 释放速度:非常快,函数结束时自动释放。
  • 原因
    • 栈的内存分配是连续的,只需简单地移动栈指针。
    • 释放时只需将栈指针回退到之前的位置。

  • 分配速度:较慢,需要查找合适的内存块。
  • 释放速度:较慢,需要手动管理内存。
  • 原因
    • 堆的内存分配是不连续的,需要查找足够大小的空闲内存块。
    • 释放时需要显式调用 deletefree,并可能涉及内存碎片整理。

3. 生命周期

  • 生命周期:与作用域绑定。当函数调用结束时,栈上的局部变量会自动释放。
  • 特点
    • 变量的生命周期由其作用域决定。
    • 无需手动管理内存,避免内存泄漏。
  • 示例
    void func() {
        int a = 10; // a 的生命周期始于此处
    } // a 的生命周期结束于此
    

  • 生命周期:由程序员手动管理。内存的生命周期从分配开始,到释放结束。
  • 特点
    • 变量的生命周期不受作用域限制。
    • 需要手动管理内存,否则可能导致内存泄漏。
  • 示例
    void func() {
        int* ptr = new int(10); // ptr 的生命周期始于此处
        delete ptr; // ptr 的生命周期结束于此
    }
    

4. 内存大小

  • 大小:固定且较小(通常几 MB)。
  • 特点
    • 栈的大小在程序启动时确定,无法动态扩展。
    • 如果栈空间不足,会导致栈溢出(Stack Overflow)。

  • 大小:灵活且较大(受限于系统的可用内存)。
  • 特点
    • 堆的大小可以动态扩展,直到系统内存耗尽。
    • 如果堆空间不足,会导致内存分配失败。

5. 访问速度

  • 访问速度:非常快。
  • 原因
    • 栈的内存是连续的,访问时只需简单的指针偏移。
    • 栈的数据通常位于 CPU 缓存中,访问速度更快。

  • 访问速度:较慢。
  • 原因
    • 堆的内存是不连续的,访问时需要通过指针间接寻址。
    • 堆的数据可能不在 CPU 缓存中,访问速度较慢。

6. 使用场景

  • 适用场景
    • 局部变量、函数参数、返回值等。
    • 生命周期短且大小固定的数据。
  • 示例
    int add(int a, int b) {
        return a + b; // a 和 b 分配在栈上
    }
    

  • 适用场景
    • 动态分配的内存(如数组、对象等)。
    • 生命周期长或大小不固定的数据。
  • 示例
    int* createArray(int size) {
        return new int[size]; // 动态分配数组
    }
    

7. 内存管理

  • 管理方式:由编译器自动管理。
  • 特点
    • 无需手动分配和释放内存。
    • 内存管理简单且安全。

  • 管理方式:由程序员手动管理。
  • 特点
    • 需要手动分配和释放内存。
    • 内存管理复杂,容易导致内存泄漏或悬空指针。

8. 示例程序

以下程序展示了栈和堆的使用:

#include <iostream>

void stackExample() {
    int a = 10; // a 分配在栈上
    std::cout << "Stack value: " << a << std::endl;
} // a 自动释放

void heapExample() {
    int* ptr = new int(20); // ptr 指向堆上分配的内存
    std::cout << "Heap value: " << *ptr << std::endl;
    delete ptr; // 手动释放内存
}

int main() {
    stackExample();
    heapExample();
    return 0;
}

输出:

Stack value: 10
Heap value: 20

9. 总结

特性
分配方式编译器自动分配和释放程序员手动分配和释放
速度非常快较慢
生命周期与作用域绑定由程序员手动管理
内存大小固定且较小(通常几 MB)灵活且较大(受限于系统内存)
访问速度非常快较慢
使用场景局部变量、函数参数、返回值等动态分配的内存、生命周期长的数据
内存管理由编译器自动管理由程序员手动管理

通过理解堆和栈的区别,可以更好地管理程序的内存,提高程序的性能和稳定性。


四、 左值和右值

在 C++ 中,**左值(Lvalue)右值(Rvalue)**是表达式的两种基本分类,它们描述了表达式的值类别(Value Category)。理解左值和右值对于掌握 C++ 的语义、移动语义和完美转发等高级特性非常重要。以下是对左值和右值的详细介绍。


1. 左值(Lvalue)

定义

左值是指可以取地址的表达式,通常是有名字的变量或对象。左值的特点是:

  • 可以出现在赋值运算符的左侧。
  • 具有持久性(即生命周期超出当前表达式)。

示例

int a = 10; // a 是左值
int* ptr = &a; // 可以取 a 的地址
a = 20; // a 可以出现在赋值运算符的左侧

常见左值

  • 变量名(如 int a;)。
  • 返回左值引用的函数调用(如 std::cout << "Hello";)。
  • 数组元素(如 arr[0])。
  • 解引用指针(如 *ptr)。

2. 右值(Rvalue)

定义

右值是指不能取地址的表达式,通常是临时对象或字面量。右值的特点是:

  • 不能出现在赋值运算符的左侧。
  • 通常是短暂的(即生命周期仅限于当前表达式)。

示例

int a = 10; // 10 是右值
int b = a + 5; // a + 5 是右值

常见右值

  • 字面量(如 103.14"Hello")。
  • 返回值的函数调用(如 int func();)。
  • 算术表达式的结果(如 a + b)。
  • 临时对象(如 std::string("Hello"))。

3. 左值和右值的区别

特性左值(Lvalue)右值(Rvalue)
定义可以取地址的表达式不能取地址的表达式
赋值运算符可以出现在赋值运算符的左侧不能出现在赋值运算符的左侧
生命周期持久性(生命周期超出当前表达式)短暂性(生命周期仅限于当前表达式)
示例变量名、数组元素、解引用指针字面量、临时对象、算术表达式结果

4. 右值引用(Rvalue Reference)

右值引用是 C++11 引入的特性,用于绑定到右值。它的语法是 类型&&。右值引用的主要用途是实现移动语义完美转发

示例

int&& rref = 10; // rref 是右值引用

移动语义

移动语义允许将一个对象的资源“移动”到另一个对象,而不是复制资源。这通常通过移动构造函数和移动赋值运算符实现。

std::string s1 = "Hello";
std::string s2 = std::move(s1); // 使用 std::move 将 s1 转为右值

完美转发

完美转发允许将参数以原始的值类别(左值或右值)传递给其他函数。这通常通过 std::forward 实现。

template <typename T>
void forward(T&& arg) {
    process(std::forward<T>(arg)); // 完美转发
}

5. std::movestd::forward

std::move

  • 将左值转换为右值引用,从而允许应用移动语义。
  • 示例:
    std::string s1 = "Hello";
    std::string s2 = std::move(s1); // 将 s1 转为右值
    

std::forward

  • 将参数以原始的值类别(左值或右值)传递给其他函数,实现完美转发。
  • 示例:
    template <typename T>
    void forward(T&& arg) {
        process(std::forward<T>(arg)); // 完美转发
    }
    

6. 左值和右值的转换

左值转右值

  • 使用 std::move 将左值转换为右值引用。
  • 示例:
    std::string s1 = "Hello";
    std::string s2 = std::move(s1); // 将 s1 转为右值
    

右值转左值

  • 右值可以通过绑定到左值引用(如 const T&)转换为左值。
  • 示例:
    const std::string& s = "Hello"; // 右值绑定到左值引用
    

7. 示例程序

以下程序展示了左值、右值和右值引用的使用:

#include <iostream>
#include <string>

void process(int& x) {
    std::cout << "Lvalue: " << x << std::endl;
}

void process(int&& x) {
    std::cout << "Rvalue: " << x << std::endl;
}

int main() {
    int a = 10; // a 是左值
    process(a); // 调用 process(int&)
    process(20); // 调用 process(int&&)

    std::string s1 = "Hello";
    std::string s2 = std::move(s1); // 将 s1 转为右值
    std::cout << "s1: " << s1 << std::endl; // s1 可能为空
    std::cout << "s2: " << s2 << std::endl; // s2 为 "Hello"

    return 0;
}

输出:

Lvalue: 10
Rvalue: 20
s1: 
s2: Hello

8. 总结

  • 左值:可以取地址的表达式,通常是有名字的变量或对象。
  • 右值:不能取地址的表达式,通常是临时对象或字面量。
  • 右值引用:用于绑定到右值,实现移动语义和完美转发。
  • std::move:将左值转换为右值引用。
  • std::forward:将参数以原始的值类别传递给其他函数。

通过理解左值和右值,可以更好地掌握 C++ 的语义和高级特性,如移动语义和完美转发。

五、右值引用

右值引用(Rvalue Reference)是 C++11 引入的一个重要特性,主要用于实现移动语义完美转发。它的语法是 类型&&,可以绑定到右值(如临时对象或字面量)。以下是对右值引用的详细讲解,包括其使用场景和具体用法。


1. 右值引用的基本概念

  • 右值:不能取地址的表达式,通常是临时对象或字面量。例如:
    10; // 字面量
    std::string("Hello"); // 临时对象
    
  • 右值引用:用于绑定到右值的引用,语法为 类型&&。例如:
    int&& rref = 10; // rref 是右值引用
    

右值引用的主要用途是实现移动语义完美转发


2. 移动语义

移动语义允许将一个对象的资源(如内存、文件句柄等)“移动”到另一个对象,而不是复制资源。这可以显著提高性能,尤其是在处理动态内存或大型对象时。

移动构造函数和移动赋值运算符

移动语义通过移动构造函数和移动赋值运算符实现。它们的参数是右值引用。

示例:移动构造函数

#include <iostream>
#include <string>

class MyString {
public:
    MyString(const char* str) {
        size = std::strlen(str);
        data = new char[size + 1];
        std::strcpy(data, str);
    }

    // 移动构造函数
    MyString(MyString&& other) noexcept {
        data = other.data;
        size = other.size;
        other.data = nullptr; // 将原对象的指针置空
        other.size = 0;
    }

    ~MyString() {
        delete[] data;
    }

    void print() const {
        std::cout << data << std::endl;
    }

private:
    char* data;
    size_t size;
};

int main() {
    MyString s1("Hello");
    MyString s2 = std::move(s1); // 使用 std::move 将 s1 转为右值

    s2.print(); // 输出 Hello
    // s1.print(); // 错误:s1 的资源已被移动
    return 0;
}

3. 完美转发

完美转发(Perfect Forwarding)是指将参数以原始的值类别(左值或右值)传递给其他函数。右值引用与 std::forward 结合使用,可以实现完美转发。

示例:完美转发

#include <iostream>
#include <string>

void process(int& x) {
    std::cout << "Lvalue: " << x << std::endl;
}

void process(int&& x) {
    std::cout << "Rvalue: " << x << std::endl;
}

template <typename T>
void forward(T&& arg) {
    process(std::forward<T>(arg)); // 完美转发
}

int main() {
    int a = 10;
    forward(a); // 传递左值
    forward(20); // 传递右值
    return 0;
}

输出:

Lvalue: 10
Rvalue: 20

4. 右值引用的使用场景

(1) 实现移动语义

  • 当需要将资源从一个对象“移动”到另一个对象时,可以使用右值引用。
  • 例如,动态内存管理、文件句柄、大型对象等。

(2) 实现完美转发

  • 当需要将参数以原始的值类别传递给其他函数时,可以使用右值引用和 std::forward
  • 例如,模板函数或通用函数。

(3) 优化性能

  • 当需要避免不必要的资源复制时,可以使用右值引用。
  • 例如,返回临时对象或大型对象。

5. 如何使用右值引用

(1) 定义右值引用

右值引用可以绑定到右值。

int&& rref = 10; // 右值引用

(2) 使用 std::move 将左值转为右值

std::move 可以将左值转换为右值引用,从而应用移动语义。

std::string s1 = "Hello";
std::string s2 = std::move(s1); // 将 s1 转为右值

(3) 实现移动构造函数和移动赋值运算符

移动构造函数和移动赋值运算符的参数是右值引用。

MyString(MyString&& other) noexcept; // 移动构造函数
MyString& operator=(MyString&& other) noexcept; // 移动赋值运算符

(4) 使用 std::forward 实现完美转发

std::forward 可以将参数以原始的值类别传递给其他函数。

template <typename T>
void forward(T&& arg) {
    process(std::forward<T>(arg)); // 完美转发
}

6. 注意事项

  1. 被移动的对象状态

    • 被移动的对象处于有效但未定义的状态。通常,它的资源已被转移,但可以重新赋值或销毁。
    • 例如,std::string 被移动后通常为空,但这不是标准规定的。
  2. 避免重复使用被移动的对象

    • 被移动的对象不应再被使用,除非它被重新赋值。
    • 例如:
      std::string s1 = "Hello";
      std::string s2 = std::move(s1);
      s1 = "World"; // 重新赋值
      std::cout << s1; // 输出 World
      
  3. std::move 的本质

    • std::move 并不实际移动任何东西,它只是将左值转换为右值引用。
    • 真正的移动操作由移动构造函数或移动赋值运算符完成。

7. 总结

  • 右值引用是 C++11 引入的重要特性,用于实现移动语义和完美转发。
  • 移动语义允许将资源从一个对象“移动”到另一个对象,避免不必要的复制。
  • 完美转发允许将参数以原始的值类别传递给其他函数。
  • 右值引用的使用场景包括动态内存管理、大型对象、模板函数等。

通过合理使用右值引用,可以显著提高 C++ 程序的效率和资源管理能力。