C++ 面向对象_0

C++ 结构体--向面向对象的过渡

C++ 中的结构体是从 C 语言继承而来,允许程序员将相关的数据组合在一起形成一个抽象数据类型,同时拓展了面向对象的设计思想

声明(declaration)和定义(definition)

声明是向编译器引入一个标识符(如变量、函数、类、结构体等)的名称和类型,但不分配存储空间或提供完整实现。声明的主要目的是告诉编译器"这个东西存在",以便编译器在遇到该标识符的使用时能够知道它的类型和基本特性。在声明时,对于变量来讲并不提供分配内存的操作,对于函数来讲并不提供具体的实现,只在链接时才会找到具体的定义,因此声明可以多次出现在程序中。

定义不仅包含声明中的所有信息,还分配存储空间或提供实现。定义的主要目的是告诉编译器"这个东西是什么"和"如何创建它"。在定义时,对于变量来讲会提供分配内存的操作(在指令层面如何将数据存入一块分配的内存中),对于函数来讲提供具体的实现。因此在整个程序中对于同一个标识符只能有一个定义(ODR:One Definition Rule,单一定义规则)避免如果存在同一个标识符的多个不同操作造成的冲突问题。

比如:

// 声明一个外部变量
extern int x;      // 不分配内存,只告诉编译器这个变量存在
// 定义一个变量
int y = 10;         // 分配内存并可能初始化
extern int x;
int y = 10;

int main()
{
    extern int x;
    int y = 10;

    return 0;
}

在指令层面:

y:
        .long   10
main:
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], 10
        mov     eax, 0
        pop     rbp
        ret

可以看到声明只是告知编译器引入一个标识符的相关类型信息在编译阶段并不会提供具体的操作,只确保在链接阶段可以链接到类型和名称符合的标识符定义,如果链接不到标识符,则会由链接器 ld 报告链接时错误,比如找不到 symbol xxx,而定义则是告诉编译器引入一个标识符的相关类型信息并提供具体的操作。

再比如对于函数的声明和定义:

// 声明一个函数
int calculateSum(int a, int b);  // 只提供函数签名,不提供实现
// 定义一个函数
int calculateSum1(int a, int b) {
    return a + b;        // 提供函数的完整实现
}

在指令层面:

calculateSum1(int, int):
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], edi
        mov     DWORD PTR [rbp-8], esi
        mov     edx, DWORD PTR [rbp-4]
        mov     eax, DWORD PTR [rbp-8]
        add     eax, edx
        pop     rbp
        ret

可以看到同一个翻译单元内(同一个源文件内)编译器并没有给出函数声明的具体实现,即使这个函数已经定义在其他翻译单元内,因为这是链接时的工作。

为什么需要区分声明和定义?

或者可以问如果没有声明和定义会怎样?现代编程语言以 C++ 为例都支持将程序分割成多个源文件独立编译(模块化的方式进行编程),然后通过链接器合并成最终的可执行文件,如果某个标识符需要在多个源文件中使用,如果都定义一遍想一想都很麻烦,如果需要修改或者某处定义错误。。。
因此对于程序而言引入了一种 ODR(One Definition Rule,单一定义规则)并在其他位置引入这个定义的类型信息,我们关心一个数据无非是它的数据类型,即在内存中给它多少的内存空间以及映射到这块内存的标识符和它所在内存布局的位置,因此我们声明时也给出这些信息即可,当一个大型项目的某部分代码修改后,只需重新编译包含该代码的源文件,而不必重新编译整个项目。

引入了声明之后,还解决了循环依赖时定义滞后的问题,通过向前声明比如:

// 前向声明
class B;

class A {
    B* b_ptr;  // 使用指针,不需要知道B的完整定义
};

class B {
    A a_obj;   // 使用完整的A,此时A已经声明过了
};

结构体的声明和定义

引入声明和定义的概念后,可以看看 C++ 中结构体的声明和定义

结构体的声明

