C++学习-入门到精通-【2】类、对象和字符串的介绍
C++学习-入门到精通-【2】类、对象和字符串的介绍
类、对象和字符串的介绍
- C++学习-入门到精通-【2】类、对象和字符串的介绍
- 前言
- 一、定义具有成员函数的类
- 二、定义具有形参的成员函数
- 三、数据成员、set成员函数、get成员函数
- 四、使用构造函数初始化对象
- 五、一个类对应一个独立文件的可复用性
- 六、接口和实现分离
- 七、用set成员函数确定数据的有效性
前言
在本文中我们会从一个类的例子中逐步拆分介绍如何定义一个类,以及如何使用它;
一、定义具有成员函数的类
类的定义语法如下:
class name
{// 成员函数// ......// 成员属性// ......
};
一个类的定义需要使用关键字class
,之后接着类的名字,通常使用骆驼风格(Pascal case),首字母大写随后的每个单词的首字母大写;注意类的体由{}
包围,类的以}
之后的;
结束。
类的定义示例:
#include <iostream>
using namespace std;// 定义一个用于管理学生成绩的类
// 它具有一个实现打印欢迎功能的成员函数
class GradeBook
{
public:// 输出一条欢迎信息void display() const{cout << "Welcome to the Grade Book" << endl;}
};int main()
{GradeBook gradebook; // 实例化一个GradeBook类的对象gradebook.display(); // 调用类对象的成员函数
}
在使用一个类之前,必须要先对其进行定义;所以在main函数中声明一个GradeBook类的对象之前,先在前面定义了GradeBook类。
该类有一个public权限的成员函数。类中实现的功能会隐藏在它向外提供的接口之下,此时GradeBook类提供了打印欢迎信息的功能,外部只能通过调用它提供的接口(display函数)来使用该功能。可以看到在main函数中显示的调用了该成员函数,对象变量名(gradebook
)后面加上点运算符(.
),再跟上成员函数名(display
)和空的圆括号对(()
)。
可能大家还注意到了类中还有一个比较特别的语句public:
。public
是成员访问说明符,它后面一定要有一个冒号:
,这个语句表示在此之后的类的成员都是公开共用的(任何函数都可以调用它们),直到遇到其他成员访问说明符。类还有两个其他的成员访问说明符(私有的)prrvate
和(受保护的)protected
,我们会在后续章节讲解。
在成员函数的参数列表后面我们使用了一个const,它表示这个成员函数不能修改调用它的类对象(这个只能在定义成员函数时使用,非成员函数不可以)。
之前我们提到了在面向对象系统中,我们会使用UML来描述类。下面我们就给出GradeBook类的UML类图:
每个类在类图中分为三个部分。上部包含水平居中、黑体的类的名字。中部包含类的属性(例子中的GradeBook此时并没有属性,所以这部分是空的)。下部包含类的操作,对应成员函数。
大家可以看到图中的成员函数前面还有一个+
号,这表示该函数的访问权限,+
表示这个成员函数是公有的(public),所以相应的还有另外两种私有的-
和受保护的#
。同样的属性前面也有这种标明访问权限的符号。
二、定义具有形参的成员函数
在上面的代码中,我们定义的GradeBook类中的唯一的成员函数display是没有参数,有时我们在调用函数功能时,会有一些具体的需求,这时函数的功能实现就会依赖用户的需求,所以成员函数有时是需要有参数来接收这些需求的,那么我们要如何定义一个能接受参数的成员函数呢?下面我们对之前的类进行修改。
#include <iostream>
#include <string>
using namespace std;// 定义一个用于管理学生成绩的类
// 它具有一个实现打印欢迎功能的成员函数
class GradeBook
{
public:// 输出一条欢迎信息// 接受一个字符串类型的参数void display(string courseName) const{cout << "Welcome to the Grade Book for " << courseName << "!" << endl;}
};int main()
{string courseName; // 实例化一个string类的对象GradeBook gradebook; // 实例化一个GradeBook类的对象cout << "Enter the Course Name:>";getline(cin, courseName); // 从标准输入流cin中获取输入gradebook.display(courseName); // 调用类对象的成员函数
}
上面代码包含了头文件string
,这个头文件中包含了string类的定义,可以使用string
来实例化一个string类的对象,之后可以像int等其他类型一样看待。如果没有使用using
声明的话也需要用std::
来指明命名空间。
在main函数中没有使用cin来接收字符串的信息,是因为cin读取到一个空白字符时就会停止,如果此时课程的名字中包含一个空格就无法接收到正确的课程名称。而getline
函数会从参数指定的流中接收字符串,直到遇到一个换行符(这个换行符会被丢弃),该函数包含在<string>
这个头文件中。
更新之后的UML类图如下:
仅有下部的成员函数部分发生了变化,有参数的成员函数在UML图中,是在括号内依次列出形参名、冒号、形参类型。
三、数据成员、set成员函数、get成员函数
之前我们提到,类中是可以包含一些属性的。这是属性是定义在类中的一些变量(在类中,不在类的成员函数体内)。这些属性在一个类的对象被实例化之后就会一直存在,直到该对象被销毁。这些属性由它所属于的对象管理。
下面继续对GradeBook类进行修改,为它添加一个courseName的属性,并增加一个set成员函数和get成员函数。
在这里插入代码片#include <iostream>
#include <string>
using namespace std;// 定义一个用于管理学生成绩的类
// 它具有一个实现打印欢迎功能的成员函数
class GradeBook
{
public:// 输出一条欢迎信息// 接受一个字符串类型的参数void display() const{cout << "Welcome to the Grade Book for " << getCourseName() << "!" << endl;}void setCourseName(string name){// 修改属性courseName的值courseName = name;}// 获取属性courseName的值string getCourseName() const{return courseName;}
// 属性一般都是私有属性的,只能通过成员函数来修改或使用,无法直接对其进行操作
private:// 类的属性string courseName;
};int main()
{string courseName; // 实例化一个string类的对象GradeBook gradebook; // 实例化一个GradeBook类的对象cout << "initial Course Name is " << gradebook.getCourseName() << endl;cout << "Enter the Course Name:>";getline(cin, courseName); // 从标准输入流cin中获取输入gradebook.setCourseName(courseName);gradebook.display(); // 调用类对象的成员函数
}
上面的类中包含一个string类型的变量courseName,它在类中但不在任何函数体内,所以它是一个数据成员。这个数据成员有什么作用呢?我们这个类是为教师提供来管理他教学的课程的,一个老师不可能只上一门课,所以有了一个数据成员来保存课程名字,可以使得老师能够同时创建多个对象来管理多门课程。
private访问说明符
在私有的private访问说明符之后声明的函数或变量都只能被声明它们的类的成员函数(或友元,之后介绍)访问。类的默认访问说明符是private(换言之,在类中没有声明访问说明符的情况下,类中所有的成员函数和数据成员都是私有的)。访问说明符在类中可以多次使用,但是通常没必要,将不同权限的变量和函数集中定义即可。
提示:将类的数据成员设置为私有的,将成员函数设为公有的有利于程序的调试,因为数据的操作要么发生在类的成员函数中,要么发生在类的友元中。
运行结果:
上面代码中还提供了两个成员函数,setCourseName和getCourseName,该类的私有数据成员只能通过这两个函数来操作,用户使用这两个函数来设置或获取该类的数据成员courseName的值,但是事实上用户并不知道在内部是如何实现这两个功能的。同时这个设置函数还可以检查设置的值是否合法(当前例子中没有检查机制,会在后面介绍)。
同时可以注意到在display函数中,仍然使用的get成员函数来获取数据成员的值,虽然它可以直接获取到。这是因为所有获取数据成员的值的操作都通过get成员函数来完成,可以增加程序的健壮性(更易维护、发生问题的可能性更小),
该例子的UML类图
从图中可以看出,该类有一个私有的string类型的数据成员名为courseName,有三个你公有的成员函数,setCoureName这个函数有一个string类型的名字为name的形参,getCourseName这个函数没有形参,但是返回一个string类型的值,display函数既没有形参,也没有返回值。
四、使用构造函数初始化对象
在前面的例子中,可以发现对象的数据成员默认初始化为一个空串。那么如何在初始化时就为数据成员赋一个值呢?使用构造函数。
每个类都可以提供多个构造函数(constructor),用于类的对象创建时的初始化。构造函数是一种特殊的类的成员函数。它具有特殊的定义语法:必须与类同名、没有返回值(void也不行)。通常构造函数应该声明为public。在C++中创建一个类对象时会自动调用它的构造函数。在没有任何显式地包括构造函数的类中,C++会提供默认的构造函数,该构造函数没有任何形参。之前例子中创建一个类的对象时,就是调用了默认的构造函数,它不会为任何类型为C++中基本类型的数据成员赋值,对于是其他类的对象的数据成员,它会调用该类的构造函数来对其进行初始化。之前例子中,就是因为默认的构造函数调用string类的构造函数才使得类中数据成员courseName为空串。
下面是一个默认构造函数的性质的验证:
#include <iostream>
#include <string>
using namespace std;class Test
{
public:int i;char c;
};class Test2
{
public:string str;
};int main()
{Test2 test2;cout << "str = " << test2.str << endl;cout << "string can be correctly output" << endl;Test test;cout << "i = " << test.i << "\n"<< "c = " << test.c << endl;
}
VS中无法正确运行,因为使用未初始化的类的数据成员是一种未定义行为,在VS中是一种语义错误。
在linux中(使用g++进行编译)该行为可以正常运行。
可以看到此时i
的值是一个脏值。
代码改造,提供一个显式的构造函数
#include <iostream>
#include <string>
using namespace std;class GradeBook
{
public:// 构造函数,接收一个string类型的参数explicit GradeBook(string name): courseName(name) // 使用参数列表为数据成员赋值{// 空的构造函数体}// set成员函数void setCourseName(string name){courseName = name; // 修改数据成员(没有有效性检查)}// get成员函数string getCourseName() const{return courseName; // 返回数据成员courseName}// 打印欢迎信息void display() const{// 输出一条欢迎语cout << "Welcome to the Grade Book for " << getCourseName() << "!" << endl; }private:string courseName;
};int main()
{GradeBook myGradeBook1("CS1201 C++ programming");GradeBook myGradeBook2("CS1202 Data Structures in C++");// 输出对象的初始化结果cout << "myGradeBook1`s Course Name is " << myGradeBook1.getCourseName() << "\n"<< "myGradeBook2`s Course Name is " << myGradeBook2.getCourseName() << endl;
}
运行结果:
定义构造函数
可以看到在上面代码中定义了一个构造函数,注意该构造函数必须与类同名(否则编译时因为语法错误无法通过)。
除此之外,构造函数在他的参数列表中中指定它执行任务所需的数据,当创建新的对象时,在对象名之后的圆括号中放置这些数据(正如main函数中一样)。构造函数同样是没有返回值的,在该构造函数名前面加了一个explicit
,目前我们声明所有的单形参构造函数时都用explicit,至于为什么在后面介绍构造函数的章节会详细讲解。构造函数还还可以设置const,因为构造函数是用来初始化数据成员的,该函数会对它们进行修改,所以不能使用const修饰。
大家肯定还注意到了例子中的构造函数并没有在函数体中对数据成员进行初始化,这是因为构造函数可以使用成员初始化列表来初始化数据成员,这样效率更高。因为数据成员如果要在构造函数体内进行初始化,如果数据成员的类型是C++中的基本类型的话则没什么区别,但如果数据成员是其他类的对象时,此时就需要先对这些数据成员进行默认初始化,之后再在函数体进行赋值,这就多了一次默认初始化的过程,如果这个数据成员是一个复杂类的对象,默认初始化消耗的时间远大于直接赋值所花的时间,此时初始化的效率就很低了。
除此之外,还有一些只能使用成员初始化列表进行初始化的数据成员,比如,const修饰的数据成员,只能在初始化时赋值,无法在之后进行修改。
所以建议所有的数据成员都在成员初始化列表中进行初始化,对于是一个类的对象的数据成员,成员初始化列表的格式(成员名:(初始化参数)
)也符合构造函数的调用语法。如果类包含多个数据成员,每个成员之间的初始化用逗号,
隔开。
默认的构造函数
任何不接受参数的构造函数都是默认构造函数,所以类可以通过下列方法得到默认构造函数:
-
编译器会隐式的在没有任何用户自定义的构造函数的类中创建一个默认的构造函数,这样构造函数一般不会初始化类的数据成员,除非这个数据成员是其他类的对象,此时该默认构造函数会调用该数据成员所属类的默认构造函数来进行初始化。没有初始化的变量通常包含未定义的垃圾值。
-
程序员可以显式的定义一个没有参数的构造函数,该构造函数会调用是其他类对象的每个数据成员的默认构造函数,并执行构造函数体中规定的其他任务。
-
如果程序中定义一个包含参数的构造函数,那么C++就不会隐式的创建一个默认的构造函数,但是仍可以显式的创建一个默认的构造函数。
UML类图
可以看到在UML类图中构造函数需要在前面使用一个双尖括号包含一个constructor表示这是一个构造函数,之后就和正常成员函数的表达相同。按照惯例,构造函数常常列在其他成员函数之前。
五、一个类对应一个独立文件的可复用性
因为main函数是每个程序开始的起点,如果其他程序员想要像使用string类一样使用我们上面定义的GradeBook类是不可行的,因为一个程序不能包含多个main函数。所以为了使得我们定义的类可以复用,下面我们将类的定义和测试代码分为两个不同的文件。
头文件
// GradeBook.h
#pragma once#include <iostream>
#include <string>class GradeBook
{
public:explicit GradeBook(std::string name):courseName(name){}void setCourseName(std::string name){courseName = name;}std::string getCourseName() const{return courseName;}void display() const{std::cout << "Welcome to the Grade Book for " << getCourseName() << " !" << std::endl;}
private:std::string courseName;
};
可以看到在头文件中并没有使用using
声明,这点会在后续内容中进行讲解。
有了这个头文件,其他程序要使用这个类时,只需要像使用string类时一样,包含它的头文件即可。
#include "GradeBook.h"
using namespace std;int main()
{GradeBook gradeBook1("CS1201 C++ programming");GradeBook gradeBook2("CS1202 Data Structures in C++");cout << "myGradeBook1`s Course Name is " << gradeBook1.getCourseName() << "\n"<< "myGradeBook2`s Course Name is " << gradeBook2.getCourseName() << endl;
}
上面的代码中使用预处理符号#define包含了头文件GradeBook.h,使得该程序可以使用在头文件中定义的类。由此可见,在头文件中定义类,在之后任何需要使用到该类的地方,只需要包含该头文件即可,使得该类有了复用性。
细心的同学可能发现在上面包含头文件使用预处理符""
与之前使用的<>
不同,这是因为<>
符号会直接到C++标准库的存放位置去查找,找不到则预处理失败;而""
是先到当前目录下查找,找不到之后再到C++标准库存放位置查找。所以在查找C++标准库时其实也可以使用""
,但是这样的效率不高(明知道在当前目录下找不到)。
六、接口和实现分离
在上一个部分中,我们将类定义和类的客户代码进行了分离,这种行为可以增强代码的可复用性。下面再介绍一条好的软件工程基本原则——分离类的接口与类的实现。
类的接口
接口定义并标准化了人和系统等诸如此类事物彼此交互的方式。比如,一个空调遥控器,你可以通过几个按钮来控制空调的温度,模式等等,但是你并不知道按下某个按键之后,它内部是如何实现这个功能的。接口只是指出了产品允许客户进行的操作,但并没有详细说明这些操作在内部是如何实现的。
类似的类的接口描述了该类的客户所能使用的服务,以及如何请求这些服务,但并不描述类是如何实现这些的服务的。类的接口由类的public成员函数组成。
分享接口和实现
在前面的例子,类的接口和实现是放在一个文件中完成的。但是一个更好的软件工程实践是在类定义的外部定义成员函数,这样这些成员函数的实现细节对客户代码而言就是隐藏的。
下面的代码将之前的文件分成两个文件,一个包含类定义GradeBook.h,一个包含成员函数的实现源代码GradeBook.cpp。
// GradeBook.h
#include <iostream>
#include <string>class GradeBook
{
public:explicit GradeBook(std::string);void setCourseName(std::string);std::string getCourseName() const;void display() const;private:std::string courseName;
};
与之前的代码相比,该版本的代码中成员函数使用了函数原型来替代函数定义。它们仅描述了该类的公共接口,但并没有暴露类的成员函数的实现。函数原型是函数的声明,它告诉编译器函数的名字、返回的类型和形参的类型。此外,编译器必须知道类的数据成员,以决定为类的每个对象分配多大的内存空间。
// GradeBook.cpp
#include "GradeBook.h"
using namespace std;GradeBook::GradeBook(string name):courseName(name)
{}
void GradeBook::setCourseName(string name)
{courseName = name;
}
string GradeBook::getCourseName() const
{return courseName;
}
void GradeBook::display() const
{cout << "Welcome to the Grade Book for " << getCourseName() << " !" << endl;
}
大家可以注意到这段代码中每个成员函数前面都有一个GradeBook::
(类名::成员函数名),::
该符号是作用域分辨运算符。通过这种方式将每个成员函数与分离开的”GradeBook“类定义捆绑在一起。该类定义声明了类的成员函数和数据成员。如果每个成员函数之前没有GradeBook::
,编译器就会把这些函数当成是全局函数,它们是无法对类的私有数据成员进行操作的。
提示:在类定义的外部定义类的成员函数时,省略函数名前面的类名和作用域分辨运算符会导致错误。
除此之外,这里的成员函数的定义的第一行都要与头文件中的函数声明相匹配。
此时使用相同的测试代码即可测试代码的可用性。
在这样的修改之后,代码实现功能并没有发生改变,只是编译、链接过程发生了变化。
在修改之后,需要将类的成员函数实现源代码编译生成对应的目标文件,使用GradeBook.h文件中提供的接口调用目标功能编写客户代码,客户代码再经过编译生成对应的目标文件,最后再将这两个目标文件和程序会使用到的C++标准库链接在一起生成可执行程序。客户代码获得的是类实现的目标文件,该文件是一个elf格式的文件,里面保存的类成员函数实现的机器指令(二进制指令)客户实际上仍无法获取到成员函数的实现细节。
七、用set成员函数确定数据的有效性
在之前我们定义的set成员函数中,仅实现了将参数对应的数据直接赋值给数据成员,并没有检查它的有效性,在实际问题中,是存在用户的输入是不合法的情况的,所以我们还需要修改上面的代码,使得它可以检查数据是否有效,仅将有效的数据保存下来。
例如,此时我们规定一个课程的长度不能超过25个字符。
要在上面代码中增加检查有效性的功能,仅需要修改成员函数的定义,其他部分都不需要修改。
应该会对课程名进行修改的地方只有两个,初始化和set成员函数,所以修改如下:
#include "GradeBook.h"
using namespace std;GradeBook::GradeBook(string name)
{setCourseName(name);
}
void GradeBook::setCourseName(string name)
{// size()是string类的一个成员函数,返回调用它的对象的长度,// 还有一个相同作用的函数length(),这两个函数在这里的作用完全相同,你喜欢哪个就用哪个// 字符串的长度不超过25,该字符串有效if (name.size() <= 25){courseName = name;}else // 长度不合法{courseName = name.substr(0, 25); // 将name的前25个字符截断,赋值给数据成员cerr << "Name \"" << name << "\" exceeds maximum length(25).\n"<< "limiting courseName to firse 25 characters.\n" << endl;}
}
string GradeBook::getCourseName() const
{return courseName;
}
void GradeBook::display() const
{cout << "Welcome to the Grade Book for " << getCourseName() << " !" << endl;
}
注意,string类的对象与使用字符数组char arr[]
表示的字符串是不同的;
验证代码:
#include <string>
using namespace std;int main()
{string str("hello world");char arr[] = "hello world";
}
在set成员函数中使用了name.size()和name.substr(0, 25)这样的语句,size和substr和我们定义的setCourseName一样都是成员函数,它们是string类的成员函数,所以调用它们的方式是通过类的对象加上点运算符再跟上函数名。
在string类中还有一个和size功能完全相同的函数——length,关于string类中有两个不同名但功能完全相同的函数的原因是,名字为size的函数是遵循STL容器的通用接口的命名,而length则是为了保持和c语言中的字符串使用习惯一致的命名习惯。
对于substr,它的定义如下:
它的作用是生成一个子串,接受两个参数,第一个表示子中开始的字符的位置,第二个表示子串的长度。
此时该程序的运行结果如下:
第二个课程的名字长度超过了限制,set成员函数将其截断,并输出了错误信息。
提示:在set成员函数中,除了能判断数据的有效性之外,还可以设置它的返回值,规定在成功设置数据之后返回一个值,失败返回另一个值,这样能够方便异常处理