类与对象-基础篇
类的介绍
类的基本思想是数据抽象和封装。数据抽象是一个很重要的概念,数据抽象能帮助我们对对象的具体实现和对对象的所能执行的操作分离开来。
数据抽象是一种依赖于接口和实现分离的编程技术。类的接口包括用户所能执行的操作;类的实现则包括类的数据成员、负责接口实现的函数体以及定义类所需的各种私有函数。
类要想实现数据抽象和封装,需要定义一个抽象数据类型。下面来看一个定义抽象数据类型的例子。
例如我们想要定义一个Person类,代表一个正常人。这个人应该需要睡觉和吃饭。此外,这个人还需要有一个名字,他/她的性别和出生日期。我们还需要设计一个函数,看看两个人的性别是否相同。我们总结一下Person类的接口都有什么:
- 一个
info成员函数,返回这个人的姓名、性别和出生日期; - 一个
sleep成员函数,让这个人能够睡觉; - 一个
eat成员函数,让这个人能够吃饭; - 一个
isSameGender非成员函数,用于比较两个人的性别。
你可能注意到了成员函数和非成员函数,简单来说,成员函数是定义在类内部的函数,而非成员函数就是普通的函数,不属于任何类。
在设计一个类时,作者需要了解用户(使用这个类的程序员)的需求,一个设计良好的类,要有直观且易于使用的接口,还有高效的实现过程。
类的定义
我们已经抽象好了Person这一数据类型,那么怎样在C++中编写呢,这里我们用到的时struct关键字,具体实现如下:
struct Person
{
// 成员函数
std::string get_info() const { return information; }
void sleep();
void eat();
// 数据成员
std::string name;
unsigned int gender = 0;
std::string birthday;
std::string information;
};
// 非成员函数
bool isSameGender(const Person &, const Person &);
在类内部定义的函数都是隐式的inline函数。inline函数主要用于小型、频繁调用的函数。编译器会将每个inline函数的调用直接插入原始的代码块,减少函数调用的开销。过度使用会导致程序的总体大小增加。
成员函数的定义
在上面的类的定义中,我们将get_info作为了成员函数,注意到函数体内直接返回了一个string对象,这个string对象是Person类的数据成员。但是我们并没有在函数体内显式地定义string对象,那么它是如何获取这个类中的数据成员呢?
this在成员函数中
实际上,成员函数有一个名为this的隐式参数来访问调用的对象。当我们调用这个成员函数时,相当于把类的地址隐式的传给了成员函数,这个地址命名为this,当get_info调用infomation时,实际上相当于this->information。也就是说:
std::string get_info() const { return this->information; }
与上面的写法是等价的。
由于this总是指向这个对象(类),所以this是一个常量指针。
const成员函数
const成员函数也被称作常量成员函数,常量成员函数一个最大的特性就是,他不能改变this指针指向的对象的内容。也就是只读模式。
struct Person
{
// 成员函数
std::string get_info() const {
information = 'Content is empty.'; // 报错,常量成员函数不能修改this指向的对象
return information; // 合法,只读取不修改
}
};
对于常量对象,只能调用常量成员函数。
类作用域
类本身就是一个作用域,编译器编译类分两步: 首先编译成员的声明,然后轮到成员函数体。成员函数体可以随意使用类中的其他成员而无需在一成员出现的次序。
在类外部定义成员函数
在之前我们定义的成员函数get_info是在类的内部进行定义的,除去在类的内部定义,我们还可以在类的外部进行定义。在定义外部成员函数时,成员函数的声明必须与它的声明匹配。如有const属性,也需要指定:
struct Person
{
// 成员函数
std::string get_info() const;
};
std::string Person::get_info() const
{
return information;
}
可以看到,在外部定义类成员函数时,需要指明作用域。而这个函数作用域就是在类Person之内的。使用information则隐式的使用了Person的成员。
定义一个返回this对象的函数
接下来我们再为Peron类添加几个成员:
age,用来记录当前的年龄;update_age成员函数,每过一次生日,年龄+1。
在类中声明后,定义update_age如下:
Person& Person::update_age()
{
age++;
return *this
}
在上面的成员函数中,想要返回this对象,为了与原来定义的类保持一致(不创建一个新的类),那么返回类型应该为Person&。总体来说,上面这个成员函数返回该类的引用。
非成员函数使用类
定义非成员函数
在之前我们定义了一个非成员函数isSameGender用来判断两个人的性别是否相同。下面我们来完善这个函数:
bool isSameGender(Person& p1, Person& p2)
{
return p1.gender == p2.gender ? true : false;
}
可以看到,我们想要访问类中的数据成员,使用.运算符即可。
构造函数
每个类都可以通过一个或几个特殊的成员函数来初始化对象,这些函数称为构造函数。只要类的对象被创建,那么就会执行构造函数。这里学习的内容只是构造函数的一些简单用法,更加详细的用法会在后面讲到。
构造函数的名字和类名相同,但是没有返回类型。构造函数也有一个参数列表和函数体。要想构建多个构造函数,不同的构造函数之间必须在参数数量和参数类型上有所区别。
构造函数不能被声明成const。当我们创建类的一个const对象时,直到构造函数完成初始化过程,对象才能真正取得其“常量”属性。因此构造函数在const对象的构造过程中可以向其写值。
默认构造函数
你可能已经注意到,我们之前构建Person类的时候并没有写构造函数,但是程序仍然可以编译运行。究其原因,类执行了默认初始化。进行默认初始化的构造函数称为默认构造函数,默认构造函数无需任何实参。
虽然我们没有显式的创建默认构造函数,但是编译器为我们隐式的定义了一个默认构造函数,称为合成的默认构造函数,其初始化数据成员的规则为:
- 如果存在类内的初始值,用它来初始化成员。
- 否则,默认初始化该成员。
在之前的类中,gender被初始化为0,而其他的字符串类型的成员则被默认初始化。
不要总是依赖合成的默认构造函数
当我们的类中定义了一个构造函数,你并不想设定它为默认构造函数,那么你需要自己写一个默认构造函数。编译器只会在类中没有构造函数的情况下生成默认构造函数。
其次,合成的默认构造函数可能会导致一些错误,例如复合类型的对象将被默认初始化,其值是未定义的。
struct Person
{
// 成员函数
std::string get_info() const { return information; }
void sleep();
void eat();
// 数据成员
std::string name;
unsigned int gender = 0;
std::string birthday;
std::string information;
int age;
};
上面这个类中,age是int类型,默认初始化则会导致其值不能确定。
如果类中有一个成员是其他没有默认构造函数的类,那么这个类不能合成默认构造函数。
定义构造函数
我们将为Person类定义三个构造函数,其中一个构造函数为默认构造函数。
struct Person
{
// 构造函数
// 构造函数
Person() = default;
Person(std::string inputName, std::string inputBirthday, int inputAge, unsigned int inputGender)
: name(inputName), birthday(inputBirthday), age(inputAge), gender(inputGender) {}
Person(std::string inputName, std::string inputBirthday, unsigned int inputGender);
// 成员函数
std::string get_info() const { return information; }
void sleep();
void eat();
// 数据成员
std::string name;
unsigned int gender = 0;
std::string birthday;
std::string information;
int age;
};
先从默认构造函数开始。在C++11新标准中,如果我们需要默认的行为,可以在参数列表中后面写上 = default来要求编译器生成构造函数。= default可以出现在类的内部,表示默认构造函数是内联(inline)的;也可以放在类的外部,表示默认构造函数不是内联的。
在第二个构造函数中,出现了冒号和冒号至花括号新增的部分,这部分称为构造函数初始值列表,它负责为新创建的对象的一个或几个数据成员赋初始值。虽然information这个成员还没有被初始化,但是它也可以像合成默认构造函数那样被隐式的初始化。
如果你的编译器不支持类内初始值,则所有的构造函数应该显式的初始化每个内置类型的成员。
在类的外部定义构造函数
你可能已经注意到,第三个构造函数只是被声明而没有定义,那么接下来我们将会尝试在类的外部定义该构造函数。
Person::Person(std::string inputName, std::string inputBirthday, unsigned int inputGender)
{
name = inputName;
birthday = inputBirthday;
gender = inputGender;
information = "I am " + this->name + ", " +
"My birthday is " + this->birthday + ". I am a " + (gender == 0 ? "man." : "woman.");
}
这个构造函数没有初始值列表,但是我们可以在函数体内显式的初始化一些数据成员,例如上面的函数。在main函数内调用:
int main()
{
Person p1("Jack", "1999-01-01", 0);
std::cout << p1.get_info() << std::endl;
}
控制台输出了:
I am Jack, My birthday is 1999-01-01. I am a man.
拷贝、赋值与析构
除了定义对象如何进行初始化之外,类还需要控制拷贝、赋值和销毁对象。自定义这些函数是以后的内容,在这里我们只需要知道,编译器会自动替我们合成这些函数。
我们初始化变量以及以值的方式传递或返回一个对象就称为拷贝;当我们使用赋值运算符会发生对象的赋值操作;当对象不再存在就进行销毁的操作。
对于某些类来说,合成拷贝、赋值和析构将无法正常工作。例如,当类需要分配类对象之外的资源时,合成的版本常常会失效。管理动态内存的类不能依赖于上述操作的合成版本。
好了,以下是本次学习的参考代码:
#include <string>
#include <iostream>
struct Person
{
// 构造函数
Person() = default;
Person(std::string inputName, std::string inputBirthday, int inputAge, unsigned int inputGender)
: name(inputName), birthday(inputBirthday), age(inputAge), gender(inputGender) {}
Person(std::string inputName, std::string inputBirthday, unsigned int inputGender);
// 成员函数
std::string get_info() const
{
return information;
}
void sleep();
void eat();
// 数据成员
unsigned int gender = 0;
std::string name;
std::string birthday;
std::string information;
int age = 0;
};
// 非成员函数
bool isSameGender(const Person &, const Person &);
bool isSameGender(Person &p1, Person &p2)
{
return p1.gender == p2.gender ? true : false;
}
Person::Person(std::string inputName, std::string inputBirthday, unsigned int inputGender)
{
name = inputName;
birthday = inputBirthday;
gender = inputGender;
information = "I am " + this->name + ", " +
"My birthday is " + this->birthday + ". I am a " + (gender == 0 ? "man." : "woman.");
}
int main()
{
Person p1("Jack", "1999-01-01", 0);
std::cout << p1.get_info() << std::endl;
}
评论区