结构体的声明告诉编译器"这个结构体存在",但不提供关于其成员的任何信息(成员变量和成员函数)。

// 结构体的前向声明
struct Student;  // 只告诉编译器Student是一个结构体类型

向前声明的特点和性质包括:

  1. 可以用于声明指针或者引用:

    struct Student;  // 前向声明
    
    void processStudent(Student* student);  // 合法
    Student& createStudent();               // 合法
    
  2. 不能用于创建实例或访问成员:

    struct Student;  // 前向声明
    
    // 下面的代码是非法的
    Student s;                  // 错误:编译器不知道结构体的大小
    int id = student->id;       // 错误:不知道结构体的成员
    
  3. 不能用于继承:

    struct Student;  // 前向声明
    
    // 下面的代码是非法的
    struct GraduateStudent : Student {};  // 错误:基类不完整
    

结构体声明的用途主要是用来解决循环依赖、在不需要知道结构体具体信息时使用结构体的指针或者引用。

结构体的定义

结构体的定义提供了结构体的完整信息,包括所有成员和它们的类型。

// 结构体的完整定义
struct Student {
    std::string name;
    int id;
    int age;
    float gpa;
    
    // 结构体也可以包含函数
    void print() {
        std::cout << "Name: " << name << ", ID: " << id 
                  << ", Age: " << age << ", GPA: " << gpa << std::endl;
    }
};

定义结构体后,编译器知道了结构体的大小,可以分配内存,并且可以创建结构体的实例,通过结构体的实例访问和修改成员,调用结构的函数,从面向对象的角度看可以作为基类被其他类或结构体继承。

使用结构体定义和声明

实践中的通常做法:

头文件中给出定义

// student.h
#ifndef STUDENT_H
#define STUDENT_H

#include <string>

// 完整定义结构体
struct Student {
    std::string name;
    int id;
    int age;
    float gpa;
    
    // 方法声明
    void print() const;
    void updateGPA(float newGPA);
};

#endif

源文件中实现函数

// student.cpp
#include "student.h"
#include <iostream>

// 方法定义
void Student::print() const {
    std::cout << "Name: " << name << ", ID: " << id 
              << ", Age: " << age << ", GPA: " << gpa << std::endl;
}

void Student::updateGPA(float newGPA) {
    gpa = newGPA;
}

成员变量的声明和初始化

const和引用限定

数据成员可以使用const、引用和其他限定符。这些限定符会影响数据成员的行为和使用方式。

#include <iostream>
#include <string>

class Person {
private:
    const int id;            // const成员,一旦初始化就不能修改
    std::string& name_ref;   // 引用成员,必须在构造函数初始化列表中初始化
    const double& tax_rate;  // const引用成员
    
public:
    // 必须在初始化列表中初始化const成员和引用成员
    Person(int i, std::string& n, const double& t) 
        : id(i), name_ref(n), tax_rate(t) {}
    
    void display() const {
        std::cout << "ID: " << id << ", Name: " << name_ref 
                  << ", Tax Rate: " << tax_rate << std::endl;
    }
    
    void updateName(const std::string& new_name) {
        // 可以修改name_ref引用的对象的内容
        name_ref = new_name;
        // 但不能重新绑定引用
        // name_ref = another_string;  // 错误:不能重新绑定引用
    }
    
    // 不能修改const成员
    // void updateId(int new_id) { id = new_id; }  // 错误:不能修改const成员
};

int main() {
    std::string name = "John";
    double rate = 0.15;
    
    Person person(1001, name, rate);
    person.display();  // 输出: ID: 1001, Name: John, Tax Rate: 0.15
    
    person.updateName("John Doe");
    person.display();  // 输出: ID: 1001, Name: John Doe, Tax Rate: 0.15
    
    // name变量的内容也会改变,因为name_ref是它的引用
    std::cout << "Original name: " << name << std::endl;  // 输出: Original name: John Doe
    
    return 0;
}

