
在C++里,类的核心思想其实就是“数据抽象”和“封装”。你可以把“数据抽象”理解为:只暴露给外部用的人(程序员)需要用到的操作(接口),而把具体怎么实现、数据怎么存放这些细节都藏起来。这样,别人用你的类时,只需要关心“能做什么”,不用关心“怎么做的”。
“封装”就是把这些实现细节都包裹起来,不让外部随便访问。外部只能通过你提供的接口来操作对象,不能直接动里面的数据。这样做的好处是,类的使用者不用担心内部实现变了会影响到自己,类的设计者也可以随时优化实现。 举个例子,假如你有一个“学生”类,外部只需要知道怎么设置和获取学生的名字、分数,而不用知道这些数据是怎么存储的,或者分数有没有经过加权处理。
设计一个好用的类,最重要的是把接口设计得清晰、易用。比如你想让Student类支持获取学号、加分、打印信息等操作,你就要在类里提供这些成员函数。比如:
|class Student { public: std::string getId() const; // 获取学号 void addScore(int delta); // 增加分数 void printInfo() const; // 打印学生信息 private: std::string id; int score = 0; };
外部只能通过这些函数来操作Student对象,不能直接访问id和score。这样一来,类的内部实现就被“封装”起来了。
上面我们已经写好了一个简单的Student类,现在我们就可以像这样使用它:
|Student stu; // 创建一个学生对象 stu.addScore(10); // 增加10分 stu.printInfo(); // 打印学生信息
他们根本不用关心你的score变量是怎么存的,也不用知道addScore里是不是还做了别的校验。
但是根据类的接口,他们可以知道Student类可以做什么,怎么用。
在类里写的函数叫“成员函数”。这些函数其实都是“带着对象一起工作”的。比如你写了getId(),它其实是“某个学生对象的getId”,而不是全局的。
例如我们创建了一个学生叫小红,那我们通过这个小红的实例修改的也只能是小红这个对象的分数,而不能是其他对象的分数。
每当你调用stu.getId(),编译器会自动把stu这个对象的地址传给函数内部的一个隐藏指针,叫this。你可以把this理解为“当前对象的指针”。在成员函数里,直接用成员变量名,其实就是this->变量名的简写。
比如:
|std::string getId() const { return id; } // 等价于 std::string getId() const { return this->id; }
你不能自己声明一个叫
this的变量,这个名字是C++保留的。
有时候你希望某些成员函数保证“不修改对象的内容”,比如getId()、printInfo()这种只读操作。
这时你可以在函数声明后面加上const,告诉编译器“我保证不改对象”。
|std::string getId() const; void printInfo() const;
C++中,你可以选择在类的声明里直接写成员函数的实现,也可以在类外面写。类外定义时,要用类名::函数名的格式,比如:
|void Student::addScore(int delta) { score += delta; }
这样做有助于让类的声明更简洁,代码结构更清晰。
除了创建对象,我们还需要知道对象是如何被复制、赋值和销毁的。这些操作在C++中非常重要,因为它们决定了对象在程序中的行为。
想象一下,你有一张照片,你想给朋友一张一模一样的。你可以用复印机复印一张,这样你就有两张相同的照片了。
在C++中,对象的拷贝也是类似的:
|Student stu1("2023001", 85); // 原始对象 Student stu2 = stu1; // 拷贝stu1创建stu2
这里,stu2是stu1的一个拷贝,它们有相同的学号和分数。
赋值就像把一张照片的内容复制到另一张照片上:
|Student stu1("2023001", 85); Student stu2("2023002", 90); stu1 = stu2; // 把stu2的内容赋值给stu1
现在stu1的学号变成了"2023002",分数变成了90。这个例子虽然在我们的例子中没有意义,但在有的情况下,我们可能需要将一个对象的值赋值给另一个对象。
当对象不再需要时,它会被自动销毁。比如:
|{ Student stu("2023001", 85); // 创建对象 // 使用stu... } // 离开这个作用域,stu被销毁
对于简单的类(像我们的Student),编译器生成的版本就足够了。但是,如果类包含指针或需要特殊处理的资源,你可能需要自己定义这些操作。
比如,如果Student类包含一个指向动态分配内存的指针:
|class Student { public: std::string id; int* scores; // 指向动态分配的分数数组 Student(const std::string& id, int score) : id(id) { scores = new int[1]; scores[0] = score; } // 需要自己定义拷贝构造函数
想象一下,当你买了一个新手机,第一次开机的时候,手机会自动设置一些基本的东西,比如语言、时区、WiFi等。这个过程就像是在"初始化"手机。
在C++中,当我们创建一个对象时,也需要一个类似的过程来设置对象的初始状态。这个负责"初始化"对象的特殊函数就叫做构造函数。 构造函数是类的一个特殊成员函数,它的名字和类名完全一样。当你创建对象时,构造函数会自动被调用,用来给对象的成员变量设置初始值。
比如,当我们写:
|Student stu; // 创建一个学生对象
这行代码实际上会调用Student类的构造函数来初始化stu对象。
构造函数有几个特别的地方:
int、void等返回类型默认构造函数是C++中的一种特殊构造函数,它没有参数。当你创建对象时,如果没有任何参数,编译器会自动调用默认构造函数。
让我们先看一个简单的例子。假设我们有一个简单的Student类:
|class Student { public: std::string id; // 学号 int score; // 分数 };
当我们这样创建对象时:
|Student stu1; // 没有给任何初始值
编译器会自动生成一个"默认构造函数"来初始化stu1。这个默认构造函数会把id设为空字符串,把score设为0。
但是,我们通常希望创建对象时就给它合适的初始值。例如我们可能需要创建这个学生的时候就给它一个合适的id,或者给它一个合适的分数,比如:
|Student stu2("2023001", 85); // 学号是2023001,分数是85
为了实现这个功能,我们需要自己写构造函数:
|class Student { public: // 构造函数:接受学号和分数 Student(const std::string& studentId, int studentScore) { id = studentId; score = studentScore; } // 构造函数:只接受学号,分数默认为0 Student(const std::string& studentId) { id = studentId;
现在我们可以用三种方式创建Student对象:
|Student stu1; // 调用默认构造函数 Student stu2("2023001"); // 调用只接受学号的构造函数 Student stu3("2023001", 85); // 调用接受学号和分数的构造函数
上面的写法虽然可以工作,但C++提供了一种更高效的方式,叫做"构造函数初始化列表":
|class Student { public: // 使用初始化列表的构造函数 Student(const std::string& studentId, int studentScore) : id(studentId), score(studentScore) { // 函数体可以为空 } Student(const std::string& studentId) : id(studentId), score(
初始化列表的语法是:在构造函数参数后面加一个冒号,然后列出要初始化的成员变量和它们的初始值。
使用初始化列表有两个好处:
如果你没有写任何构造函数,编译器会自动生成一个默认构造函数。但是,一旦你写了任何一个构造函数,编译器就不会再自动生成默认构造函数了。
这意味着如果你写了:
|class Student { public: Student(const std::string& id, int score) : id(id), score(score) {} std::string id; int score; };
那么你就不能这样创建对象了:
|Student stu; // 错误!没有默认构造函数
如果你需要默认构造函数,必须自己写:
|class Student { public: Student() : id(""), score(0) {} // 默认构造函数必须提供! Student(const std::string& id, int score) : id(id), score(score) {} std::string id; int score; };
或者使用= default让编译器生成:
|class Student { public: Student() = default; // 让编译器生成默认构造函数 Student(const std::string& id, int score) : id(id), score(score) {} std::string id; int score; };
析构函数是与构造函数相对应的函数,它在对象的生命周期结束时被自动调用。析构函数的作用是释放对象占用的资源,例如动态分配的内存、打开的文件等。
析构函数的名字与类名相同,但前面加一个波浪号(~)。例如:
|class Student { public: ~Student() { // 释放资源 } };
析构函数有几个重要的特点:
对于简单的类(像我们之前的Student类),编译器会自动生成一个默认的析构函数,它什么也不做。但是,当类包含需要特殊清理的资源时,我们就需要自己定义析构函数。
让我们看一个需要析构函数的例子:
|class Student { public: std::string id; int* scores; // 指向动态分配的分数数组 int scoreCount; // 构造函数 Student(const std::string& studentId, int score) : id(studentId), scoreCount(1) { scores = new int[1]; // 动态分配内存 scores[
析构函数在以下情况下会被调用:
|{ Student stu("2023001", 85); // 构造函数被调用 // 使用stu... } // 离开作用域,析构函数被调用
|Student* pStu = new Student("2023001", 85); // 构造函数被调用 // 使用pStu... delete pStu; // 析构函数被调用
|Student getStudent() { return Student("2023001", 85); // 临时对象,函数结束后析构 }
如果没有析构函数来释放动态分配的内存,就会发生内存泄漏。让我们看一个错误的例子:
|class BadStudent { public: int* scores; BadStudent(int score) { scores = new int[1]; scores[0] = score; } // 没有析构函数!这会导致内存泄漏 }; void badFunction() { BadStudent stu(85); // 函数结束时,stu离开作用域,但没有析构函数来释放scores指向的内存
当有继承关系时,析构函数的调用顺序与构造函数的调用顺序相反:
|class Person { public: Person() { std::cout << "Person构造函数" << std::endl; } ~Person() { std::cout << "Person析构函数" << std::endl; } }; class Student : public Person { public: Student() { std::cout << "Student构造函数" << std::endl; } ~Student
析构函数是C++中资源管理的重要组成部分,正确使用析构函数可以避免内存泄漏和其他资源泄漏问题。
想象一下,你有一个银行账户。银行不会让你直接进入金库去拿钱,而是通过ATM机、柜台等安全的方式来操作你的账户。这就是"封装"的概念——把重要的东西保护起来,只通过安全的接口来访问。
在C++中,我们也需要这样的保护机制。访问控制就是决定谁可以访问类的哪些部分。C++提供了三个访问级别:
让我们看一个没有访问控制的例子:
|class Student { public: std::string id; int score; }; // 外部代码可以直接修改数据 Student stu("2023001", 85); stu.score = -50; // 可以设置负数分数! stu.id = ""; // 可以设置空学号!
这样很危险,因为外部代码可以设置不合理的数据。
现在我们用访问控制来保护数据:
|class Student { public: // 公开的接口:外部可以使用的函数 Student(const std::string& id, int score) { setId(id); setScore(score); } std::string getId() const { return id; } int getScore() const { return score; }
现在外部代码不能直接修改数据了:
|Student stu("2023001", 85); // stu.score = -50; // 错误!不能直接访问私有成员 // stu.id = ""; // 错误!不能直接访问私有成员 stu.setScore(90); // 正确!通过公开的接口修改 stu.setScore(-50); // 会被拒绝,因为分数不合理
C++中有两个关键字可以定义类:class和struct。它们的唯一区别是默认访问级别:
|// 使用class:默认是private class Student { std::string id; // 默认是private int score; // 默认是private public: // 公开的成员... }; // 使用struct:默认是public struct Student { std::string id; // 默认是public int score; // 默认是public private: // 私有的成员... };
一个好的设计原则是:
想象一下,你有一个私人日记本,通常只有你自己能看。但是,你可能会让你的好朋友看,因为你们关系很好,你信任他们。 在C++中,友元(friend)就是这样的概念。即使某个函数或类不是你的成员,你也可以给它"特殊权限"来访问你的私有部分。
让我们看一个例子。假设我们有一个Student类,数据成员是私有的:
|class Student { public: Student(const std::string& id, int score) : id(id), score(score) {} std::string getId() const { return id; } int getScore() const { return score; } private: std::string id;
现在,如果我们想写一个函数来打印学生信息:
|void printStudent(const Student& stu) { std::cout << "学号: " << stu.getId() << ", 分数: " << stu.getScore() << std::endl; }
这个函数可以工作,因为它使用了公开的接口。但是,如果我们想写一个函数来从文件读取学生信息:
|Student readStudentFromFile(std::ifstream& file) { std::string id; int score; file >> id >> score; return Student(id, score); // 使用构造函数,这是可以的 }
这个函数也可以工作。但是,如果我们想写一个函数来直接修改学生的私有数据(比如批量更新分数),我们就需要友元了。
要声明一个友元,在类内部使用friend关键字:
|class Student { public: Student(const std::string& id, int score) : id(id), score(score) {} std::string getId() const { return id; } int getScore() const { return score; } // 声明友元函数 friend void updateScore
stu.updateScore()这样的方式调用虽然我们的Student类相对简单,但它已经让我们探索了C++中类的很多基本特性。现在让我们来看看一些更高级的类特性,这些特性能让我们的类更加强大和灵活。
除了定义数据成员和函数成员,类还可以定义自己的类型别名。这些类型成员和普通成员一样受到访问控制,可以是public或private的。
让我们扩展我们的Student类,添加一些类型定义:
|class Student { public: // 定义类型成员 typedef std::string::size_type string_size; using score_type = int; // 使用using关键字定义类型别名 // 构造函数 Student(const std::string& id, score_type score) : id(id), score(score) {} // 成员函数 std::string
我们定义score_type为int的别名,这样做有几个好处。首先,如果将来我们想把分数改成double类型,只需要修改类型定义,而不需要修改所有使用分数的地方。其次,它让代码更清晰,一看就知道score_type是专门用来表示分数类型的。
类型成员通常放在类的开头,因为C++要求类型定义在使用之前必须出现。这样可以让代码结构更清晰,读者一眼就能看到这个类定义了哪些自定义类型。
类中经常有一些小函数,它们可以从内联中受益。我们已经看到,在类内部定义的成员函数会自动成为内联函数。因此,Student类的构造函数和在类内部定义的getter函数默认就是内联的。
我们可以在类内部声明时显式地声明一个成员函数为inline,也可以在类外部的函数定义中指定inline:
|class Student { public: using score_type = int; // 构造函数自动内联 Student(const std::string& id, score_type score) : id(id), score(score) {} // 在类内部定义的函数自动内联 std::string getId() const { return id; } score_type getScore
虽然我们不需要这样做,但在声明和定义上都指定inline是合法的。然而,只在类外部的定义上指定inline可以让类更容易阅读。
和非成员函数一样,成员函数也可以重载,只要函数的参数数量或类型不同即可。成员函数调用的函数匹配过程和非成员函数相同。
例如,我们的Student类可以定义两个版本的setScore函数:
|class Student { public: using score_type = int; Student(const std::string& id, score_type score) : id(id), score(score) {} // 重载的setScore函数 void setScore(score_type newScore) { if (newScore >= 0 && newScore
编译器使用参数的数量和类型来确定调用哪个版本:
|Student stu("2023001", 85); stu.setScore(90); // 调用第一个版本:设置分数为90 stu.setScore(5, true); // 调用第二个版本:增加5分
有时候,我们希望即使在const成员函数中也能修改某个数据成员。我们用mutable关键字来声明这样的成员。
mutable数据成员永远不会是const的,即使它是const对象的成员。因此,const成员函数可以改变mutable成员。
让我们给Student类添加一个mutable成员来跟踪函数调用次数:
|class Student { public: using score_type = int; Student(const std::string& id, score_type score) : id(id), score(score) {} // const成员函数,但可以修改mutable成员 void printInfo() const { ++access_count; // 可以修改mutable成员 std::cout <<
尽管printInfo、getId和getScore是const成员函数,但它们可以改变access_count的值。这个成员是mutable的,所以任何成员函数,包括const函数,都可以改变它的值。
有些成员函数希望支持链式调用,或者模仿C++里的+=操作。这时你可以让函数返回*this的引用。比如:
|class Student { public: using score_type = int; Student(const std::string& id, score_type score) : id(id), score(score) {} // 返回*this的引用,支持链式调用 Student& addScore(score_type delta) { score_type newScore = score + delta;
现在你可以这样使用:
|Student stu("2023001", 85); stu.addScore(5).addScore(10).printInfo(); // 链式调用
这些操作会在同一个对象上执行。在这个表达式中,我们首先给stu增加5分,然后增加10分,最后打印信息。这相当于:
|stu.addScore(5); stu.addScore(10); stu.printInfo();
如果我们定义addScore返回Student而不是Student&,这个语句的执行会完全不同。在这种情况下,它相当于:
|// 如果addScore返回Student而不是Student& Student temp = stu.addScore(5); // 返回值会被复制 temp.addScore(10); // stu的内容不会改变
如果addScore有非引用返回类型,那么addScore的返回值会是*this的副本。对addScore的调用会改变临时副本,而不是stu。
我们可以基于函数是否为const来重载成员函数,就像我们可以基于指针参数是否指向const来重载函数一样。非const版本对const对象不可行;我们只能在const对象上调用const成员函数。我们可以在非const对象上调用任一版本,但非const版本会是更好的匹配。
在这个例子中,我们定义一个私有的do_print成员来做实际的打印工作。每个打印操作都会调用这个函数,然后返回执行它的对象:
|class Student { public: using score_type = int; Student(const std::string& id, score_type score) : id(id), score(score) {} // 基于对象是否为const重载display Student& display(std::ostream& os) { do_print(os);
当我们在对象上调用display时,该对象是否为const决定了调用哪个版本的display:
|Student stu("2023001", 85); const Student constStu("2023002", 90); stu.display(std::cout); // 调用非const版本 constStu.display(std::cout); // 调用const版本
你可能会奇怪我们为什么要定义一个单独的do_print操作。毕竟,调用do_print并不比在do_print内部做的动作简单多少。
我们这样做是希望避免在多个地方写相同的代码。我们期望打印操作会随着类的演化而变得更加复杂。
当涉及的动作变得更加复杂时,在一个地方而不是两个地方写这些动作会更有意义。
这个额外的函数调用可能不会有任何开销。我们在类体内定义了do_print,所以它是隐式内联的。因此,调用do_print可能不会有运行时开销。
在实践中,设计良好的C++程序往往有很多像do_print这样的小函数,它们被调用来做其他函数集的"真正"工作。
每个类都定义了自己的新作用域。在类作用域之外,普通的数据和函数成员只能通过对象、引用或指针使用成员访问操作符来访问。我们使用作用域操作符来访问类的类型成员。在这两种情况下,操作符后面的名称必须是相关类的成员。让我们用我们的Student类来举例说明:
|Student::score_type score = 85; // 使用Student类定义的score_type类型 Student stu("2023001", score); Student *p = &stu; std::string id = stu.getId(); // 从对象stu获取getId成员 id = p->getId(); // 从p指向的对象获取getId成员
类是一个作用域的事实解释了为什么当我们在类外定义成员函数时必须提供类名和函数名。在类外,成员的名称是隐藏的。 一旦看到类名,定义的其余部分(包括参数列表和函数体)就在类的作用域内。因此,我们可以不加限定地引用其他类成员。
例如,假设我们有一个StudentManager类来管理多个学生:
|class StudentManager { public: using student_id_type = std::string; using score_type = int; // 添加学生到管理器并返回其索引 student_id_type addStudent(const Student&); // 清除指定学生的分数 void clearScore(student_id_type id); private: std::vector<
因为编译器在注意到我们在StudentManager类的作用域内后看到了参数列表,所以不需要指定我们想要的是StudentManager定义的student_id_type。 出于同样的原因,函数体中students的使用引用了StudentManager类内部声明的名称。
另一方面,函数的返回类型通常出现在函数名之前。当成员函数在类体外定义时,返回类型中使用的任何名称都在类作用域之外。
因此,返回类型必须指定它是其成员的类。例如,我们可能给StudentManager一个名为addStudent的函数来添加另一个学生到管理器中:
|class StudentManager { public: // 添加学生到管理器并返回其ID student_id_type addStudent(const Student&); // 其他成员... }; // 返回类型在我们进入StudentManager作用域之前就被看到了 StudentManager::student_id_type StudentManager::addStudent(const Student &s) { students.push_back(s); return s.getId(); }
因为返回类型出现在看到类名之前,它出现在StudentManager类的作用域之外。要在返回类型中使用student_id_type,我们必须指定定义该类型的类。
类声明中使用的名称,包括返回类型和参数列表中使用的类型,必须在使用之前被看到。如果成员声明使用了类内尚未看到的名称,编译器将在定义类的作用域中查找该名称。 例如:
|typedef double Height; std::string height; class Student { public: Height getHeight() { return height; } // 使用Height类型 private: Height height; // 使用Height类型 // ... };
当编译器看到getHeight函数的声明时,它会在Student类中查找Height的声明。编译器只考虑Student内部在使用Height之前出现的声明。 因为没有找到匹配的成员,编译器然后在包围作用域中查找声明。在这个例子中,编译器会找到Height的typedef。 该类型将用于函数getHeight的返回类型和作为数据成员height的类型。另一方面,getHeight的函数体只有在看到整个类后才被处理。 因此,该函数内部的return返回名为height的成员,而不是来自外部作用域的string。
通常,内部作用域可以重新定义来自外部作用域的名称,即使该名称已经在内部作用域中使用过。然而,在类中,如果成员使用来自外部作用域的名称且该名称是类型,则类不能随后重新定义该名称:
|typedef double Height; class Student { public: Height getHeight() { return height; } // 使用外部作用域的Height private: typedef double Height; // 错误:不能重新定义Height Height height; // ... };
值得注意的是,即使Student内部Height的定义与外部作用域中的定义使用相同的类型,这段代码仍然是错误的。 虽然重新定义类型名称是错误的,但编译器不需要诊断此错误。一些编译器会静默接受这样的代码,即使程序是错误的。
类型名称的定义通常应该出现在类的开头。这样,使用该类型的任何成员都会在类型名称已经定义之后被看到。
成员函数体中使用的名称按以下方式解析:首先,在成员函数内部查找该名称的声明。通常,只考虑函数体中名称使用之前的声明。如果在成员函数内部找不到声明,则在类内部查找声明。 考虑类的所有成员。如果在类中找不到该名称的声明,则查找成员函数定义之前作用域中的声明。 通常,使用另一个成员的名称作为成员函数中参数的名称是不好的做法。
下面的示例违反了这种正常做法:
|int score; // 定义了一个随后在Student内部使用的名称 class Student { public: using score_type = int; void dummy_fcn(score_type score) { // 这里的score指的是哪个?参数 this->score = score; // 明确指定成员score } private: score_type score = 0; };
当编译器处理dummy_fcn内部的赋值表达式时,它首先在该函数的作用域中查找该表达式中使用的名称。函数的参数在函数的作用域中。因此,在dummy_fcn主体中使用的名称score指的是这个参数声明。
在这种情况下,score参数隐藏了名为score的成员。如果我们想覆盖正常的查找规则,我们可以这样做:
|// 不好的做法:成员函数的局部名称不应该隐藏成员名称 void Student::dummy_fcn(score_type score) { this->score = score; // 成员score // 指示成员的另一种方式 Student::score = score; // 成员score }
即使类成员被隐藏,仍然可以通过用类名限定成员名称或显式使用this指针来使用该成员。
确保我们获得名为score的成员的更好方法是给参数一个不同的名称:
|// 好的做法:不要为参数或其他局部变量使用成员名称 void Student::dummy_fcn(score_type newScore) { score = newScore; // 成员score }
在这种情况下,当编译器查找名称score时,在dummy_fcn内部找不到它。编译器接下来查看Student中的所有声明。即使score的声明出现在dummy_fcn内部使用之后,编译器也会将此使用解析为名为score的数据成员。
如果编译器在函数或类作用域中找不到名称,它会在包围作用域中查找该名称。在我们的例子中,名称score在Student定义之前在外层作用域中定义。
然而,外层作用域中的对象被我们名为score的成员隐藏了。如果我们想要来自外层作用域的名称,我们可以使用作用域操作符明确地请求它:
|// 不好的做法:不要隐藏来自包围作用域所需的名称 void Student::dummy_fcn(score_type score) { ::score = score; // 哪个score?全局的那个 }
即使外层对象被隐藏,仍然可以通过使用作用域操作符访问该对象。
类有时需要与类本身相关联的成员,而不是与类类型的单个对象相关联。例如,一个银行账户类可能需要一个数据成员来表示当前的基准利率。 在这种情况下,我们希望将利率与类关联,而不是与每个单独的对象关联。 从效率的角度来看,每个对象存储利率是没有理由的。更重要的是,如果利率发生变化,我们希望每个对象都使用新值。
我们通过在声明中添加static关键字来表示成员与类相关联。像任何其他成员一样,静态成员可以是public或private的。静态数据成员的类型可以是const、引用、数组、类类型等。
加设一个学生的成绩只占总成绩的50%,那么我们就可以用一个静态成员来表示这个比例。让我们用我们的Student类来举例说明:
|class Student { public: void calculateGrade() { // 使用静态成员计算最终成绩 finalScore = score * gradeWeight; } static double getGradeWeight() { return gradeWeight; } static void setGradeWeight(double); private: std::string id; double score; double finalScore; static
类的静态成员存在于任何对象之外。对象不包含与静态数据成员相关的数据。因此,每个Student对象将包含三个数据成员:id、score和finalScore。只有一个gradeWeight对象将被所有Student对象共享。
类似地,静态成员函数不绑定到任何对象;它们没有this指针。因此,静态成员函数不能被声明为const,我们也不能在静态成员的主体中引用this。这个限制既适用于this的显式使用,也适用于通过调用非静态成员对this的隐式使用。
我们可以通过作用域操作符直接访问静态成员:
|double weight; weight = Student::getGradeWeight(); // 使用作用域操作符访问静态成员
即使静态成员不是其类对象的一部分,我们也可以使用类类型的对象、引用或指针来访问静态成员:
|Student stu1("2023001", 85); Student *stu2 = &stu1; // 调用静态成员函数的等效方式 weight = stu1.getGradeWeight(); // 通过Student对象或引用 weight = stu2->getGradeWeight(); // 通过指向Student对象的指针
成员函数可以直接使用静态成员,无需作用域操作符:
|class Student { public: void calculateGrade() { finalScore = score * gradeWeight; // 直接使用静态成员 } private: static double gradeWeight; // 其他成员... };
像任何其他成员函数一样,我们可以在类体内或类体外定义静态成员函数。当我们在类外定义静态成员时,我们不重复static关键字。该关键字只出现在类体内的声明中:
|void Student::setGradeWeight(double newWeight) { gradeWeight = newWeight; }
像任何类成员一样,当我们在类体外引用类静态成员时,我们必须指定定义该成员的类。然而,static关键字只用于类体内的声明。
因为静态数据成员不是类类型单个对象的一部分,所以当我们创建类的对象时,它们不会被定义。因此,它们不会被类的构造函数初始化。 此外,通常我们不能在类内初始化静态成员。相反,我们必须定义并在类体外初始化每个静态数据成员。像任何其他对象一样,静态数据成员只能定义一次。
像全局对象一样,静态数据成员定义在任何函数之外。因此,一旦它们被定义,它们就会继续存在直到程序完成。
我们定义静态数据成员的方式类似于在类外定义类成员函数的方式。我们命名对象的类型,后跟类名、作用域操作符和成员自己的名称:
|// 定义并初始化静态类成员 double Student::gradeWeight = initGradeWeight();
这个语句定义了名为gradeWeight的对象,它是Student类的静态成员,类型为double。一旦看到类名,定义的其余部分就在类的作用域内。 因此,我们可以不加限定地使用initGradeWeight作为gradeWeight的初始化器。 还要注意,虽然initGradeWeight是私有的,但我们可以用它来初始化gradeWeight。像任何其他成员定义一样,静态数据成员定义可以访问其类的私有成员。
确保对象只定义一次的最佳方法是将静态数据成员的定义放在包含类非内联成员函数定义的文件中。
通常,类静态成员不能在类体内初始化。但是,我们可以为具有const整型类型的静态成员提供类内初始化器,并且必须为字面量类型的constexpr静态成员这样做。 初始化器必须是常量表达式。这样的成员本身就是常量表达式;它们可以在需要常量表达式的地方使用。例如,我们可以使用初始化的静态数据成员来指定数组成员的维度:
|class Student { public: static double getGradeWeight() { return gradeWeight; } static void setGradeWeight(double); private: static constexpr int maxStudents = 100; // maxStudents是一个常量表达式 static double gradeWeight; Student* studentList[maxStudents]; // 使用静态成员作为数组维度 };
如果成员只在编译器可以替换成员值的上下文中使用,那么初始化的const或constexpr静态不需要单独定义。但是,如果我们在值不能被替换的上下文中使用成员,那么必须有该成员的定义。
例如,如果我们对maxStudents的唯一用途是定义studentList的维度,那么就不需要在Student外定义maxStudents。
但是,如果我们省略定义,即使是看似微不足道的程序更改也可能导致程序因缺少定义而无法编译。
例如,如果我们将Student::maxStudents传递给接受const int&的函数,那么maxStudents必须被定义。
如果在类内提供了初始化器,成员的定义不能指定初始值:
|// 没有初始化器的静态成员定义 constexpr int Student::maxStudents; // 初始化器在类定义中提供
即使const static数据成员在类体中初始化,该成员通常也应该在类定义外定义。
正如我们所看到的,静态成员独立于任何其他对象而存在。因此,它们可以以对非静态数据成员来说非法的方式使用。
作为一个例子,静态数据成员可以有不完整类型。特别是,静态数据成员可以具有与其所属类类型相同的类型。非静态数据成员被限制为声明为其类的指针或引用:
|class StudentManager { public: // ... private: static StudentManager manager; // 正确:静态成员可以有不完整类型 StudentManager *nextManager; // 正确:指针成员可以有不完整类型 StudentManager currentManager; // 错误:数据成员必须有完整类型 };
静态成员和普通成员之间的另一个区别是,我们可以使用静态成员作为默认参数:
|class Student { public: // defaultGradeWeight引用类定义后面声明的静态成员 Student& setScore(double score, double weight = defaultGradeWeight); private: static const double defaultGradeWeight; };
非静态数据成员不能用作默认参数,因为它的值是它所属对象的一部分。使用非静态数据成员作为默认参数没有提供获取成员值的对象,因此是错误的。
让我们把我们前面学的知识串起来,来一次实战演练。
想象你是一家玩具工厂的经理,需要管理工厂的生产、库存和员工。我们需要创建一个程序来帮助管理这些信息。这个程序将包含:
在开始实现工厂管理系统之前,我们首先需要设计和定义一些基础的数据结构,用于描述工厂中涉及的各种实体信息。这里我们会用到 C++ 中的 struct(结构体)来表示产品、员工和生产记录等对象。
结构体是一种将不同类型的数据组合在一起的复合数据类型,非常适合用来描述具有多个属性的实体。
具体来说,我们将定义以下几个结构体:
|// 产品信息结构体 struct Product { std::string name; // 产品名称 double price; // 产品价格 int stock; // 库存数量 std::string category; // 产品类别 }; // 员工信息结构体 struct Employee { std::string name; // 员工姓名 std::string position; // 职位 double salary; // 工资 int employeeId; // 员工编号 bool isActive;
这里我们使用了不同的数据类型来满足不同的业务需求。std::string 类型用于存储文本信息,比如产品名称、员工姓名等,这些信息在程序中需要频繁的字符串操作和比较。
double 类型用于存储价格和工资,因为货币计算需要高精度的小数运算,避免浮点数精度丢失导致的财务错误。
int 类型用于存储数量、编号等整数值,这些数据通常用于计数和标识,不需要小数部分。bool 类型用于存储员工在职状态,这种二值状态非常适合用布尔类型表示,既节省内存又提高代码可读性。
接下来,我们将设计并实现一个名为 FactoryManager 的工厂管理类,用于集中管理工厂的各项核心业务。这个类将作为工厂管理系统的“大脑”,负责协调和操作产品、员工以及生产记录等数据。具体来说,FactoryManager 类会包含以下几类方法:
通过将这些功能封装在一个类中,我们可以实现数据的集中管理和操作,提升代码的可维护性和扩展性。同时,类的私有成员变量可以保护数据不被外部随意修改,只有通过公有方法才能安全地访问和更改数据。
|class FactoryManager { private: // 私有成员变量 - 存储数据 std::vector<Product> products; // 产品列表 std::vector<Employee> employees; // 员工列表 std::vector<ProductionRecord> records; // 生产记录列表 int nextEmployeeId; // 下一个员工编号 public: // 构造函数 - 初始化对象 FactoryManager() : nextEmployeeId(1001) {
在这个类中,products、employees、records 等数据都被声明为 private,外部代码无法直接访问这些数据,只能通过类提供的公有接口来操作。
这种设计确保了数据的一致性和安全性,比如在添加产品时,我们可以检查产品是否已存在,避免重复添加;在更新库存时,我们可以验证产品是否存在,防止操作无效数据。
FactoryManager 类还展示了构造函数和析构函数的使用。构造函数 FactoryManager() 在对象创建时自动调用,负责初始化对象的状态。这里的构造函数将 nextEmployeeId 初始化为 1001,这个值作为员工编号的起始值,确保每个员工都有唯一的编号。
构造函数还输出系统启动的提示信息,让用户知道系统已经准备就绪。通过构造函数,我们可以确保每个新建的 FactoryManager 对象都处于有效的初始状态。
析构函数 ~FactoryManager() 在对象生命周期结束时自动调用,用于清理资源和执行收尾工作。
虽然在这个简单的例子中,析构函数只是输出一条关闭信息,但在实际应用中,析构函数通常用于释放动态分配的内存、关闭文件、断开数据库连接等资源清理工作。析构函数的存在确保了资源的正确释放,防止内存泄漏。
类中还定义了多个成员函数,每个函数都有明确的职责。产品管理相关的方法处理产品的增删改查操作,员工管理方法处理员工信息的维护, 生产记录方法处理生产数据的记录和查询,统计报表方法提供数据分析功能。这些方法通过参数传递接收外部数据,通过返回值或输出流返回处理结果,实现了类与外部环境的交互。
现在让我们实现产品管理的方法。这些方法展示了如何操作数据和处理业务逻辑。
|void FactoryManager::addProduct(const std::string& name, double price, const std::string& category, int initialStock) { // 检查产品是否已存在 for (const auto& product : products) { if (product.name == name) { std::cout << "产品 '" << name
addProduct 方法首先通过遍历现有产品列表来检查产品是否已存在,这种重复检查是数据完整性的重要保障。如果产品已存在,方法会输出提示信息并直接返回,避免重复添加。如果产品不存在,方法会创建一个新的 Product 对象,逐个设置其成员变量的值,然后将对象添加到 products 向量中。这里使用了 push_back 方法,它会自动扩展向量的容量来容纳新元素。
updateStock 方法展示了指针的使用和空指针检查的重要性。方法首先调用 findProduct 来获取产品的指针,如果返回的是 nullptr,说明产品不存在,方法会输出错误信息并返回。如果找到了产品,方法会通过指针直接修改产品的库存数量。这里使用了 += 操作符,允许正数(入库)和负数(出库)的库存变化。方法还根据数量的正负来输出不同的提示信息,提高用户体验。
displayProducts 方法展示了格式化输出的技巧。方法首先检查产品列表是否为空,如果为空则输出提示信息并返回。如果列表不为空,方法会输出表头,使用 std::setw 来控制每列的宽度,确保输出的表格整齐美观。在输出每个产品信息时,方法使用 std::fixed 和 std::setprecision(2) 来控制价格的小数位数,确保货币显示的格式统一。
findProduct 方法是一个辅助方法,它遍历产品列表,通过比较产品名称来查找指定的产品。如果找到匹配的产品,方法返回指向该产品的指针;如果没有找到,方法返回 nullptr。这个方法的返回值类型是 Product*,允许调用者通过指针直接修改找到的产品对象,这是C++中常用的设计模式。
员工管理部分展示了如何处理更复杂的数据操作和状态管理。
|void FactoryManager::addEmployee(const std::string& name, const std::string& position, double salary) { // 检查员工姓名是否重复 for (const auto& emp : employees) { if (emp.name == name && emp.isActive) { std::cout << "员工 '" << name << "' 已存在!"
addEmployee 方法在检查员工重复时考虑了员工的状态,只有当员工姓名相同且状态为在职时才认为是重复。这种设计允许重新雇佣已离职的员工,符合实际业务需求。方法使用 nextEmployeeId++ 来分配员工编号,这个表达式先返回当前值,然后递增,确保每个员工都有唯一的编号。新员工的 isActive 字段被设置为 true,表示新添加的员工默认是在职状态。
updateEmployeeSalary 方法展示了多层验证的重要性。方法首先检查员工是否存在,然后检查员工是否在职。这种验证顺序很重要,因为如果员工不存在,检查其状态就没有意义。方法在更新工资前保存了旧工资值,这样可以在输出信息中显示工资的变化情况,提高操作的透明度。这种设计让用户清楚地知道操作的结果。
setEmployeeStatus 方法使用三元操作符来根据布尔值生成相应的状态文本。isActive ? "在职" : "离职" 这个表达式根据 isActive 的值返回不同的字符串,这种写法简洁明了,避免了使用 if-else 语句。方法通过指针直接修改员工的状态,然后输出状态变化的确认信息。
displayEmployees 方法在显示员工信息时,将布尔值转换为可读的文本。emp.isActive ? "在职" : "离职" 这个表达式将内部的布尔状态转换为用户友好的中文描述。方法使用格式化输出确保表格的整齐性,每列的宽度都经过精心设计,确保信息能够清晰展示。
findEmployee 方法通过员工编号来查找员工,这与 findProduct 方法通过名称查找产品不同。员工编号是唯一的标识符,而产品名称可能重复,这种差异反映了不同业务实体的特点。方法返回指向找到员工的指针,允许调用者修改员工信息。
生产记录管理是工厂管理系统中的一个重要部分,它主要负责记录每天各个班次生产了哪些产品、生产的数量,并将这些信息与产品数据进行关联。通过管理生产记录,我们可以追踪产品的生产历史、分析生产效率,并为后续的库存统计和报表生成提供数据支持。
在实现生产记录管理时,通常需要处理以下几个方面:
时间序列数据的管理:每条生产记录都包含一个日期字段,表示生产发生的具体时间。通过日期字段,我们可以按天、按月等方式对生产数据进行统计和分析。
数据关联:生产记录中的产品名称需要与产品信息表进行关联,确保记录的产品是工厂实际存在的产品。如果输入的产品名称不存在,系统应当给出提示,防止无效数据的产生。
数据验证:在添加生产记录时,需要验证班次是否合法(如只能是“早班”、“中班”、“晚班”),并检查生产数量是否为正数,保证数据的准确性和一致性。
自动更新库存:每当添加一条生产记录时,系统会自动将对应产品的库存数量增加生产数量,实现生产与库存的联动,避免手动同步带来的错误。
生产记录的展示与统计:系统可以按照日期、产品、班次等维度展示所有生产记录,方便管理者随时了解生产情况。同时,也可以基于这些记录生成日报、月报等统计报表,辅助决策。
|void FactoryManager::addProductionRecord(const std::string& date, const std::string& productName, int quantity, const std::string& shift) { // 验证产品是否存在 Product* product = findProduct(productName); if (product == nullptr) {
addProductionRecord 方法展示了数据验证和关联操作的重要性。方法首先验证产品是否存在,如果产品不存在,就无法添加生产记录,这种验证确保了数据的一致性。方法还验证班次的有效性,只允许特定的三个班次值,这种限制防止了无效数据的输入。在创建生产记录后,方法自动调用 updateStock 来更新产品库存,这种关联操作确保了库存数据的实时性,避免了数据不一致的问题。
displayProductionRecords 方法以表格形式展示所有的生产记录。方法使用格式化输出确保表格的整齐性,每列的宽度都经过精心设计。这种展示方式让用户能够快速浏览所有的生产记录,了解生产的历史情况。
generateDailyReport 方法展示了复杂的数据统计功能。方法使用两个 std::map 对象来分别统计按产品和按班次的生产数量。std::map 是C++标准库中的关联容器,它自动按键排序,并且支持高效的查找和插入操作。方法首先遍历所有生产记录,筛选出指定日期的记录,然后分别累加到相应的统计容器中。这种设计允许我们生成多维度的统计报告,满足不同的分析需求。
最后,我们来详细实现工厂管理系统中的统计和报表功能,帮助管理者全面了解工厂的运营状况。这些功能主要包括:
|void FactoryManager::generateInventoryReport() const { std::cout << "\n=== 库存报表 ===" << std::endl; if (products.empty()) { std::cout << "暂无产品信息" << std::endl; return; } double totalValue = calculateTotalInventoryValue(); std
generateInventoryReport 方法生成详细的库存报表,包括每个产品的库存价值和总库存价值。方法在循环中计算每个产品的库存价值(库存数量乘以单价),这种计算方式反映了库存的实际价值。
方法使用 std::fixed 和 std::setprecision(2) 来确保货币显示的格式统一,所有金额都显示两位小数。报表的最后显示总库存价值,这个值通过调用 calculateTotalInventoryValue 方法获得,体现了代码的模块化设计。
generateEmployeeReport 方法生成员工统计报表,包括在职员工数量、总工资支出、平均工资和按职位的员工分布。
方法只统计在职员工的信息,这种筛选确保了报表的准确性。在计算平均工资时,方法检查在职员工数量是否大于零,避免除零错误。
方法使用 std::map 来统计每个职位的员工数量,这种数据结构自动处理了职位的分组和计数。
calculateTotalInventoryValue 方法是一个简单的计算函数,它遍历所有产品,累加每个产品的库存价值。
这个方法的返回值类型是 double,确保计算的精度。方法被声明为 const,表明它不会修改对象的状态,这种设计让方法可以在 const 对象上调用,提高了代码的灵活性。
接下来,我们将把前面实现的所有结构体、类和方法,组合成一个完整的主程序。这个主程序将模拟工厂日常管理的各项操作,包括添加产品、员工、生产记录,以及展示和统计各类信息。 通过这个完整的示例,你可以看到如何在实际项目中组织和调用各个功能模块,体会变量、数据结构、类、成员函数等C++基础知识在真实业务场景下的应用。
|#include <iostream> #include <string> #include <vector> #include <map> #include <iomanip> // 包含我们之前定义的所有结构体和类 // ... (前面的代码) int main() { // 创建工厂管理器对象 FactoryManager factory; std::cout << "=== 玩具工厂管理系统 ===" << std::endl; std::cout << "欢迎使用工厂管理系统!" << std::endl; // 添加一些示例产品 factory.
主程序main展示了如何创建和使用 FactoryManager 对象。我们首先创建了一个 FactoryManager 对象,这个对象的构造函数会自动初始化系统并输出启动信息。
然后程序添加了一些示例数据,包括产品、员工和生产记录,这些数据为后续的演示提供了基础。
随后我们通过调用各种方法来展示系统的功能。displayProducts、displayEmployees 和 displayProductionRecords 方法展示了当前的数据状态,让用户了解系统的内容。
而generateInventoryReport、generateEmployeeReport 和 generateDailyReport 方法则生成了各种统计报表,展示了系统的分析能力。
最后我们演示了一些动态操作,比如更新员工工资和调整库存。这些操作展示了系统的交互性,用户可以根据实际需要修改数据。最后程序显示更新后的信息,让用户看到操作的结果。
当你运行这个程序时,你会看到类似这样的输出:
|=== 玩具工厂管理系统 === 欢迎使用工厂管理系统! 工厂管理系统已启动 产品 '泰迪熊' 添加成功! 产品 '积木套装' 添加成功! 产品 '遥控车' 添加成功! 产品 '拼图' 添加成功! 员工 '张三' (ID: 1001) 添加成功! 员工 '李四' (ID: 1002) 添加成功! 员工 '王五' (ID: 1003) 添加成功! 员工 '赵六' (ID: 1004) 添加成功! 生产记录添加成功:2024-01-15 早班
类对于初学者来说,确实是一个比较抽象和难以理解的概念。但不用担心,随着你编写的代码越来越多,逐步接触和实践类的相关内容,你会慢慢体会到它的强大和灵活。 只要保持耐心,多加练习,类的用法和意义自然会变得清晰起来。
现在让我们通过一些简单的练习题来巩固本章学到的知识。每道题都涵盖了类的核心概念,请先尝试独立完成,然后再查看答案。
设计一个Book类,包含以下功能:
|#include <iostream> #include <string> using namespace std; class Book { private: string title; // 书名 string author; // 作者 double price; // 价格 public: // 构造函数 Book(string t, string a, double p) { title = t; author =
在上面的Book类中,为什么要把成员变量设为private?如果改为public会有什么问题?
|#include <iostream> #include <string> using namespace std; class Book { public: // 如果改为public string title; string author; double price; Book(string t, string a, double p) { title = t; author = a; price =
设计一个Student类,使用静态成员来记录学生总数。每创建一个学生对象,总数就增加1。
|#include <iostream> #include <string> using namespace std; class Student { private: string name; int score; public: static int totalCount; // 静态成员变量,记录学生总数 Student(string n, int s) { name = n; score = s;
设计一个Rectangle(矩形)类,包含长度和宽度,提供计算面积和周长的方法。
|#include <iostream> using namespace std; class Rectangle { private: double length; double width; public: // 构造函数 Rectangle(double l, double w) { length = l; width = w; } // 计算面积 double getArea() {
创建一个简单的BankAccount类,包含账户余额,提供存款和取款的方法,并确保余额不能为负数。
|#include <iostream> using namespace std; class BankAccount { private: double balance; // 余额 public: // 构造函数,初始余额为0 BankAccount() { balance = 0.0; } // 存款 void deposit(double amount) { if (amount > 0) { balance
这个例子展示了类的基本结构:私有成员变量存储数据,公有方法提供访问接口。构造函数用于初始化对象,成员函数可以访问类的所有成员。
将成员变量设为private可以保护数据,防止外部代码直接修改,避免设置无效值(如负数价格)。通过公有方法(如setPrice)可以添加验证逻辑,确保数据的有效性。这就是"封装"的概念:隐藏实现细节,只暴露必要的接口。
静态成员属于类本身,而不是某个对象。所有对象共享同一个静态成员变量。静态成员函数可以直接通过类名调用,不需要创建对象。注意静态成员变量必须在类外定义并初始化。
这个例子展示了如何使用类来组织相关的数据和操作。矩形有长度和宽度(数据),可以计算面积和周长(操作)。类将这些内容组织在一起,使代码更加清晰和易于维护。
这个例子综合运用了类的封装、数据保护和成员函数。通过私有成员变量保护余额,公有方法提供安全的访问接口,并在方法中添加了验证逻辑,确保余额不会变成负数。这展示了类如何帮助我们构建更安全、更可靠的程序。