概述
C++的招牌能力之一,也是C++的核心特性没有之一, 也是在 C 基础扩展的最重要的能力,一切皆可封装为对象,有三大主要特性,封装、多态、继承。
基础
简单理解,类就是用户自定义的一种数据结构,封装了数据和行为(函数)的组合。类中的数据称为成员变量,函数称为成员函数。类可以被看作是一种模板,可以用来创建具有相同属性和行为的多个对象,类定义格式如下。
class ClassName {
Access Specifiers: // 访问修饰符 public、private、
type VarName; // 成员变量
type function(){} // 成员函数
}
定义名称为 Person
的类,包含一个成员变量、成员函数
class Person {
public:
string name;
void say() {
std::cout << "Im " << this->name << endl;
}
}
上面示例,在类的内部使用了this
指针访问成员属性name
,也可以使用作用域解析运算符::
,调整 say 函数代码如下,效果相同
void say() {
std::cout << "Im " << Person::name << endl;
}
然后,以该类为模板创建对象实体
Person p;
p.name = "tom";
p.say();
和声明普通变量类似,创建了对象p
,同时修改了 name
属性和调用 say
函数,访问成员使用.
点运算符。
通过指针方式访问成员属性
Person *ptr = &p;
// 两种方式都可以
(*ptr).say();
ptr->say();
上面示例中,两种方能等价,(*ptr).say()
不能写成*ptr.say();
,因为点运算符.
的优先级高于*
,这种写法会将t.age
看成一个指针,然后取它对应的值,会出现无法预料的结果,因为写法很麻烦C 语言引入新的箭头运算符->
,在 C++中得到沿用,并扩大到对象属性
权限修饰符可控制成员的可见范围,C++支持三种修饰符,分别是:
public
,公开属性,对象内部、外部都可以访问和修改private
,私有属性,默认属性,仅对象内部可以访问和修改protected
,受保护属性,对象内部、子类可以访问和修改
简单示例
class Person {
public:
string name;
private:
int age;
protected:
string address;
}
看起来类和结构体定义比较相似,的确两者部分特性相同,但有本质区别,简单总结如下
- 两者都是自定义复合数据结构,可组合数据和方法
- 结构体的属性默认是
public
且不允许修改,类属性默认是private
并支持修改 - 类支持继承,结构体不支持继承
- 类支持构造函数、析构函数,结构体不支持
- 类提供了封装机制,可以隐藏内部实现,只暴露必要的接口;结构体通常用于存储数据,不太注重封装。
- 类支持运算符重载,结构体不支持
- 类支持模板类,结构体不支持
总结,类和结构体在C++中都用于定义自定义数据类型,但类更注重封装、继承和多态,而结构体更注重存储数据。
构造函数
构造函数是一种特殊的成员函数,在创建对象时自动执行,主要用于初始化对象,从类型可以分为:无参构造函数、有参构造函数、拷贝构造函数。构造函数的定义也有所区别,首先函数名必须和类名称相同,并且没有返回值(注意void
都不需要),不能手动调用,支持初始化列表,支持重载。
构造函数示例,分别定义了三种类型的构造函数
class Person {
public:
string name;
Person() { // 无参构造函数
this->name = "tom";
}
Person(string name) { // 有参构造函数
this->name = name;
}
Person(Person const &p) { // 拷贝构造函数
this->name = p.name;
}
}
创建对象,根据参数自动匹配对应的构造函数,并调用执行
Person p; // 调用无参构造函数
Person p1("jerry"); // 调用有参构造函数
Person p2(p1); // 调用拷贝构造函数
特别注意,调用无惨构造函数时候没有小括号 ()
,和函数申明语法冲突了,两者格式一样产生二义性,编译器无法识别是函数申明、还是调用构造函数。
C++规范所有的类有两个默认构造函数,由系统隐式提供,分别是无惨构造函数、拷贝构造函数。默认的拷贝构造函数,自动拷贝所有的属性到新对象上,使用浅拷贝,可以重载实现深拷贝。
class Person {
public:
string name;
Addr *p_addr; // 增加一个指针类型成员变量
Person(Person const &p) { // 重载拷贝函数
this->name = p.name;
this->p_addr = new Addr(*p.p_addr); // 深度拷贝,在堆申请内存并创建新对象,记得在析构函数中释放堆内存
}
}
如果重载了构造函数,系统有可能不再提供默认构造函数,遵循如下规则:
- 重载有参构造函数,系统就不再提供默认无惨构造函数
- 重载拷贝构造函数,系统就不再提供默认无惨构造函数、拷贝构造函数。
一般情况如果重载了构造函数,会显示再添加一个无参构造,如下示例
class Person {
public:
Person(string name) { // 重载有参构造函数
...
}
Persion() = default; // 使用C++11默认构造函数特性
}
C++支持多种方式调用构造函数,也就是多种不同的方式创建对象,Java
程序员可能感到惊讶,分别有如下三种
括号法
和申明变量的格式一样,申请内存,创建对象,调用对应构造函数初始化对象
Person p; // 调用无参构造函数
Person p1("jerry"); // 调用有参构造函数
Person p2(p1); // 调用拷贝构造函数
显示法
有点类似函数调用,返回匿名对象
Person p = Person() // 调用无参构造函数
Person p1 = Person("jerry"); // 调用有参构造函数
Person p2 = Person(p1); // 调用拷贝构造函数
隐式法
最诡异的调用方式,看起来是给变量赋值。单参数的构造函数有个默认隐藏技能,类型转换操作符,当等号右边的类型恰好匹配构造的参数类型,就会调用对应构造函数。
Person p1 = "jerry"; // 调用有参构造函数
Person p2 = p1; // 调用拷贝构造函数
上面示例中两个语句都触发类型转换运算符,会调用对应的构造函数。特别是p2 = p1
语句,看起来p2
等于p1
赋值语句,其实底层调用拷贝构造函数,两者是完全独立的对象。
这个技能看起来很酷,但在某些情况下容易产生困扰,C++提供了一个修饰符 explicit
,被修饰的构造器关闭类型转换操作符的功能
class Person {
public:
string name;
explicit Person(Person const &p) { // 修饰, 关闭类型转换
this->name = p.name;
}
}
隐式调用创建对象
// 创建对象
Person p;
Person p1 = p; // 编译失败
简单总结,使用哪种方式都可以,要统一风格。
调用方法 | 无参构造 | 有参构造 | 拷贝构造 |
---|---|---|---|
括号法 | 有限支持 | 支持 | 支持 |
显示法 | 支持 | 支持 | 支持 |
隐式法 | 不支持 | 有限支持 | 支持 |
使用new
创建对象,任何创建对象的方式前都可以加new
关键字,会改变对象存储的位置,将存储在堆内存中,创建过程:申请堆内存,创建对象,返回指针
Person *p = new Person("jerry");
if (p == NULL) {
exit(-1);
}
p->say();
delete p;
示例中,创建的对象就存储在堆内存,如果分配失败则退出程序,程序最后释放堆内存。
初始化列表,构造函数的特有技能,可在函数定义中增加增加初始化信息,在函数执行前就完成对象属性的初始化
class Person {
public:
string name;
explicit Person(const string &new_name) : name(new_name) {}
}
上面示例,函数定义中的: name(name)
就是初始化列表,在函数执行前,使用实参new_name
初始化对象的name
属性,函数逻辑为空,也完成了赋值初始化,当然可以在函数继续修改。
初始化参数列表,还可以用于默认值初始化
class Person {
public:
string name;
Person() : name("tom") {} // 构造函数
}
上面示例,在无参构造函数上增加了初始化列表,参数是固定的 tom
,只要无参构造函数创建对象name
属性总是tom
。
析构函数
与构造函数相对应的析构函数,对象销毁时自动执行,主要用于释放资源,如堆内存、文件描述符等,函数定义也有特定格式,函数名称是类名前加波浪号、没有返回值(注意void
都不需要)、不能有参数、不支持重载
class Person {
public:
string name;
Person() : name(new_name) {} // 构造函数
~Person() { // 析构函数
...
}
}
对象销毁收会自动执行析构函数,如果对象在栈存储则退出栈时候执行,如果在堆存储则释放内存时候执行。
函数传参
对象也可以做为函数参数传递,注意:和结构体特性一样是值传递,形参和实参是不相等,也就是说在传递过程中会发生对象拷贝,Java
程序员可能又会感到惊讶。
定义了一个函数,形参是Person
类型
void match_name(Person person) {
if (person.name == "tom") {
// ...
}
}
调用该函数,注意:此时会触发对象拷贝,创建的p
和传入的p
是两个独立对象,底层是调用对象的拷贝构造函数实现。
Person p("tom");
metch_name(p);
函数的返回值如果是对象,也会触发对象拷贝,如下示例
Addr match_name(Person person) { // 返回值是addr对象
if (person.name == "tom") {
Addr *addr = person.p_addr;
return *addr;
}
}
调用该函数,也会触发函数拷贝,返回的addr
和接收addr
是两个独立的对象,底层也调用 addr
拷贝构造函数实现
Person p("tom");
Addr addr = match_name(p);
这种特性并不友好,大多是情况下都是希望传递对象自身,而不是拷贝后的新对象,可能是沿用了 C 语言的结构体特性,C++的对象是在结构体基础扩充而来的。解决方案有两种,分别是指针传递和引用传递,其实两者本质一样,都是地址传递,引用传递简化了语法,更加推荐引用传递方式。下面是两种传递方式示例。
使用指针传递
void match_name(Person const *person) {
if (person->name == "tom") {
...
}
}
调用
Person p("tom");
Addr addr = match_name(&p);
使用引用传递,使用更加简洁
void match_name(Person const &person) {
if (person.name == "tom") {
...
}
}
调用
Person p("tom");
Addr addr = match_name(p);
特别注意的是返回值,如果使用指针或引用返回对象类型,一定要确保指针不能指向局部变量,局部变量存储在栈中,函数调用结束就随之销毁了,返回的地址就是野指针
Addr* match_name(Person const &person) {
return person.p_addr; // ok
// ok, 指向堆内存
// return new Addr(*(person.p_addr));
// err, 指向局部变量了,野指针
// Addr addr(*(person.p_addr));
// return &addr;
}
this 指针
这是个比较特殊的成员变量,由系统默认提供,它是一个指针,总是指向当前的对象实例。和 Java 的 this、python 的 self 等功能类似
简单示例
class Person {
public:
string name;
explicit Person(string const &name) : name(name) {}
void say() {
cout << "Im " << this->name << endl;
}
};
创建两个对象,并执行 say
函数
Person p1("tom");
p1.say(); // Im tom
Person p2("jerry");
p2.say(); // Im Jerry
相同的语句this->name
读取内容不一样,因为this
总是指向当前的对象
代码调整下,把类定义中的this->name
调整为 name
,然后看看效果
void say() {
cout << "Im " << name << endl;
}
和上面的示例一样,创建两个对象,然后执行 say
Person p1("tom");
p1.say(); // Im tom
Person p2("jerry");
p2.say(); // Im Jerry
可以发现两者完完全一样。这个读取变量优先级有关系:局部变量->对象变量->全局变量,逐层向上查找,如果是对象变量,系统会自动补齐this
指针。
如果对象属性和局部变量同名时,又想访问对象的成员变量,就可以使用this
指针,精确控制读取位置
class Person {
private:
string name;
public:
void setName(string const &name) {
this->name = name; // this指针
}
};
如上示例,使用this
指针,把局部变量name
赋值给对象变量name
。也可以总显示的使用this
指针,代码指向更加清晰。
还有个重要作用,如果成员函数希望返回对象本身,就可以使用this
class Person {
private:
string name;
int age;
public:
Person* setName(string const &name) {
this->name = name;
return this;
}
Person* setAge(int const &age) {
this->age = age;
return this;
}
};
使用示例,有点类似 Java
常用的Build
技能
Person p;
p1.setName("tom").setAge(10);
另外特别注意, 指针this
是被 const
修饰过的指针常量,也就是说不允许修改的指向位置,但是可以修改指向的值。声明this
的伪代码如下
Person * const this;
如下示例,如果修改this
指向将编译失败
void setName(string const &name) {
this = NULL: // err
}
const 修饰
被const
修饰的变量为只读变量,也可以用于修饰类的多个位置,分别有不同的功能,逐一介绍
修饰成员属性,可称为常属性,就算是 public 的成员属性,只要被修饰都变成只读,不可修改
class Person {
public:
int age;
string const name; // 修饰
void setName(string const &name) {
this->name = name; // err, 编译失败
}
}
修饰成员函数,可称为常函数,被修饰的成员函数,不允许修改对象自身的任何属性。
class Person {
public:
string name;
void setName(string const &name) const { // 修饰
this->name = name; // err, 编译失败
}
}
以上示例,修饰后的成员函数不允许修改成员属性,另外注意const
的位置,是在函数小括号之后,这是个专用语法。
修饰成员函数,其本质是进一步修饰this
指针,指向的值也不能修改了,修饰后的this
声明伪代码如下
const Person * const this;
所以被const
修饰的成员函数,可以读取任意属性,但是不能修改任何属性。
但是 C++有开又增加mutable
修饰符,被修饰的成员属性,在常函数中也允许修改,可以更精细的控制权限。
class Person {
public:
int age;
mutable string name;
void setName(string const &name) const {
this->name = name; // ok
}
void setAge(int const &age) const {
this->age = age; // err, 编译失败
}
void say() {
cout << "Im " << this->name << endl;
}
}
如上示例,name
属性被mutable
修饰了,所以name
属性允许在常函数中修改;age
属性则不允许被修改。
修饰对象,可称为常对象,被修饰后不允许修改对象的任何属性,被mutable
的除外
const Person p; // const 修饰
p.name = "tom"; // ok
p.age = 10; // err
另外注意,常对象只允许调用常函数,下面示例编译失败
p.setNmae("ok"); // ok, setNmae是常函数
p.say(); // err, 普通不允许调用
静态成员
使用 static 关键字定义的是静态属性,静态成员无论创建多少个类的对象,静态成员都只有一个副本。
class Person {
private
static string category; // 静态属性
string getCategory() { // 静态函数
category;
}
public:
string name;
}