C++学习-入门到精通-【7】类的深入剖析
C++学习-入门到精通-【7】类的深入剖析
类的深入剖析
- C++学习-入门到精通-【7】类的深入剖析
- 一、Time类的实例研究
- 二、组成和继承
- 三、类的作用域和类成员的访问
- 类作用域和块作用域
- 圆点成员选择运算符(.)和箭头成员选择运算符(->)
- 访问函数和工具函数
- 四、具有默认实参的构造函数
- 重载的构造函数和委托构造函数
- 五、析构函数
- 六、何时调用构造函数和析构函数
- 全局作用域内对象的构造函数和析构函数
- 局部对象的构造函数和析构函数
- static局部对象的构造函数和析构函数
- 七、Time类实例研究:微妙的陷阱——返回private数据成员的引用或指针
- 八、使用=运算符对类对象进行赋值
- 九、const对象和const成员函数
- 十、组成:对象作为类的成员
- 十一、friend函数和friend类
- friend的声明
- 使用friend函数修改类的private数据
- 重载友元函数
- 十二、使用this指针
- 使用this指针来避免名字冲突
- this指针的类型
- 隐式和显式的使用this指针来访问对象的数据成员
- 使用this指针使串联的函数调用成员成为可能
- 十三、stastic类成员
- 使用类范围数据的动机
- 静态数据成员的作用域和初始化
- 访问静态数据成员
- 静态数据成员的使用
一、Time类的实例研究
Time.h
#pragma once // case 1,用于“包含防护”,使得该头文件不会被其他源头文件多次包含
// case 2, 使用下面的语句能达到同样的效果
// #ifndef TIME_H
// #define TIME_H
// ....
// #endif
// or
//#if !define(TIME_H)
//#define TIME_H
//
//#endifclass Time {
public:Time(); // 构造函数void setTime(int, int, int); // 设置时间void printUniversal() const; // 打印universal-time format的时间 -- 格林威治时间格式void printStandard() const; // 打印standard-time format的时间 -- 标准时间格式
private:unsigned int hour;unsigned int minute;unsigned int second;
};
Time.cpp
#include "Time.h"
#include <stdexcept>
#include <iostream>
#include <iomanip>
using namespace std;Time::Time():hour(0), minute(0), second(0)
{}void Time::setTime(int h, int m, int s)
{if ((h >= 0 && h < 24) &&(m >= 0 && m < 60) &&(s >= 0 && s < 60)){hour = h;minute = m;second = s;}else{throw invalid_argument("hour,minute and/or second was out of range.");}
}void Time::printUniversal()const
{cout << setfill('0') << setw(2) << hour << ":"<< setw(2) << minute << ":"<< setw(2) << second << endl;
}void Time::printStandard()const
{cout << ((hour == 0 || hour == 12) ? 12 : (hour % 12)) << ":"<< setfill('0') << setw(2) << minute << ":"<< setw(2) << second << (hour < 12 ? " AM" : " PM") << endl;
}
上面代码的set成员函数中使用了一个在头文件中定义的invalid_argument类,定义了一个该类的对象用以在函数的实参不合法时抛出一个类型为invalid_argument
的异常。客户代码中可以使用之前使用过的try...catch
语句来捕获该异常。跟在类名称后面的圆括号表示对该类的构造函数的一个调用,其中我们指定一个用户自定义的错误信息字符串。在异常对象被创建之后,此throw语句立即终止函数setTime,然后异常返回到尝试设置时间的代码处。
在printUniversal函数中使用流操纵符setfill
,它用于指定当输出位宽大于输出整数值中数字个数时,所显示的填充字符。因为默认情况下数的输出为右对齐,所以填充字符出现在数中数字的左边。当使用left设置为左对齐时,填充字符出现在右边。在这段代码中,如果hour为2,则输出02。注意setfill是一个黏性设置。
提示
每一个黏性设置在不需要之后,应该手动将它恢复为以前的设置,以防出现预期之外的错误。
上面的成员函数都是在类定义的外部定义的。它们通过二元作用域分辨运算符“绑定”到该类,这样的成员函数仍在该类的作用域中。
但是当成员函数定义在类定义的体内时,该成员函数被隐式地声明为inline
的,但是它到底是不是内联由编译器决定。
大家可以发现上面的两个print函数都没有参数,但是它们都知道要打印的数据是什么——数据成员,成员函数是有权访问同一个类的数据成员的。所以在面向对象语言中,使用成员函数可以减少传递错误参数、错误的参数类型或错误的参数个数的可能性。
test.cpp
#include <iostream>
#include <stdexcept>
#include "Time.h"using namespace std;int main()
{Time t; // 实例化一个Time类的对象cout << "The initial Universal time is ";t.printUniversal();cout << "\nThe initial Standard time is ";t.printStandard();t.setTime(13, 27, 6);cout << "Universal time after setTime is ";t.printUniversal();cout << "\nStandard time after setTime is ";t.printStandard();try{t.setTime(99, 99, 99);}catch (invalid_argument& e){cout << "Exception: " << e.what() << endl;}cout << "\n\nAfter attempting invalid settings: "<< "\nUniversal time is ";t.printUniversal();cout << "\nStandard time is ";t.printStandard();cout << endl;
}
运行结果:
在上面的客户代码中,我们尝试使用setTime函数设置一个无效时间99:99:99
,从结果中可以看到setTime函数抛出了invalid_argument
异常,并在客户代码中的try...catch
语句块中处理了该异常。由该异常的what
成员函数打印异常的错误信息。
二、组成和继承
类通常不需要从头开始创建。类可以将其他类的对象包含进来作为其成员,或者由其他能为该类提供可以使用的属性和行为的类派生而来。
包含其他类的对象作为类的成员称为组成,或者称为聚合;
从已有的类派生出新的类称为继承。
对象的大小
对象只包含类中的数据(一些数据成员,static const的数据成员并不保存在对象中,直接保存在类中)。编译器只创建独立于类的所有对象的一份成员函数的副本。该类的所有对象共享这份副本。每个对象只需保存自己的类数据的副本即可。因为在对象之间这些数据是不同的,但是函数代码是不可修改的,所以可以被类的不同对象共享。
三、类的作用域和类成员的访问
类的数据成员和成员函数属于该类的作用域。
在类的作用域内,类的成员可以被类的所有成员函数直接访问,也可以通过名字引用。
在类的作用域外,public类成员通过对象的句柄之一而引用。句柄可以是对象名称、对象的引用或者对象的指针。
类作用域和块作用域
在成员函数中声明的变量具有块作用域,只有该函数知道它们。如果成员函数定义了与类作用域内变量同名的另一个变量,那么在函数中块作用域中的变量将隐藏类作用域中的变量。如果想要访问这样被隐藏起来的变量,可以通过在其名前加类名
和二元作用域分辨运算符(::
)的方法而访问。同样的被隐藏起来的全局变量可以通过一元作用域分辨运算符(::
)来访问。
圆点成员选择运算符(.)和箭头成员选择运算符(->)
加点成员选择运算符(.)前面可以加对象名称或对象的引用,如此就可以访问该对象的成员。
箭头成员选择运算符(->)前面加对象的指针,则可以访问该对象的成员。
通过对象、引用、指针访问类的public成员
考虑一个含有一个public setBalance成员函数的Account类。
Account account;
Account &accountRef = account;
Account *accountPtr = &account;
程序员可以通过如下代码调用成员函数setBalance
// case 1
account.setBalance(123.45);
// case 2
accountRef.setBalance(123.45);
// case 3
accountPtr->setBalance(123.45);
访问函数和工具函数
访问函数
访问函数可以读取或显示数据。访问函数另一个常见用法是测试条件是真是假,常常称这样的函数为判定函数。例如,任何容器类中都有的isEmpty函数就是判定函数。
工具函数
工具函数(也称助手函数),是一个用来支持类的其他成员操作的private
成员函数。它们是用来给成员函数实现功能提供服务的,并不希望被类的客户所使用,所以需要声明为private。
四、具有默认实参的构造函数
Time.h
class Time {
public:Time(int = 0, int = 0, int = 0); // 有三个默认实参的构造函数void setTime(int, int, int); // 设置时间void setHour(int); // 设置小时(在有效性检查之后)void setMinute(int); // 设置分钟(在有效性检查之后)void setSecond(int); // 设置秒数(在有效性检查之后)unsigned int getHour() const;unsigned int getMinute() const;unsigned int getSecond() const;void printUniversal() const; // 打印universal-time format的时间void printStandard() const; // 打印standard-time format的时间
private:unsigned int hour;unsigned int minute;unsigned int second;
};
Time.cpp
#include <iostream>
#include <stdexcept>
#include <iomanip>
#include "Time.h"using namespace std;Time::Time(int h, int m, int s)
{setTime(h, m, s);
}void Time::setTime(int h, int m, int s)
{setHour(h);setMinute(m);setSecond(s);
}void Time::setHour(int h)
{if (h >= 0 && h < 24){hour = h;}else{throw invalid_argument("hour must be 0-23.");}
}void Time::setMinute(int m)
{if (m >= 0 && m < 60){minute = m;}else{throw invalid_argument("minute must be 0-59.");}
}void Time::setSecond(int s)
{if (s >= 0 && s < 60){second = s;}else{throw invalid_argument("second must be 0-59.");}
}unsigned int Time::getHour() const
{return hour;
}unsigned int Time::getMinute() const
{return minute;
}unsigned int Time::getSecond() const
{return second;
}void Time::printUniversal() const
{cout << setfill('0') << setw(2) << getHour() << ":"<< setw(2) << getMinute() << ":"<< setw(2) << getSecond() ;
}void Time::printStandard() const
{cout << setw(2) << ((getHour() == 0 || getHour() == 12) ? 0 : (getHour() % 12))<< ":"<< setfill('0') << setw(2) << getMinute() << ":"<< setw(2) << getSecond() << (getHour() < 12 ? "AM" : "PM");
}
上面代码中的构造函数有三个默认实参。而一个类中只能有一个默认所有实参的构造函数,因为默认所有实参的构造函数是一个默认构造函数,而一个类只能有一个默认构造函数。
其中构造函数并没有直接访问这些private的数据成员,而是使用hour、minute、second各自的设置和获取函数。大家可能会疑惑同一个类中的成员函数和数据成员不是在同一个作用域下吗,应该是可以直接访问的,为何要用一个函数来访问这些成员呢?这不是多此一举吗。
举个例子:
当前的setTime函数是接收3个参数来设置时间的,现在如果要进行修改,只使用一个int类型参数(记录从午夜0点开始的经过的秒数)来设置时间。此时就只需要修改那些直接访问数据成员的函数,其他的函数(比如printUniversal和printStandard)就不需要修改,这样就能便于代码维护且降低改变类的实现时产生编程错误的可能性。
测试代码 test.cpp
#include "Time.h"
#include <iostream>
#include <stdexcept>using namespace std;int main()
{Time t1; // 全部使用默认实参Time t2(1); // 传入一个实参(第一个)Time t3(21,34); // 传入两个实参(前二个)Time t4(12,45,14); // 传入三个实参cout << "Constructed with:\n\nt1: all arguments defaulted\n"<< "Universal time is ";t1.printUniversal();cout << "\nStandard time is ";t1.printStandard();cout << "\n\nt2: hour specified; other arguments defaulted\n"<< "Universal time is ";t2.printUniversal();cout << "\nStandard time is ";t2.printStandard();cout << "\n\nt3: hour and minute specified; second defaulted\n"<< "Universal time is ";t3.printUniversal();cout << "\nStandard time is ";t3.printStandard();cout << "\n\nt4: all arguments specified\n"<< "Universal time is ";t4.printUniversal();cout << "\nStandard time is ";t4.printStandard();// 试图用一个无效时间初始化Time类型的变量try{Time t5(24, 66, 88);}catch (invalid_argument& e){cerr << "\n\nException while initializing t5: " << e.what() << endl;}
}
运行结果:
上面的测试代码中几个变量的初始化还可以使用之前我们提到过的列表初始化器
,例如,上面的代码可以用下面的语句进行初始化:
Time t2{1}; Time t3{21, 34}; Time t4{12, 45, 14};// or Time t2 = { 1 };Time t3 = { 21, 34 };Time t4 = { 12, 45, 14 };
上面的代码中程序员更倾向于使用没有等号的写法。
重载的构造函数和委托构造函数
在之前的章节中我们介绍过了重载函数。类的构造函数和成员函数也可以被重载。通常,重载的构造函数允许用不同类型和(或)数量的实参初始化对象。如果要重载构造函数需要在类的定义中为构造函数提供相应的函数原型,并为各个重载的版本提供独立的构造函数的定义。这同样适用于成员函数。
对于上面代码Time类的构造函数,它有三个形参并均有默认实参。但除了上面使用方法之外,还可以将这个构造函数定义为函数原型如下的四个重载的构造函数。
Time(); // 有3个默认实参的构造函数
Time(int); // 指定小时的构造函数
Time(int, int); // 指定小时和分钟的构造函数
Time(int, int, int); // 所有参数均指定的构造函数
正如类的成员函数可以调用其他成员函数来实现功能,C++11中构造函数也可以调用同一个类的其他构造函数来实现功能。这样构造函数被称为委托构造函数
,它将自己的工作委托给其他构造函数。这种机制对于重载的构造函数具有相同的代码时很有用。而在此之前的解决方法是将这些相同的代码定义在一个private的工具函数中,供所有的构造函数调用。
上面声明的前三个构造函数都可以将工作委托给最后一个构造函数。
Time::Time(): Time(0, 0, 0)
{
}Time::Time(int h): Time(h, 0, 0)
{
}Time::Time(int h, int m): Time(h, m, 0)
{
}
注意:使用委托构造函数时,不可以使用初始化列表初始化其他的数据成员,所有的数据成员只能通过委托的构造函数进行初始化。
五、析构函数
析构函数是另一种特殊的成员函数。类的析构函数的名字是在类名之前加上一个~
字符,该字符是按位取反运算符。在某种意义上析构函数和构造函数互补。析构函数不接收任何参数,也没有返回任何值。
当对象撤销时,类的析构函数会被隐式的调用。例如,当程序的执行离开类的实例化自动对象所在的作用域时,自动对象就会撤销,这时就会发生析构函数的隐式调用。实际上析构函数本身并不释放对象占用的内存空间,它只是在系统回收对象的内存空间之前执行扫尾工作。
之前的所有代码中都没有显式的定义一个析构函数,但是每个类都会有一个析构函数。如果程序员没有显式的定义,那么编译器将会生成一个“空的”析构函数。而这些析构函数到底有什么用呢?隐式生成的析构函数又有什么呢?在下一章我们会对一些包含动态分配内存的类或者使用其他系统资源的类构建合适的析构函数,在组成和继承中对隐式生成的析构函数的作用进行说明。
六、何时调用构造函数和析构函数
编译器隐式的调用构造函数和析构函数。这些函数的调用发生的顺序由执行过程进行和离开对象实例化的作用域的顺序决定。一般而言,析构函数的调用顺序和相应的构造函数的调用顺序相反。但是对象的存储类别可以改变调用构造函数的顺序。
下面我们就从一个例子中看一下构造函数和析构函数的执行顺序:
CreateAndDestrory.h
#pragma once#include <string>class CreateAndDestory
{
public:CreateAndDestory(int, std::string); // 构造函数,接收两个参数,一个表示对象id,一个是描述语句~CreateAndDestory(); // 析构函数
private:int objectID;std::string message;
};
CreateAndDestory.cpp
#include <iostream>
#include <string>
#include "CreateAndDestory.h"using namespace std;CreateAndDestory::CreateAndDestory(int id, string messageStr): objectID(id), message(messageStr)
{// 输出该实例化对象的信息cout << "objectID is " << objectID << "\tconstructor runs\t" << message << endl;
}CreateAndDestory::~CreateAndDestory()
{// 当ID等于1或6时,输出一个换行cout << ((objectID == 1 || objectID == 6) ? "\n" : "");cout << "objectID " << objectID << "\tdestructor runs\t" << message << endl;
}
test.cpp
#include <iostream>
#include "CreateAndDestory.h"using namespace std;CreateAndDestory first(1, "(global before main)");void create();int main()
{cout << "\nMAIN FUNCTION: EXECUTION BEGINS" << endl;CreateAndDestory second(2, "(local automatic in main)");static CreateAndDestory third(3, "(local static in main)");create();cout << "\nMAIN FUNCTION: EXECUTION RESUMES" << endl;CreateAndDestory forth(4, "(local automatic in main)");cout << "\nMAIN FUNCTION: EXECUTION ENDS" << endl;
}void create()
{cout << "\nCREATE FUNCTION: EXECUTION BEGINS" << endl;CreateAndDestory fifth(5, "(local automatic in create)");static CreateAndDestory sixth(6, "(local static in create)");CreateAndDestory seventh(7, "(local automatic in create)");cout << "\nCREATE FUNCTION: EXECUTION ENDS" << endl;
}
运行结果:
从结果中可以看出构造函数和析构函数的调用顺序确实和上述的顺序一样,按执行顺序先后对这些对象进行实例化,如果没有存储类别的变动,析构函数与构造函数的调用相反,上面的代码中,id为1、3和6的存储类别是静态的,所以它们会在程序执行结束时才调用构造函数来进行内存释放之前的扫尾工作。而相同的存储类别的对象,它们的析构函数的调用顺序刚好和构造函数的调用顺序相反。
全局作用域内对象的构造函数和析构函数
全局作用域内定义的对象的构造函数,在文件内任何其他函数(包括main函数)开始执行之前调用。当main函数执行结束时,相应的析构函数被调用。但是使用exit函数可以使用程序立即结束,不执行自动对象
的析构函数。当程序检测到输入中有错误,或者程序要处理的文件不能打开时,常常使用这个函数来终止程序。
局部对象的构造函数和析构函数
当程序执行到自动局部变量的定义处时,该对象的构造函数被调用;当程序执行离开对象的作用域时,相应的析构函数被调用。当程序每次进入或者离开自动对象的作用域时,自动对象的构造函数或者析构函数就会被调用。如果程序的终止是由调用exit函数或者abort函数而完成,那么自动对象的析构函数将不会被调用。
static局部对象的构造函数和析构函数
static局部对象的构造函数只被调用一次(只在程序第一次执行到该对象的定义处被调用)。而相应的析构函数的调用发生在main函数结束或者程序调用exit函数时。如果调用abort函数终止程序,那么static对象的析构函数将不会被调用。
七、Time类实例研究:微妙的陷阱——返回private数据成员的引用或指针
对象的引用就是该对象名称的别名。因此它是一个左值可以在赋值语句的左边使用。下面我们对之前的Time类进行一些简化,将其作为一个错误示例来展示。
Time.h
class Time {
public:Time(int = 0, int = 0, int = 0); // 有三个默认实参的构造函数void setTime(int, int, int);unsigned int getHour() const;unsigned int& badsetHour(int);
private:unsigned int hour;unsigned int minute;unsigned int second;
};
Time.cpp
#include "Time.h"
#include <iostream>
#include <stdexcept>using namespace std;Time::Time(int h, int m, int s)
{setTime(h, m, s);
}void Time::setTime(int h, int m, int s)
{if (h >= 0 && h < 24){hour = h;}else{throw invalid_argument("hour was out of range.");}if (m >= 0 && m < 60){minute = m;}else{throw invalid_argument("minute was out of range.");}if (s >= 0 && s < 60){second = s;}else{throw invalid_argument("second was out of range.");}
}unsigned int Time::getHour() const
{return hour;
}unsigned int& Time::badsetHour(int h)
{if (h >= 0 && h < 24){hour = h;}else{throw invalid_argument("hour was out of range.");}return hour;
}
test.cpp
#include <iostream>
#include "Time.h"using namespace std;int main()
{Time t(11, 23, 34);unsigned int &hourRef = t.badsetHour(20);cout << "Hour before modification: " << t.getHour() << endl;hourRef = 30; // 为小时设置一个无效的值cout << "Hour after modification: " << t.getHour() << endl;// 将badsetHour函数返回的引用作为一个左值使用t.badsetHour(12) = 75;cout << "\n\n**************************\n"<< "POOR PROGRAMMING PRCTICE!!!!!\n"<< "t.badsetHour(12) as a lvalue, invalid hour: "<< t.getHour() << "\n**************************" << endl;
}
运行结果:
从上面的结果中可以看到在程序中我们直接使用了类的private的数据成员,这破坏了类的封装性。同样的使用指针一样会导致在main中能够直接访问到类的private类型的数据成员。
八、使用=运算符对类对象进行赋值
在C++中可以使用赋值运算符进行两个相同类型的对象之间的赋值。默认情况下,这样的赋值通过逐个成员赋值的方式进行,即赋值运算符右边对象的每个数据成员逐一赋值给赋值运算符左边对象中的同一数据成员。但是,当类中存在动态分配内存的数据成员时,使用这种默认的赋值方法会产生严重的错误。这种错误该如何解决我们会在下一章节中介绍。
下面给出使用逐个成员赋值的例子。
Date.h
#pragma onceclass Date
{
public:// month, day, yearDate(int = 1, int = 1, int = 1900);void print() const;
private:int month;int day;int year;
};
Date.cpp
#include <iostream>
#include "Date.h"using namespace std;Date::Date(int m, int d, int y): month(m), day(d), year(y)
{}void Date::print() const
{cout << month << "/" << day << "/"<< year << endl;
}
test.cpp
#include <iostream>
#include "Date.h"using namespace std;int main()
{Date date1(7, 5, 2077);Date date2;cout << "date1 = ";date1.print();cout << "date2 = ";date2.print();date2 = date1;cout << "\nAfter default memberwise assignment." << endl;cout << "date2 = ";date2.print();
}
运行结果:
九、const对象和const成员函数
声明为const的对象是不可修改的,任何尝试修改一个const对象的操作都将导致编译错误。
提示:任何修改const对象的企图在编译时就会被发现,而不是直到执行时才会导致错误
对于const对象,C++编译器不允许进行成员函数的调用,除非调用的这个成员函数本身也声明为const。这一点是非常严格的,即使调用的函数并不会修改对象也不行,这也是我们为什么要将不修改对象的成员函数声明为const的原因。
注意一个声明为const的对象是可以使用构造函数来进行初始化的,虽然构造函数无法声明为const,且当构造函数中调用一个非const的函数来初始化对象时,也是被允许的。
十、组成:对象作为类的成员
一个闹钟类的对象是需要知道什么时候让闹钟响起的,因此可以将一个Time类的对象纳入闹钟类的定义中将其作为它的一个成员。这种功能被称为“组成”,有时也称为“有”关系。一个类可以将其他类的对象作为成员。
下面将介绍构造函数如何通过成员初始化列表完成将参数传递给对象成员构造函数的任务。
成员对象是以在类的定义中的声明顺序(不是在构造函数的成员初始化器列表中列出的顺序)且在包含它们的对象(有时称为宿主对象)构造之前建立。也就是一个对象会在其包含的所有数据成员创建之后才被创建。
下面使用Date类及Employee类演示组成。
Date.h
#pragma onceclass Date
{
public:// 声明为static const的数据成员可以在类定义中进行初始化// 该数据成员存储一年中的有多少个月static const unsigned int monthPerYear = 12;Date(int = 1, int = 1, int = 1900);void print() const;~Date(); // 析构函数
private:unsigned int month;unsigned int day;unsigned int year;// 声明一个工具函数,用于判断该月份对应的日期是否合法unsigned int checkDay(int) const;
};
Date.cpp
#include <iostream>
#include <stdexcept>
#include <array>
#include "Date.h"using namespace std;Date::Date(int m, int d, int y)
{if (m > 0 && m <= monthPerYear){month = m;}else{throw invalid_argument("month should be 1-12.");}year = y;day = checkDay(d);cout << "Date object constructor for date ";print();cout << endl;
}void Date::print() const
{cout << month << "/"<< day << "/"<< year;
}Date::~Date()
{cout << "Date object destructor for date ";print();cout << endl;
}unsigned int Date::checkDay(int d) const
{static const array<int, monthPerYear + 1> daysPerMonth ={ 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };if (d > 0 && d <= daysPerMonth[month]){return d;}// 月份为2月,且日期为29号,此时判断是否为闰年if (month == 2 && d == 29 &&(year % 400 == 0 ||(year % 4 == 0 && year % 100 != 0))){return d;}// 不符合上述两种情况就是参数d不合法throw invalid_argument("Invalid day for current month and year.");
}
Employee.h
#pragma once#include <string>
#include "Date.h"class Employee
{
public:// 包含firstName、lastName、birthDate、hireDateEmployee(const std::string &, const std::string &,const Date &, const Date &);void print() const;~Employee();
private:std::string firstName;std::string lastName;Date birthDate;Date hireDate;
};
Employee.cpp
#include <iostream>
#include "Employee.h"using namespace std;// 使用const的引用,可以避免参数复制导致的开销,且声明为const可以防止这些实参被修改
Employee::Employee(const string& first, const string& last, const Date& birth, const Date& hire):firstName(first),lastName(last),birthDate(birth),hireDate(hire)
{cout << "Employee object constructor: "<< firstName << ' ' << lastName << endl;
}void Employee::print() const
{cout << lastName << ", " << firstName << ", "<< "Hire: ";hireDate.print(); // 调用Date类的成员函数cout << ", Birthday: ";birthDate.print();cout << endl;
}Employee::~Employee()
{cout << "Employee object destructor: "<< lastName << ' ' << firstName << endl;
}
注意在上面的Employee类的构造函数中传入两个Date类的实参,是被传递给了对应对象的构造函数——复制构造函数
。大家可以发现在Date类中,我们并没有定义一个接收Date类对象的参数的构造函数,所以编译器会给每个类提供一个默认的复制构造函数,该函数将构造函数的参数对象的每个成员复制给将要初始化的对象的相应成员。在之后一章我们会介绍如何定义一个自己的复制构造函数。
test.cpp
#include <iostream>
#include "Employee.h"using namespace std;int main()
{Date birth(8, 17, 2005);Date hire(3,14, 2024);Employee manager("Bob", "Blue", birth, hire);cout << endl;manager.print();
}
运行结果:
可以看到在程序结束时,输出了四条Date类对象的销毁信息,说明包含其他类的对象的对象在初始化时的确会调用一次构造函数(复制构造函数)。
还记得我们之前说过的使用成员初始化器列表进行初始化和在构造函数体中进行初始化的区别吗?
如果对象的构造函数没有在初始化器列表中初始化,而是在函数体中进行初始化。实际上,该对象会进行两次初始化,先调用默认的构造函数,对所有数据成员进行初始化,然后再执行构造函数中的语句进行初始化。
所以这就存在问题:
当程序员显式定义了构造函数,但是没有定义默认的构造函数,在不使用初始化器列表进行初始化时,此时程序就会调用默认的构造函数来进行初始化,因为程序员自己定义了构造函数,所以编译器并不会生成默认的构造函数,此时出现编译错误;
十一、friend函数和friend类
类的friend函数(友元函数)在类的作用域之外定义,却具有访问类的非public
(以及public)成员的权限。单独的函数、整个类或其他类的成员函数都可以被声明为另一个类的友元。
friend的声明
在类定义中函数原型前加保留字friend,就将函数声明为该类的友元。
要将一个类中的所有成员函数声明为另一个类的友元,应该在另一个类的定义中加入一条声明。例如现在要将ClassTwo中所有的成员函数声明为ClassOne的友元,应该在ClassOne的声明中加入friend class ClassTwo;
友元关系是授予的不是索取的。也就是说,若使类B成为类A的友元,类A必须显式地声明类B是它的友元。另外,友元关系既不对称也不传递。即如果类A是类B的友元,类B是类C的友元,并不能得出类B是类A的友元、类C是类B的友元的结论,也不能得出类A是类C的友元的结论。
使用friend函数修改类的private数据
下面例子中定义了一个友元函数setX来设置Count类中的private数据x的值。友元声明可以出现在类的任何地方,但是习惯于将友元设置在类的定义最上面,甚至出现在pubilic成员函数声明之前。
Count.h
#pragma onceclass Count
{// Count类的友元函数friend void setX(Count &, int);
public:Count();void print() const;
private:int x;
};
Count.cpp
#include <iostream>
#include "Count.h"using namespace std;Count::Count():x(0) // 将x初始化为0
{// empty body
}void Count::print() const
{cout << x << endl;
}void setX(Count& count, int val)
{count.x = val;
}
test.cpp
#include <iostream>
#include "Count.h"using namespace std;int main()
{Count counter;cout << "counter.x after instantiation: ";counter.print();setX(counter, 8);cout << "counter.x after call to setX friend function: ";counter.print();
}
运行结果:
重载友元函数
可以指定重载函数为类的友元。每个打算成为友元的重载函数必须在类的定义时显式地声明为类的一个友元。
提示:即使一个友元函数的原型在类定义内出现,它仍然不是成员函数。
十二、使用this指针
从上面的代码中我们可以看到对象的成员函数可以操作对象的数据成员。那么在调用成员函数时,它们是如何知道是哪个对象的数据成员要被操作呢?
每个对象都可以使用一个被称为this
(一个C++的保留字)的指针来访问自己的地址。对象的this指针并不是对象的一部分,也就是this指针占用的内存大小不会反映到在对象进行sizeof运算得到的结果中。相反,this指针作为一个隐式的参数(被编译器)传递给对象的每个非static成员函数。
使用this指针来避免名字冲突
对象隐式地使用this指针或者显式地使用this指针来引用它们的数据成员和成员函数。一个常见的this指针的明确应用就是用来避免类数据成员和成员函数的参数之间的名字冲突。例如,在Time类中,成员函数setHour的形参名为hour与数据成员hour同名,在该函数中使用hour = hour;
进行hour数据成员的赋值时,实际上执行的语句是this->hour = hour;
,它明确的指出前面的hour是类的数据成员,且由于当局部变量和作用域比它更大的变量在该作用域使用时,局部变量会隐藏作用域更的变量,使用this指针使得该成员函数可以访问该数据成员。
所以为了代码的简洁和可维护性,在设置形参名时,不要让本地变量名称隐藏数据成员。
this指针的类型
this指针的类型取决于对象的类型及使用this的成员函数是否被声明为const。例如在Employee类的非const成员函数中,this指针具有的类型为Employee * const
(指针指向的地址不能发生改变,该地址对应的内容可以改变)。在Employee类的const成员函数中,this指针具有类型为const Employee * const
(地址和地址对应的内容都不能发生改变)。
隐式和显式的使用this指针来访问对象的数据成员
下面给出一个例子来演示隐式和显式的使用this指针。
Test.h
#pragma onceclass Test
{
public:explicit Test(int = 0);void print() const;
private:int x;
};
Test.cpp
#include <iostream>
#include "Test.h"using namespace std;Test::Test(int val): x(val)
{// empty body
}void Test::print() const
{cout << " x = " << x << endl;cout << " this->x = " << this->x << endl;cout << "(*this).x = " << (*this).x << endl;
}
test.cpp
#include <iostream>
#include "Test.h"using namespace std;int main()
{Test testObject(12);testObject.print();
}
运行结果:
使用this指针使串联的函数调用成员成为可能
this指针的另一种用法是使串联的成员函数调用成为可能,也就是多个函数在同一个语句中被调用。下面对Time类进行修改,使得设置函数setTime、setHour、setMinute和setSecond返回一个对Time对象的引用,以便进行串联的成员函数调用。上述的成员函数都在其函数体的最后一条语句返回*this
,返回类型为Time &
。
Time.h
class Time {
public:Time(int = 0, int = 0, int = 0); // 有三个默认实参的构造函数Time& setTime(int, int, int); // 设置时间Time& setHour(int); // 设置小时(在有效性检查之后)Time& setMinute(int); // 设置分钟(在有效性检查之后)Time& setSecond(int); // 设置秒数(在有效性检查之后)unsigned int getHour() const;unsigned int getMinute() const;unsigned int getSecond() const;void printUniversal() const; // 打印universal-time format的时间void printStandard() const; // 打印standard-time format的时间
private:unsigned int hour;unsigned int minute;unsigned int second;
};
Time.cpp
#include <iostream>
#include <stdexcept>
#include <iomanip>
#include "Time.h"using namespace std;Time::Time(int h, int m, int s)
{setTime(h, m, s);
}Time& Time::setTime(int h, int m, int s)
{setHour(h);setMinute(m);setSecond(s);return *this;
}Time& Time::setHour(int h)
{if (h >= 0 && h < 24){hour = h;}else{throw invalid_argument("hour must be 0-23.");}return *this;
}Time& Time::setMinute(int m)
{if (m >= 0 && m < 60){minute = m;}else{throw invalid_argument("minute must be 0-59.");}return *this;
}Time& Time::setSecond(int s)
{if (s >= 0 && s < 60){second = s;}else{throw invalid_argument("second must be 0-59.");}return *this;
}unsigned int Time::getHour() const
{return hour;
}unsigned int Time::getMinute() const
{return minute;
}unsigned int Time::getSecond() const
{return second;
}void Time::printUniversal() const
{cout << setfill('0') << setw(2) << getHour() << ":"<< setw(2) << getMinute() << ":"<< setw(2) << getSecond();
}void Time::printStandard() const
{cout << ((getHour() == 0 || getHour() == 12) ? 12 : (getHour() % 12)) << ":"<< setfill('0') << setw(2) << getMinute() << ":"<< setw(2) << getSecond() << (getHour() >= 12 ? " PM" : " AM");
}
test.cpp
#include <iostream>
#include "Time.h"using namespace std;int main()
{Time t;// 串联调用t.setHour(18).setMinute(30).setSecond(22);cout << "Universal time: ";t.printUniversal();cout << "\nStandard time: ";t.printStandard();cout << "\n\nNew Standard time: ";t.setTime(20, 20, 20).printStandard();
}
运行结果:
十三、stastic类成员
对于类的每个对象来说,一般都满足一条规则,即它们各自拥有类所有数据成员的一份副本。但是,有一个例外——static数据成员,这些变量仅有一份副本供类的所有对象共享。
使用类范围数据的动机
下面我们举个例子来说明,假设现在有一个关于火星人和虫族的游戏,当火星人意识到至少有5个火星人存在时,每个火星人将获得勇气buff,增加对虫族造成的伤害降低虫族对自己造成的伤害;当存在的火星人少于5个时,火星人获得胆怯debuff,每个火星人都会变成怯战蜥蜴,降低对虫族造成的伤害,增加虫族对自己造成的伤害。因此每个火星人都需要知道火星人的数量martianCount。我们可以将martianCount作为Martian类的每个实例的数据成员。如果真是这样做的话,每个Martian对象都将有一份独立的该数据成员的副本。每次创建新的Martian对象时,都不得不更新所有Martian对象的数据成员martianCount,这就需要每个Martian对象都具有或者可以访问内存中其他Martian对象的句柄。所以这些多余的副本将浪费空间,并且更新每份单独的副本也将浪费时间。为此,我们将martianCount声明为stastic。这样使得martianCount成为类范围的数据。每个Martian类对象都可以访问martianCount,就好像它就是这个类对象的数据成员一样,但是实际上只有一份副本由C++进行维护。这样就节省了空间。此外通过用Martian构造函数使static变量martianCount的值自增,通过Martian的析构函数使martianCount的值自减,从而节省时间。
静态数据成员的作用域和初始化
尽管类的static数据成员看上去就像是全局变量,但是它们只在类的作用域中起作用。静态数据成员必须被精确地初始化一次。基本类型的static数据成员默认情况下会被初始化为0。在C++11之前,static const
的int或enum类型的数据成员能够在声明的时候进行初始化,而其他的静态数据成员必须在全局命名空间(类定义之外)中被定义和初始化。在C++11中,类内初始化能允许你在类定义中变量声明的位置初始化它。注意类类型的static数据成员(即static成员对象),如果这个类类型具有默认的构造函数,那么这样的数据成员无须初始化(进行初始化也会出错),因为它们的默认构造函数将会被调用。
注意仅声明为static的数据成员无法在类内进行初始化,必须是声明static const的数据成员才行
访问静态数据成员
类的private和protected的static成员通常通过类的public成员函数或者类的友元访问。即使没有任何类的对象存在时,类的static数据成员仍然存在。当没有类的对象存在时,要访问public的static数据成员可以在前面加上类名和二元作用域分辨运算符(::)来访问。比如,之前提到的martianCount是Martian类的一个public static数据成员,就可以使用Martian::martianCount
来访问。
但是当没有类的对象存在,要访问private或protected的static类成员时,应提供public static类成员函数,并通过在函数名前面加上类名和二元作用域分辨运算符来调用该函数。每个static成员函数都是类的一项服务,而不是类的特定对象的一项服务。
即使不存在已实例化的类的对象,类的static数据成员和static成员函数仍存在并且可以使用。
静态数据成员的使用
下面对Employee类提供一个static的数据成员count用于记录雇员的人数。
Employee.h
#include <string>class Employee
{
public:// 雇员的姓和名Employee(const std::string&, const std::string&);~Employee();std::string getFirstName() const;std::string getLastName() const;// 提供一个static的成员函数用于获取static的数据成员countunsigned int static getCount(); // 静态成员函数不可以使用类型限定符
private:std::string firstName;std::string lastName;// 该类的所有对象共享该数据成员static unsigned int count;
};
Employee.cpp
#include <string>
#include <iostream>
#include "Employee.h"using namespace std;// 初始化static成员count为0,该类定义时没有一个雇员,所以初始化为0
unsigned int Employee::count = 0;// 定义static成员函数,用于获取static成员count,它作为成员函数可以直接访问所有数据成员
unsigned int Employee::getCount()
{return count;
}Employee::Employee(const string& first, const string& last): firstName(first),lastName(last)
{cout << "Employee constructor for " << getFirstName() << ' ' << getLastName() << " called" << endl;// 对count自增count++;
}Employee::~Employee()
{cout << "Employee destructor for " << getFirstName() << ' ' << getLastName() << " called" << endl;// count自减count--;
}string Employee::getFirstName() const
{return firstName;
}string Employee::getLastName() const
{return lastName;
}
test.cpp
#include <iostream>
#include "Employee.h"using namespace std;int main()
{cout << "Number of employeess before instantiation of any objects is "<< Employee::getCount() << endl;{// 实例化两个Employee对象Employee e1("Bob", "Griffin");Employee e2("Alice", "Baker");cout << "Number of employees after objects are instantiated is "<< Employee::getCount() << endl;cout << "\n\nEmployee 1: "<< e1.getFirstName() << ' ' << e1.getLastName() << endl<< "Employee 2: "<< e2.getFirstName() << ' ' << e2.getLastName() << "\n\n";}cout << "Number of employees after objects are deleted is "<< Employee::getCount() << endl;
}
运行结果:
提示
static成员函数不具有this指针,因为static数据成员和static成员函数独立于类的任何对象而存在。this指针必须指向类的具体的对象。但是当static的成员函数被调用时,可能内存中并不存在该类的任何对象。
相关文章:

C++学习-入门到精通-【7】类的深入剖析
C学习-入门到精通-【7】类的深入剖析 类的深入剖析 C学习-入门到精通-【7】类的深入剖析一、Time类的实例研究二、组成和继承三、类的作用域和类成员的访问类作用域和块作用域圆点成员选择运算符(.)和箭头成员选择运算符(->)访问函数和工具函数 四、具有默认实参的构造函数重…...
API 加速方案:如何使用 Redis 与 Memcached 进行高效缓存优化
API 加速方案:如何使用 Redis 与 Memcached 进行高效缓存优化 1. 引言 在现代 Web 开发中,API 响应速度至关重要。用户期望实时访问数据,而后端服务可能受到数据库查询、计算开销或网络传输的限制。这时候,缓存技术可以有效减少 API 延迟,提升系统性能。 本篇文章将深入…...

主成分分析的应用之sklearn.decomposition模块的PCA函数
主成分分析的应用之sklearn.decomposition模块的PCA函数 一、模型建立整体步骤 二、数据 2297.86 589.62 474.74 164.19 290.91 626.21 295.20 199.03 2262.19 571.69 461.25 185.90 337.83 604.78 354.66 198.96 2303.29 589.99 516.21 236.55 403.92 730.05 438.41 225.80 …...

1. Go 语言环境安装
👑 博主简介:高级开发工程师 👣 出没地点:北京 💊 人生目标:自由 ——————————————————————————————————————————— 版权声明:本文为原创文章…...

IP协议深度解析:互联网世界的核心基石
作为互联网通信的基础协议,IP(Internet Protocol)承载着全球99%的网络数据流量。本文将深入剖析IP协议的核心特性、工作原理及演进历程,通过技术原理、协议对比和实战案例分析,为您揭示这个数字世界"隐形交通规则…...

Oracle DBMS_STATS.GATHER_DATABASE_STATS 默认行为
Oracle DBMS_STATS.GATHER_DATABASE_STATS 默认行为 DBMS_STATS.GATHER_DATABASE_STATS的默认选项究竟是’GATHER’还是’GATHER AUTO’?这个问题非常重要,因为理解默认行为直接影响统计信息收集策略。 一 官方文档确认 根据Oracle 19c官方文档&#…...

C++天空之城的树 全国信息素养大赛复赛决赛 C++小学/初中组 算法创意实践挑战赛 内部集训模拟题详细解析
C++天空之城的树 全国青少年信息素养大赛 C++复赛/决赛模拟练习题 博主推荐 所有考级比赛学习相关资料合集【推荐收藏】1、C++专栏 电子学会C++一级历年真题解析...
HTTP 请求走私(HTTP Request Smuggling)
HTTP 请求走私(HTTP Request Smuggling)是一种通过利用前端代理(如负载均衡器、CDN)和后端服务器在 解析 HTTP 请求时存在不一致性 的漏洞,从而实现 注入恶意请求 的攻击技术。 一、基本原理 HTTP 请求走私主要依赖两…...
基于WebRTC的实时语音对话系统:从语音识别到AI回复
基于WebRTC的实时语音对话系统:从语音识别到AI回复 在当今数字化时代,实时语音交互已成为人机界面的重要组成部分。本文将深入探讨一个基于WebRTC技术的实时语音对话系统,该系统集成了语音识别(ASR)、大语言模型(LLM)和语音合成(TTS)技术&am…...
typeof运算符和深拷贝
typeof运算符 识别所有值类型识别函数判断是否是引用类型(不可再细分) //判断所有值类型 let a; typeof a //undefined const strabc; typeof str //string const n100; typeof n //number const …...
.Net HttpClient 使用 Cookie
在 HttpClient 中使用 Cookie Cookie 是服务器存储在客户端的小型数据片段,可用于身份验证、会话跟踪等。 .Net HttpClient 支持 Cookie 功能,本教程详细介绍了Cookie 的管理与使用。 初始化 #!import "./Ini.ipynb"什么是 Cookie Cookie …...
Python爬虫实战:通过PyExecJS库实现逆向解密
1. 核心定义 1.1 PyExecJS 库 PyExecJS 是 Python 的第三方库,通过调用 JavaScript 运行时环境(如 Node.js、PhantomJS),实现 Python 与 JavaScript 的无缝交互。其核心功能包括: JavaScript 代码编译与执行跨语言函数调用与数据传递多引擎支持与自动环境检测1.2 字段加…...
Java中的伪共享(False Sharing):隐藏的性能杀手与高并发优化实战
引言 在高性能Java应用中,开发者通常会关注锁竞争、GC频率等显性问题,但一个更隐蔽的陷阱——伪共享(False Sharing)——却可能让精心设计的并发代码性能骤降50%以上。伪共享是由CPU缓存架构引发的底层问题,常见于多…...

GO语言语法---switch语句
文章目录 基本语法1. 特点1.1 不需要break1.2 表达式可以是任何类型1.3 省略比较表达式1.4 多值匹配1.5 类型switch1.6 case穿透1.7 switch后直接声明变量1.7.1 基本语法1.7.2 带比较表达式1.7.3 不带比较表达式1.7.4 结合类型判断 1.8 switch后的表达式必须与case语句中的表达…...

开疆智能Profient转ModbusTCP网关连接ABB机器人MODBUS TCP通讯案例
本案例是通过开疆智能Profinet转ModbusTCP网关将西门子PLC与ABB机器人进行通讯 因西门子PLC采用Profinet协议,而ABB机器人采用的是ModbusTCP通讯。故采取此种方案。 配置过程: 1.MODBUS/TCP基于以太网,故ABB机器人在使用时需要有616-1PCIN…...

解决qt.network.ssl: QSslSocket::connectToHostEncrypted: TLS initialization failed
可以参考:解决qt.network.ssl: QSslSocket::connectToHostEncrypted: TLS initialization failed-CSDN博客 讲的是程序执行目录下可能缺少了: libssl-1_1-x64.dll 和 libcrypto-1_1-x64.dll 库文件,将其复制到可执行文件exe的同级目录下即可…...
【洛谷P3386】二分图最大匹配之Kuhn算法/匈牙利算法:直观理解
题目:洛谷P3386 【模板】二分图最大匹配 🥕 匈牙利算法本来是针对带权图最大匹配的,这里由于题目只是求最大匹配的边数,所以我们也只考虑无权的情况。 🚀 本文旨在服务于看了别的关于匈牙利算法的文章但不甚理解的童…...

Text2SQL:自助式数据报表开发---0517
Text2SQL技术 早期阶段:依赖于人工编写的规则模板来匹配自然语言和SQL语句之间的对应关系 机器学习阶段:采用序列到序列模型等机器学习方法来学习自然语言与SQL之间的关系 LLM阶段:借助LLM强大的语言理解和代码生成能力,利用提示…...

使用Visual Studio将C#程序发布为.exe文件
说明 .exe 是可执行文件(Executable File)的扩展名。这类文件包含计算机可以直接运行的机器代码指令,通常由编程语言(如 C、C、C#、Python 等)编译或打包生成。可以用于执行自动化操作(执行脚本或批处理操…...
写spark程序数据计算( 数据库的计算,求和,汇总之类的)连接mysql数据库,写入计算结果
1. 添加依赖 在项目的 pom.xml(Maven)中添加以下依赖: xml <!-- Spark SQL --> <dependency> <groupId>org.apache.spark</groupId> <artifactId>spark-sql_2.12</artifactId> <version>3.3.0…...

React Flow 边的基础知识与示例:从基本属性到代码实例详解
本文为《React Agent:从零开始构建 AI 智能体》专栏系列文章。 专栏地址:https://blog.csdn.net/suiyingy/category_12933485.html。项目地址:https://gitee.com/fgai/react-agent(含完整代码示例与实战源)。完整介绍…...

oracle 资源管理器的使用
14.8.2资源管理器的使用 资源管理器控制CPU资源使用说明: 第一种分配方法:EMPHASIS CPU 分配方法确定在资源计划中对不同使用者组中的会话的重视程度。CPU占用率的分配级别为从1 到8,级别1 的优先级最高。百分比指定如何将CPU 资源分配给每…...
新手入门系列-linux系统下安装和使用docker
新手入门系列一 virtualbox+vagrant创建linux虚拟机 新手入门系列二 linux系统下安装和使用docker 前言 前面一章节我们安装了unbuntu虚拟机,这一节我们在虚拟机上安装和使用docker。 Docker 是一个开源的应用容器引擎,让开发者可以打包他们的应用以及依赖包到一个可移植的…...
mysql中4种扫描方式和聚簇索引非聚簇索引【爽文一篇】
目录 一 mysql的聚簇索引&非聚簇索引 1.1 数据表 1.2 聚簇索引 1.3 非聚簇索引 1.4 覆盖索引 二 mysql的4种扫描查询 2.1 全表扫描 2.2 索引扫描 2.3 覆盖索引扫描 2.4 回表扫描 2.5 总结 三 mysql的回表查询详解 3.1 回表查询 一 mysql的聚簇索引&非聚簇…...

贝叶斯优化Transformer融合支持向量机多变量回归预测,附相关性气泡图、散点密度图,Matlab实现
贝叶斯优化Transformer融合支持向量机多变量回归预测,附相关性气泡图、散点密度图,Matlab实现 目录 贝叶斯优化Transformer融合支持向量机多变量回归预测,附相关性气泡图、散点密度图,Matlab实现效果一览基本介绍程序设计参考资料…...
水平可见直线--上凸包(andrew算法
P3194 [HNOI2008] 水平可见直线 - 洛谷 不过只有90% #include<bits/stdc.h> using namespace std; #define N 100011 typedef long long ll; typedef pair<ll,int> pii; int n; struct no {double k,b;int id; }a[N],an[N]; int k; bool cmp(no a,no b) {if(a.k…...
【mysql】并发 Insert 的死锁问题 第二弹
上次死锁的场景还历历在目(【mysql】并发 Insert 的死锁问题:Deadlock found when trying to get lock; try restarting transaction_1213 - deadlock found when trying to get lock; try-CSDN博客),这次又把代码写死…...

Docker配置SRS服务器 ,ffmpeg使用rtmp协议推流+vlc拉流
目录 演示视频 前期配置 Docker配置 ffmpeg配置 vlc配置 下载并运行 SRS 服务 推拉流流程实现 演示视频 2025-05-18 21-48-01 前期配置 Docker配置 运行 SRS 建议使用 Docker 配置 Docker 请移步: 一篇就够!Windows上Docker Desktop安装 汉化完整指…...

一个stm32工程从底层上都需要由哪些文件构成
原文链接:https://kashima19960.github.io/2025/05/17/stm32/一个stm32工程从底层上都需要由哪些文件构成/ 前言 我最近因为做课设要用到stm32,所以去找了一些开源的stm32工程来看看,然后发现现在新版的keil mdk对于环境的配置跟以前 相比发…...

[Mac] 开发环境部署工具ServBay 1.12.2
[Mac] 开发环境部署工具ServBay 链接:https://pan.xunlei.com/s/VOQS0LDsC_J6XU4p-R6voF6YA1?pwdnbyg# 非常给力的本地 Web 开发/测试环境工具:ServBay。之前我们本地搭个 PHP MySQL Nginx 环境,或者搞个 PHP web 环境啥的,不…...