在这个例子中,id 是一个 const 成员,一旦初始化后就不能再修改,初始化的值是在编译器第一次执行到对这个成员进行初始化时确定的,之后会再执行就会报错。
name_ref是一个引用,必须在构造函数的初始化列表中初始化,由于引用的特点在初始化时必须绑定到一个已经存在的值上,并且一旦绑定到一个对象上就不能再重新绑定到其他的对象,可以理解为在使用构造函数给类或结构体分配内存时就要确定引用成员要绑定的值,所有性质都和引用保持一致。
tax_rate是一个const引用成员,它结合了const和引用的所有性质。

成员初始化

C++11 引入了类内成员初始化(in-class member initializers),允许我们在声明数据成员的同时进行初始化。这简化了构造函数的编写,并且可以避免在多个构造函数中重复相同的初始化代码。

#include <iostream>
#include <string>
#include <vector>

class Configuration {
private:
    std::string server_name = "localhost";  // 类内初始化
    int port = 8080;                        // 类内初始化
    bool is_secure = false;                 // 类内初始化
    std::vector<std::string> endpoints{"api", "auth", "status"};  // 类内初始化
    
public:
    // 默认构造函数,会使用类内初始化值
    Configuration() {}
    
    // 自定义构造函数,可以覆盖类内初始化值
    Configuration(const std::string& server, int p)
        : server_name(server), port(p) {}
    
    void display() const {
        std::cout << "Server: " << server_name << ":" << port 
                  << " (Secure: " << (is_secure ? "Yes" : "No") << ")" << std::endl;
        std::cout << "Endpoints: ";
        for (const auto& endpoint : endpoints) {
            std::cout << endpoint << " ";
        }
        std::cout << std::endl;
    }
};

int main() {
    Configuration default_config;
    default_config.display();
    // 输出: Server: localhost:8080 (Secure: No)
    // Endpoints: api auth status
    
    Configuration custom_config("example.com", 443);
    custom_config.display();
    // 输出: Server: example.com:443 (Secure: No)
    // Endpoints: api auth status
    
    return 0;
}

在这个例子中,所有数据成员都在类定义中进行了初始化。
默认构造函数没有显式初始化成员,因此使用类内初始化值。
自定义构造函数覆盖了server_name和port的类内初始化值,但保留了is_secure和endpoints的默认值。

需要注意的是,构造函数的初始化列表会覆盖类内初始化值。如果同一个成员在类内和初始化列表中都有初始化,则以初始化列表中的值为准。

mutable限定符

mutable关键字用于修饰类的数据成员,允许我们在const成员函数中修改这些成员。

#include <iostream>
#include <string>
#include <mutex>

class User {
private:
    std::string name;
    int age;
    mutable int access_count = 0;  // 可以在const函数中修改的计数器
    mutable std::mutex mutex;      // 可以在const函数中使用的互斥锁
    
public:
    User(const std::string& n, int a) : name(n), age(a) {}
    
    // const成员函数,但可以修改mutable成员
    std::string getName() const {
        std::lock_guard<std::mutex> lock(mutex);  // 锁定互斥锁
        access_count++;  // 修改mutable成员
        return name;
    }
    
    int getAge() const {
        std::lock_guard<std::mutex> lock(mutex);
        access_count++;
        return age;
    }
    
    int getAccessCount() const {
        return access_count;
    }
};

int main() {
    const User user("Alice", 30);
    
    // 调用const成员函数
    std::cout << "Name: " << user.getName() << std::endl;
    std::cout << "Age: " << user.getAge() << std::endl;
    
    // 查看访问计数
    std::cout << "Access count: " << user.getAccessCount() << std::endl;
    // 输出: Access count: 2
    
    return 0;
}

在这个例子中,access_count和mutex被声明为mutable,因此可以在const成员函数中修改。
即使user对象是const的,我们仍然可以修改其mutable成员。

mutable本质上是告诉编译器:"这个成员的修改不会改变对象的逻辑状态"。它允许在不违反const语义的情况下进行物理状态的修改。