C--17-示例-全-
C++17 示例(全)
原文:
zh.annas-archive.org/md5/b8b6b1cd57099dd88cf2f1045e179753
译者:飞龙
前言
C++是一种通用的编程语言,它偏向于嵌入式编程和系统编程。多年来,C++不断发展,被用于开发许多不同领域的软件。鉴于其多功能性和健壮性,C++是一个开始编码旅程的绝佳语言。本书涵盖了用 C++构建的激动人心的项目,展示了如何在不同的场景中实现该语言。在开发这些项目的过程中,你不仅将学习语言结构,还将了解如何使用 C++来满足你的软件需求。
在本书中,你将学习一系列用 C++编写的应用程序,从抽象数据类型到图书馆管理系统、图形应用程序、游戏,以及领域特定语言(DSL)。
本书面向对象
本书面向希望用 C++开发软件的开发者。具备基本的编程经验将是一个额外的优势。
本书涵盖内容
第一章,《C++入门》,介绍了 C++中的面向对象编程(OOP)。我们首先通过查看一个简单的掷骰子程序开始。我们编写代码,编译,链接并执行程序。然后,我们继续构建一个简单的面向对象层次结构,包括指针和动态绑定。最后,我们创建了两个简单的抽象数据类型:栈和队列。栈是一组按从底到顶的顺序排列的值,其中只有最顶部的值是可访问的,而队列是一个传统的队列,我们在前面检查值,在后面添加值。
第二章,《数据结构与算法》,在上一章所学内容的基础上进行扩展,特别是列表和集合抽象数据类型。我们还介绍了模板和运算符重载,并探讨了线性搜索和二分搜索算法,以及插入、选择、冒泡、归并和快速排序算法。
第三章,《构建图书馆管理系统》,将帮助你开发一个现实世界的系统:由书籍和客户组成的图书馆管理系统。书籍跟踪借阅和预留它们的客户,而客户则跟踪他们借阅和预留的书籍。
第四章,《指针实现的图书馆管理系统》,进一步扩展了图书馆管理系统。在前一章中,每本书和每个客户都通过整数编号进行标识。然而,在这一章中,我们使用指针。每本书都持有指向已借阅或预留它的客户的指针,而每个客户则持有指向他们已借阅或预留的书籍的指针。
第五章,Qt 图形应用程序,深入探讨了三个我们使用 Qt 图形库开发的图形应用程序:一个带有时针、分针和秒针的模拟时钟,一个可以以不同颜色绘制线条、矩形和椭圆的绘图程序,以及一个用户可以输入和编辑文本的编辑器。我们将学习如何在 Qt 库中处理窗口和小部件,以及菜单和工具栏。我们还将学习如何绘制图形和写入文本,以及如何捕获鼠标和键盘输入。
第六章,增强 Qt 图形应用程序,进一步开发了三个图形应用程序:模拟时钟、绘图程序和编辑器。我们在时钟表盘上添加了数字,我们在绘图程序中添加了移动、修改和剪切粘贴图形的可能性,并在编辑器中添加了更改字体和文本对齐的可能性。
第七章,游戏,介绍了基本游戏开发。在本章中,我们使用 Qt 库开发了 Othello 和井字棋游戏。在 Othello 中,两名玩家轮流在游戏网格中添加黑白标记,以包围对方的标记。在井字棋中,两名玩家轮流在游戏网格中添加圆圈和叉号,以在行中放置五个标记。
第八章,计算机游戏,使计算机能够与人类玩家对战。在 Othello 中,计算机试图添加尽可能多的标记来包围对方的标记。在井字棋中,计算机试图添加标记以获得一行五个标记,并防止对方获得一行五个标记。
第九章,领域特定语言,教你如何开发领域特定语言 (DSL),这是一种针对特定领域的语言。更具体地说,我们开发了一种用于在 Qt 小部件中编写图形对象的编程语言。我们首先通过语法正式定义我们的语言。然后我们编写了一个扫描器,它可以识别有意义的字符序列,一个解析器,它检查源代码是否符合语法,以及一个查看器,它可以显示图形对象。
第十章,高级领域特定语言,在几个方面改进了我们的领域特定语言:我们添加了选择和迭代,这些可以改变程序的流程,我们添加了可以在程序执行期间分配值的变量,我们还添加了具有参数和返回值的函数。
为了充分利用本书
本书面向所有读者,从初学者到更熟练的 C++ 程序员。然而,一些 C++ 的先前经验是有用的。
本书中的示例是在 Visual Studio 和 Qt Creator 中开发的。
下载示例代码文件
您可以从 www.packtpub.com 的账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在 www.packtpub.com 上登录或注册。
-
选择“支持”标签。
-
点击“代码下载与勘误”。
-
在搜索框中输入书籍名称,并遵循屏幕上的说明。
文件下载完成后,请确保使用最新版本解压缩或提取文件夹:
-
Windows 下的 WinRAR/7-Zip
-
Mac 下的 Zipeg/iZip/UnRarX
-
Linux 下的 7-Zip/PeaZip
本书代码包也托管在 GitHub 上,网址为 github.com/PacktPublishing/CPP17-By-Example
。我们还有其他来自我们丰富图书和视频目录的代码包可供选择,网址为 github.com/PacktPublishing/
。请查看它们!
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表的彩色图像的 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/CPP17ByExample_ColorImages.pdf
。
使用的约定
本书使用了多种文本约定。
CodeInText
:表示文本中的代码单词。例如;“让我们继续一个类层次结构,其中 Person
是基类,Student
和 Employee
是其子类:”
代码块是这样设置的:
class Person {
public:
Person(string name);
virtual void print();
private:
string m_name;
};
当我们希望将您的注意力引到代码块中的特定部分时,相关的行或项目将以粗体显示:
class Person {
public:
Person(string name);
virtual void print();
private:
string m_name;
};
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“在第一个对话框中,我们只需按下 Next 按钮:”
警告或重要提示如下所示。
小技巧和技巧如下所示。
联系我们
我们欢迎读者的反馈。
总体反馈:请发送邮件至 feedback@packtpub.com
,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请通过 questions@packtpub.com
发送邮件给我们。
勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现错误,我们将非常感激您能向我们报告。请访问 www.packtpub.com/submit-errata,选择您的书籍,点击“勘误提交表单”链接,并输入详细信息。
盗版:如果您在互联网上遇到任何形式的我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过发送链接至 copyright@packtpub.com
与我们联系。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com.
评论
请留下评论。一旦您阅读并使用了这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
想了解更多关于 Packt 的信息,请访问 packtpub.com.
第一章:C++入门
本章介绍了 C++中的面向对象编程(OOP)。我们首先查看一个简单的掷骰子程序。我们编写代码,编译、链接和执行程序。
然后我们继续构建一个简单的面向对象层次结构,包括Person
基类及其两个子类Student
和Employee
。我们还探讨了指针和动态绑定。
最后,我们创建了两种简单的数据类型——栈和队列。栈是由一组从底到顶排序的值构成的,我们只对顶部值感兴趣。队列是一个传统的值队列,我们在后面添加值,在前面检查值。
本章我们将涵盖以下主题:
-
我们首先实现一个简单的游戏:掷骰子。其主要目的是介绍环境,并教你如何设置项目,以及如何编译、链接和执行程序。
-
然后我们通过编写以
Person
作为基类和Student
、Employee
作为子类的类层次结构来开始面向对象编程的探讨。这为继承、封装和动态绑定提供了介绍。 -
最后,我们为抽象数据类型栈和队列编写类。栈是一个结构,我们在顶部添加和移除值,而队列更像是一个传统的队列,我们在后面添加值,从前面移除。
掷骰子
作为介绍,我们首先编写一个掷骰子的程序。我们使用内置的随机数生成器生成一个介于 1 到 6(包括 1 和 6)之间的整数:
Main.cpp
#include <CStdLib>
#include <CTime>
#include <IOStream>
using namespace std;
void main() {
srand((int) time(nullptr));
int dice = (rand() % 6 ) + 1;
cout << "Dice: " << dice << endl;
}
在前面的程序中,初始的include
指令允许我们包含头文件,这些头文件主要包含标准库的声明。我们需要CStdLib
头文件来使用随机数生成器,CTime
头文件用当前时间初始化随机数生成器,以及IOStream
头文件来写入结果。
标准库存储在一个名为std
的namespace
中。namespace
可以被认为是一个包含代码的容器。我们通过using namespace
指令来访问标准库。
每个 C++程序恰好包含一个main
函数。程序的执行总是从main
函数开始。我们使用srand
和time
标准函数来初始化随机数生成器,并使用rand
生成实际的随机值。百分号(%
)是取模运算符,它将两个整数相除并给出除法的余数。这样,dice
整数变量的值总是至少为 1,最多为 6。最后,我们使用cout
输出dice
变量的值,cout
是标准库中用于写入文本和值的对象。
前四章的程序是用 Visual Studio 编写的,而剩余章节的程序是用 Qt Creator 编写的。
以下是如何创建项目、编写代码和执行应用程序的说明。当我们启动 Visual Studio 时,我们按照以下步骤创建我们的项目:
- 首先,我们在文件菜单中选择“新建”和“项目”,如图所示:
- 我们选择 Win32 控制台应用程序类型,并将项目命名为
Dice
:
- 在第一个对话框中,我们只需按下“下一步”按钮:
- 在第二个对话框中,我们选择“空项目”复选框,然后单击“完成”按钮。这样,就会创建一个没有文件的空项目:
- 当我们创建了自己的项目后,我们需要添加一个文件:
- 我们选择一个 C++ 文件(.cpp)并命名为
Main.cpp
:
- 然后,我们在
Main.cpp
文件中输入代码:
- 最后,我们执行程序。最简单的方法是选择“开始调试”或“不调试启动”菜单选项。这样,程序将被编译、链接和执行:
- 执行的输出显示在命令窗口中:
理解类 - 汽车类
让我们继续看看一个简单的类,它处理汽车的速度和方向。在面向对象的语言中,类是一个非常核心的特性。在 C++ 中,其规范由两部分组成——其定义和实现。定义部分通常放在一个带有 .h
后缀的头文件中,而实现部分则放在一个带有 .cpp
后缀的文件中,例如 Car.h
和 Car.cpp
文件。然而,在 第三章 中引入的模板类,即 构建图书馆管理系统,只存储在一个文件中。
一个类由其成员组成,其中成员是一个字段或方法。字段保存特定类型的值。方法是一个数学抽象,可能需要输入值并返回一个值。方法输入值被称为参数。然而,在 C++ 中,可以定义没有参数和没有返回类型的函数。
对象是类的实例;我们可以创建一个类的多个对象。方法可以分为以下几类:
-
构造函数: 当对象被创建时调用构造函数
-
检查员: 检查员检查类的字段
-
修饰符: 修饰符修改字段的值
-
析构函数: 当对象被销毁时调用析构函数
理想情况下,类的成员方法不直接访问字段,因为这意味着如果字段发生变化,方法名称/类型也必须改变。相反,方法应该提供对类属性的访问。这些是类的概念元素,可能不映射到单个字段。类的每个成员都是public
、protected
或private
:
-
一个
public
成员可以被程序的其他部分访问。 -
一个
protected
成员只能被其自身的成员或其子类的成员访问,这些将在下一节介绍。 -
一个
private
成员只能被其自身的成员访问。然而,这并不完全正确。一个类可以邀请其他类成为其友元,在这种情况下,它们被赋予了访问其private
和protected
成员的权限。我们将在下一章探讨友元。
以下Car
类的定义有两个构造函数和一个析构函数。在这种情况下,它们总是与Car
类的名称相同。析构函数前面有一个波浪号(~
)。没有参数的构造函数被称为默认构造函数。
只要方法具有不同的参数列表,就可以有多个具有相同名称的方法,这称为重载。更具体地说,它被称为无上下文重载。还有上下文相关重载,在这种情况下,两个方法具有相同的名称和参数列表,但返回类型不同。然而,C++不支持上下文相关重载。
因此,一个类可以包含多个构造函数,只要它们的参数列表不同。然而,析构函数不允许有参数。因此,一个类只能包含一个析构函数:
Car.h
class Car {
public:
Car();
Car(int speed, int direction);
~Car();
getSpeed
和getDirection
方法都是检查器,返回汽车当前的速度和方向。返回值持有int
类型,即整数的缩写。由于它们不改变类的字段,因此它们被标记为常量,使用const
关键字。然而,构造函数或析构函数不能是常量:
int getSpeed() const;
int getDirection() const;
accelerate
、decelerate
、turnLeft
和turnRight
方法都是修改器,用于设置汽车当前的速度和方向。由于它们改变类的字段,因此不能标记为常量:
void accelerate(int speed);
void decelerate(int speed);
void turnLeft(int degrees);
void turnRight(int degrees);
m_speed
和m_direction
字段持有汽车当前的速度和方向。-m
前缀表示它们是类的成员,而不是方法局部字段:
private:
int m_speed, m_direction;
};
在实现文件中,我们必须包含Car.h
头文件。#include
指令是预处理程序的一部分,它只是简单地将Car.h
文件的内容包含到文件中。在前面的一节中,我们使用尖括号字符(<
和>
)包含了系统文件。在这种情况下,我们使用引号包含本地文件。系统包含文件(带有尖括号)包含语言的一部分系统代码,而本地包含文件(带有引号)包含我们自己的代码,作为项目的一部分。技术上,系统包含文件通常从文件系统中的特殊目录中包含,而本地包含文件通常在文件系统中本地包含:
Car.cpp
#include "Car.h"
默认构造函数初始化speed
和direction
并将它们设置为0
。冒号(:
)符号用于初始化字段。在两个斜杠(//
)和行尾之间的文本被称为行注释,会被忽略:
Car::Car()
:m_speed(0),
m_direction(0) {
// Empty.
}
第二个构造函数初始化speed
和direction
为给定的参数值:
Car::Car(int speed, int direction)
:m_speed(speed),
m_direction(direction) {
// Empty.
}
在前面的构造函数中,可以使用赋值运算符(=
)而不是类初始化符号,就像以下代码所示。然而,这被认为是不高效的,因为代码可以通过前面的初始化符号进行优化。注意,我们使用一个等号(=
)进行赋值。对于两个值的比较,我们使用两个等号(==
),这是一种在第二章中引入的方法,数据结构和算法:
Car::Car() {
m_speed = 0;
m_direction = 0;
}
在这个类中,析构函数不做任何事情;它只为了完整性而包含:
Car::~Car() {
// Empty.
}
getSpeed
和getDirection
方法只是简单地返回汽车当前的速度和方向:
int Car::getSpeed() const {
return m_speed;
}
int Car::getDirection() const {
return m_direction;
}
一个加号直接跟在等号后面被称为复合赋值,它会导致右边的值加到左边的值上。同样,一个减号直接跟在等号后面会导致右边的值从左边的值中减去。
在一个斜杠(/
)直接跟在一个星号(*
)之后,以及一个星号直接跟在一个斜杠之后之间的文本被称为块注释,会被忽略:
void Car::accelerate(int speed) {
m_speed += speed; /* Same effect as: m_speed = m_speed + speed; */
}
void Car::decelerate(int speed) {
m_speed -= speed;
}
void Car::turnLeft(int degrees) {
m_direction -= degrees;
}
void Car::turnRight(int degrees) {
m_direction += degrees;
}
现在是时候测试我们的类了。为此,我们需要包含Car.h
文件,就像我们在Car.cpp
文件中所做的那样。然而,我们还需要包含系统IOStream
头文件。与前面的章节一样,系统头文件被括在箭头括号(<
和>
)中。我们还需要使用namespace std
来使用其功能。
Main.cpp
#include <IOStream>
using namespace std;
#include "Car.h"
在 C++中,一个函数可以是类的一部分,也可以是独立于类的。类的函数通常被称为方法。函数是一个数学抽象。它有输入值,这些值被称为参数,并返回一个值。然而,在 C++中,一个函数允许没有参数,并且它可以返回特殊类型 void,表示它不返回值。
如前节所述,程序的执行总是从名为main
的函数开始,每个程序必须恰好有一个名为main
的函数。与某些其他语言不同,没有必要将文件命名为Main
。
然而,在这本书中,出于方便起见,每个包含main
函数的文件都命名为Main.cpp
。void
关键字表示main
不返回值。请注意,虽然构造函数和析构函数从不返回值,且不标记为void
,但其他不返回值的函数和方法必须标记为void
:
void main() {
我们创建了一个名为redVolvo
的Car
类对象。对象是类的实例;redVolvo
是众多汽车之一:
Car redVolvo;
在编写信息时,我们使用cout
对象(代表控制台输出),它通常写入一个文本窗口。由两个左尖括号(<<
)组成的操作符被称为输出流操作符。endl
指令使得下一个输出从下一行的开头开始:
cout << "Red Volvo Speed: " << redVolvo.getSpeed()
<< " miles/hour" << ", Direction: "
<< redVolvo.getDirection() << " degrees" << endl;
redVolvo.accelerate(30);
redVolvo.turnRight(30);
cout << "Red Volvo Speed: " << redVolvo.getSpeed()
<< " miles/hour" << ", Direction: "
<< redVolvo.getDirection() << " degrees" << endl;
redVolvo.decelerate(10);
redVolvo.turnLeft(10);
cout << "Red Volvo Speed: " << redVolvo.getSpeed()
<< " miles/hour" << ", Direction: "
<< redVolvo.getDirection() << " degrees" << endl;
blueFiat
对象是Car
类的常量对象。这意味着它只能通过其中一个构造函数进行初始化,然后进行检查,但不能修改。更具体地说,只有常量方法可以在常量对象上调用,并且只有不修改对象字段的那些方法可以是常量:
const Car blueFiat(100, 90);
cout << "Blue Fiat Speed: " << blueFiat.getSpeed()
<< " miles/hour" << ", Direction: "
<< blueFiat.getDirection() << " degrees" << endl;
}
当我们执行代码时,输出显示在命令窗口中:
扩展Car
类
在本节中,我们修改了Car
类。在早期版本中,我们在构造函数中初始化了字段。初始化字段的一个替代方法是直接在类定义中初始化它们。然而,这个特性应该谨慎使用,因为它可能会导致不必要的初始化。如果调用Car
类的第二个构造函数,字段会被初始化两次,这是无效的。
Car.h
class Car {
public:
// ...
private:
int m_speed = 0, m_direction = 0;
};
虽然Car
类定义在Car.h
文件中,但其方法定义在Car.cpp
文件中。请注意,我们首先包含Car.h
文件,以便方法的定义符合其在Car.h
中的声明:
Car.cpp
#include "Car.h"
Car::Car() {
// Empty.
}
Car::Car(int speed, int direction)
:m_speed(speed),
m_direction(direction) {
// Empty.
}
此外,前节中的Car
类有一些局限性:
-
可以无限加速汽车,也可以减速汽车到负速度
-
可以将汽车转向,使其方向为负或超过 360 度
首先,我们将汽车的最高速度设置为200
英里/小时。如果速度超过200
英里/小时,我们将它设置为200
英里/小时。我们使用if
语句,它接受一个条件,如果条件为真,则执行以下语句。在这种情况下,语句(m_speed = 200;)
被括号包围。这并不是必需的,因为它只包含一个语句。然而,如果有多个语句,则这是必需的。在这本书中,我们总是为了清晰起见使用括号,无论语句的数量多少。
Car.cpp
void Car::accelerate(int speed) {
m_speed += speed;
if (m_speed > 200) {
m_speed = 200;
}
}
如果速度变为负数,我们改变速度的符号使其变为正数。请注意,我们不能写m_speed -= m_speed
。那样会将速度设置为零,因为它会从自身减去速度。
由于值是负数,当我们改变符号时,它变为正数。我们还通过旋转180
度来改变汽车的方向。请注意,在这种情况下,我们也必须检查汽车是否超过速度限制。
此外,请注意,我们必须检查方向是否小于 180 度。如果是,我们加上180
度;否则,我们减去180
度以保持方向在0
到360
度的区间内。我们使用if...else
语句来完成这个操作。如果if
语句的条件不为真,则执行else
关键字后的语句:
void Car::decelerate(int speed) {
m_speed -= speed;
if (m_speed < 0) {
m_speed = -m_speed;
if (m_speed > 200) {
m_speed = 200;
}
if (m_direction < 180) {
m_direction += 180;
}
else {
m_direction -= 180;
}
}
}
当转向汽车时,我们使用取模(%
)运算符。当除以360
时,取模运算符给出除法的余数。例如,当370
除以360
时,余数是10
:
void Car::turnLeft(int degrees) {
m_direction -= degrees;
m_direction %= 360;
if (m_direction < 0) {
m_direction += 360;
}
}
void Car::turnRight(int degrees) {
m_direction += degrees;
m_direction %= 360;
}
main
函数创建了一个Car
类的对象——redVolvo
。我们首先写下它的速度和方向,然后加速并向左转,再次写下它的速度和加速度。最后,我们减速并向右转,最后一次写下它的速度和方向:
Main.cpp
#include <IOStream>
using namespace std;
#include "Car.h"
void main() {
Car redVolvo(20, 30);
cout << "Red Volvo Speed: " << redVolvo.getSpeed()
<< " miles/hour" << ", Direction: "
<< redVolvo.getDirection() << " degrees" << endl;
redVolvo.accelerate(30);
redVolvo.turnLeft(60);
cout << "Red Volvo Speed: " << redVolvo.getSpeed()
<< " miles/hour" << ", Direction: "
<< redVolvo.getDirection() << " degrees" << endl;
redVolvo.decelerate(60);
redVolvo.turnRight(50);
cout << "Red Volvo Speed: " << redVolvo.getSpeed()
<< " miles/hour" << ", Direction: "
<< redVolvo.getDirection() << " degrees" << endl;
}
当我们执行代码时,输出将如下显示在命令窗口中:
类层次结构——Person、Student 和 Employee 类
让我们继续使用类层次结构,其中Person
是基类,Student
和Employee
是其子类:
正如人有一个名字一样,我们使用 C++标准库中的string
类来存储名字。virtual
关键字标记了print
方法受动态绑定的影响,我们将在本节稍后探讨这一点:
Person.h
class Person {
public:
Person(string name);
virtual void print();
private:
string m_name;
};
我们包含String
头文件,这允许我们使用string
类:
Person.cpp
#include <String>
#include <IOStream>
using namespace std;
#include "Person.h"
Person::Person(string name)
:m_name(name) {
// Empty.
}
void Person::print() {
cout << "Person " << m_name << endl;
}
Student
和Employee
类是Person
的子类,并且以public
方式继承Person
。有时术语扩展代替继承。继承可以是public
、protected
或private
:
-
在
public
继承中,基类的所有成员在子类中都有相同的访问权限 -
在
protected
继承中,基类的所有public
成员在子类中变为protected
-
在私有继承中,基类的所有
public
和protected
成员在子类中变为私有。
Student
和 Employee
类具有文本字段 m_university
和 m_company
:
Student.h
class Student : public Person {
public:
Student(string name, string university);
void print();
private:
string m_university;
};
文件 Student.cpp
定义了 Student
类的方法:
Student.cpp
#include <String>
#include <IOStream>
using namespace std;
#include "Person.h"
#include "Student.h"
子类可以通过使用冒号表示法 (:
) 来指定其 name
来调用基类的构造函数。Student
的构造函数使用参数 name
调用 Person
的构造函数:
Student::Student(string name, string university)
:Person(name),
m_university(university) {
// Empty.
}
我们必须声明,我们通过使用双冒号表示法 (::
) 在 Person
而不是 Student
中调用 print
:
void Student::print() {
Person::print();
cout << "University " << m_university << endl;
}
Employee
类与 Student
类类似。然而,它持有字段 c_company
而不是 m_university
。
Employee.h
class Employee : public Person {
public:
Employee(string name, string company);
void print();
private:
string m_company;
};
文件 Employee.cpp
定义了 Employee
类的方法。
Employee.cpp
#include <String>
#include <IOStream>
using namespace std;
#include "Person.h"
#include "Employee.h"
构造函数初始化人员的姓名和他们受雇的公司:
Employee::Employee(string name, string company)
:Person(name),
m_company(company) {
// Empty.
}
void Employee::print() {
Person::print();
cout << "Company " << m_company << endl;
}
最后,main
函数首先包含系统头文件 String
和 IOStream
,它们包含有关字符串处理和输入输出流的声明。由于所有标准头文件都包含在标准命名空间中,我们可以通过使用 using
命令来访问系统声明。
Main.cpp
#include <String>
#include <IOStream>
using namespace std;
#include "Person.h"
#include "Student.h"
#include "Employee.h"
我们定义了三个对象,Monica
、Demi
和 Charles
,并且对它们中的每一个都调用了 print
方法。在所有三种情况下,都调用了 Person
、Student
和 Employee
类的 print
方法:
void main() {
Person monica("Monica");
person.print();
Student demi("Demi", "MIT");
student.print();
Employee charles("Charles", "Microsoft");
employee.print();
星号 (*
) 标记 personPtr
是指向 Person
对象的指针,而不是 Person
对象本身。指针持有对象的内存地址,而不是对象本身。然而,目前它并没有持有任何地址。我们很快就会将它分配给一个对象的地址:
Person *personPtr;
&
是一个操作符,它提供对象的地址,该地址被分配给指针 personPtr
。我们依次将 personPtr
分配给 Person
、Student
和 Employee
对象的地址,并在每种情况下调用 print
。由于 print
在 Person
中被标记为虚拟的,因此调用当前指针指向的对象类的 print
。由于 print
在基类 Person
中被标记为虚拟的,因此不需要在子类 Student
和 Employee
中将 print
标记为虚拟。在访问对象指针的成员时,我们使用箭头 (->
) 操作符而不是点操作符。
当 personPtr
指向 Person
对象时,调用 Person
中的 print
:
personPtr = &person;
personPtr->print();
当 personPtr
指向 Student
对象时,调用 Student
中的 print
:
personPtr = &student;
personPtr->print();
当 personPtr
指向 Employee
对象时,调用 Employee
中的 print
:
personPtr = &employee;
personPtr->print();
}
这个过程称为动态绑定。如果我们省略 Person
中的虚拟标记,则会发生静态绑定,并且所有情况下都会调用 Person
中的 print
。
面向对象编程的概念建立在封装、继承和动态绑定这三个基石之上。不支持这些特性的语言不能被称为面向对象的语言。
一个简单的数据类型——栈
栈是一种简单的数据类型,我们可以在顶部添加值,移除顶部的值,并且只能检查顶部值。在本节中,我们实现了一个整数栈。在下一章中,我们将探讨可以持有任意类型值的模板类。我们使用链表,这是一种结构,其中指针指向链表中的第一个单元格,每个单元格都持有指向链表中下一个单元格的指针。自然地,链表必须最终结束。我们使用nullptr
来标记链表的结束,它是 C++标准指向特殊空地址的指针。
首先,我们需要一个类来保存链表中的每个单元格。单元格包含一个整数值和指向列表中下一个单元格的指针,或者如果它是列表的最后一个单元格,则为nullptr
。在下一节中,我们将探讨同时持有前一个和下一个单元格指针的单元格类。
Cell.h
class Cell {
public:
Cell(int value, Cell *next);
可以直接在类定义中实现方法;它们被称为内联方法。然而,这通常只用于短方法。一个经验法则是内联方法不应超过一行:
int value() const { return m_value; }
Cell *next() const { return m_next; }
每个单元格都持有一个值和链表中下一个单元格的地址:
private:
int m_value;
Cell *m_next;
};
Cell.h
#include "Cell.h"
一个单元格通过一个值和一个指向链表下一个单元格的指针来初始化。注意,如果单元格是链表中的最后一个单元格,则m_next
的值为nullptr
:
Cell::Cell(int value, Cell *next)
:m_value(value),
m_next(next) {
// Empty.
}
在栈中,我们只对它的顶部值感兴趣。默认构造函数将栈初始化为空。压栈在栈顶添加一个值,顶部返回顶部值,弹出移除顶部值,大小返回栈中的值的数量,如果栈为空,则返回true
。布尔类型是一种逻辑类型,可以持有true
或false
的值。
Stack.h
class Stack {
public:
Stack();
void push(int value);
int top();
void pop();
int size() const;
bool empty() const;
m_firstCellPtr
字段是指向包含栈值的链表第一个单元格的指针。当栈为空时,m_firstCellPtr
将持有值nullptr
。m_size
字段持有栈的当前大小:
private:
Cell *m_firstCellPtr;
int m_size;
};
包含了CAssert
头文件,用于 assert 宏,该宏用于测试某些条件是否为真。宏是预处理器的组成部分,它执行某些文本替换。
Stack.cpp
#include <CAssert>
using namespace std;
#include "Cell.h"
#include "Stack.h"
默认构造函数通过将指向第一个单元格的指针初始化为nullptr
并将大小设置为零来将栈设置为空:
Stack::Stack()
:m_firstCellPtr(nullptr),
m_size(0) {
// Empty.
}
当在栈顶压入新值时,我们使用新操作符动态分配单元格所需的内存。如果我们耗尽内存,则返回nullptr
,这通过 assert 宏进行测试。如果m_firstCellPtr
等于nullptr
,则执行会因错误信息而终止。紧跟在等于号(=
)后面的感叹号(!
)构成了不等于操作符。两个加号(++
)构成了增量操作符,意味着值增加一。
增量操作符实际上有两种版本——前缀(++m_size
)和后缀(m_size++
)。在前缀情况下,值首先增加然后返回,而在后缀情况下,值增加但返回原始值。然而,在这种情况下,我们使用哪个版本都无关紧要,因为我们只对结果感兴趣——即m_size
的值增加一:
void Stack::push(int value) {
m_firstCellPtr = new Cell(value, m_firstCellPtr);
++m_size;
}
当返回栈顶值时,我们必须首先检查栈是否为空,因为返回空栈的顶部值是不合逻辑的。如果栈为空,则执行会因错误信息而终止。单个感叹号(!
)是逻辑非操作符。我们返回存储在链表第一个单元格中的顶部值:
int Stack::top() {
assert(!empty());
return m_firstCellPtr->getValue();
}
当弹出栈顶值时,我们也必须检查栈是否为空。我们将指向链表第一个单元格的指针设置为指向下一个单元格。然而,在那之前,我们必须存储第一个指针,deleteCellPtr
,以便释放它所指向的单元格的内存。
我们使用delete
操作符释放内存:
void Stack::pop() {
assert(!empty());
Cell *deleteCellPtr = m_firstCellPtr;
m_firstCellPtr = m_firstCellPtr->getNext();
delete deleteCellPtr;
与上面提到的增量操作符一样,两个减号(--
)构成了减量
操作符,它将值减一:
--m_size;
}
size
方法简单地返回m_size
字段的值:
int Stack::size() const {
return m_size;
}
如果指向第一个单元格指针的指针等于nullptr
,则栈为空。非正式地说,如果它等于nullptr
,则指针为空:
bool Stack::empty() const {
return (m_firstCellPtr == nullptr);
}
我们通过压栈、查看顶部和弹出一些值来测试栈。
Main.cpp
#include <String>
#include <IOStream>
using namespace std;
#include "Cell.h"
#include "Stack.h"
void main() {
Stack s;
s.push(1);
s.push(2);
s.push(3);
当打印布尔值时,stream
操作符不会打印true
或false
,而是为true
打印一个值,为false
打印零。为了真正打印true
或false
,我们使用条件操作符。它接受三个值,由问号(?
)和冒号(:
)分隔。如果第一个值是true
,则返回第二个值。如果第一个值是false
,则返回第三个值:
cout << "top " << s.top() << ", size " << s.size()
<< ", empty " << (s.empty() ? "true" : "false") << endl;
s.pop();
s.pop();
s.push(4);
cout << "top " << s.top() << ", size " << s.size()
<< ", empty " << (s.empty() ? "true" : "false") << endl;
}
更高级的数据类型——队列
队列是一个传统队列的模型;我们在队列的尾部输入值,并在队列的前端检查和移除值。还可以决定它所持有的值的数量以及它是否为空。
与上一节中的栈类似,我们使用链表来实现队列。我们重用Cell
类;然而,在队列的情况下,我们需要设置一个单元格的下一个链接。因此,我们将next
重命名为getNext
并添加了新的setNext
方法:
Cell.h
class Cell {
public:
Cell(int value, Cell *next);
int value() const {return m_value;}
Cell *getNext() const { return m_next; }
void setNext(Cell* next) { m_next = next; }
private:
int m_value;
Cell *m_next;
};
我们以类似于栈的方式使用链表实现队列。构造函数初始化一个空队列,enter
方法在队列的末尾插入一个值,remove
方法从队列的前端删除一个值,size
方法返回队列的当前大小,而 empty
方法如果队列为空则返回 true
:
Queue.h
class Queue {
public:
Queue();
void enter(int value);
int first();
void remove();
int size() const;
bool empty() const;
在栈的情况下,我们只对它的顶部感兴趣,它存储在链表的开始处。在队列的情况下,我们既对前部也对后部感兴趣,这意味着我们需要访问链表的第一和最后一个单元格。因此,我们有两个指针,m_firstCellPtr
和 m_lastCellPtr
,分别指向链表中的第一个和最后一个单元格:
private:
Cell *m_firstCellPtr, *m_lastCellPtr;
int m_size;
};
Queue.cpp
#include <CAssert>
using namespace std;
#include "Cell.h"
#include "Queue.h"
当队列被创建时,它是空的;指针是空的,大小为零。由于链表中没有单元格,两个单元格指针都指向 nullptr
:
Queue::Queue()
:m_firstCellPtr(nullptr),
m_lastCellPtr(nullptr),
m_size(0) {
// Empty.
}
当在队列的末尾输入新值时,我们检查队列是否为空。如果它是空的,两个指针都设置为指向新单元格。如果不为空,最后一个单元格的下一个指针设置为指向新单元格,然后最后一个单元格指针设置为指向新单元格:
void Queue::enter(int value) {
Cell *newCellPtr = new Cell(value, nullptr);
if (empty()) {
m_firstCellPtr = m_lastCellPtr = newCellPtr;
}
else {
m_lastCellPtr->setNext(newCellPtr);
m_lastCellPtr = newCellPtr;
}
++m_size;
}
第一种方法简单地返回链表中的第一个单元格的值:
int Queue::first() {
assert(!empty());
return m_firstCellPtr->value();
}
remove
方法将第一个单元格设置为指向第二个单元格。然而,首先我们必须存储它的地址,以便使用 C++ 标准的 delete
操作符来释放它的内存:
void Queue::remove() {
assert(!empty());
Cell *deleteCellPtr = m_firstCellPtr;
m_firstCellPtr = m_firstCellPtr->getNext();
delete deleteCellPtr;
--m_size;
}
int Queue::size() const {
return m_size;
}
bool Queue::empty() const {
return (m_firstCellPtr == nullptr);
}
我们通过输入和删除一些值来测试队列。我们输入的值为一、二、三,这些值按照顺序放入队列中。然后我们删除前两个值,并输入值四。此时队列中包含的值为三和四:
Main.cpp
#include <CMath>
#include <String>
#include <IOStream>
using namespace std;
#include "Cell.h"
#include "Queue.h"
void main() {
Queue q;
q.enter(1);
q.enter(2);
q.enter(3);
cout << "first " << q.first() << ", size " << q.size()
<< ", empty " << (q.empty() ? "true" : "false") << endl;
q.remove();
q.remove();
q.enter(4);
cout << "first " << q.first() << ", size " << q.size()
<< ", empty " << (q.empty() ? "true" : "false") << endl;
}
摘要
在本章中,我们探讨了面向对象编程的基础。我们首先创建了一个项目并执行了一个掷骰子的程序。我们还创建了一个类层次结构,包括基类 Person
和它的两个子类 Student
和 Employee
。通过定义对象的指针,我们执行了动态绑定。
最后,我们创建了两种数据类型——栈和队列。栈是一种结构,我们只对顶部的值感兴趣。我们可以在顶部添加值,检查顶部值,并删除顶部值。队列是一种传统的队列,我们在后面输入值,同时在前面检查和删除值。
在下一章中,我们将继续创建数据类型,以及更高级的数据类型,如列表和集合。我们还将探讨 C++ 的更多高级特性。
第二章:数据结构和算法
在上一章中,我们创建了 stack
和 queue
抽象数据类型的类。在这一章中,我们将继续 list
和 set
抽象数据类型。
类似于上一章中的栈和队列,列表是一个有序结构,有开始和结束。然而,可以在列表的任何位置添加和删除值。还可以遍历列表。
另一方面,集合是无序的值结构。我们只能说集合中是否存在某个值。我们无法说一个值相对于其他值有任何位置。
在这一章中,我们将探讨以下主题:
-
我们将从列表和集合类的相对简单且效率不高的版本开始。我们还将研究搜索和排序的基本算法。
-
然后,我们将继续创建列表和集合类的更高级版本,并研究更高级的搜索和排序算法。我们还将介绍新的概念,如模板、运算符重载、异常和引用重载。
我们还将研究搜索算法线性搜索,它适用于任何序列,有序和无序,但效率较低,以及二分搜索,它更有效,但仅适用于有序序列。
最后,我们将研究相对简单的排序算法,插入排序、选择排序和冒泡排序,以及更高级且更有效的归并排序和快速排序算法。
The List class
LinkedList
类比栈和队列更复杂,它可以在列表的任何位置添加和删除值。还可以遍历列表。
The Cell class
本节中的单元格是 stack
和 queue
节中的单元格的扩展。类似于它们,它包含一个值和一个指向下一个单元格的指针。然而,这个版本还包含一个指向前一个单元格的指针,这使得本节中的列表成为双向链表。
注意,构造函数是 private
的,这意味着单元格对象只能通过其自己的方法创建。然而,有一种方法可以绕过这种限制。我们可以定义一个类或函数作为 LinkedList
的朋友。这样,我们将 LinkedList
定义为 Cell
的朋友。这意味着 LinkedList
可以访问 Cell
的所有私有和受保护成员,包括构造函数,从而可以创建 Cell
对象。
Cell.h:
class Cell {
private:
Cell(double value, Cell *previous, Cell *next);
friend class LinkedList;
public:
double getValue() const { return m_value; }
void setValue(double value) { m_value = value; }
Cell *getPrevious() const { return m_previous; }
void setPrevious(Cell *previous) { m_previous = previous; }
Cell *getNext() const { return m_next; }
void setNext(Cell *getNext) { m_next = getNext; }
private:
double m_value;
Cell *m_previous, *m_next;
};
Cell.cpp:
#include "Cell.h"
Cell::Cell(double value, Cell *previous, Cell *next)
:m_value(value),
m_previous(previous),
m_next(next) {
// Empty.
}
The Iterator class
在遍历列表时,我们需要一个迭代器,它被初始化为列表的开始,并逐步移动到其结束。类似于前面的单元格,Iterator
的构造函数是私有的,但我们也将 LinkedList
定义为 Iterator
的朋友。
Iterator.h:
class Iterator {
private:
Iterator(Cell *cellPtr);
friend class LinkedList;
public:
Iterator();
第三个构造函数是一个复制构造函数
。它接受另一个迭代器并将其复制。我们不能仅仅接受迭代器作为参数。相反,我们定义一个引用参数。&
符号表示该参数是迭代器对象的引用,而不是迭代器对象本身。这样,迭代器的内存地址作为参数传递,而不是对象本身。我们还声明所引用的对象是常量,因此构造函数不能修改它。
在这种情况下,使用引用参数是必要的。如果我们定义了一个简单的迭代器对象作为参数,它将导致不确定的循环初始化。然而,在其他情况下,我们出于效率原因使用这种技术。传递对象地址所需的时间比复制对象本身作为参数所需的时间更少,并且需要的内存也更少:
Iterator(const Iterator& iterator);
double getValue() { return m_cellPtr->getValue(); }
void setValue(double value) { m_cellPtr->setValue(value); }
hasNext
方法返回true
,如果迭代器尚未到达列表的末尾,next
方法将迭代器向前移动一步,向列表的末尾移动,如下面的示例所示:
bool hasNext() const { return (m_cellPtr != nullptr); }
void next() { m_cellPtr = m_cellPtr->getNext(); }
同样,hasPrevious
方法返回true
,如果迭代器尚未到达列表的开始,previous
方法将迭代器向后移动一步,到列表的开始:
bool hasPrevious() const { return (m_cellPtr != nullptr); }
void previous() { m_cellPtr = m_cellPtr->getPrevious(); }
private:
Cell *m_cellPtr;
};
Iterator.cpp:
#include "Cell.h"
#include "Iterator.h"
Iterator::Iterator(Cell *cellPtr)
:m_cellPtr(cellPtr) {
// Empty.
}
Iterator::Iterator()
:m_cellPtr(nullptr) {
// Empty.
}
Iterator::Iterator(const Iterator& iterator)
:m_cellPtr(iterator.m_cellPtr) {
// Empty.
}
List
类
LinkedList
类包含查找、添加、插入和删除值的方法,以及比较列表的方法。此外,它还包含读取和写入列表的方法,以及正向和反向遍历列表的方法。实际上,这是一个双向链表。我们可以从两个方向跟踪单元格的链接:从开始到结束,以及从结束到开始的反向:
LinkedList.h:
class LinkedList {
public:
LinkedList();
复制构造函数
和assign
方法都复制给定的列表:
LinkedList(const LinkedList& list);
void assign(const LinkedList& list);
析构函数释放链表中所有单元格分配的内存:
~LinkedList();
int size() const {return m_size;}
bool empty() const {return (m_size == 0);}
find
方法搜索value
。如果找到value
,则返回true
并将findIterator
设置为value
的位置:
bool find(double value, Iterator& findIterator);
equal
和notEqual
方法比较这个链表与给定的链表,如果它们相等或不相等,则分别返回true
,如下面的代码片段所示:
bool equal(const LinkedList& list) const;
bool notEqual(const LinkedList& list) const;
如果我们想在现有列表中添加一个值或另一个列表,add
方法将值或另一个列表添加到列表的末尾,而insert
方法将值或列表插入到迭代器指定的位置:
void add(double value);
void add(const LinkedList& list);
void insert(const Iterator& insertPosition, double value);
void insert(const Iterator& insertPosition,
const LinkedList& list);
erase
方法删除给定位置上的值,而clear
方法删除列表中的所有值,如下面的示例所示:
void erase(const Iterator& erasePosition);
void clear();
remove
方法从第一个迭代器到最后一个迭代器(包括)删除值。第二个参数是默认参数。这意味着该方法可以用一个或两个参数调用。如果只有一个参数,则第二个参数在声明中给出的值,在这种情况下是表示列表末尾位置一步之外的Iterator(nullptr)
。这意味着当remove
用一个迭代器调用时,从该迭代器(包括)到列表末尾的所有值都将被删除。nullptr
指针实际上是一个特殊的指针,它被转换为它指向的类型或与之比较。在这种情况下,一个指向Cell
的指针。非正式地说,我们可以称一个指针为 null,当它持有值nullptr
时:
void remove(const Iterator& firstPosition,
const Iterator& lastPosition = Iterator(nullptr));
first
和last
方法返回位于列表第一个和最后一个值处的迭代器:
Iterator first() const { return Iterator(m_firstCellPtr); }
Iterator last() const { return Iterator(m_lastCellPtr); }
read
和write
方法从输入文件流中读取列表的值并将其写入输出文件流。文件流用于与文件通信。请注意,我们在前面的章节中使用的cin
和cout
对象实际上是输入和输出流对象:
void read(istream& inStream);
void write(ostream& outStream);
与前一部分的队列类似,该列表持有指向链表第一个和最后一个单元格的指针:
private:
int m_size;
Cell *m_firstCellPtr, *m_lastCellPtr;
};
LinkedList.cpp:
#include <IOStream>
using namespace std;
#include "Cell.h"
#include "Iterator.h"
#include "List.h"
LinkedList::LinkedList()
:m_size(0),
m_firstCellPtr(nullptr),
m_lastCellPtr(nullptr) {
// Empty.
}
构造函数简单地调用assign
来复制list
参数的值:
LinkedList::LinkedList(const LinkedList& list) {
assign(list);
}
assign
方法将给定的列表复制到其自己的链表中:
void LinkedList::assign(const LinkedList& list) {
m_size = 0;
m_firstCellPtr = nullptr;
m_lastCellPtr = nullptr;
Cell *listCellPtr = list.m_firstCellPtr;
add(list);
}
析构函数简单地调用clear
来释放链表单元格分配的所有内存:
LinkedList::~LinkedList() {
clear();
}
clear
方法遍历链表并释放每个单元格:
void LinkedList::clear() {
Cell *currCellPtr = m_firstCellPtr;
对于链表中的每个单元格,我们首先必须将其地址保存在deleteCellPtr
中,然后在链表中前进,并释放该单元格。如果我们直接在currCellPtr
上调用delete
,那么接下来的getNext
调用将不会工作,因为在这种情况下,我们会调用一个已释放对象的函数:
while (currCellPtr != nullptr) {
Cell *deleteCellPtr = currCellPtr;
currCellPtr = currCellPtr->getNext();
delete deleteCellPtr;
}
当列表变为空时,两个单元格指针都是 null,大小为零:
m_firstCellPtr = nullptr;
m_lastCellPtr = nullptr;
m_size = 0;
}
find
方法遍历链表,设置findIterator
,并在找到值时返回true
。如果没有找到值,则返回false
,并且findIterator
保持不变。为了使这起作用,findIterator
必须是一个Iterator
对象的引用,而不是Iterator
对象本身。一个Iterator
对象的指针也会工作:
bool LinkedList::find(double value, Iterator& findIterator) {
Iterator iterator = first();
while (iterator.hasNext()) {
if (value == iterator.getValue()) {
findIterator = iterator;
return true;
}
iterator.next();
}
return false;
}
如果两个列表的大小不同,它们不相等。同样,如果它们的大小相同,但值不同,它们也不相等:
bool LinkedList::equal(const LinkedList& list) const {
if (m_size != list.m_size) {
return false;
}
Iterator thisIterator = first(), listIterator = list.first();
while (thisIterator.hasNext()) {
if (thisIterator.getValue() != listIterator.getValue()) {
return false;
}
thisIterator.next();
listIterator.next();
}
然而,如果列表持有相同的大小和相同的值,它们是相等的:
return true;
}
当我们需要决定两个列表是否不相等时,我们只需调用equal
。感叹号(!
)是逻辑非运算符,如下例所示:
bool LinkedList::notEqual(const LinkedList& list) const {
return !equal(list);
}
当向列表添加值时,我们动态分配一个单元格:
void LinkedList::add(double value) {
Cell *newCellPtr = new Cell(value, m_lastCellPtr, nullptr);
如果第一个单元格指针为 null,我们将其设置为指向新单元格,因为列表为空:
if (m_firstCellPtr == nullptr) {
m_firstCellPtr = newCellPtr;
}
然而,如果第一个单元格指针不为空,列表不为空,我们将最后一个单元格指针的下一个指针设置为指向新单元格:
else {
m_lastCellPtr->setNext(newCellPtr);
}
无论哪种方式,我们都将最后一个单元格指针设置为指向新单元格,并增加列表的大小:
m_lastCellPtr = newCellPtr;
++m_size;
}
向现有列表中添加列表
当向列表中添加整个列表时,我们对列表中的每个值所采取的行动与我们在add
中添加单个值时相同。如果第一个单元格指针为空,我们动态分配一个新的单元格,并将其分配给指向新单元格。如果它不为空,我们将最后一个单元格指针的下一个指针设置为指向新单元格。无论哪种方式,我们都将最后一个单元格指针设置为指向新单元格:
void LinkedList::add(const LinkedList& list) {
Cell *listCellPtr = list.m_firstCellPtr;
while
语句会一直重复,直到其条件为真。在这种情况下,只要我们没有到达列表的末尾:
while (listCellPtr != nullptr) {
double value = listCellPtr->getValue();
Cell *newCellPtr = new Cell(value, m_lastCellPtr, nullptr);
如果m_firstList
为空,我们的链表仍然是空的,而newCellPtr
指向新链表的第一个单元格。在这种情况下,我们让m_firstList
指向新单元格:
if (m_firstCellPtr == nullptr) {
m_firstCellPtr = newCellPtr;
}
如果m_firstList
不为空,我们的列表不为空,且m_firstList
不应被修改。相反,我们将m_lastCellPtr
的下一个指针设置为指向新单元格:
else {
m_lastCellPtr->setNext(newCellPtr);
}
无论哪种方式,都将最后一个单元格指针设置为新的单元格指针:
m_lastCellPtr = newCellPtr;
最后,将列表单元格指针设置为指向其下一个单元格指针。最终,列表单元格指针将为空,while
语句结束:
listCellPtr = listCellPtr->getNext();
}
m_size += list.m_size;
}
当在迭代器给出的位置插入值时,我们将它的前指针设置为指向列表中该位置之前的单元格(如果位置是列表中的第一个,则该位置为空)。然后我们以与前面add
方法相同的方式检查第一个单元格指针是否为空:
void LinkedList::insert(const Iterator& insertPosition,
double value) {
Cell *insertCellPtr = insertPosition.m_cellPtr;
Cell *newCellPtr =
new Cell(value, insertCellPtr->getPrevious(), insertCellPtr);
insertCellPtr->setPrevious(newCellPtr);
if (insertCellPtr == m_firstCellPtr) {
m_firstCellPtr = newCellPtr;
}
else {
newCellPtr->getPrevious()->setNext(newCellPtr);
}
++m_size;
}
当插入列表时,我们首先检查位置是否表示空指针。在这种情况下,位置超出了我们列表的末尾,我们只需调用add
:
void LinkedList::insert(const Iterator& insertPosition,
const LinkedList& list) {
Cell *insertCellPtr = insertPosition.m_cellPtr;
if (insertCellPtr == nullptr) {
add(list);
}
else {
Cell *firstInsertCellPtr = nullptr,
*lastInsertCellPtr = nullptr,
*listCellPtr = list.m_firstCellPtr;
while (listCellPtr != nullptr) {
Cell *newCellPtr = new Cell(listCellPtr->getValue(),
lastInsertCellPtr, nullptr);
if (firstInsertCellPtr == nullptr) {
firstInsertCellPtr = newCellPtr;
}
else {
lastInsertCellPtr->setNext(newCellPtr);
}
lastInsertCellPtr = newCellPtr;
listCellPtr = listCellPtr->getNext();
}
我们通过比较firstInsertCellPtr
与nullptr
来检查要插入的列表是否为空。由于firstInsertCellPtr
指向列表的第一个值,如果它是空的,则列表为空:
if (firstInsertCellPtr != nullptr) {
if (insertCellPtr->getPrevious() != nullptr) {
insertCellPtr->getPrevious()->setNext(firstInsertCellPtr);
firstInsertCellPtr->
setPrevious(insertCellPtr->getPrevious());
}
else {
m_firstCellPtr = firstInsertCellPtr;
}
}
if (lastInsertCellPtr != nullptr) {
lastInsertCellPtr->setNext(insertCellPtr);
insertCellPtr->setPrevious(lastInsertCellPtr);
}
m_size += list.m_size;
}
}
从列表中删除值
erase
方法简单地调用remove
,并将给定位置作为其起始和结束位置:
void LinkedList::erase(const Iterator& removePosition) {
remove(removePosition, removePosition);
}
当从列表中删除值时,我们遍历列表,并为要删除的每个值释放单元格:
void LinkedList::remove(const Iterator& firstPosition,
const Iterator& lastPosition /*= Iterator(nullptr)*/) {
Cell *firstCellPtr = firstPosition.m_cellPtr,
*lastCellPtr = lastPosition.m_cellPtr;
lastCellPtr = (lastCellPtr == nullptr)
? m_lastCellPtr : lastCellPtr;
Cell *previousCellPtr = firstCellPtr->getPrevious(),
*nextCellPtr = lastCellPtr->getNext();
Cell *currCellPtr = firstCellPtr;
while (currCellPtr != nextCellPtr) {
Cell *deleteCellPtr = currCellPtr;
currCellPtr = currCellPtr->getNext();
delete deleteCellPtr;
--m_size;
}
当我们必须删除单元格时,我们有三种情况要考虑。如果第一个要移除的单元格之前的最后一个单元格不为空,这意味着在移除位置之前有列表的剩余部分,我们将它的下一个指针设置为指向移除位置之后的第一个单元格。如果第一个要移除的单元格之前的最后一个单元格为空,我们将第一个单元格指针设置为指向该单元格:
if (previousCellPtr != nullptr) {
previousCellPtr->setNext(nextCellPtr);
}
else {
m_firstCellPtr = nextCellPtr;
}
我们对要移除的最后一个单元格之后的列表位置做同样的事情。如果有列表的剩余部分,我们将它的第一个单元格的前指针设置为指向移除部分之前的最后一个单元格:
if (nextCellPtr != nullptr) {
nextCellPtr->setPrevious(previousCellPtr);
}
else {
m_lastCellPtr = previousCellPtr;
}
}
当读取列表时,我们首先读取其size
。然后读取值:
void LinkedList::read(istream& inStream) {
int size;
inStream >> size;
int count = 0;
while (count < size) {
double value;
inStream >> value;
add(value);
++count;
}
}
当写入列表时,我们以逗号分隔值,并用括号("[
"和"]
")括起来:
void LinkedList::write(ostream& outStream) {
outStream << "[";
bool firstValue = true;
Iterator iterator = first();
while (iterator.hasNext()) {
outStream << (firstValue ? "" : ",") << iterator.getValue();
firstValue = false;
iterator.next();
}
outStream << "]";
}
我们通过添加一些值并正向和反向遍历来测试列表:
Main.cpp:
#include <IOStream>
using namespace std;
#include "Cell.h"
#include "Iterator.h"
#include "List.h"
void main() {
LinkedList list;
list.add(1);
list.add(2);
list.add(3);
list.add(4);
list.add(5);
list.write(cout);
cout << endl;
{ Iterator iterator = list.first();
while (iterator.hasNext()) {
cout << iterator.getValue() << " ";
iterator.next();
}
cout << endl;
}
{ Iterator iterator = list.last();
while (iterator.hasPrevious()) {
cout << iterator.getValue() << " ";
iterator.previous();
}
cout << endl;
}
}
当执行代码时,输出显示在命令窗口中:
Set
类
集合是一个无序的结构,不包含重复项。Set
类是LinkedList
的子类。请注意,继承是私有的,这导致LinkedList
的所有公共和受保护成员在Set
中都是私有的:
Set.h:
class Set : private LinkedList {
public:
Set();
Set(double value);
Set(const Set& set);
void assign(const Set& set);
~Set();
equal
方法如果集合包含这些值则返回true
。请注意,我们不在意集合中的任何顺序:
bool equal(const Set& set) const;
bool notEqual(const Set& set) const;
exists
方法如果给定的值或给定集合中的每个值分别存在,则返回true
:
bool exists(double value) const;
bool exists(const Set& set) const;
insert
方法插入给定的值或给定集合中的每个值。它只插入集合中尚未存在的值,因为集合不包含重复项:
bool insert(double value);
bool insert(const Set& set);
remove
方法如果存在,则移除给定的值或给定集合中的每个值:
bool remove(double value);
bool remove(const Set& set);
size
、empty
和first
方法简单地调用LinkedList
中的对应方法。由于集合中没有顺序,因此覆盖LinkedList
中的end
方法是没有意义的:
int size() const { return LinkedList::size(); }
bool empty() const { return LinkedList::empty(); }
Iterator first() const { return LinkedList::first(); }
unionSet
、intersection
和difference
独立函数是Set
的朋友,这意味着它们可以访问Set
的所有私有和受保护成员:
我们不能将unionSet
方法命名为union
,因为在 C++中它是关键字:
注意,当一个类中的方法被标记为friend
时,实际上它不是该类的方法,而是一个函数:
friend Set unionSet(const Set& leftSet, const Set& rightSet);
friend Set intersection(const Set& leftSet,
const Set& rightSet);
friend Set difference(const Set& leftSet,
const Set& rightSet);
read
和write
方法以与LinkedList
中的对应方法相同的方式读取和写入集合:
void read(istream& inStream);
void write(ostream& outStream);
};
unionSet
、intersection
和difference
函数是Set
的朋友,它们在类定义外部声明:
Set unionSet(const Set& leftSet, const Set& rightSet);
Set intersection(const Set& leftSet, const Set& rightSet);
Set difference(const Set& leftSet, const Set& rightSet);
Set.cpp:
#include <IOStream>
using namespace std;
#include "..\ListBasic\Cell.h"
#include "..\ListBasic\Iterator.h"
#include "..\ListBasic\List.h"
#include "Set.h"
构造函数调用LinkedList
中的对应函数。默认构造函数(无参数)实际上隐式调用LinkedList
的默认构造函数:
Set::Set() {
// Empty.
}
Set::Set(double value) {
add(value);
}
Set::Set(const Set& set)
:LinkedList(set) {
// Empty.
}
析构函数隐式调用LinkedList
中的对应函数,从而释放与集合值关联的内存。在这种情况下,我们可以省略析构函数,使用以下代码调用LinkedList
的析构函数:
Set::~Set() {
// Empty.
}
assign
方法简单地清除集合并添加给定的集合:
void Set::assign(const Set& set) {
clear();
add(set);
}
如果两个集合具有相同的大小
,并且一个集合中的每个值都存在于另一个集合中,那么这两个集合是相等的。在这种情况下,另一个集合中的每个值也必须存在于第一个集合中:
bool Set::equal(const Set& set) const {
if (size() != set.size()) {
return false;
}
Iterator iterator = first();
while (iterator.hasNext()) {
if (!set.exists(iterator.getValue())) {
return false;
}
iterator.next();
}
return true;
}
bool Set::notEqual(const Set& set) const {
return !equal(set);
}
exists
方法使用LinkedList
的迭代器遍历集合。如果找到该值,则返回true
:
bool Set::exists(double value) const {
Iterator iterator = first();
while (iterator.hasNext()) {
if (value == iterator.getValue()) {
return true;
}
iterator.next();
}
return false;
}
第二个exists
方法遍历给定的集合,如果其中任何值不在集合中,则返回false
。如果所有值都在集合中,则返回true
:
bool Set::exists(const Set& set) const {
Iterator iterator = set.first();
while (iterator.hasNext()) {
if (!exists(iterator.getValue())) {
return false;
}
iterator.next();
}
return true;
}
第一个 insert
方法如果值不在集合中则添加它:
bool Set::insert(double value) {
if (!exists(value)) {
add(value);
return true;
}
return false;
}
第二个 insert
方法遍历给定的集合,通过调用第一个插入方法将每个值插入。这样,每个不在集合中已经存在的值都会被插入:
bool Set::insert(const Set& set) {
bool inserted = false;
Iterator iterator = set.first();
while (iterator.hasNext()) {
double value = iterator.getValue();
if (insert(value)) {
inserted = true;
}
iterator.next();
}
return inserted;
}
第一个 remove
方法移除值,如果它在集合中存在则返回 true
。如果不存在,则返回 false
:
bool Set::remove(double value) {
Iterator iterator;
if (find(value, iterator)) {
erase(iterator);
return true;
}
return false;
}
第二个 remove
方法遍历给定的集合并移除其每个值。如果至少移除了一个值,则返回 true
:
bool Set::remove(const Set& set) {
bool removed = false;
Iterator iterator = set.first();
while (iterator.hasNext()) {
double value = iterator.getValue();
if (remove(value)) {
removed = true;
}
iterator.next();
}
return removed;
}
并集、交集和差集操作
unionSet
函数创建一个以左侧集合初始化的结果集合,然后添加右侧集合:
Set unionSet(const Set& leftSet, const Set& rightSet) {
Set result(leftSet);
result.insert(rightSet);
return result;
}
intersection
方法比 union
或 difference
方法稍微复杂一些。两个集合 A 和 B 的交集可以定义为它们的并集与它们的差集之差:
A∩B=(A∪B)-((A-B)-(B-A))
Set intersection(const Set& leftSet, const Set& rightSet) {
return difference(difference(unionSet(leftSet, rightSet),
difference(leftSet, rightSet)),
difference(rightSet, leftSet));
}
difference
方法创建一个结果集合,以左侧集合为基础,然后移除右侧集合:
Set difference(const Set& leftSet, const Set& rightSet) {
Set result(leftSet);
result.remove(rightSet);
return result;
}
read
方法与 LinkedList
中的对应方法类似。然而,insert
被调用而不是 add
。这样,就不会在集合中插入重复项:
void Set::read(istream& inStream) {
int size;
inStream >> size;
int count = 0;
while (count < size) {
double value;
inStream >> value;
insert(value);
++count;
}
}
write
方法与 LinkedList
中的对应方法类似。然而,集合是用括号 ("{
" 和 "}
") 而不是方括号 ("[
" 和 "]
") 括起来的:
void Set::write(ostream& outStream) {
outStream << "{";
bool firstValue = true;
Iterator iterator = first();
while (iterator.hasNext()) {
outStream << (firstValue ? "" : ",") << iterator.getValue();
firstValue = false;
iterator.next();
}
outStream << "}";
}
我们通过让用户输入两个集合并评估它们的并集、交集和差集来测试集合。
Main.cpp:
#include <IOStream>
using namespace std;
#include "..\ListBasic\Cell.h"
#include "..\ListBasic\Iterator.h"
#include "..\ListBasic\List.h"
#include "Set.h"
void main() {
Set s, t;
s.read(cin);
t.read(cin);
cout << endl << "s = ";
s.write(cout);
cout << endl;
cout << endl << "t = ";
t.write(cout);
cout << endl << endl;
cout << "union: ";
unionSet(s, t).write(cout);
cout << endl;
cout << "intersection: ";
unionSet(s, t).write(cout);
cout << endl;
cout << "difference: ";
unionSet(s, t).write(cout);
cout << endl;
}
基本搜索和排序
在本章中,我们还将研究一些搜索和排序算法。当使用线性搜索查找值时,我们只需从列表的开始遍历到结束。我们返回值的零基于索引,如果没有找到则返回负一。
Search.h:
int linarySearch(double value, const LinkedList& list);
Search.cpp:
#include <IOStream>
using namespace std;
#include "..\ListBasic\Cell.h"
#include "..\ListBasic\Iterator.h"
#include "..\ListBasic\List.h"
#include "Search.h"
int linarySearch(double value, const LinkedList& list) {
int index = 0;
我们使用列表的 first
方法来获取我们用来遍历列表的迭代器;hasNext
只要列表中还有另一个值就返回 true
,next
将迭代器在列表中向前移动一步:
Iterator iterator = list.first();
while (iterator.hasNext()) {
if (iterator.getValue() == value) {
return index;
}
++index;
iterator.next();
}
return -1;
}
现在我们研究选择排序、插入排序和冒泡排序算法。请注意,它们接受列表的引用,而不是列表本身,以便列表可以改变。还请注意,在这些情况下引用不是常量;如果它是常量,我们就无法对列表进行排序。
Sort.h:
void selectSort(LinkedList& list);
void insertSort(LinkedList& list);
void bubbleSort(LinkedList& list);
Sort.cpp:
#include <IOStream>
using namespace std;
#include "..\ListBasic\Cell.h"
#include "..\ListBasic\Iterator.h"
#include "..\ListBasic\List.h"
#include "Sort.h"
void insert(double value, LinkedList& list);
void swap(Iterator iterator1, Iterator iterator2);
选择排序算法
选择排序算法相当简单,我们重复遍历列表直到它为空。在每次迭代中,我们找到最小的值,将其从列表中移除并添加到结果列表中。这样,结果列表最终将填充与列表相同的值。由于值是有序选择的,因此结果列表是有序的。最后,我们将结果列表赋值给原始列表:
void selectSort(LinkedList& list) {
LinkedList result;
while (!list.empty()) {
Iterator minIterator = list.first();
double minValue = minIterator.getValue();
Iterator iterator = list.first();
while (iterator.hasNext()) {
if (iterator.getValue() < minValue) {
minIterator = iterator;
minValue = iterator.getValue();
}
iterator.next();
}
list.erase(minIterator);
result.add(minValue);
}
list.assign(result);
}
插入排序算法
在插入排序算法中,我们遍历列表,并为每个值将其插入到结果列表中的适当位置。然后我们将结果列表赋值给原始列表:
void insertSort(LinkedList& list) {
LinkedList result;
Iterator iterator = list.first();
while (iterator.hasNext()) {
insert(iterator.getValue(), result);
iterator.next();
}
list.assign(result);
}
insert
函数接受一个列表和一个值,并将该值放置在列表中的正确位置。它遍历列表,并将值放置在第一个小于它的值之前。如果列表中没有这样的值,该值将被添加到列表的末尾:
void insert(double value, LinkedList& list) {
Iterator iterator = list.first();
while (iterator.hasNext()) {
if (value < iterator.getValue()) {
list.insert(iterator, value);
return;
}
iterator.next();
}
list.add(value);
}
冒泡排序算法
冒泡排序算法逐对比较值,如果它们出现在错误的位置,则允许它们交换位置。在第一次迭代之后,我们知道最大的值位于列表的末尾。因此,我们不需要第二次遍历整个列表,可以省略最后一个值。这样,我们最多遍历列表的值数减一,因为当除了第一个值之外的所有值都位于其正确的位置时,第一个值也位于其正确的位置。然而,列表可能在那时已经正确排序。因此,我们在每次迭代后检查是否有任何一对值被交换。如果没有,则列表已经正确排序,我们可以退出算法:
void bubbleSort(LinkedList& list) {
int listSize = list.size();
if (listSize > 1) {
int currSize = listSize - 1;
int outerCount = 0;
while (outerCount < (listSize - 1)) {
Iterator currIterator = list.first();
Iterator nextIterator = currIterator;
nextIterator.next();
bool changed = false;
int innerCount = 0;
while (innerCount < currSize) {
if (currIterator.getValue() > nextIterator.getValue()) {
swap(currIterator, nextIterator);
changed = true;
}
++innerCount;
currIterator.next();
nextIterator.next();
}
if (!changed) {
break;
}
--currSize;
++outerCount;
}
}
}
swap
函数交换由迭代器给出的位置的值:
void swap(Iterator iterator1, Iterator iterator2) {
double tempValue = iterator1.getValue();
iterator1.setValue(iterator2.getValue());
iterator2.setValue(tempValue);
}
我们通过向列表中添加一些值并排序列表来测试这些算法。
Main.cpp:
#include <IOStream>
#include <CStdLib>
using namespace std;
#include "..\ListBasic\Cell.h"
#include "..\ListBasic\Iterator.h"
#include "..\ListBasic\List.h"
#include "Search.h"
#include "Sort.h"
void main() {
cout << "LinkedList" << endl;
LinkedList list;
list.add(9);
list.add(7);
list.add(5);
list.add(3);
list.add(1);
list.write(cout);
cout << endl;
我们使用 iterator
类遍历列表,并对列表中的每个值调用 linarySearch
:
Iterator iterator = list.first();
while (iterator.hasNext()) {
cout << "<" << iterator.getValue() << ","
<< linarySearch(iterator.getValue(), list) << "> ";
iterator.next();
}
我们还测试了列表中不存在的值的搜索算法,它们的索引将是负一:
cout << "<0," << linarySearch(0, list) << "> ";
cout << "<6," << linarySearch(6, list) << "> ";
cout << "<10," << linarySearch(10, list) << ">"
<< endl;
我们通过冒泡排序、选择排序和插入排序算法对列表进行排序:
cout << "Bubble Sort ";
bubbleSort(list);
list.write(cout);
cout << endl;
cout << "Select Sort ";
selectSort(list);
list.write(cout);
cout << endl;
cout << "Insert Sort ";
insertSort(list);
list.write(cout);
cout << endl;
}
将搜索和排序算法进行分类的一种方法是用大 O 表示法。非正式地说,该表示法关注最坏的情况。在插入排序的情况下,我们为每个值遍历列表一次,并且可能需要为每个值遍历整个列表以找到其正确的位置。同样,在选择排序的情况下,我们为每个值遍历列表一次,并且可能需要为每个值遍历整个列表。
最后,在冒泡排序的情况下,我们为每个值遍历列表一次,并且可能需要为每个值遍历整个列表。在所有三种情况下,我们可能需要在包含 n 个值的列表上执行 n² 次操作。因此,插入、选择和冒泡排序算法在时间效率方面具有大-O n²,或 O (n²)。然而,当涉及到空间效率时,冒泡排序更好,因为它在同一个列表上操作,而插入和选择排序则需要额外的列表来存储排序后的列表。
扩展的 List 类
在本节中,我们将重新审视 LinkedList
类。然而,我们将以几种方式扩展它:
-
Cell
类有一组set
和get
方法。相反,我们将每个对替换为一对重载的引用方法。 -
之前的列表只能存储
double
类型的值。现在我们将列表定义为template
,这样它就可以存储任意类型的值。 -
我们将用重载运算符替换一些方法。
-
Cell
和Iterator
原本是独立类。现在我们将它们定义为LinkedList
内部的内部类:
List.h:
class OutOfMemoryException : public exception {
// Empty.
};
在前几节的课程中,列表存储的是double
类型的值。然而,在这些类中,我们不再使用double
,而是使用模板类型T
,它是一种泛型类型,可以被任何任意类型实例化。本节中的LinkedList
类是template
的,具有泛型类型T
:
template <class T>
class LinkedList {
private:
class Cell {
private:
Cell(const T& value, Cell* previous, Cell* next);
value
方法有两个重载版本。第一个版本是常量版本,返回一个常量值。另一个版本不是常量版本,返回值的引用。这样,就可以将值赋给单元格的值,如下面的示例所示:
public:
const T value() const { return m_value; }
T& value() { return m_value; }
Cell*&
构造意味着方法返回一个指向Cell
对象的指针的引用。然后,可以使用这个引用将新值赋给指针:
const Cell* previous() const { return m_previous; }
Cell*& previous() { return m_previous; }
const Cell* next() const { return m_next; }
Cell*& next() { return m_next; }
friend class LinkedList;
private:
T m_value;
Cell *m_previous, *m_next;
};
public:
class Iterator {
public:
Iterator();
private:
Iterator(Cell* cellPtr);
public:
Iterator(const Iterator& iterator);
Iterator& operator=(const Iterator& iterator);
我们用重载的相等和不等运算符替换了equal
和notEqual
:
bool operator==(const Iterator& iterator);
bool operator!=(const Iterator& iterator);
我们还用增量(++
)和减量(--
)运算符替换了增量方法和减量方法。每个运算符有两种版本——前缀和后缀。不带参数的版本是前缀版本(++i
和--i
),带有整数参数的版本是后缀版本(i++
和i--
)。请注意,实际上我们没有传递整数参数给运算符。参数只包含在内以区分两个版本,并且被编译器忽略:
bool operator++(); // prefix: ++i
bool operator++(int); // postfix: i++
bool operator--(); // prefix: --i
bool operator--(int); // postfix: i--
我们用两个重载的解引用运算符(*
)替换了getValue
和setValue
方法。它们的工作方式与前面Cell
类中的value
方法类似。第一个版本是常量版本,返回一个值,而第二个版本不是常量版本,返回值的引用:
T operator*() const;
T& operator*();
friend class LinkedList;
private:
Cell *m_cellPtr;
};
ReverseIterator
类
为了从末尾到开头以及从开头到末尾进行迭代,我们添加了ReverseIterator
。它与之前使用的Iterator
几乎相同;唯一的区别是增量运算符和减量运算符的方向相反:
class ReverseIterator {
public:
ReverseIterator();
private:
ReverseIterator(Cell* cellPtr);
public:
ReverseIterator(const ReverseIterator& iterator);
const ReverseIterator&
operator=(const ReverseIterator& iterator);
bool operator==(const ReverseIterator& iterator);
bool operator!=(const ReverseIterator& iterator);
bool operator++(); // prefix: ++i
bool operator++(int); // postfix: i++
bool operator--();
bool operator--(int);
T operator*() const;
T& operator*();
friend class LinkedList;
private:
Cell *m_cellPtr;
};
public:
LinkedList();
LinkedList(const LinkedList& list);
LinkedList& operator=(const LinkedList& list);
~LinkedList();
void clear();
int size() const {return m_size;}
bool empty() const {return (m_size == 0);}
bool operator==(const LinkedList& list) const;
bool operator!=(const LinkedList& list) const;
void add(const T& value);
void add(const LinkedList& list);
void insert(const Iterator& insertPosition, const T& value);
void insert(const Iterator& insertPosition,
const LinkedList& list);
void erase(const Iterator& erasePosition);
void remove(const Iterator& firstPosition,
const Iterator& lastPosition = Iterator(nullptr));
在前面的章节中,只有first
和last
方法,它们返回一个迭代器。在本节中,使用begin
和end
方法进行正向迭代,而rbegin
和rend
(表示反向开始和反向结束)用于反向迭代:
Iterator begin() const { return Iterator(m_firstCellPtr); }
Iterator end() const { return Iterator(nullptr); }
ReverseIterator rbegin() const
{return ReverseIterator(m_lastCellPtr);}
ReverseIterator rend() const
{ return ReverseIterator(nullptr); }
我们用重载的输入和输出流运算符替换了read
和write
方法。由于它们是函数而不是方法,它们需要自己的模板标记:
template <class U>
friend istream& operator>>(istream& outStream,
LinkedList<U>& list);
template <class U>
friend ostream& operator<<(ostream& outStream,
const LinkedList<U>& list);
private:
int m_size;
Cell *m_firstCellPtr, *m_lastCellPtr;
};
注意,当我们实现template
类的成员函数时,我们是在头文件中实现的。因此,在实现template
类时,我们不需要实现文件。
与类定义类似,方法定义必须以 template
关键字开头。请注意,类名 LinkedList
后跟类型标记 <T>
:
template <class T>
LinkedList<T>::Cell::Cell(const T& value, Cell* previous,
Cell* next)
:m_value(value),
m_previous(previous),
m_next(next) {
// Empty.
}
template <class T>
LinkedList<T>::Iterator::Iterator()
:m_cellPtr(nullptr) {
// Empty.
}
注意,当我们实现内部类的方法时,我们需要在实现中包含外部类(LinkedList
)和内部类(Cell
)的名称:
template <class T>
LinkedList<T>::Iterator::Iterator(Cell* cellPtr)
:m_cellPtr(cellPtr) {
// Empty.
}
template <class T>
LinkedList<T>::Iterator::Iterator(const Iterator& position)
:m_cellPtr(position.m_cellPtr) {
// Empty.
}
由于 LinkedList
是一个 template
类,编译器并不知道其内部类 Iterator
实际上是一个类。就编译器所知,迭代器可能是一个类型、一个值或一个类。因此,我们需要通过使用 typename
关键字来通知编译器:
template <class T>
typename LinkedList<T>::Iterator&
LinkedList<T>::Iterator::operator=(const Iterator& iterator) {
m_cellPtr = iterator.m_cellPtr;
return *this;
}
以下运算符版本与之前版本 LinkedList
中的方法对应方式相同。也就是说,equal
方法已被等式运算符(operator==
)所取代,而 notEqual
方法已被不等式运算符(operator!=
)所取代:
template <class T>
bool LinkedList<T>::Iterator::operator==(const Iterator&position){
return (m_cellPtr == position.m_cellPtr);
}
template <class T>
bool LinkedList<T>::Iterator::operator!=(const Iterator&position){
return !(*this == position);
}
增量运算符已被前缀和后缀版本的 operator++
所取代。它们之间的区别在于,前缀版本不接收任何参数,而后缀版本接收一个整数参数作为参数。请注意,整数参数不会被运算符使用。它的值是未定义的(然而,通常设置为零)并且总是被忽略。它的存在只是为了区分前缀和后缀情况:
template <class T>
bool LinkedList<T>::Iterator::operator++() {
if (m_cellPtr != nullptr) {
m_cellPtr = m_cellPtr->next();
return true;
}
return false;
}
template <class T>
bool LinkedList<T>::Iterator::operator++(int) {
if (m_cellPtr != nullptr) {
m_cellPtr = m_cellPtr->next();
return true;
}
return false;
}
decrease
运算符也有前缀和后缀版本,其工作方式与 increase
运算符类似:
template <class T>
bool LinkedList<T>::Iterator::operator--() {
if (m_cellPtr != nullptr) {
m_cellPtr = m_cellPtr->previous();
return true;
}
return false;
}
template <class T>
bool LinkedList<T>::Iterator::operator--(int) {
if (m_cellPtr != nullptr) {
m_cellPtr = m_cellPtr->previous();
return true;
}
return false;
}
取引用运算符也有两种版本。第一种是常量版本,返回一个值。第二种版本不是常量版本,返回对值的引用,而不是值本身。这样,第一种版本可以在常量对象上调用,在这种情况下,我们不允许更改其值。第二种版本只能在非常量对象上调用,我们可以通过将新值赋给方法返回的值来更改值:
template <class T>
T LinkedList<T>::Iterator::operator*() const {
return m_cellPtr->value();
}
template <class T>
T& LinkedList<T>::Iterator::operator*() {
return m_cellPtr->value();
}
ReverseIterator
类有三个构造函数。第一个构造函数是一个默认构造函数,第二个构造函数使用一个 Cell
指针进行初始化,第三个构造函数是一个 copy
构造函数。它接受另一个 ReverseIterator
对象的引用,并初始化 Cell
指针:
template <class T>
LinkedList<T>::ReverseIterator::ReverseIterator()
:m_cellPtr(nullptr) {
// Empty.
}
template <class T>
LinkedList<T>::ReverseIterator::ReverseIterator(Cell* currCellPtr)
:m_cellPtr(currCellPtr) {
// Empty.
}
template <class T>
LinkedList<T>::ReverseIterator::ReverseIterator
(const ReverseIterator& position)
:m_cellPtr(position.m_cellPtr) {
// Empty.
}
等于运算符使用给定 ReverseIterator
对象引用的 Cell
指针初始化 Cell
指针:
template <class T>
const typename LinkedList<T>::ReverseIterator&
LinkedList<T>::ReverseIterator::operator=(const ReverseIterator& position) {
m_cellPtr = position.m_cellPtr;
return *this;
}
两个反向迭代器相等,如果它们的单元格指针指向相同的单元格:
template <class T>
bool LinkedList<T>::ReverseIterator::operator==
(const ReverseIterator& position) {
return (m_cellPtr == position.m_cellPtr);
}
template <class T>
bool LinkedList<T>::ReverseIterator::operator!=
(const ReverseIterator& position) {
return !(*this == position);
}
Iterator
和 ReverseIterator
类的增量运算符和减量运算符之间的区别在于,在 Iterator
中,增量运算符调用 next
,而 decrement
运算符调用 Cell
中的 previous
。在 ReverseIterator
中情况相反:增量运算符调用 previous
,而减量运算符调用 next
。正如其名称所暗示的:Iterator
向前迭代,而 ReverseIterator
向后迭代:
template <class T>
bool LinkedList<T>::ReverseIterator::operator++() {
if (m_cellPtr != nullptr) {
m_cellPtr = m_cellPtr->previous();
return true;
}
return false;
}
template <class T>
bool LinkedList<T>::ReverseIterator::operator++(int) {
if (m_cellPtr != nullptr) {
m_cellPtr = m_cellPtr->previous();
return true;
}
return false;
}
template <class T>
bool LinkedList<T>::ReverseIterator::operator--() {
if (m_cellPtr != nullptr) {
m_cellPtr = m_cellPtr->next();
return true;
}
return false;
}
template <class T>
bool LinkedList<T>::ReverseIterator::operator--(int) {
if (m_cellPtr != nullptr) {
m_cellPtr = m_cellPtr->next();
return true;
}
return false;
}
template <class T>
T LinkedList<T>::ReverseIterator::operator*() const {
return m_cellPtr->value();
}
template <class T>
T& LinkedList<T>::ReverseIterator::operator*() {
return m_cellPtr->value();
}
LinkedList
的默认构造函数将列表初始化为空,第一个和最后一个单元格的指针设置为 null:
template <class T>
LinkedList<T>::LinkedList()
:m_size(0),
m_firstCellPtr(nullptr),
m_lastCellPtr(nullptr) {
// Empty.
}
template <class T>
LinkedList<T>::LinkedList(const LinkedList<T>& list) {
*this = list;
}
赋值操作符以与非模板方法相同的方式复制给定列表的值:
template <class T>
LinkedList<T>& LinkedList<T>::operator=(const LinkedList<T>&list){
m_size = 0;
m_firstCellPtr = nullptr;
m_lastCellPtr = nullptr;
if (list.m_size > 0) {
for (Cell *listCellPtr = list.m_firstCellPtr,
*nextCellPtr = list.m_lastCellPtr->next();
listCellPtr != nextCellPtr;
listCellPtr = listCellPtr->next()) {
Cell *newCellPtr = new Cell(listCellPtr->value(),
m_lastCellPtr, nullptr);
if (m_firstCellPtr == nullptr) {
m_firstCellPtr = newCellPtr;
}
注意,我们使用next
方法的引用版本,这允许我们将值分配给方法调用。由于next
返回单元格下一个指针的引用,我们可以将newCellPtr
的值分配给该指针:
else {
m_lastCellPtr->next() = newCellPtr;
}
m_lastCellPtr = newCellPtr;
++m_size;
}
}
return *this;
}
析构函数简单地调用clear
方法,该方法遍历链表并删除每个单元格:
template <class T>
LinkedList<T>::~LinkedList() {
clear();
}
template <class T>
void LinkedList<T>::clear() {
Cell *currCellPtr = m_firstCellPtr;
while (currCellPtr != nullptr) {
Cell *deleteCellPtr = currCellPtr;
currCellPtr = currCellPtr->next();
delete deleteCellPtr;
}
当单元格被删除时,第一个和最后一个单元格的指针被设置为 null:
m_size = 0;
m_firstCellPtr = nullptr;
m_lastCellPtr = nullptr;
}
如果两个列表具有相同的大小,并且它们的单元格包含相同的值,则这两个列表相等:
template <class T>
bool LinkedList<T>::operator==(const LinkedList<T>& list) const {
if (m_size != list.m_size) {
return false;
}
for (Iterator thisIterator = begin(),
listIterator = list.begin();
thisIterator != end(); ++thisIterator, ++listIterator) {
if (*thisIterator != *listIterator) {
return false;
}
}
return true;
}
template <class T>
bool LinkedList<T>::operator!=(const LinkedList<T>& list) const {
return !(*this == list);
}
add
方法在列表末尾添加一个具有新值的单元格,如下例所示:
template <class T>
void LinkedList<T>::add(const T& value) {
Cell *newCellPtr = new Cell(value, m_lastCellPtr, nullptr);
if (m_lastCellPtr == nullptr) {
m_firstCellPtr = newCellPtr;
m_lastCellPtr = newCellPtr;
}
else {
m_lastCellPtr->next() = newCellPtr;
m_lastCellPtr = newCellPtr;
}
++m_size;
}
add
的第二个版本将给定的列表添加到列表的末尾,如下例所示:
template <class T>
void LinkedList<T>::add(const LinkedList<T>& list) {
for (Cell *listCellPtr = list.m_firstCellPtr;
listCellPtr != nullptr; listCellPtr = listCellPtr->next()){
const T& value = listCellPtr->value();
Cell *newCellPtr = new Cell(value, m_lastCellPtr, nullptr);
if (m_lastCellPtr == nullptr) {
m_firstCellPtr = newCellPtr;
}
else {
m_lastCellPtr->next() = newCellPtr;
}
m_lastCellPtr = newCellPtr;
}
m_size += list.m_size;
}
insert
方法在给定位置添加一个值或一个列表:
template <class T>
void LinkedList<T>::insert(const Iterator& insertPosition,
const T& value) {
if (insertPosition.m_cellPtr == nullptr) {
add(value);
}
else {
Cell *insertCellPtr = insertPosition.m_cellPtr;
Cell *newCellPtr =
new Cell(value, insertCellPtr->previous(), insertCellPtr);
insertCellPtr->previous() = newCellPtr;
if (insertCellPtr == m_firstCellPtr) {
m_firstCellPtr = newCellPtr;
}
else {
newCellPtr->previous()->next() = newCellPtr;
}
++m_size;
}
}
template <class T>
void LinkedList<T>::insert(const Iterator& insertPosition,
const LinkedList<T>& list) {
if (insertPosition.m_cellPtr == nullptr) {
add(list);
}
else {
Cell *insertCellPtr = insertPosition.m_cellPtr;
Cell *firstInsertCellPtr = nullptr,
lastInsertCellPtr = nullptr;
for (Cell *listCellPtr = list.m_firstCellPtr;
listCellPtr != nullptr;listCellPtr=listCellPtr->next()) {
double value = listCellPtr->value();
Cell *newCellPtr =
new Cell(value, lastInsertCellPtr, nullptr);
if (firstInsertCellPtr == nullptr) {
firstInsertCellPtr = newCellPtr;
}
else {
lastInsertCellPtr->next() = newCellPtr;
}
lastInsertCellPtr = newCellPtr;
}
if (firstInsertCellPtr != nullptr) {
if (insertCellPtr->previous() != nullptr) {
insertCellPtr->previous()->next() = firstInsertCellPtr;
firstInsertCellPtr->previous() =
insertCellPtr->previous();
}
else {
m_firstCellPtr = firstInsertCellPtr;
}
}
if (lastInsertCellPtr != nullptr) {
lastInsertCellPtr->next() = insertCellPtr;
insertCellPtr->previous() = lastInsertCellPtr;
}
m_size += list.m_size;
}
}
erase
和remove
方法从列表中移除子列表的值:
template <class T>
void LinkedList<T>::erase(const Iterator& removePosition) {
remove(removePosition, removePosition);
}
template <class T>
void LinkedList<T>::remove(const Iterator& firstPosition,
const Iterator& lastPosition /*= Iterator(nullptr)*/) {
Cell *firstCellPtr = firstPosition.m_cellPtr,
*lastCellPtr = lastPosition.m_cellPtr;
lastCellPtr = (lastCellPtr == nullptr)
? m_lastCellPtr : lastCellPtr;
Cell *previousCellPtr = firstCellPtr->previous(),
*nextCellPtr = lastCellPtr->next();
Cell *currCellPtr = firstCellPtr;
while (currCellPtr != nextCellPtr) {
Cell *deleteCellPtr = currCellPtr;
currCellPtr = currCellPtr->next();
delete deleteCellPtr;
--m_size;
}
if (previousCellPtr != nullptr) {
previousCellPtr->next() = nextCellPtr;
}
else {
m_firstCellPtr = nextCellPtr;
}
if (nextCellPtr != nullptr) {
nextCellPtr->previous() = previousCellPtr;
}
else {
m_lastCellPtr = previousCellPtr;
}
}
输入流操作符首先读取列表的size
,然后读取值本身:
template <class T>
istream& operator>>(istream& inStream, LinkedList<T>& list) {
int size;
inStream >> size;
for (int count = 0; count < size; ++count) {
T value;
inStream >> value;
list.add(value);
}
return inStream;
}
输出流操作符将列表写入给定的流中,列表被括号包围,并且值之间用逗号分隔:
template <class T>
ostream& operator<<(ostream& outStream,const LinkedList<T>& list){
outStream << "[";
bool first = true;
for (const T& value : list) {
outStream << (first ? "" : ",") << value;
first = false;
}
outStream << "]";
return outStream;
}
我们通过让用户输入一个列表,然后自动使用for
语句以及手动使用前向和后向迭代器来迭代该列表,来测试LinkedList
类。
Main.cpp:
#include <IOStream>
#include <Exception>
using namespace std;
#include "List.h"
void main() {
LinkedList<double> list;
cin >> list;
cout << list <&lt; endl;
注意,可以直接在列表上使用for
语句,因为扩展的列表包含begin
方法,该方法返回一个具有前缀增量(++
)和解除引用(*
)操作符的迭代器:
for (double value : list) {
cout << value << " ";
}
cout << endl;
我们还可以使用Iterator
类的begin
和end
方法手动遍历列表:
for (LinkedList<double>::Iterator iterator = list.begin();
iterator != list.end(); ++iterator) {
cout << *iterator << " ";
}
cout << endl;
使用rbegin
和rend
方法以及ReverseIterator
类,我们可以从其末尾迭代到其开头。注意,我们仍然使用增量(++
)而不是减量(--
),尽管我们正在反向遍历列表:
for (LinkedList<double>::ReverseIterator iterator =
list.rbegin(); iterator != list.rend(); ++iterator) {
cout << *iterator << " ";
}
cout << endl;
}
扩展的Set
类
与前一个章节的版本相比,本节中的Set
类在三个方面进行了扩展:
-
集合被存储为有序列表,这使得一些方法更有效
-
该类是一个模板;只要这些类型支持排序,它就可以存储任意类型的值
-
该类具有操作符重载,这(希望)使得它更容易且更直观地使用
在 C++ 中,我们可以使用 typedef
关键字定义自己的类型。我们将 Set
的 Iterator
定义为与 LinkedList
中的相同迭代器。在前面的小节中,Iterator
是一个独立的类,我们可以在处理集合时重用它。然而,在本节中,Iterator
是一个内部类。否则,在处理集合时无法访问 LinkedList
,因为 Set
是私有继承 LinkedList
的。记住,当我们私有继承时,基类的所有方法和字段都成为子类的私有成员。
Set.h:
template <class T>
class Set : private LinkedList<T> {
public:
typedef LinkedList<T>::Iterator Iterator;
Set();
Set(const T& value);
Set(const Set& set);
Set& operator=(const Set& set);
~Set();
我们用重载的比较运算符替换 equal
和 notEqual
方法。这样,就可以像比较两个整数一样比较两个集合:
bool operator==(const Set& set) const;
bool operator!=(const Set& set) const;
int size() const { return LinkedList<T>::size(); }
bool empty() const { return LinkedList<T>::empty(); }
Iterator begin() const { return LinkedList<T>::begin(); }
我们用加法、乘法和减法运算符替换 unionSet
、intersection
和 difference
方法:
Set operator+(const Set& set) const;
Set operator*(const Set& set) const;
Set operator-(const Set& set) const;
merge
函数由集合方法调用以执行集合的高效合并。由于它是一个函数而不是方法,它必须有自己的模板标记:
private:
template <class U>
friend Set<U>
merge(const Set<U>& leftSet, const Set<U>& rightSet,
bool addLeft, bool addEqual, bool addRight);
public:
Set& operator+=(const Set& set);
Set& operator*=(const Set& set);
Set& operator-=(const Set& set);
与前面的 LinkedList
类类似,我们用重载的流运算符替换 read
和 write
方法。由于它们也是函数而不是方法,它们也需要自己的模板标记:
template <class U>
friend istream& operator>>(istream& inStream, Set<U>& set);
template <class U>
friend ostream& operator<<(ostream& outStream,
const Set<U>& set);
};
与非模板版本相比,构造函数看起来几乎相同:
template <class T>
Set<T>::Set() {
// Empty.
}
template <class T>
Set<T>::Set(const T& value) {
add(value);
}
template <class T>
Set<T>::Set(const Set& set)
:LinkedList(set) {
// Empty.
}
template <class T>
Set<T>::~Set() {
// Empty.
}
template <class T>
Set<T>& Set<T>::operator=(const Set& set) {
clear();
add(set);
return *this;
}
在测试两个集合是否相等时,我们可以直接在 LinkedList
中调用等号运算符,因为本节中的集合是有序的:
template <class T>
bool Set<T>::operator==(const Set& set) const {
return LinkedList::operator==(set);
}
与前面的类类似,我们通过调用 equal
来测试两个集合是否不相等。然而,在这个类中,我们通过使用 this
指针比较自身对象(对象本身)与给定的集合来显式使用等号运算符:
template <class T>
bool Set<T>::operator!=(const Set& set) const {
return !(*this == set);
}
并集、交集和差集
我们用加法、减法和乘法运算符替换 unionSet
、intersection
和 difference
方法。它们都调用 merge
,并为 addLeft
、addEqual
和 addRight
参数提供不同的集合和值。在并集的情况下,这三个都是 true
,这意味着应包含左手集合中仅存在的值、两个集合都存在的值或右手集合中仅存在的值:
template <class T>
Set<T> Set<T>::operator+(const Set& set) const {
return merge(*this, set, true, true, true);
}
在交集的情况下,只有 addEqual
是 true
,这意味着应包含两个集合中存在的值,但不包含只存在于一个集合中的值,在交集集中。看看以下示例:
template <class T>
Set<T> Set<T>::operator*(const Set& set) const {
return merge(*this, set, false, true, false);
}
如果有差异,只有 addLeft
是 true
,这意味着只有左手集合中存在但不在两个集合或右手集合中存在的值应包含在差集中:
template <class T>
Set<T> Set<T>::operator-(const Set& set) const {
return merge(*this, set, true, false, false);
}
merge
方法接受两个集合和三个布尔值 addLeft
、addEqual
和 addRight
。如果 addLeft
是 true
,则将左手集合中存在的值添加到结果集中;如果 addEqual
是 true
,则将两个集合中存在的值添加;如果 rightAdd
是 true
,则将右手集合中仅存在的值添加:
template <class T>
Set<T> merge(const Set<T>& leftSet, const Set<T>& rightSet,
bool addLeft, bool addEqual, bool addRight) {
Set<T> result;
Set<T>::Iterator leftIterator = leftSet.begin(),
rightIterator = rightSet.begin();
while
语句在左侧集合和右侧集合中还有值时持续迭代:
while ((leftIterator != leftSet.end()) &&
(rightIterator != rightSet.end())) {
如果左侧值较小,如果addLeft
为true
,则将其添加到结果集合中。然后增加左侧集合的迭代器:
if (*leftIterator < *rightIterator) {
if (addLeft) {
result.add(*leftIterator);
}
++leftIterator;
}
如果右侧值较小,如果addRight
为true
,则将其添加到结果集合中。然后增加右侧集合的迭代器:
else if (*leftIterator > *rightIterator) {
if (addRight) {
result.add(*rightIterator);
}
++rightIterator;
}
最后,如果值相等,其中一个(但不是两个,因为集合中没有重复项)被添加,并且两个迭代器都增加:
else {
if (addEqual) {
result.add(*leftIterator);
}
++leftIterator;
++rightIterator;
}
}
如果addLeft
为true
,则将左侧集合的所有剩余值(如果有)添加到结果集合中:
if (addLeft) {
while (leftIterator != leftSet.end()) {
result.add(*leftIterator);
++leftIterator;
}
}
如果addRight
为true
,则将右侧集合的所有剩余值(如果有)添加到结果集合中:
if (addRight) {
while (rightIterator != rightSet.end()) {
result.add(*rightIterator);
++rightIterator;
}
}
最后,使用以下方式返回结果集合:
return result;
}
当对这个集合和另一个集合执行并集操作时,我们只需调用加法操作符。注意,我们通过使用this
指针返回自己的对象:
template <class T>
Set<T>& Set<T>::operator+=(const Set& set) {
*this = *this + set;
return *this;
}
以相同的方式,我们在对集合和另一个集合执行交集和差集操作时调用乘法和减法操作符。查看以下示例:
template <class T>
Set<T>& Set<T>::operator*=(const Set& set) {
*this = *this * set;
return *this;
}
template <class T>
Set<T>& Set<T>::operator-=(const Set& set) {
*this = *this - set;
return *this;
}
读取集合时,首先输入集合的值数,然后输入值本身。这个函数与LinkedList
类中的对应函数非常相似。然而,为了避免重复,我们调用复合加法操作符(+=
)而不是add
方法:
template <class T>
istream& operator>>(istream& inStream, Set<T>& set) {
int size;
inStream >> size;
for (int count = 0; count < size; ++count) {
T value;
inStream >> value;
set += value;
}
return inStream;
}
当编写集合时,我们将值放在括号中("{"和"}"),而不是方括号中("["和"]"),就像列表的情况一样:
template <class T>
ostream& operator<<(ostream& outStream, const Set<T>& set) {
outStream << "{";
bool first = true;
for (const T& value : set) {
outStream << (first ? "" : ",") << value;
first = false;
}
outStream << "}";
return outStream;
}
我们通过让用户输入两个集合来测试集合,我们手动使用迭代器和自动使用for
语句迭代。我们还评估集合之间的并集、交集和差集。
Main.cpp:
#include <IOStream>
using namespace std;
#include "..\ListAdvanced\List.h"
#include "Set.h"
void main() {
Set<double> s, t;
cin >> s >> t;
cout << endl << "s: " << s << endl;
cout << "t: " << t << endl;
cout << endl << "s: ";
for (double value : s) {
cout << value << " ";
}
cout << endl << "t: ";
for (Set<double>::Iterator iterator = t.begin();
iterator != t.end(); ++iterator) {
cout << *iterator << " ";
}
cout << endl << endl << "union: " << (s + t) << endl;
cout << "intersection: " << (s *t) << endl;
cout << "difference: " << (s - t) << endl << endl;
}
当我们执行程序时,输出显示在命令窗口中:
高级搜索和排序
我们在前面章节中讨论了线性搜索。在本节中,我们将讨论二分搜索。二分搜索算法在列表中间查找值,然后使用列表的一半进行搜索。因此,它具有O(log2n),因为它在每次迭代中都将列表分成两半。
Search.h:
template <class ListType, class ValueType>
int binarySearch(const ValueType& value, const ListType& list) {
ListType::Iterator* positionBuffer =
new ListType::Iterator[list.size()];
int index = 0;
for (ListType::Iterator position = list.begin();
position != list.end(); ++position) {
positionBuffer[index++] = position;
}
int minIndex = 0, maxIndex = list.size() - 1;
while (minIndex <= maxIndex) {
int middleIndex = (maxIndex + minIndex) / 2;
ListType::Iterator iterator = positionBuffer[middleIndex];
const ValueType& middleValue = *iterator;
if (value == middleValue) {
return middleIndex;
}
else if (value < middleValue) {
maxIndex = middleIndex - 1;
}
else {
minIndex = middleIndex + 1;
}
}
return -1;
}
归并排序算法
归并排序算法将列表分为两个相等的子列表,通过递归调用(方法或函数调用自身时发生递归调用)对子列表进行排序,然后以类似于前面章节中扩展版本的Set
类中的merge
方法的方式合并排序后的子列表。
Sort.h:
template <class ListType, class ValueType>
void mergeSort(ListType& list) {
int size = list.size();
if (size > 1) {
int middle = list.size() / 2;
ListType::Iterator iterator = list.begin();
ListType leftList;
for (int count = 0; count < middle; ++count) {
leftList.add(*iterator);
++iterator;
}
ListType rightList;
for (; iterator != list.end(); ++iterator) {
rightList.add(*iterator);
}
mergeSort<ListType, ValueType>(leftList);
mergeSort<ListType,ValueType>(rightList);
ListType resultList;
merge<ListType,ValueType>(leftList, rightList, resultList);
list = resultList;
}
}
本节中的merge
方法重用了本章前面扩展Set
类中merge
的想法:
template <class ListType, class ValueType>
void merge(ListType& leftList, ListType& rightList,
ListType& result) {
ListType::Iterator leftPosition = leftList.begin();
ListType::Iterator rightPosition = rightList.begin();
while ((leftPosition != leftList.end()) &&
(rightPosition != rightList.end())) {
if (*leftPosition < *rightPosition) {
result.add(*leftPosition);
++leftPosition;
}
else {
result.add(*rightPosition);
++rightPosition;
}
}
while (leftPosition != leftList.end()) {
result.add(*leftPosition);
++leftPosition;
}
while (rightPosition != rightList.end()) {
result.add(*rightPosition);
++rightPosition;
}
}
快速排序算法
快速排序算法选择第一个值(称为基准值),然后将所有小于基准值的值放在较小的子列表中,所有大于或等于基准值的值放在较大的子列表中。然后通过递归调用对两个列表进行排序,然后将它们连接起来。让我们看看以下示例:
template <class ListType, class ValueType>
void quickSort(ListType& list) {
if (list.size() > 1) {
ListType smaller, larger;
ValueType pivotValue = *list.begin();
ListType::Iterator position = list.begin();
++position;
for (;position != list.end(); ++position) {
if (*position < pivotValue) {
smaller.add(*position);
}
else {
larger.add(*position);
}
}
quickSort<ListType,ValueType>(smaller);
quickSort<ListType,ValueType>(larger);
list = smaller;
list.add(pivotValue);
list.add(larger);
}
}
归并排序算法在平衡方面做得很好,它总是将列表分成两个相等的部分并对其进行排序。算法必须遍历列表一次以将它们分成两个子列表并对子列表进行排序。给定一个值列表,它必须遍历其n个值并将列表分成log[2]n次。因此,归并排序O(n log[2]n)。
快速排序算法,另一方面,在最坏的情况下(如果列表已经排序),并不比插入、选择或冒泡排序好:O(n²)。然而,在平均情况下,它很快。
摘要
在本章中,我们为抽象数据类型列表和集合创建了类。列表是一个有序结构,有开始和结束,而集合是一个无序结构。
我们从相对简单的版本开始,其中列表有单独的单元格和迭代器类。然后我们创建了一个更高级的版本,其中我们使用了模板和运算符重载。我们还把单元格和迭代器类放在列表类内部。最后,我们引入了重载引用方法。
同样,我们首先创建了一个相对简单且效率较低的集合类版本。然后我们创建了一个更高级的版本,其中我们使用了模板和运算符重载,以便能够更有效地执行并集、交集和差集操作。
此外,我们还实现了线性搜索和二分搜索算法。线性搜索适用于每个无序序列,但效率较低。二分搜索更有效,但它只适用于有序序列。
最后,我们探讨了排序算法。我们从简单但效率较低的插入、选择和冒泡排序算法开始。然后我们继续使用更高级和有效的归并排序和快速排序算法。
在下一章中,我们将开始构建一个图书馆管理系统。
第三章:构建图书馆管理系统
在本章中,我们研究了一个图书馆管理系统。我们继续开发 C++类,就像前几章一样。然而,在本章中,我们开发了一个更贴近现实世界的系统。本章的图书馆系统可以被真实的图书馆使用。
图书馆由书籍和客户的集合组成。书籍跟踪哪些客户借阅或预订了它们。客户跟踪他们借阅和预订了哪些书籍。
主要思想是图书馆包含一组书籍和一组客户。每本书都标记为已借出或未借出。如果已借出,则存储借阅该书的客户的身份号码。此外,一本书也可以被一个或多个客户预订。因此,每本书还包含一个已预订该书的客户身份号码列表。它必须是一个列表而不是集合,因为书籍应按照客户预订的顺序借出。
每个客户持有两个集合,包含他们借阅和预订的书籍的身份号码。在这两种情况下,我们使用集合而不是列表,因为它们借阅或预订书籍的顺序并不重要。
在本章中,我们将涵盖以下主题:
-
处理书籍和客户类,这些类构成了一个小型数据库,以整数作为键。
-
使用标准输入和输出流处理,其中我们写入有关书籍和客户的信息,并提示用户输入。
-
使用文件处理和流。书籍和客户使用标准 C++文件流写入和读取。
-
最后,我们使用 C++标准库中的泛型类
set
和list
。
Book
类
我们有三个类:Book
、Customer
和Library
:
-
Book
类跟踪一本书。每本书都有一个作者、一个标题和一个唯一的身份号码。 -
Customer
类跟踪一个客户。每个客户都有一个姓名、一个地址和一个唯一的身份号码。 -
Library
类跟踪图书馆操作,例如添加和删除书籍和客户、借阅、归还和预订书籍,以及列出书籍和客户。 -
main
函数简单地创建一个Library
类的对象。
此外,每本书还记录了它是否被借阅的信息。如果被借阅,则存储借阅该书的客户的身份号码。每本书还包含一个预订列表。同样,每个客户还包含他们当前借阅和预订的书籍集合。
Book
类有两个构造函数。第一个构造函数是一个默认构造函数,用于从文件中读取书籍。第二个构造函数用于向图书馆添加新书。它接受书籍的作者名和标题作为参数。
Book.h
class Book {
public:
Book(void);
Book(const string& author, const string& title);
author
和title
方法简单地返回书籍的作者和标题:
const string& author(void) const { return m_author; }
const string& title(void) const { return m_title; }
图书馆的书籍可以读取和写入文件:
void read(ifstream& inStream);
void write(ofstream& outStream) const;
一本书可以被借出、预订或归还。预订也可以被删除。请注意,当书籍被借出或预订时,我们需要提供顾客的身份证号。然而,在归还书籍时,这不必要,因为 Book
类跟踪当前借出书籍的顾客:
void borrowBook(int customerId);
int reserveBook(int customerId);
void unreserveBookation(int customerId);
void returnBook();
当书籍被借出时,顾客的身份证号被存储,并由 bookId
返回:
int bookId(void) const { return m_bookId; }
borrowed
方法返回 true,如果此时书籍已被借出。在这种情况下,customerId
返回借出书籍的顾客的身份证号:
bool borrowed(void) const { return m_borrowed; }
int customerId(void) const { return m_customerId; }
一本书可以被一组顾客预订,并且 reservationList
返回该列表:
list<int>& reservationList(void) { return m_reservationList; }
MaxBookId
字段是静态的,这意味着它是类中所有对象的公共属性:
static int MaxBookId;
输出流操作符写入书籍的信息:
friend ostream& operator<<(ostream& outStream,
const Book& book);
当书籍被借出时,m_borrowed
字段为 true。书籍和潜在借阅者的身份信息存储在 m_bookId
和 m_customerId
中:
private:
bool m_borrowed = false;
int m_bookId, m_customerId;
作者的名字和书的标题存储在 m_author
和 m_title
中:
string m_author, m_title;
多个顾客可以预订一本书。当他们这样做时,他们的身份信息被存储在 m_reservationList
中。它是一个列表而不是集合,因为预订是按顺序存储的。当一本书被归还时,下一个按预订顺序的顾客借阅这本书:
list<int> m_reservationList;
};
在本章中,我们使用 C++ 标准库中的泛型 set
、map
和 list
类。它们的规范存储在 Set
、Map
和 List
头文件中。set
和 list
类包含与上一章中我们的集合和列表类类似的集合和列表。一个映射是一个结构,其中每个值都通过一个唯一的键来标识,以便提供快速访问。
Book.cpp
#include <Set>
#include <Map>
#include <List>
#include <String>
#include <FStream>
using namespace std;
#include "Book.h"
#include "Customer.h"
#include "Library.h"
由于 MaxBookId
是静态的,我们使用双冒号 (::
) 符号初始化它。每个静态字段都需要在类定义之外初始化:
int Book::MaxBookId = 0;
默认构造函数不执行任何操作。它在从文件读取时使用。尽管如此,我们仍然必须有一个默认构造函数来创建 Book
类的对象:
Book::Book(void) {
// Empty.
}
当创建新书时,它被分配一个唯一的身份号码。这个身份号码存储在 MaxBookId
中,每次创建新的 Book
对象时都会增加:
Book::Book(const string& author, const string& title)
:m_bookId(++MaxBookId),
m_author(author),
m_title(title) {
// Empty.
}
写入书籍
以类似的方式将书籍写入流。但是,我们使用 write
而不是 read
。它们的工作方式类似:
void Book::write(ofstream& outStream) const {
outStream.write((char*) &m_bookId, sizeof m_bookId);
当读取字符串时,我们使用 getline
而不是流操作符,因为流操作符只读取一个单词,而 getline
读取多个单词。然而,在写入流时,我们可以使用流操作符。名字和标题是由一个或多个单词组成无关紧要:
outStream << m_author << endl;
outStream << m_title << endl;
outStream.write((char*) &m_borrowed, sizeof m_borrowed);
outStream.write((char*) &m_customerId, sizeof m_customerId);
与这里的读取情况类似,我们首先在列表中写入预订的数量。然后写入预订身份本身:
{ int reservationListSize = m_reservationList.size();
outStream.write((char*) &reservationListSize,
sizeof reservationListSize);
for (int customerId : m_reservationList) {
outStream.write((char*) &customerId, sizeof customerId);
}
}
}
读取书籍
当从文件中读取任何类型的值(除了字符串)时,我们使用 read
方法,该方法读取固定数量的字节。sizeof
操作符给我们 m_bookId
字段的大小(以字节为单位)。sizeof
操作符也可以用来查找类型的大小。例如,sizeof (int)
给出 int
类型值的字节大小。类型必须用括号括起来:
void Book::read(ifstream& inStream) {
inStream.read((char*) &m_bookId, sizeof m_bookId);
当从文件中读取字符串值时,我们使用 C++ 标准函数 getline
来读取作者的名字和书籍的标题。如果名字由多个单词组成,使用输入流操作符将不起作用。如果作者或标题由多个单词组成,则只会读取第一个单词。其余的单词将不会被读取:
getline(inStream, m_author);
getline(inStream, m_title);
注意,我们甚至使用 read
方法来读取 m_borrowed
字段的值,尽管它持有的是 bool
类型而不是 int
类型:
inStream.read((char*) &m_borrowed, sizeof m_borrowed);
inStream.read((char*) &m_customerId, sizeof m_customerId);
在读取预订列表时,我们首先读取列表中的预订数量。然后读取预订的身份证号码:
{ int reservationListSize;
inStream.read((char*) &reservationListSize,
sizeof reservationListSize);
for (int count = 0; count < reservationListSize; ++count) {
int customerId;
inStream.read((char*) &customerId, sizeof customerId);
m_reservationList.push_back(customerId);
}
}
}
借阅和预订书籍
当书籍被借出时,m_borrowed
变为 true
,并且 m_customerId
被设置为借出书籍的顾客的身份证号:
void Book::borrowBook(int customerId) {
m_borrowed = true;
m_customerId = customerId;
}
当书籍被预订时,情况略有不同。虽然一本书只能被一位顾客借阅,但它可以被多位顾客预订。顾客的身份证号被添加到 m_reservationList
中。列表的大小被返回给调用者,以便他们知道自己在预订列表中的位置:
int Book::reserveBook(int customerId) {
m_reservationList.push_back(customerId);
return m_reservationList.size();
}
当书籍归还时,我们只需将 m_borrowed
设置为 false
。我们不需要将 m_customerId
设置为任何特定的值。只要书籍没有被借出,这与它无关:
void Book::returnBook() {
m_borrowed = false;
}
顾客可以自己从预订列表中移除。在这种情况下,我们在 m_reservationList
上调用 remove
:
void Book::unreserveBookation(int customerId) {
m_reservationList.remove(customerId);
}
显示书籍
输出流操作符写入书籍的标题和作者。如果书籍被借出,则写入顾客的姓名,如果预订列表已满,则写入预订顾客的姓名:
ostream& operator<<(ostream& outStream, const Book& book) {
outStream << """ << book.m_title << "" by " << book.m_author;
当访问静态字段时,我们使用双冒号表示法(::
),例如 Library
中的 s_customerMap
:
if (book.m_borrowed) {
outStream << endl << " Borrowed by: "
<< Library::s_customerMap[book.m_customerId].name()
<< ".";
}
if (!book.m_reservationList.empty()) {
outStream << endl << " Reserved by: ";
bool first = true;
for (int customerId : book.m_reservationList) {
outStream << (first ? "" : ",")
<< Library::s_customerMap[customerId].name();
first = false;
}
outStream << ".";
}
return outStream;
}
Customer
类
Customer
类跟踪顾客信息。它持有顾客当前借阅和预订的书籍集合。
Customer.h
class Customer {
public:
Customer(void);
Customer(const string& name, const string& address);
void read(ifstream& inStream);
void write(ofstream& outStream) const;
void borrowBook(int bookId);
void reserveBook(int bookId);
void returnBook(int bookId);
void unreserveBook(int bookId);
hasBorrowed
方法返回 true
,如果顾客此时至少借阅了一本书。在下一节的 Library
类中,无法移除当前借阅书籍的顾客:
bool hasBorrowed(void) const { return !m_loanSet.empty(); }
const string& name(void) const {return m_name;}
const string& address(void) const {return m_address;}
int id(void) const {return m_customerId;}
与之前使用的 Book
类类似,我们使用静态字段 MaxCustomerId
来计数顾客的身份证号。我们还使用输出流操作符来写入有关顾客的信息:
static int MaxCustomerId;
friend ostream& operator<<(ostream& outStream,
const Customer& customer);
每个客户都有一个姓名、地址和唯一的身份号码。集合m_loanSet
和m_reservationSet
保存了客户当前借阅和预订的书籍的身份号码。请注意,我们使用集合而不是列表,因为借阅和预订的书籍的顺序并不重要:
private:
int m_customerId;
string m_name, m_address;
set<int> m_loanSet, m_reservationSet;
};
Customer.cpp
#include <Set>
#include <Map>
#include <List>
#include <String>
#include <FStream>
using namespace std;
#include "Book.h"
#include "Customer.h"
#include "Library.h"
由于MaxCustomerId
是一个静态字段,它需要在类外部定义:
int Customer::MaxCustomerId;
默认构造函数用于仅从文件中加载对象。因此,不需要初始化字段:
Customer::Customer(void) {
// Empty.
}
第二个构造函数用于创建新的书籍对象。我们使用MaxCustomerId
字段来初始化客户身份号码;我们还初始化他们的name
和address
:
Customer::Customer(const string& name, const string& address)
:m_customerId(++MaxCustomerId),
m_name(name),
m_address(address) {
// Empty.
}
从文件中读取客户信息
read
方法从文件流中读取客户信息:
void Customer::read(ifstream& inStream) {
inStream.read((char*) &m_customerId, sizeof m_customerId);
与Book
类的read
方法相同,我们必须使用getline
函数而不是输入流运算符,因为输入流运算符只会读取一个单词:
getline(inStream, m_name);
getline(inStream, m_address);
{ int loanSetSize;
inStream.read((char*) &loanSetSize, sizeof loanSetSize);
for (int count = 0; count < loanSetSize; ++count) {
int bookId;
inStream.read((char*) &bookId, sizeof bookId);
m_loanSet.insert(bookId);
}
}
{ int reservationListSize;
inStream.read((char*) &reservationListSize,
sizeof reservationListSize);
for (int count = 0; count < reservationListSize; ++count) {
int bookId;
inStream.read((char*) &bookId, sizeof bookId);
m_loanSet.insert(bookId);
}
}
}
将客户信息写入文件
write
方法以与之前Book
类中相同的方式将客户信息写入流中:
void Customer::write(ofstream& outStream) const {
outStream.write((char*) &m_customerId, sizeof m_customerId);
outStream << m_name << endl;
outStream << m_address << endl;
当写入集合时,我们首先写入集合的大小,然后是集合的各个值:
{ int loanSetSize = m_loanSet.size();
outStream.write((char*) &loanSetSize, sizeof loanSetSize);
for (int bookId : m_loanSet) {
outStream.write((char*) &bookId, sizeof bookId);
}
}
{ int reservationListSize = m_reservationSet.size();
outStream.write((char*) &reservationListSize,
sizeof reservationListSize);
for (int bookId : m_reservationSet) {
outStream.write((char*) &bookId, sizeof bookId);
}
}
}
借阅和预订书籍
当客户借阅书籍时,它被插入到客户的借阅集中:
void Customer::borrowBook(int bookId) {
m_loanSet.insert(bookId);
}
同样,当客户预订书籍时,它被插入到客户的预订集中:
void Customer::reserveBook(int bookId) {
m_reservationSet.insert(bookId);
}
当客户归还或取消预订书籍时,它将从借阅集或预订集中删除:
void Customer::returnBook(int bookId) {
m_loanSet.erase(bookId);
}
void Customer::unreserveBook(int bookId) {
m_reservationSet.erase(bookId);
}
显示客户
输出流运算符写入客户的姓名和地址。如果客户借阅或预订了书籍,它们也会被写入:
ostream& operator<<(ostream& outStream, const Customer& customer){
outStream << customer.m_customerId << ". " << customer.m_name
<< ", " << customer.m_address << ".";
if (!customer.m_loanSet.empty()) {
outStream << endl << " Borrowed books: ";
bool first = true;
for (int bookId : customer.m_loanSet) {
outStream << (first ? "" : ",")
<< Library::s_bookMap[bookId].author();
first = false;
}
}
if (!customer.m_reservationSet.empty()) {
outStream << endl << " Reserved books: ";
bool first = true;
for (int bookId : customer.m_reservationSet) {
outStream << (first ? "" : ",")
<< Library::s_bookMap[bookId].title();
first = false;
}
}
return outStream;
}
Library
类
最后,Library
类处理图书馆本身。它执行一系列关于借阅和归还书籍的任务。
Library.h
class Library {
public:
Library();
private:
static string s_binaryPath;
lookupBook
方法通过作者和标题查找书籍。如果找到书籍,则返回 true。如果找到,其信息(Book
类的对象)将被复制到由bookPtr
指向的对象中:
bool lookupBook(const string& author, const string& title,
Book* bookPtr = nullptr);
同样,lookupCustomer
通过姓名和地址查找客户。如果找到客户,则返回 true,并将信息复制到由customerPtr
指向的对象中:
bool lookupCustomer(const string& name, const string& address,
Customer* customerPtr = nullptr);
本章的应用围绕以下方法展开。它们执行图书馆系统的任务。每个方法都会提示用户输入,然后执行一项任务,例如借阅或归还书籍。
以下方法各自执行一项任务,包括查找书籍或客户的信息、添加或删除书籍、列出书籍、从图书馆添加和删除书籍以及借阅、预订和归还书籍:
void addBook(void);
void deleteBook(void);
void listBooks(void);
void addCustomer(void);
void deleteCustomer(void);
void listCustomers(void);
void borrowBook(void);
void reserveBook(void);
void returnBook(void);
load
和save
方法在执行开始和结束时被调用:
void load();
void save();
有两个映射分别存储图书馆的书籍和客户。如前所述,映射是一种结构,其中每个值都通过一个唯一键来标识,以便提供快速访问。书籍和客户的唯一身份号码是键:
public:
static map<int,Book> s_bookMap;
static map<int,Customer> s_customerMap;
};
Library.cpp
#include <Set>
#include <Map>
#include <List>
#include <String>
#include <FStream>
#include <IOStream>
#include <Algorithm>
using namespace std;
#include "Book.h"
#include "Customer.h"
#include "Library.h"
map<int,Book> Library::s_bookMap;
map<int,Customer> Library::s_customerMap;
在两次执行之间,图书馆信息存储在硬盘上的Library.bin
文件中。注意,我们在string
中使用两个反斜杠来表示一个反斜杠。第一个反斜杠表示该字符是一个特殊字符,第二个反斜杠表示它是一个反斜杠:
string Library::s_binaryPath("Library.bin");
构造函数加载图书馆,显示菜单,并迭代直到用户退出。在执行完成之前,保存图书馆:
Library::Library(void) {
在显示菜单之前,从文件中加载图书馆信息(书籍、客户、贷款和预订):
load();
当quit
为真时,while 语句会继续执行。它保持为假,直到用户从菜单中选择退出选项:
bool quit = false;
while (!quit) {
cout << "1\. Add Book" << endl
<< "2\. Delete Book" << endl
<< "3\. List Books" << endl
<< "4\. Add Customer" << endl
<< "5\. Delete Customer" << endl
<< "6\. List Customers" << endl
<< "7\. Borrow Book" << endl
<< "8\. Reserve Book" << endl
<< "9\. Return Book" << endl
<< "0\. Quit" << endl
<< ": ";
用户从控制台输入流(cin
)中输入一个整数值,该值存储在choice
变量中:
int choice;
cin >> choice;
我们使用switch
语句来执行请求的任务:
switch (choice) {
case 1:
addBook();
break;
case 2:
deleteBook();
break;
case 3:
listBooks();
break;
case 4:
addCustomer();
break;
case 5:
deleteCustomer();
break;
case 6:
listCustomers();
break;
case 7:
borrowBook();
break;
case 8:
reserveBook();
break;
case 9:
returnBook();
break;
case 0:
quit = true;
break;
}
cout << endl;
}
在程序完成之前,保存图书馆信息:
save();
}
查找书籍和客户
lookupBook
方法遍历书籍映射。如果存在具有作者和标题的书籍,则返回 true。如果书籍存在,其信息被复制到由bookPtr
参数指向的对象中,并且只要指针不为空,就返回 true。如果书籍不存在,则返回 false,并且不会将信息复制到对象中:
bool Library::lookupBook(const string& author,
const string& title, Book* bookPtr /* = nullptr*/) {
for (const pair<int,Book>& entry : s_bookMap) {
const Book& book = entry.second;
注意,bookPtr
可能为nullptr
。在这种情况下,只返回 true,并且不会将信息写入bookPtr
所指向的对象:
if ((book.author() == author) && (book.title() == title)) {
if (bookPtr != nullptr) {
*bookPtr = book;
}
return true;
}
}
return false;
}
同样,lookupCustomer
遍历客户映射,如果存在具有相同名称的客户,则返回 true,并将客户信息复制到Customer
对象中:
bool Library::lookupCustomer(const string& name,
const string& address, Customer* customerPtr /*=nullptr*/){
for (const pair<int,Customer>& entry : s_customerMap) {
const Customer& customer = entry.second;
此外,在这种情况下,customerPtr
可能为nullptr
。在这种情况下,只返回 true。当添加新客户时,我们希望知道是否已经存在具有相同名称和地址的客户:
if ((customer.name() == name) &&
(customer.address() == address)) {
if (customerPtr != nullptr) {
*customerPtr = customer;
}
return true;
}
}
return false;
}
添加书籍
addBook
方法提示用户输入新书的名称和标题:
void Library::addBook(void) {
string author;
cout << "Author: ";
cin >> author;
string title;
cout << "Title: ";
cin >> title;
如果存在具有相同author
和title
的书籍,将显示错误消息:
if (lookupBook(author, title)) {
cout << endl << "The book "" << title << "" by "
<< author << " already exists." << endl;
return;
}
如果书籍尚未存在,我们创建一个新的Book
对象并将其添加到书籍映射中:
Book book(author, title);
s_bookMap[book.bookId()] = book;
cout << endl << "Added: " << book << endl;
}
删除书籍
deleteBook
方法提示用户输入书籍的作者和标题,如果存在则删除:
void Library::deleteBook() {
string author;
cout << "Author: ";
cin >> author;
string title;
cout << "Title: ";
cin >> title;
如果书籍不存在,将显示错误消息:
Book book;
if (!lookupBook(author, title, &book)) {
cout << endl << "There is no book "" << title << "" by "
<< "author " << author << "." << endl;
return;
}
当删除书籍时,我们遍历所有客户,并对每个客户返回并取消预订书籍。我们为每本书都这样做,以防书籍已被客户借阅或预订。在下一章中,我们将使用指针,这允许我们更有效地返回和取消预订书籍。
注意,当我们遍历映射并获取每个 Customer
对象时,在修改了其字段值之后,我们需要将其放回映射中:
for (pair<int,Customer> entry : s_customerMap) {
Customer& customer = entry.second;
customer.returnBook(book.bookId());
customer.unreserveBook(book.bookId());
s_customerMap[customer.id()] = customer;
}
最后,当我们确认书籍存在,并且已经归还和取消预订后,我们将它从书籍映射中删除:
s_bookMap.erase(book.bookId());
cout << endl << "Deleted." << endl;
}
列出书籍
listBook
方法相当简单。首先,我们检查书籍映射是否为空。如果为空,我们写入 "No books."
。如果书籍映射不为空,我们遍历它,并且对于每本书,我们将其信息写入控制台输出流 (cout
):
void Library::listBooks(void) {
if (s_bookMap.empty()) {
cout << "No books." << endl;
return;
}
for (const pair<int,Book>& entry : s_bookMap) {
const Book& book = entry.second;
cout << book << endl;
}
}
添加客户
addCustomer
方法提示用户输入新客户的 name
和 address
:
void Library::addCustomer(void) {
string name;
cout << "Name: ";
cin >> name;
string address;
cout << "Address: ";
cin >> address;
如果存在具有相同 name
和 address
的客户,将显示错误信息:
if (lookupCustomer(name, address)) {
cout << endl << "A customer with name " << name
<< " and address " << address << " already exists."
<< endl;
return;
}
最后,我们创建一个新的 Customer
对象并将其添加到客户映射中:
Customer customer(name, address);
s_customerMap[customer.id()] = customer;
cout << endl << "Added." << endl;
}
删除客户
deleteCustomer
方法如果客户存在,则删除客户:
void Library::deleteCustomer(void) {
string name;
cout << "Name: ";
cin >> name;
string address;
cout << "Address: ";
cin >> address;
Customer customer;
if (!lookupCustomer(name, address, &customer)) {
cout << endl << "There is no customer with name " << name
<< " and address " << address << "." << endl;
return;
}
如果客户至少借阅了一本书,在客户被删除之前必须归还:
if (customer.hasBorrowed()) {
cout << "Customer " << name << " has borrowed at least "
<< "one book and cannot be deleted." << endl;
return;
}
然而,如果客户已经预订了书籍,我们在删除客户之前先取消预订:
for (pair<int,Book> entry : s_bookMap) {
Book& book = entry.second;
book.unreserveBookation(customer.id());
s_bookMap[book.bookId()] = book;
}
cout << endl << "Deleted." << endl;
s_customerMap.erase(customer.id());
}
列出客户
listCustomer
方法的工作方式与 listBooks
类似。如果没有客户,我们写入 "No Customers."
。如果有客户,我们将它们写入控制台输出流 (cout
):
void Library::listCustomers(void) {
if (s_customerMap.empty()) {
cout << "No customers." << endl;
return;
}
for (const pair<int,Customer>& entry : s_customerMap) {
const Customer& customer = entry.second;
cout << customer << endl;
}
}
借阅书籍
borrowBook
方法提示用户输入书籍的 author
和 title
:
void Library::borrowBook(void) {
string author;
cout << "Author: ";
cin >> author;
string title;
cout << "Title: ";
cin >> title;
如果不存在具有 author
和 title
的书籍,将显示错误信息:
Book book;
if (!lookupBook(author, title, &book)) {
cout << endl << "There is no book "" << title << "" by "
<< "author " << author << "." << endl;
return;
}
此外,如果 book
已经被借出,将显示错误信息:
if (book.borrowed()) {
cout << endl << "The book "" << title << "" by " << author
<< " has already been borrowed." << endl;
return;
}
然后我们提示用户输入客户的 name
和 address
:
string name;
cout << "Customer name: ";
cin >> name;
string address;
cout << "Adddress: ";
cin >> address;
如果没有具有 name
和 address
的 customer
,将显示错误信息:
Customer customer;
if (!lookupCustomer(name, address, &customer)) {
cout << endl << "There is no customer with name " << name
<< " and address " << address << "." << endl;
return;
}
然而,如果书籍存在且尚未被借出,并且客户存在,我们将书籍添加到客户的借阅集合中,并标记书籍为客户借阅:
book.borrowBook(customer.id());
customer.borrowBook(book.bookId());
注意,在修改了 Book
和 Customer
对象之后,我们必须将它们放回它们的映射中。在下一章中,我们将使用更直接的方式来处理指针:
s_bookMap[book.bookId()] = book;
s_customerMap[customer.id()] = customer;
cout << endl << "Borrowed." << endl;
}
预订书籍
reserveBook
方法的工作方式与 borrowBook
相同。它提示用户输入书籍的 author
和 title
:
void Library::reserveBook(void) {
string author;
cout << "Author: ";
cin >> author;
string title;
cout << "Title: ";
cin >> title;
与 borrowBook
的情况类似,我们检查具有 author
和 title
的书籍是否存在:
Book book;
if (!lookupBook(author, title, &book)) {
cout << endl << "There is no book "" << title << "" by "
<< "author " << author << "." << endl;
return;
}
然而,与 borrowBook
相比的一个区别是,书籍必须已被借出才能被预订。如果没有被借出,就没有预订的必要。在这种情况下,用户应该借阅该书籍:
if (!book.borrowed()) {
cout << endl << "The book with author " << author
<< " and title "" << title << "" has not been "
<< "borrowed. Please borrow the book instead." << endl;
return;
}
如果书籍存在且未被借出,我们提示用户输入客户的 name
和 address
:
string name;
cout << "Customer name: ";
cin >> name;
string address;
cout << "Address: ";
cin >> address;
如果客户不存在,将显示错误信息:
Customer customer;
if (!lookupCustomer(name, address, &customer)) {
cout << endl << "No customer with name " << name
<< " and address " << address << " exists." << endl;
return;
}
此外,如果客户已经借阅了书籍,我们将显示错误信息:
if (book.customerId() == customer.id()) {
cout << endl << "The book has already been borrowed by "
<< name << "." << endl;
return;
}
如果书籍存在且已被借出,但不是由该客户借出,我们将客户添加到书籍的预订列表中,并将书籍添加到客户的预订集中:
customer.reserveBook(book.bookId());
int position = book.reserveBook(customer.id());
此外,在这种情况下,我们必须将 Book
和 Customer
对象放回它们的映射中:
s_bookMap[book.bookId()] = book;
s_customerMap[customer.id()] = customer;
最后,我们写入客户在预订列表中的位置:
cout << endl << position << "nd reserve." << endl;
}
返回书籍
returnBook
方法提示用户输入书籍的作者和标题:
void Library::returnBook(void) {
string author;
cout << "Author: ";
cin >> author;
string title;
cout << "Title: ";
cin >> title;
如果书籍不存在,将显示错误信息:
Book book;
if (!lookupBook(author, title, &book)) {
cout << endl << "No book "" << title
<< "" by " << author << " exists." << endl;
return;
}
如果书籍未被借出,将显示错误信息:
if (!book.borrowed()) {
cout << endl << "The book "" << title
<< "" by " << author
<< "" has not been borrowed." << endl;
return;
}
与之前描述的方法不同,在这种情况下,我们不询问客户。相反,我们返回书籍,并在每位客户的预订列表中查找该书籍:
book.returnBook();
cout << endl << "Returned." << endl;
Customer customer = s_customerMap[book.customerId()];
customer.returnBook(book.bookId());
s_customerMap[customer.id()] = customer;
如果书籍已被预订,我们查找预订列表中的第一位客户,将其从预订列表中删除,并允许他们借阅书籍:
list<int>& reservationList = book.reservationList();
if (!reservationList.empty()) {
int newCustomerId = reservationList.front();
reservationList.erase(reservationList.begin());
book.borrowBook(newCustomerId);
Customer newCustomer = s_customerMap[newCustomerId];
newCustomer.borrowBook(book.bookId());
s_customerMap[newCustomerId] = newCustomer;
cout << endl << "Borrowed by " << newCustomer.name() << endl;
}
s_bookMap[book.bookId()] = book;
}
将图书馆信息保存到文件中
当保存图书馆信息时,我们首先打开文件:
void Library::save() {
ofstream outStream(s_binaryPath);
如果文件正确打开,首先我们写入书籍的数量,然后通过在 Book
对象上调用 write
方法来写入每本书的信息:
if (outStream) {
int numberOfBooks = s_bookMap.size();
outStream.write((char*) &numberOfBooks, sizeof numberOfBooks);
for (const pair<int,Book>& entry : s_bookMap) {
const Book& book = entry.second;
book.write(outStream);
}
同样,我们通过调用 write
方法写入客户数量,然后写入每位客户的信息:
int numberOfCustomers = s_customerMap.size();
outStream.write((char*) &numberOfCustomers,
sizeof numberOfCustomers);
for (const pair<int,Customer>& entry : s_customerMap) {
const Customer& customer = entry.second;
customer.write(outStream);
}
}
}
从文件中加载图书馆信息
当从文件中加载图书馆信息时,我们使用与 read
相同的方法。我们首先打开文件:
void Library::load() {
ifstream inStream(s_binaryPath);
我们读取书籍数量,然后通过调用 read
方法读取每本书的信息:
if (inStream) {
int numberOfBooks;
inStream.read((char*) &numberOfBooks, sizeof numberOfBooks);
对于每一本书,我们创建一个新的 Book
对象,通过调用 read
方法读取其信息,并将其添加到书籍映射中。我们还通过将自身和书籍的身份证号的最大值赋给静态字段 MaxBookId
来计算新的 MaxBookId
值:
for (int count = 0; count < numberOfBooks; ++count) {
Book book;
book.read(inStream);
s_bookMap[book.bookId()] = book;
Book::MaxBookId = max(Book::MaxBookId, book.bookId());
}
同样,我们读取客户数量,然后通过调用 read
方法读取每位客户的信息:
int numberOfCustomers;
inStream.read((char*) &numberOfCustomers,
sizeof numberOfCustomers);
对于每一位客户,我们创建一个 Customer
对象,从文件中读取其信息,将其添加到客户映射中,并为静态字段 MaxCustomerId
计算一个新的值:
for (int count = 0; count < numberOfCustomers; ++count) {
Customer customer;
customer.read(inStream);
s_customerMap[customer.id()] = customer;
Customer::MaxCustomerId =
max(Customer::MaxCustomerId, customer.id());
}
}
}
主函数
最后,我们写入 main
函数,该函数执行图书馆。这相当简单;唯一要做的就是实例化 Library
类的对象。然后构造函数显示主菜单:
Main.cpp
#include <Set>
#include <Map>
#include <List>
#include <String>
#include <FStream>
#include <IOStream>
using namespace std;
#include "Book.h"
#include "Customer.h"
#include "Library.h"
void main(void) {
Library();
}
概述
在本章中,我们构建了一个由类 Book
、Customer
和 Library
组成的图书馆管理系统。
Book
类包含关于一本书的信息。每个 Book
对象都有一个唯一的身份号码。它还跟踪借阅者(如果这本书被借出)和预订列表。同样,Customer
类包含关于客户的信息。与书类似,每个客户也持有唯一的身份号码。每个 Customer
对象还持有借阅和预订的书籍集合。最后,Library
类提供了一系列服务,例如添加和删除书籍和客户,借阅、归还和预订书籍,以及显示书籍和客户列表。
在本章中,每本书和每个客户都有一个唯一的身份号码。在下一章中,我们将再次探讨图书馆系统。然而,我们将省略身份号码,而是使用指针来工作。
第四章:基于指针的图书馆管理系统
在本章中,我们将继续研究一个图书馆管理系统。类似于第三章,构建图书馆管理系统,我们有三个类—Book
、Customer
和Library
。然而,有一个很大的不同:我们不使用身份号码。相反,我们使用指针;每个Book
对象都包含一个指向借阅该书的客户(Customer
类的对象)的指针,以及一个指向已预订该书的客户的指针列表。同样,每个客户都持有他们借阅和预订的书籍(Book
类的对象)的指针集合。
然而,这种方法引发了一个问题;我们无法直接在文件中存储指针的值。相反,当我们保存文件时,我们需要将指针转换为书籍和客户列表中的索引,而当我们加载文件时,我们需要将索引转换回指针。这个过程被称为棉花糖化。
在本章中,我们将更深入地探讨以下主题:
-
正如第三章,构建图书馆管理系统中一样,我们将使用构成小型数据库的书籍和客户类。然而,在本章中,我们将直接使用指针而不是整数数字。
-
由于我们使用指针而不是整数数字,文件处理变得更加复杂。我们需要执行一个称为棉花糖化的过程。
-
最后,我们将使用通用的标准 C++类
set
和list
。然而,在本章中,它们持有指向书籍和客户对象的指针,而不是对象本身。
书籍类
与上一章的系统类似,我们有三个类:Book
、Customer
和Library
。Book
类跟踪书籍,其中每本书都有一个作者和标题。Customer
类跟踪客户,其中每个客户都有一个姓名和地址。Library
类跟踪图书馆操作,如借阅、归还和预订。最后,main
函数简单地创建了一个Library
类的对象。
Book
类与第三章,构建图书馆管理系统中的Book
类相似。唯一的真正区别是没有身份号码,只有指针。
Book.h:
class Customer;
class Book {
public:
Book();
Book(const string& author, const string& title);
const string& author() const { return m_author; }
const string& title() const { return m_title; }
void read(ifstream& inStream);
void write(ofstream& outStream) const;
int reserveBook(Customer* customerPtr);
void removeReservation(Customer* customerPtr);
void returnBook();
我们没有返回书籍身份号码的方法,因为本章中的书籍不使用身份号码。
borrowedPtr
方法返回借阅书籍的客户的地址,或者如果此刻没有书籍被借出,则返回nullptr
。它有两种版本,其中第一种版本返回对Customer
对象指针的引用。这样,我们可以分配指针的新值给客户。第二种版本是常量版本,这意味着我们可以在常量对象上调用它:
Customer*& borrowerPtr() { return m_borrowerPtr; }
const Customer* borrowerPtr() const { return m_borrowerPtr; }
注意,在本章中我们没有borrowed
方法。我们不需要它,因为如果此时没有借阅书籍,borrowerPtr
将返回nullptr
。
在本章中,reservationPtrList
返回客户指针的列表而不是整数值。它有两种版本,其中第一种返回列表的引用。这样,我们可以向列表中添加和移除指针。第二种版本是常量,返回一个常量列表,这意味着它可以在常量Book
对象上调用,并返回一个不可更改的列表:
list<Customer*>& reservationPtrList()
{ return m_reservationPtrList; }
const list<Customer*> reservationPtrList() const
{ return m_reservationPtrList; }
输出流操作符的工作方式与第三章,构建图书馆管理系统中的方式相同:
friend ostream& operator<<(ostream& outStream,
const Book& book);
m_author
和m_title
字段是字符串,类似于第三章,构建图书馆管理系统:
private:
string m_author, m_title;
然而,我们省略了m_bookId
字段,因为在本章中我们不使用身份号码。我们还用m_borrowerPtr
替换了m_borrowedId
和m_customerId
字段,因为从开始就没有借阅书籍,所以它被初始化为nullptr
:
Customer* m_borrowerPtr = nullptr;
m_reservationPtrList
字段包含指向已预约书籍的客户的指针列表,而不是第三章,构建图书馆管理系统中的整数身份号码列表:
list<Customer*> m_reservationPtrList;
};
Book.cpp:
#include <Set>
#include <Map>
#include <String>
#include <FStream>
#include <Algorithm>
using namespace std;
#include "Book.h"
#include "Customer.h"
#include "Library.h"
默认构造函数与第三章,构建图书馆管理系统的构造函数类似:
Book::Book() {
// Empty.
}
第二个构造函数与第三章,构建图书馆管理系统的构造函数类似。但是没有m_bookId
字段需要初始化:
Book::Book(const string& author, const string& title)
:m_author(author),
m_title(title) {
// Empty.
}
阅读和写入书籍
在本章中,read
和write
方法已被简化。它们只读取和写入书籍的作者和标题。潜在的借阅和预约列表由Library
类的save
和write
方法读取和写入:
void Book::read(ifstream& inStream) {
getline(inStream, m_author);
getline(inStream, m_title);
}
void Book::write(ofstream& outStream) const {
outStream << m_author << endl;
outStream << m_title << endl;
}
借阅和预约书籍
当客户预约书籍时,将Customer
对象的指针添加到书籍的预约指针列表中。返回列表的大小,以便客户知道他们在预约列表中的位置:
int Book::reserveBook(Customer* borrowerPtr) {
m_reservationPtrList.push_back(borrowerPtr);
return m_reservationPtrList.size();
}
当客户归还书籍时,我们只需将m_borrowerPtr
设置为nullptr
,这表示书籍不再被借阅:
void Book::returnBook() {
m_borrowerPtr = nullptr;
}
removeReservation
方法简单地从预约列表中移除客户指针:
void Book::removeReservation(Customer* customerPtr) {
m_reservationPtrList.remove(customerPtr);
}
显示书籍
输出流操作符写入标题和作者,以及借阅书籍的客户(如果有),以及已预约书籍的客户(如果有):
ostream& operator<<(ostream& outStream, const Book& book) {
outStream << """ << book.m_title << "" by " << book.m_author;
如果书籍被借阅,我们将借阅者写入流中:
if (book.m_borrowerPtr != nullptr) {
outStream << endl << " Borrowed by: "
<< book.m_borrowerPtr->name() << ".";
}
如果书籍的预约列表不为空,我们遍历它,并为每个预约写入客户:
if (!book.m_reservationPtrList.empty()) {
outStream << endl << " Reserved by: ";
bool first = true;
for (Customer* customerPtr : book.m_reservationPtrList) {
outStream << (first ? "" : ",") << customerPtr->name();
first = false;
}
outStream << ".";
}
return outStream;
客户类
本章的Customer
类与第三章,《构建图书馆管理系统》中的Customer
类相似。再次,在这种情况下,区别在于我们使用指针而不是整数标识号。
Customer.h:
class Customer {
public:
Customer();
Customer(const string& name, const string& address);
const string& name() const { return m_name; }
const string& address() const { return m_address; }
void read(ifstream& inStream);
void write(ofstream& outStream) const;
borrowBook
、returnBook
、reserveBook
和unreserveBook
方法接受一个指向Book
对象的指针作为参数:
void borrowBook(Book* bookPtr);
void returnBook(Book* bookPtr);
void reserveBook(Book* bookPtr);
void unreserveBook(Book* bookPtr);
loadPtrSet
和reservationPtrSet
方法返回Book
指针的集合,而不是整数标识号的集合:
set<Book*>& loanPtrSet() { return m_loanPtrSet; }
const set<Book*> loanPtrSet() const { return m_loanPtrSet; }
set<Book*>& reservationPtrSet(){ return m_reservationPtrSet; }
const set<Book*> reservationPtrSet() const
{ return m_reservationPtrSet; }
输出流操作符与第三章,《构建图书馆管理系统》相比没有变化:
friend ostream& operator<<(ostream& outStream,
const Customer& customer);
m_name
和m_address
字段存储客户的名称和地址,正如在第三章,《构建图书馆管理系统》中一样:
private:
string m_name, m_address;
m_loanPtrSet
和m_reservationPtrSet
字段持有指向Book
对象的指针,而不是整数标识号:
set<Book*> m_loanPtrSet, m_reservationPtrSet;
};
Customer.cpp:
#include <Set>
#include <Map>
#include <String>
#include <FStream>
using namespace std;
#include "Book.h"
#include "Customer.h"
#include "Library.h"
构造函数与第三章,《构建图书馆管理系统》中的构造函数相似。第一个构造函数不执行任何操作,并在从文件加载客户列表时被调用:
Customer::Customer() {
// Empty.
}
第二个构造函数初始化客户的名称和地址。然而,与第三章,《构建图书馆管理系统》中的构造函数相比,没有初始化m_customerId
字段:
Customer::Customer(const string& name, const string& address)
:m_name(name),
m_address(address) {
// Empty.
}
读取和写入客户信息
与前面的Book
案例类似,read
和write
方法已被简化。它们只读取和写入名称和地址。借阅和预约集合在Library
类中读取和写入,如下所示:
void Customer::read(ifstream& inStream) {
getline(inStream, m_name);
getline(inStream, m_address);
}
void Customer::write(ofstream& outStream) const {
outStream << m_name << endl;
outStream << m_address << endl;
}
借阅和预约书籍
borrowBook
方法将书籍指针添加到借阅集合中,并从预约集合中移除,以防它已被预约:
void Customer::borrowBook(Book* bookPtr) {
m_loanPtrSet.insert(bookPtr);
m_reservationPtrSet.erase(bookPtr);
}
reserveBook
方法简单地将书籍指针添加到预约列表中,而returnBook
和unreserveBook
方法从借阅和预约集合中移除书籍指针:
void Customer::reserveBook(Book* bookPtr) {
m_reservationPtrSet.insert(bookPtr);
}
void Customer::returnBook(Book* bookPtr) {
m_loanPtrSet.erase(bookPtr);
}
void Customer::unreserveBook(Book* bookPtr) {
m_reservationPtrSet.erase(bookPtr);
}
显示客户信息
输出流操作符与第三章,《构建图书馆管理系统》中的操作方式相同。它写入客户的名称和地址,以及借阅和预约的书籍集合(如果有):
ostream& operator<<(ostream& outStream, const Customer& customer){
outStream << customer.m_name << ", "
<< customer.m_address << ".";
如果客户的借阅列表不为空,我们遍历它,并对每个借阅项写入书籍:
if (!customer.m_loanPtrSet.empty()) {
outStream << endl << " Borrowed books: ";
bool first = true;
for (const Book* bookPtr : customer.m_loanPtrSet) {
outStream << (first ? "" : ", ") << bookPtr->author();
first = false;
}
}
同样,如果客户的预约列表不为空,我们遍历它,并对每个预约项写入书籍:
if (!customer.m_reservationPtrSet.empty()) {
outStream << endl << " Reserved books: ";
bool first = true;
for (Book* bookPtr : customer.m_reservationPtrSet) {
outStream << (first ? "" : ", ") << bookPtr->author();
first = false;
}
}
return outStream;
图书馆类
Library
类与第三章,《构建图书馆管理系统》中的对应类非常相似。然而,我们在保存和加载图书馆信息到文件时添加了查找方法
,以在指针和列表索引之间进行转换:
Library.h:
class Library {
public:
Library();
析构函数释放应用程序中所有动态分配的内存:
~Library();
private:
static string s_binaryPath;
lookupBook
和 lookupCustomer
方法返回指向 Book
和 Customer
对象的指针。如果书籍或客户不存在,则返回 nullptr
:
Book* lookupBook(const string& author, const string& title);
Customer* lookupCustomer(const string& name,
const string& address);
void addBook();
void deleteBook();
void listBooks();
void addCustomer();
void deleteCustomer();
void listCustomers();
void borrowBook();
void reserveBook();
void returnBook();
lookupBookIndex
和 lookupCustomerIndex
方法接受一个指针,在指向的对象之后的书籍和客户列表中进行搜索,并返回其在列表中的索引:
int lookupBookIndex(const Book* bookPtr);
int lookupCustomerIndex(const Customer* customerPtr);
lookupBookPtr
和 lookupCustomerPtr
方法接受一个索引,并返回书籍和客户列表中该位置的指针:
Book* lookupBookPtr(int bookIndex);
Customer* lookupCustomerPtr(int customerIndex);
save
和 write
方法从文件中保存和加载图书馆信息。然而,它们比 第三章,构建图书馆管理系统 中的对应方法更复杂:
void save();
void load();
m_bookPtrList
和 m_customerPtrList
字段持有指向 Book
和 Customer
对象的指针,而不是对象本身,正如 第三章,构建图书馆管理系统 所述:
list<Book*> m_bookPtrList;
list<Customer*> m_customerPtrList;
};
Library.cpp:
#include <Set>
#include <Map>
#include <List>
#include <String>
#include <FStream>
#include <IOStream>
#include <CAssert>
using namespace std;
#include "Book.h"
#include "Customer.h"
#include "Library.h"
string Library::s_binaryPath("C:\Users\Stefan\Library.binary");
构造函数与 第三章,构建图书馆管理系统 的构造函数相同:
Library::Library() {
load();
bool quit = false;
while (!quit) {
cout << "1\. Add Book" << endl
<< "2\. Delete Book" << endl
<< "3\. List Books" << endl
<< "4\. Add Customer" << endl
<< "5\. Delete Customer" << endl
<< "6\. List Customers" << endl
<< "7\. Borrow Book" << endl
<< "8\. Reserve Book" << endl
<< "9\. Return Book" << endl
<< "0\. Quit" << endl
<< ": ";
int choice;
cin >> choice;
cout << endl;
switch (choice) {
case 1:
addBook();
break;
case 2:
deleteBook();
break;
case 3:
listBooks();
break;
case 4:
addCustomer();
break;
case 5:
deleteCustomer();
break;
case 6:
listCustomers();
break;
case 7:
borrowBook();
break;
case 8:
reserveBook();
break;
case 9:
returnBook();
break;
case 0:
quit = true;
break;
}
cout << endl;
}
save();
}
查找书籍和客户
本章的 lookupBook
方法通过作者和标题搜索具有 Book
对象,其方式类似于 第三章,构建图书馆管理系统。然而,如果找到与作者和标题匹配的 Book
对象,它不会将信息复制到指定的对象中。相反,它只是返回该对象的指针。如果没有找到 Book
对象,则返回 nullptr
:
Book* Library::lookupBook(const string& author,
const string& title) {
for (Book* bookPtr : m_bookPtrList) {
if ((bookPtr->author() == author) &&
(bookPtr->title() == title)) {
return bookPtr;
}
}
return nullptr;
}
同样,lookupCustomer
尝试查找与名称和地址匹配的 Customer
对象。如果找到该对象,则返回其指针。如果没有找到,则返回 nullptr
:
Customer* Library::lookupCustomer(const string& name,
const string& address) {
for (Customer* customerPtr : m_customerPtrList) {
if ((customerPtr->name() == name) &&
(customerPtr->address() == address)) {
return customerPtr;
}
}
return nullptr;
添加书籍
addBook
方法提示用户输入作者和标题:
void Library::addBook() {
string author;
cout << "Author: ";
cin >> author;
string title;
cout << "Title: ";
cin >> title;
在检查书籍是否已存在时,我们调用 lookupBook
。如果书籍存在,则返回 Book
对象的指针。如果书籍不存在,则返回 nullptr
。因此,我们测试返回值是否不等于 nullptr
。如果不等于 nullptr
,则表示书籍已存在,并显示错误消息:
if (lookupBook(author, title) != nullptr) {
cout << endl << "The book "" << title << "" by "
<< author << " already exists." << endl;
return;
}
在添加书籍时,我们使用 new
运算符动态创建一个新的 Book
对象。我们使用标准的 C++ assert
宏来检查书籍指针是否不为空。如果为空,则执行将因错误消息而终止:
Book* bookPtr = new Book(author, title);
assert(bookPtr != nullptr);
m_bookPtrList.push_back(bookPtr);
cout << endl << "Added." << endl;
}
删除书籍
deleteBook
方法通过提示用户关于书籍的作者和标题来从图书馆中删除书籍。如果书籍存在,我们返回、取消保留并删除它:
void Library::deleteBook() {
string author;
cout << "Author: ";
cin >> author;
string title;
cout << "Title: ";
cin >> title;
我们通过调用 lookupBook
获取 Book
对象的指针:
Book* bookPtr = lookupBook(author, title);
如果指针是nullptr
,则书籍不存在,并显示错误消息:
if (bookPtr == nullptr) {
cout << endl << "The book "" << title << "" by "
<< author << " does not exist." << endl;
return;
}
我们通过查找借阅者来检查书籍是否已被借阅:
Customer* borrowerPtr = bookPtr->borrowerPtr();
如果borrowerPtr
返回的指针不是nullptr
,我们通过调用借阅者的returnBook
方法来归还书籍。这样,书籍就不再被注册为客户借阅的书籍:
if (borrowerPtr != nullptr) {
borrowerPtr->returnBook(bookPtr);
}
此外,我们需要检查书籍是否已被其他客户预留。我们通过获取书籍的预留列表来实现,并对列表中的每个客户,取消书籍的预留:
list<Customer*> reservationPtrList =
bookPtr->reservationPtrList();
注意,我们并不检查书籍是否实际上已被客户预留,我们只是取消预留。另外,注意我们不需要将任何对象放回列表中,因为我们处理的是对象的指针,而不是对象的副本:
for (Customer* reserverPtr : reservationPtrList) {
reserverPtr->unreserveBook(bookPtr);
}
当移除书籍时,我们从书籍指针列表中移除书籍指针,然后释放Book
对象。看起来我们首先显示消息然后删除书籍指针似乎很奇怪。然而,顺序必须如此。删除对象后,我们无法再对其进行任何操作。我们不能先删除对象再写入它,这会导致内存错误:
m_bookPtrList.remove(bookPtr);
n cout << endl << "Deleted:" << bookPtr << endl;
delete bookPtr;
}
列出书籍
当列出书籍时,我们首先检查列表是否为空。如果为空,我们简单地写入"No books."
:
void Library::listBooks() {
if (m_bookPtrList.empty()) {
cout << "No books." << endl;
return;
}
}
然而,如果列表不为空,我们遍历书籍指针列表,并对每个书籍指针,解引用指针并写入信息:
for (const Book* bookPtr : m_bookPtrList) {
cout << (*bookPtr) << endl;
}
}
添加客户
addCustomer
方法提示用户输入客户的名称和地址:
void Library::addCustomer() {
string name;
cout << "Name: ";
cin >> name;
string address;
cout << "Address: ";
cin >> address;
如果已存在具有相同名称和地址的客户,将显示错误消息:
if (lookupCustomer(name, address) != nullptr) {
cout << endl << "A customer with name " << name
<< " and address " << address << " already exists."
<< endl;
return;
}
当添加客户时,我们动态创建一个新的Customer
对象,并将其添加到客户对象指针列表中:
Customer* customerPtr = new Customer(name, address);
assert(customerPtr != nullptr);
m_customerPtrList.push_back(customerPtr);
cout << endl << "Added." << endl;
}
删除客户
当删除客户时,我们查找他们,如果不存在则显示错误消息:
void Library::deleteCustomer() {
string name;
cout << "Customer name: ";
cin >> name;
string address;
cout << "Address: ";
cin >> address;
Customer* customerPtr = lookupCustomer(name, address);
如果给定名称和地址的客户不存在,则显示错误消息。考虑以下代码:
if (customerPtr == nullptr) {
cout << endl << "Customer " << name
<< " does not exists." << endl;
return;
}
如果客户至少借阅了一本书,则不能删除,并显示错误消息,如下所示:
if (!customerPtr->loanPtrSet().empty()) {
cout << "The customer " << customerPtr->name()
<< " has borrowed books and cannot be deleted." << endl;
return;
}
然而,如果客户没有借阅任何书籍,客户首先从图书馆中每本书的预留列表中移除,如下面的代码所示:
for (Book* bookPtr : m_bookPtrList) {
bookPtr->removeReservation(customerPtr);
}
然后客户从客户列表中移除,并通过delete
运算符释放Customer
对象。再次注意,我们首先必须写入客户信息,然后删除其对象。反过来是不行的,因为我们无法检查已删除的对象。这会导致内存错误:
m_customerPtrList.remove(customerPtr);
cout << endl << "Deleted." << (*customerPtr) << endl;
delete customerPtr;
}
列出客户
当列出客户时,我们遍历客户列表,并对每个客户,解引用Customer
对象指针并写入对象信息:
void Library::listCustomers() {
if (m_customerPtrList.empty()) {
cout << "No customers." << endl;
return;
}
for (const Customer* customerPtr: m_customerPtrList) {
cout << (*customerPtr) << endl;
}
}
借阅书籍
当借阅书籍时,我们首先提示用户输入作者和标题,如下面的代码片段所示:
void Library::borrowBook() {
string author;
cout << "Author: ";
cin >> author;
string title;
cout << "Title: ";
cin >> title;
我们查找书籍,如果书籍不存在,将显示错误信息,如下面的代码所示:
Book* bookPtr = lookupBook(author, title);
if (bookPtr == nullptr) {
cout << endl << "There is no book "" << title
<< "" by " << author << "." << endl;
return;
}
如果这本书已经被其他顾客借走,则不能再被借阅:
if (bookPtr->borrowerPtr() != nullptr) {
cout << endl << "The book "" << title << "" by " << author
<< " has already been borrowed." << endl;
return;
}
我们提示用户输入顾客的姓名和地址:
string name;
cout << "Customer name: ";
cin >> name;
string address;
cout << "Address: ";
cin >> address;
Customer* customerPtr = lookupCustomer(name, address);
如果没有找到具有给定姓名和地址的顾客,将显示错误信息:
if (customerPtr == nullptr) {
cout << endl << "No customer with name " << name
<< " and address " << address << " exists." << endl;
return;
}
最后,我们将书籍添加到顾客的借阅集合中,并将顾客标记为书籍的借阅者:
bookPtr->borrowerPtr() = customerPtr;
customerPtr->borrowBook(bookPtr);
cout << endl << "Borrowed." << endl;
}
预订书籍
预订过程与之前的借阅过程类似。我们提示用户输入书籍的作者和标题,以及顾客的姓名和地址,如下所示:
void Library::reserveBook() {
string author;
cout << "Author: ";
cin >> author;
string title;
cout << "Title: ";
cin >> title;
Book* bookPtr = lookupBook(author, title);
如果书籍不存在,将显示错误信息:
if (bookPtr == nullptr) {
cout << endl << "There is no book "" << title
<< "" by " << author << "." << endl;
return;
}
如果书籍尚未被借阅,则无法预订。相反,我们鼓励用户借阅这本书:
if (bookPtr->borrowerPtr() == nullptr) {
cout << endl << "The book "" << title << "" by "
<< author << " has not been not borrowed. "
<< "Please borrow the book instead of reserving it."
<< endl;
return;
}
我们提示用户输入顾客的姓名和地址:
string name;
cout << "Customer name: ";
cin >> name;
string address;
cout << "Address: ";
cin >> address;
Customer* customerPtr = lookupCustomer(name, address);
如果顾客不存在,将显示错误信息:
if (customerPtr == nullptr) {
cout << endl << "There is no customer with name " << name
<< " and address " << address << "." << endl;
return;
}
如果顾客已经借阅了这本书,他们也不能预订这本书:
if (bookPtr->borrowerPtr() == customerPtr) {
cout << endl << "The book has already been borrowed by "
<< name << "." << endl;
return;
}
最后,我们将顾客添加到书籍的预订列表中,并将书籍添加到顾客的预订集合中。请注意,对于书籍有一个预订顾客列表,而对于顾客有一个已预订书籍集合。这样做的原因是当一本书被归还时,预订列表中的第一个顾客会借阅这本书。对于顾客的预订集合没有这样的限制:
int position = bookPtr->reserveBook(customerPtr);
customerPtr->reserveBook(bookPtr);
我们通知顾客其在预订列表中的位置:
cout << endl << position << "nd reserve." << endl;
}
归还书籍
当归还一本书时,我们提示用户输入其作者和标题。然而,我们不询问借阅这本书的顾客。这些信息已经存储在Book
对象中:
void Library::returnBook() {
string author;
cout << "Author: ";
cin >> author;
string title;
cout << "Title: ";
cin >> title;
Book* bookPtr = lookupBook(author, title);
如果给定作者和标题的书籍不存在,将显示错误信息:
if (bookPtr == nullptr) {
cout << endl << "There is no book "" << title << "" by "
<< author << "." << endl;
return;
}
Customer* customerPtr = bookPtr->borrowerPtr();
如果给定姓名和地址的顾客不存在,将显示错误信息:
if (customerPtr == nullptr) {
cout << endl << "The book "" << title << "" by "
<< author << " has not been borrowed." << endl;
return;
}
bookPtr->returnBook();
customerPtr->returnBook(bookPtr);
cout << endl << "Returned." << endl;
当我们归还了书籍后,我们需要找出是否有任何顾客已经预订了它:
list<Customer*>& reservationPtrList =
bookPtr->reservationPtrList();
如果书籍的预订列表中至少有一个顾客,我们获取该顾客,将其从书籍的预订列表中移除,将顾客标记为书籍的借阅者,并将书籍添加到顾客的借阅集合中:
if (!reservationPtrList.empty()) {
Customer* newCustomerPtr = reservationPtrList.front();
reservationPtrList.erase(reservationPtrList.begin());
bookPtr->borrowBook(newCustomerPtr);
newCustomerPtr->borrowBook(bookPtr);
cout << endl << "Borrowed by "
<< newCustomerPtr->name() << endl;
}
}
查找书籍和顾客
当从文件保存和加载图书馆信息时,我们需要在Book
和Customer
对象的指针与书籍和顾客列表中的索引之间进行转换。lookupIndex
方法接受一个指向Book
对象的指针,并返回它在书籍列表中的索引:
int Library::lookupBookIndex(const Book* bookPtr) {
int index = 0;
for (Book* testPtr : m_bookPtrList) {
if (bookPtr == testPtr) {
return index;
}
++index;
}
如果我们达到这个点,执行将通过assert
宏显示错误信息而终止。然而,我们不应该达到这个点,因为Book
指针应该在书籍指针列表中:
assert(false);
return -1;
}
lookupBookPtr
方法执行相反的任务。它根据bookIndex
在书籍指针列表中的位置找到Book
对象指针。如果索引超出列表范围,assert
宏会通过错误信息终止执行。然而,这种情况不应该发生,因为所有索引都应该在范围内:
Book* Library::lookupBookPtr(int bookIndex) {
assert((bookIndex >= 0) &&
(bookIndex < ((int) m_bookPtrList.size())));
auto iterator = m_bookPtrList.begin();
for (int count = 0; count < bookIndex; ++count) {
++iterator;
}
return *iterator;
}
lookupCustomerIndex
方法以与前面lookupBookIndex
方法相同的方式给出Customer
指针在客户指针列表中的索引:
int Library::lookupCustomerIndex(const Customer* customerPtr) {
int index = 0;
for (Customer* testPtr : m_customerPtrList) {
if (customerPtr == testPtr) {
return index;
}
++index;
}
assert(false);
return -1;
}
lookupCustomerPtr
方法以与前面lookupBookPtr
方法相同的方式在客户指针列表中查找Customer
指针的索引:
Customer* Library::lookupCustomerPtr(int customerIndex) {
assert((customerIndex >= 0) &&
(customerIndex < ((int) m_customerPtrList.size())));
auto iterator = m_customerPtrList.begin();
for (int count = 0; count < customerIndex; ++count) {
++iterator;
}
return *iterator;
}
Marshmallowing
本章中Library
类的save
和load
方法比第三章中对应的构建图书馆管理系统要复杂一些。原因是我们不能直接保存指针,因为指针持有可能在执行之间改变的内存地址。相反,我们需要将它们的索引保存到文件中。将指针转换为索引和索引转换为指针的过程称为Marshmallowing。当保存图书馆时,我们将保存过程分为几个步骤:
-
保存书籍列表:在这个阶段,我们只保存作者和标题。
-
保存客户列表:在这个阶段,我们只保存姓名和地址。
-
对于每本书:保存借阅者(如果书籍被借出)和(可能为空的)预订列表。我们保存客户列表索引,而不是客户的指针。
-
对于每个客户,我们保存借阅和预订集合。我们保存书籍列表索引,而不是书籍的指针。
将图书馆信息保存到文件中
Save
方法打开文件,如果成功打开,则读取图书馆的书籍和客户:
void Library::save() {
ofstream outStream(s_binaryPath);
编写书籍对象
我们保存书籍对象。我们通过为每个Book
对象调用write
来只保存书籍的作者和标题。在这个阶段,我们不保存潜在的借阅者和预订列表。
我们首先将列表中的书籍数量写入文件:
if (outStream) {
{ int bookPtrListSize = m_bookPtrList.size();
outStream.write((char*) &bookPtrListSize,
sizeof bookPtrListSize);
然后我们通过在每个Book
对象指针上调用write
来将每本书的信息写入文件:
for (const Book* bookPtr : m_bookPtrList) {
bookPtr->write(outStream);
}
}
编写客户对象
我们保存客户对象。类似于前面的书籍案例,我们通过为每个Customer
对象调用write
来只保存客户的名字和地址。在这个阶段,我们不保存借阅和预订的书籍集合。
同样地,就像前面的书籍案例一样,我们首先将列表中的客户数量写入文件:
{ int customerPtrListSize = m_customerPtrList.size();
outStream.write((char*) &customerPtrListSize,
sizeof customerPtrListSize);
然后我们通过在每个Customer
对象指针上调用write
方法来将每个客户的信息写入文件:
for (const Customer* customerPtr : m_customerPtrList) {
customerPtr->write(outStream);
}
}
编写借阅者索引
对于每个Book
对象,如果书籍被借出,我们查找并保存Customer
的索引,而不是对象的指针:
for (const Book* bookPtr : m_bookPtrList) {
{ const Customer* borrowerPtr = bookPtr->borrowerPtr();
对于每本书,我们首先检查它是否已被借出。如果已被借出,我们将值true
写入文件,以表示它已被借出:
if (borrowerPtr != nullptr) {
bool borrowed = true;
outStream.write((char*) &borrowed, sizeof borrowed);
然后在客户指针列表中查找借阅了这本书的客户索引,并将索引写入文件:
int loanIndex = lookupCustomerIndex(borrowerPtr);
outStream.write((char*) &loanIndex, sizeof loanIndex);
}
如果书籍没有被借出,我们只需将值false
写入文件,以表示书籍没有被借出:
else {
bool borrowed = false;
outStream.write((char*) &borrowed, sizeof borrowed);
}
}
写入预约索引
由于一本书可以被多个客户预约,我们遍历预约列表并保存每个客户在预约列表中的索引:
{ const list<Customer*>& reservationPtrList =
bookPtr->reservationPtrList();
对于每一本书,我们首先将书的预约数量写入文件:
int reserveSetSize = reservationPtrList.size();
outStream.write((char*) &reserveSetSize,
sizeof reserveSetSize);
然后我们遍历预约列表,对于每个预约,我们在文件中查找并写入预约了这本书的每个客户的索引:
for (const Customer* customerPtr : reservationPtrList) {
int customerIndex = lookupCustomerIndex(customerPtr);
outStream.write((char*) &customerIndex,
sizeof customerIndex);
}
}
}
写入借阅书籍索引
对于每个客户,我们保存他们借阅的书籍索引。首先保存借阅列表的大小,然后是书籍索引:
for (const Customer* customerPtr : m_customerPtrList) {
{ const set<Book*>& loanPtrSet =
customerPtr->loanPtrSet();
对于每个客户,我们首先写入其借阅数量到文件:
int loanPtrSetSize = loanPtrSet.size();
outStream.write((char*) &loanPtrSetSize,
sizeof loanPtrSetSize);
然后我们遍历借阅集合,对于每个借阅,我们在文件中查找并写入每本书的索引:
for (const Book* customerPtr : loanPtrSet) {
int customerIndex = lookupBookIndex(customerPtr);
outStream.write((char*) &customerIndex,
sizeof customerIndex);
}
}
写入预约书籍索引
同样地,对于每个客户,我们保存他们预约的书籍索引。首先保存预约列表的大小,然后是预约的书籍索引:
{ const set<Book*>& reservedPtrSet =
customerPtr->reservationPtrSet();
对于每个客户,我们首先写入其预约书籍的数量到文件:
int reservationPtrSetSize = reservationPtrSet.size();
outStream.write((char*) &reservationPtrSetSize,
sizeof reservationPtrSetSize);
然后我们遍历预约集合,对于每个预约,我们在文件中查找并写入每本书的索引:
for (const Book* reservedPtr : reservationPtrSet) {
int customerIndex = lookupBookIndex(reservedPtr);
outStream.write((char*) &customerIndex,
sizeof customerIndex);
}
}
}
}
}
从文件中加载图书馆信息
当加载文件时,我们按照保存文件时的相同方式操作:
void Library::load() {
ifstream inStream(s_binaryPath);
读取书籍对象
我们读取书籍列表的大小,然后读取书籍本身。记住,到目前为止,我们只读取了书籍的作者和标题:
if (inStream) {
{ int bookPtrListSize;
我们首先读取书籍数量:
inStream.read((char*) &bookPtrListSize,
sizeof bookPtrListSize);
然后读取书籍本身。对于每本书,我们动态分配一个Book
对象,通过调用指针上的read
方法读取其信息,并将指针添加到书籍指针列表中:
for (int count = 0; count < bookPtrListSize; ++count) {
Book *bookPtr = new Book();
assert(bookPtr != nullptr);
bookPtr->read(inStream);
m_bookPtrList.push_back(bookPtr);
}
}
读取客户对象
同样地,我们读取客户列表的大小,然后读取客户本身。到目前为止,我们只读取了客户的姓名和地址:
{ int customerPtrListSize;
我们首先读取客户数量:
inStream.read((char*) &customerPtrListSize,
sizeof customerPtrListSize);
然后我们读取客户本身。对于每个客户,我们动态分配一个Customer
对象,通过调用指针上的read
方法读取其信息,并将指针添加到书籍指针列表中:
for (int count = 0; count < customerPtrListSize; ++count) {
Customer *customerPtr = new Customer();
assert(customerPtr != nullptr);
customerPtr->read(inStream);
m_customerPtrList.push_back(customerPtr);
}
}
读取借阅者索引
对于每本书,我们读取借阅了它的客户(如果有)以及预约了这本书的客户列表:
for (Book* bookPtr : m_bookPtrList) {
{ bool borrowed;
inStream.read((char*) &borrowed, sizeof borrowed);
如果borrowed
是true
,则表示书籍已被借出。在这种情况下,我们读取客户索引。然后我们查找Customer
对象的指针,将其添加到书籍的预约列表中:
if (borrowed) {
int loanIndex;
inStream.read((char*) &loanIndex, sizeof loanIndex);
bookPtr->borrowerPtr() = lookupCustomerPtr(loanIndex);
}
如果 borrowed
是 false
,则表示书籍尚未被借出。在这种情况下,我们将借出书籍的客户指针设置为 nullptr
:
else {
bookPtr->borrowerPtr() = nullptr;
}
}
读取预订索引
对于每一本书,我们也会读取预订列表。首先,我们读取列表的大小,然后是客户索引本身:
{ list<Customer*>& reservationPtrList =
bookPtr->reservationPtrList();
int reservationPtrListSize;
我们首先读取书籍的预订数量:
inStream.read((char*) &reservationPtrListSize,
sizeof reservationPtrListSize);
对于每一笔预订,我们读取客户的索引并调用 lookupCustomerPtr
来获取 Customer
对象的指针,然后将其添加到书籍的预订指针列表中:
for (int count = 0; count < reservationPtrListSize;
++count) {
int customerIndex;
inStream.read((char*) &customerIndex,
sizeof customerIndex);
Customer* customerPtr =
lookupCustomerPtr(customerIndex);
reservationPtrList.push_back(customerPtr);
}
}
}
读取借阅书籍索引
对于每一位客户,我们读取借阅的书籍集合:
for (Customer* customerPtr : m_customerPtrList) {
{ set<Book*>& loanPtrSet = customerPtr->loanPtrSet();
int loanPtrSetSize = loanPtrSet.size();
我们首先读取借阅列表的大小:
inStream.read((char*) &loanPtrSetSize,
sizeof loanPtrSetSize);
对于每一笔借阅,我们读取书籍的索引并调用 lookupBookPtr
来获取 Book
对象的指针,然后将其添加到借阅指针列表中:
for (int count = 0; count < loanPtrSetSize; ++count) {
int bookIndex;
inStream.read((char*) &bookIndex, sizeof bookIndex);
Book* bookPtr = lookupBookPtr(bookIndex);
loanPtrSet.insert(bookPtr);
}
}
读取预订书籍索引
同样地,对于每一位客户,我们读取预订的书籍集合:
{ set<Book*>& reservationPtrSet =
customerPtr->reservationPtrSet();
我们首先读取预订列表的大小:
int reservationPtrSetSize = reservationPtrSet.size();
inStream.read((char*) &reservationPtrSetSize,
sizeof reservationPtrSetSize);
对于每一笔预订,我们读取书籍的索引并调用 lookupBookPtr
来获取 Book
对象的指针,然后将其添加到预订指针列表中:
for (int count = 0; count < reservationPtrSetSize;
++count) {
int bookIndex;
inStream.read((char*) &bookIndex, sizeof bookIndex);
Book* bookPtr = lookupBookPtr(bookIndex);
reservationPtrSet.insert(bookPtr);
}
}
}
}
}
释放内存
由于我们已经将动态分配的 Book
和 Customer
对象添加到列表中,我们需要在执行结束时释放它们。析构函数遍历书籍和客户指针列表,并释放所有书籍和客户指针:
Library::~Library() {
for (const Book* bookPtr : m_bookPtrList) {
delete bookPtr;
}
for (const Customer* customerPtr : m_customerPtrList) {
delete customerPtr;
}
}
主函数
与 第三章,构建图书馆管理系统 类似,main
函数只是创建一个 Library
对象:
Main.cpp
#include <Set>
#include <Map>
#include <String>
#include <FStream>
#include <IOStream>
using namespace std;
#include "Book.h"
#include "Customer.h"
#include "Library.h"
void main() {
Library();
}
摘要
在本章中,我们构建了一个类似于 第三章,构建图书馆管理系统 的图书馆管理系统。然而,我们省略了所有整数身份号码,并用指针替换了它们。这使我们能够更直接地存储借阅和预订,但也使得我们保存和加载到文件中变得更加困难。
在 第五章,Qt 图形应用程序 中,我们将探讨图形应用程序。
第五章:Qt 图形应用程序
在 第四章,指针库管理系统 中,我们开发了抽象数据类型和库管理系统。然而,那些应用程序是基于文本的。在本章中,我们将探讨我们将使用 Qt 图形库开发的三个图形应用程序:
-
时钟:我们将开发一个带有时针、分针和秒针的模拟时钟,以及标记小时、分钟和秒的线条
-
绘图程序:一个可以以不同颜色绘制线条、矩形和椭圆的程序
-
编辑器:一个用户可以输入和编辑文本的程序
我们还将了解 Qt 库:
-
窗口和小部件
-
菜单和工具栏
-
在窗口中绘制图形和写入文本
-
如何捕获鼠标和键盘事件
创建时钟应用程序
在本章和下一章中,我们将使用 Qt,它是一个面向对象的类库,用于图形应用程序。我们还将使用 Qt Creator,而不是 Visual Studio,它是一个集成开发环境。
设置环境
在 Qt Creator 中创建新的图形项目时,我们在文件菜单中选择新建文件或项目,这将使新建文件或项目对话框窗口可见。我们选择 Qt Widgets 应用程序,并点击选择按钮。
然后出现简介和项目位置对话框。我们命名项目为 Clock
,将其放置在适当的位置,并点击下一步按钮。在 KitSelection 对话框中,我们选择 Qt 库的最新版本,并点击下一步。在类信息对话框中,我们命名应用程序的基类为 clock
。通常,图形应用程序的窗口继承自 window
类。然而,在这种情况下,我们处理的是一个相对简单的应用程序。因此,我们继承 Qt 类 QWidget
,尽管小部件通常指的是经常嵌入窗口中的较小的图形对象。在 Qt Creator 中,可以添加表单。然而,我们本章不使用该功能。因此,我们取消选中生成表单选项。
Qt 中的所有类名都以字母 Q
开头。
最后,在项目管理对话框中,我们简单地接受默认值并点击完成以生成项目,包括文件 Clock.h
和 Clock.cpp
。
Clock
类
项目由文件 Clock.h
、Clock.cpp
和 Main.cpp
组成。与前面章节中的类相比,类的定义看起来略有不同。我们使用 include guards 来包围类的定义。也就是说,我们必须使用预处理指令 ifndef
、define
和 endif
来包围类的定义。预处理程序执行文本替换。
ifndef
和 endif
指令在 C++ 中工作方式类似于 if
语句。如果条件不成立,则省略指令之间的代码。在这种情况下,只有当 CLOCK_H
宏之前未定义时,才会包含代码。如果包含代码,则使用 define
指令在下一行定义宏。这样,类定义只包含在项目中一次。此外,我们还在 Clock.h
头文件中而不是 Clock.cpp
定义文件中包含系统头文件 QWidget
和 QTimer
。
Clock.h:
#ifndef CLOCK_H
#define CLOCK_H
#include <QWidget>
#include <QTimer>
由于 Clock
是 Qt QWidget
类的子类,必须包含 Q_OBJECT
宏,它包含来自 Qt 库的某些代码。我们需要它来使用这里显示的 SIGNAL
和 SLOT
宏:
class Clock : public QWidget {
Q_OBJECT
构造函数接受对其父小部件的指针,默认为 nullptr
:
public:
Clock(QWidget* parentWidgetPtr = nullptr);
每当窗口需要重新绘制时,框架都会调用 paintEvent
方法。它接受一个指向 QPaintEvent
对象的指针作为参数,可以用来确定以何种方式执行重新绘制:
void paintEvent(QPaintEvent *eventPtr);
QTimer
是一个 Qt 系统类,用于处理计时器。我们将使用它来移动时钟的指针:
private:
QTimer m_timer;
};
#endif // CLOCK_H
定义文件主要由 paintEvent
方法组成,该方法处理时钟的绘制。
Clock.cpp:
#include <QtWidgets>
#include "Clock.h"
在构造函数中,我们使用 parentWidgetPtr
参数(可能为 nullptr
)调用基类 QWidget
:
Clock::Clock(QWidget* parentWidgetPtr /* = nullptr */)
:QWidget(parentWidgetPtr) {
我们将窗口标题设置为 Clock
。在 Qt 中,我们始终使用 tr
函数用于文本字面量,它反过来调用 Qt QCoreApplication
类中的 translate
方法,确保文本被转换为适合显示的形式。我们还调整窗口大小为 1000 x 500 像素,这对于大多数屏幕来说都是合适的:
setWindowTitle(tr("Clock"));
resize(1000, 500);
我们需要一种方法将计时器与时钟小部件连接起来:当计时器完成倒计时后,时钟应该更新。为此,Qt 为我们提供了信号和槽系统。当计时器达到倒计时结束时,它调用其 timeout
方法。我们使用 connect
方法以及 SIGNAL
和 SLOT
宏将 timeout
的调用与 Qt QWidget
类中的 update
方法的调用连接起来,该调用更新时钟的绘制。SIGNAL
宏注册了调用 timeout
将引发一个信号,SLOT
宏注册了当信号被引发时将调用更新方法,connect
方法将信号与槽连接起来。我们已经设置了计时器的超时与时钟更新的连接:
m_timer.setParent(this);
connect(&m_timer, SIGNAL(timeout()), this, SLOT(update()));
m_timer.start(100);
}
每当窗口需要重新绘制时,都会调用 paintEvent
方法。这可能是由于某些外部原因,例如用户调整窗口大小。也可能是由于对 QMainWindow
类的 update
方法的调用,这最终会调用 paintEvent
。
在这种情况下,我们不需要任何关于事件的信息,所以我们用注释包围了 eventPtr
参数。width
和 height
方法给出窗口可绘制部分的宽度和高度,以像素为单位。我们调用 qMin
方法来决定窗口的最小边长,并调用 QTime
类的 currentTime
方法来找到时钟的当前时间:
void Clock::paintEvent(QPaintEvent* /* eventPtr */) {
int side = qMin(width(), height());
QTime time = QTime::currentTime();
QPainter
类可以被视为一个绘图画布。我们首先将其初始化为适当的抗锯齿。然后我们调用 translate
和 scale
方法将像素中的物理大小转换为 200
* 200
单位的逻辑大小:
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
painter.setRenderHint(QPainter::TextAntialiasing);
painter.translate(width() / 2, height() / 2);
painter.scale(side / 200.0, side / 200.0);
我们为分钟绘制 60 条线。每隔第五条线会稍微长一些,以标记当前的小时。对于每一分钟,我们绘制一条线,然后调用 Qt 的 rotate
方法,该方法将绘图旋转 6
度。这样,我们每次旋转绘图 6
度,总共旋转 60
次,累计达到 360
度,即一整圈:
for (int second = 0; second <= 60; ++second) {
if ((second % 5) == 0) {
painter.drawLine(QPoint(0, 81), QPoint(0, 98));
}
else {
painter.drawLine(QPoint(0, 90), QPoint(0, 98));
}
一个完整的跳跃是 360
度。对于每条线我们旋转 6
度,因为 360
除以 60
等于 6
度。当我们完成旋转后,绘图将重置到其原始设置:
painter.rotate(6);
}
我们从 QTime
对象中获取当前的小时、分钟、秒和毫秒:
double hours = time.hour(), minutes = time.minute(),
seconds = time.second(), milliseconds = time.msec();
我们将画笔颜色设置为黑色,背景颜色设置为灰色:
painter.setPen(Qt::black);
painter.setBrush(Qt::gray);
我们定义时针的端点。时针比分针和秒针略粗短。我们定义构成时针端点的三个点。时针的底部长度为 16
单位,位于原点 8
单位处。因此,我们将底部点的 x 坐标设置为 8
和 -8
,y 坐标为 8
。最后,我们定义时针的长度为 60
单位。这个值是负的,以便与当前旋转相对应:
{ static const QPoint hourHand[3] =
{QPoint(8, 8), QPoint(-8, 8), QPoint(0, -60)};
save
方法用于保存 QPointer
对象的当前设置。这些设置稍后可以通过 restore
方法恢复:
painter.save();
我们通过计算小时、分钟、秒和毫秒来找出当前时针的确切角度。然后我们旋转以设置时针。每个小时对应 30
度,因为我们有 12
个小时,360
度除以 12
等于 30
度:
double hour = hours + (minutes / 60.0) + (seconds / 3600.0) +
(milliseconds / 3600000.0);
painter.rotate(30.0 * hour);
我们使用时针的三个点调用 drawConvexPloygon
方法:
painter.drawConvexPolygon(hourHand, 3);
painter.restore();
}
我们以相同的方式绘制分针。它比时针细长一些。另一个区别是,我们之前有 12 个小时,而现在有 60 分钟。这导致每一分钟对应 6
度,因为 360
度除以 60
等于 6
度:
{ static const QPoint minuteHand[3] =
{QPoint(6, 8), QPoint(-6, 8), QPoint(0, -70)};
painter.save();
在计算当前分钟角度时,我们使用分钟、秒和毫秒:
double minute = minutes + (seconds / 60.0) +
(milliseconds / 60000.0);
painter.rotate(6.0 * minute);
painter.drawConvexPolygon(minuteHand, 3);
painter.restore();
}
秒针的绘制几乎与上一分钟针的绘制相同。唯一的区别是我们只使用秒和毫秒来计算秒的角度:
{ static const QPoint secondHand[3] =
{QPoint(4, 8), QPoint(-4, 8), QPoint(0, -80)};
painter.save();
double second = seconds + (milliseconds / 1000);
painter.rotate(6.0 * second);
painter.drawConvexPolygon(secondHand, 3);
painter.restore();
}
}
主函数
在 main
函数中,我们初始化并启动 Qt 应用程序。main
函数可以接受 argc
和 argv
参数。它包含应用程序的命令行参数;argc
包含参数的数量,而 argv
数组包含参数本身。argv
的第一个条目始终包含执行文件的路径,最后一个条目始终是 nullptr
。QApplication
类接受 argc
和 argv
并初始化 Qt 应用程序。我们创建了一个 Clock
类的对象,并调用 show
使其可见。最后,我们调用 QApplication
对象的 exec
。
Main.cpp:
#include <QApplication>
#include "Clock.h"
int main(int argc, char *argv[]) {
QApplication application(argc, argv);
Clock Clock;
Clock.show();
return application.exec();
}
要执行应用程序,我们选择项目的运行选项:
执行将继续,直到用户通过按下右上角的关闭按钮关闭 Clock
窗口:
设置窗口和控件的可重用类
在图形应用中,有窗口和控件。窗口通常是一个完整的窗口,包含一个带有标题、菜单栏和关闭及调整窗口大小的按钮的框架。控件通常是一个较小的图形对象,通常嵌入在窗口中。在 Clock 项目中,我们只使用了继承自 QWidget
类的 widget
类。然而,在本节中,我们将离开 Clock 项目,探讨带有窗口和控件的更高级应用。窗口包含带有菜单栏和工具栏的框架,而控件位于窗口内,负责图形内容。
在本章的后续部分,我们将探讨绘图程序和编辑器。这些应用是典型的文档应用,其中我们打开和保存文档,以及剪切、复制、粘贴和删除文档元素。为了向窗口添加菜单和工具栏,我们需要继承两个 Qt 类,QMainWindow
和 QWidget
。我们需要 QMainWindow
来向窗口框架添加菜单和工具栏,以及 QWidget
来在窗口区域绘制图像。
为了在本书剩余部分和下一章介绍的应用程序中重用文档代码,在本节中,我们定义了 MainWindow
和 DocumentWidget
类。这些类将在本章后续部分中的绘图程序和编辑器中使用。MainWindow
设置了一个带有 文件
和 编辑
菜单和工具栏的窗口,而 DocumentWidget
提供了一个框架,为 新建
、打开
、保存
、另存为
、剪切
、复制
、粘贴
、删除
和 退出
项目设置了基本代码。在本节中,我们不会创建一个新的 Qt 项目,我们只会编写 MainWindow
和 DocumentWidget
类,这些类将在本章后续部分的绘图程序和编辑器中作为基类使用,以及 LISTENER
宏,它用于设置菜单和工具栏项。
添加监听器
监听器是在用户选择菜单项或工具栏项时被调用的方法。Listener
宏将监听器添加到类中。
Listener.h:
#ifndef LISTENER_H
#define LISTENER_H
#include <QObject>
由于 Qt 关于菜单和工具栏的规则,Qt 框架在响应用户操作时调用的监听器必须是一个函数而不是一个方法。
方法属于一个类,而函数是独立的。
DefineListener
宏定义了一个友好的函数和一个方法。Qt 框架调用该函数,该函数随后调用该方法:
#define DEFINE_LISTENER(BaseClass, Listener)
friend bool Listener(QObject* baseObjectPtr) {
return ((BaseClass*) baseObjectPtr)->Listener();
}
bool Listener()
Listener
宏定义为指向方法的指针:
#define LISTENER(Listener) (&::Listener)
监听器方法接受一个 QObject
指针作为参数,并返回一个布尔值:
typedef bool (*Listener)(QObject*);
#endif // LISTENER_H
基础窗口类
MainWindow
类使用 File
和 Edit
菜单和工具栏设置文档窗口。它还提供了 addAction
方法,该方法旨在供子类添加特定于应用程序的菜单和工具栏。
MainWindow.h:
#ifndef MAINWINDOW_H
#define MAINWINDOW_H
#include <QMainWindow>
#include <QActionGroup>
#include <QPair>
#include <QMap>
#include "Listener.h"
#include "DocumentWidget.h"
class MainWindow : public QMainWindow {
Q_OBJECT
public:
MainWindow(QWidget* parentWidgetPtr = nullptr);
~MainWindow();
protected:
void addFileMenu();
void addEditMenu();
addAction
方法添加一个带有潜在快捷键、工具栏图标和监听器的菜单项,用于标记该项为复选框或单选按钮:
protected:
void addAction(QMenu* menuPtr, QString text,
const char* onSelectPtr,
QKeySequence acceleratorKey = 0,
QString iconName = QString(),
QToolBar* toolBarPtr = nullptr,
QString statusTip = QString(),
Listener enableListener = nullptr,
Listener checkListener = nullptr,
QActionGroup* groupPtr = nullptr);
我们使用 DefineListener
宏添加一个监听器来决定菜单项是否应该启用。如果项应该启用,监听器返回 true
。DocumentWidget
是 Qt 类 QWidget
的子类,我们将在下一节中定义它。使用 DEFINE_LISTENER
宏,我们将 isSaveEnabled
、isCutEnabled
、isCopyEnabled
、isPasteEnabled
和 isDeleteEnabled
方法添加到 MainWindow
类中。当用户选择菜单项时,它们将被调用:
DEFINE_LISTENER(DocumentWidget, isSaveEnabled);
DEFINE_LISTENER(DocumentWidget, isCutEnabled);
DEFINE_LISTENER(DocumentWidget, isCopyEnabled);
DEFINE_LISTENER(DocumentWidget, isPasteEnabled);
DEFINE_LISTENER(DocumentWidget, isDeleteEnabled);
onMenuShow
方法在菜单变得可见之前被调用;它调用菜单项的监听器来决定它们是否应该被禁用或用复选框或单选按钮进行注释。它还由框架调用以禁用工具栏图标:
public slots:
void onMenuShow();
m_enableMap
和 m_checkMap
字段包含菜单项的监听器映射。前面的 onMenuShow
方法使用它们来决定是否禁用项,或用复选框或单选按钮对其进行注释:
private:
QMap<QAction*,QPair<QObject*,Listener>> m_enableMap,
m_checkMap;
};
#endif // MAINWINDOW_H
MainWindow.cpp:
#include "MainWindow.h"
#include <QtWidgets>
构造函数调用 Qt QMainWindow
类的构造函数,将父小部件指针作为其参数:
MainWindow::MainWindow(QWidget* parentWidgetPtr /*= nullptr*/)
:QMainWindow(parentWidgetPtr) {
}
当添加菜单项时,它连接到一个动作。析构函数释放菜单栏的所有动作:
MainWindow::~MainWindow() {
for (QAction* actionPtr : menuBar()->actions()) {
delete actionPtr;
}
}
addFileMenu
方法将标准 File
菜单添加到菜单栏;menubar
是 Qt 方法,它返回窗口菜单栏的指针:
void MainWindow::addFileMenu() {
QMenu* fileMenuPtr = menuBar()->addMenu(tr("&File"));
与下面的代码片段中的connect
方法类似,该方法将菜单项与onMenuShow
方法连接。Qt 宏SIGNAL
和SLOT
确保在菜单变得可见之前调用onMenuShow
。onMenuShow
方法在菜单变得可见之前为菜单中的每个项设置启用、复选框和单选按钮状态。它还设置工具栏图像的启用状态。aboutToShow
方法在每次菜单变得可见之前被调用,以启用或禁用项,并可能用复选框或单选按钮标记它们:
connect(fileMenuPtr, SIGNAL(aboutToShow()), this,
SLOT(onMenuShow()));
Qt 的addToolBar
方法将工具栏添加到窗口的框架中。当我们在这里调用addAction
时,菜单项将被添加到菜单中,如果存在,也将添加到工具栏中:
QToolBar *fileToolBarPtr = addToolBar(tr("File"));
addAction
方法添加New
、Open
、Save
、SaveAs
和Exit
菜单项。它接受以下参数:
-
指向该项应属于的菜单的指针。
-
项文本。文本前的符号
&
(例如&New
)表示下一个字母(N
)将被下划线标记,并且用户可以通过按下 Alt-N 来选择该项。 -
加速器信息。
QKeySequence
是 Qt 枚举,包含加速键组合。QKeySequence::New
表示用户可以通过按下 Ctrl-N 来选择该项。文本Ctrl+N
也将添加到项文本中。 -
图标文件的名称(
new
)。文件图标既显示在项文本的左侧,也显示在工具栏上。图标文件本身是在 Qt Creator 中添加到项目中的。 -
指向工具栏的指针,如果该项未连接到工具栏则为
nullptr
。 -
当用户将鼠标悬停在工具栏项上时显示的文本。如果该项未连接到工具栏,则忽略。
-
在菜单和工具栏变得可见之前被调用的监听器(默认
nullptr
),并决定该项是否启用或用复选框或单选按钮标记:
addAction(fileMenuPtr, tr("&New"), SLOT(onNew()),
QKeySequence::New, tr("new"), fileToolBarPtr,
tr("Create a new file"));
addAction(fileMenuPtr, tr("&Open"), SLOT(onOpen()),
QKeySequence::Open, tr("open"), fileToolBarPtr,
tr("Open an existing file"));
如果自上次保存以来文档没有变化,则不需要保存文档,并且应禁用“保存”项。因此,我们添加了一个额外的参数,表示应调用isSaveEnabled
方法来启用或禁用菜单和工具栏项:
addAction(fileMenuPtr, tr("&Save"), SLOT(onSave()),
QKeySequence::Save, tr("save"), fileToolBarPtr,
tr("Save the document to disk"),
LISTENER(isSaveEnabled));
SaveAs
菜单项没有快捷键序列。此外,它没有工具栏条目。因此,图标文件名和工具栏文本是默认的QString
对象,工具栏指针是nullptr
:
addAction(fileMenuPtr, tr("Save &As"), SLOT(onSaveAs()),
0, QString(), nullptr, QString(),
LISTENER(isSaveEnabled));
addSeparator
方法在两个项之间添加一条水平线:
fileMenuPtr->addSeparator();
addAction(fileMenuPtr, tr("E&xit"),
SLOT(onExit()), QKeySequence::Quit);
}
addEditMenu
方法以与前面的File
菜单相同的方式将Edit
菜单添加到窗口的菜单栏中:
void MainWindow::addEditMenu() {
QMenu* editMenuPtr = menuBar()->addMenu(tr("&Edit"));
QToolBar* editToolBarPtr = addToolBar(tr("Edit"));
connect(editMenuPtr, SIGNAL(aboutToShow()),
this, SLOT(onMenuShow()));
addAction(editMenuPtr, tr("&Cut"), SLOT(onCut()),
QKeySequence::Cut, tr("cut"), editToolBarPtr,
tr("Cut the current selection's contents to the clipboard"),
LISTENER(isCutEnabled));
addAction(editMenuPtr, tr("&Copy"), SLOT(onCopy()),
QKeySequence::Copy, tr("copy"), editToolBarPtr,
tr("Copy the current selection's contents to the clipboard"),
LISTENER(isCopyEnabled));
addAction(editMenuPtr, tr("&Paste"), SLOT(onPaste()),
QKeySequence::Paste, tr("paste"), editToolBarPtr,
tr("Paste the current selection's contents to the clipboard"),
LISTENER(isPasteEnabled));
editMenuPtr->addSeparator();
addAction(editMenuPtr, tr("&Delete"), SLOT(onDelete()),
QKeySequence::Delete, tr("delete"), editToolBarPtr,
tr("Delete the current selection"),
LISTENER(isDeleteEnabled));
}
addAction
方法将菜单项添加到菜单栏,并将工具栏图标添加到工具栏。它还将项目与当用户选择项目时被调用的onSelectPtr
方法连接起来,以及启用项目并使用复选框或单选按钮进行标注的方法。除非为零,否则将添加一个加速器到动作中。groupPtr
参数定义了项目是否是组的一部分。如果checkListener
不为nullptr
,则如果groupPtr
为nullptr
,项目将用复选框标注,如果不是,则用单选按钮标注。在单选按钮的情况下,组中只有一个单选按钮会被同时标记:
void MainWindow::addAction(QMenu* menuPtr, QString itemText,
const char* onSelectPtr,
QKeySequence acceleratorKey /* = 0 */,
QString iconName /*= QString()*/,
QToolBar* toolBarPtr /*= nullptr*/,
QString statusTip /*= QString()*/,
Listener enableListener /*= nullptr*/,
Listener checkListener /*= nullptr*/,
QActionGroup* groupPtr /*= nullptr*/) {
QAction* actionPtr;
如果iconName
不为空,我们从项目资源中的文件加载图标,然后创建一个新的带有图标的QAction
对象:
if (!iconName.isEmpty()) {
const QIcon icon = QIcon::fromTheme("document-" + iconName,
QIcon(":/images/" + iconName + ".png"));
actionPtr = new QAction(icon, itemText, this);
}
如果iconName
为空,我们创建一个新的不带图标的QAction
对象:
else {
actionPtr = new QAction(itemText, this);
}
我们将菜单项连接到选择方法。当用户选择项目或点击工具栏图标时,会调用onSelectPtr
:
connect(actionPtr, SIGNAL(triggered()),
centralWidget(), onSelectPtr);
如果加速键不是零,我们将它添加到动作指针中:
if (acceleratorKey != 0) {
actionPtr->setShortcut(acceleratorKey);
}
最后,我们将动作指针添加到菜单指针中,以便它能够处理用户的项选择:
menuPtr->addAction(actionPtr);
如果toolBarPtr
不是nullptr
,我们将动作添加到窗口的工具栏中:
if (toolBarPtr != nullptr) {
toolBarPtr->addAction(actionPtr);
}
如果状态提示不为空,我们将它添加到工具栏的提示和状态提示中:
if (!statusTip.isEmpty()) {
actionPtr->setToolTip(statusTip);
actionPtr->setStatusTip(statusTip);
}
如果启用监听器不为空,我们将一个由窗口中心部件的指针和监听器组成的对添加到m_enableMap
中。同时,我们调用监听器以初始化菜单项和工具栏图标的启用状态:
if (enableListener != nullptr) {
QWidget* widgetPtr = centralWidget();
m_enableMap[actionPtr] =
QPair<QObject*,Listener>(widgetPtr, enableListener);
actionPtr->setEnabled(enableListener(widgetPtr));
}
同样,如果检查监听器不为空,我们将窗口中心小部件的指针和监听器添加到m_checkMap
中。m_enableMap
和m_checkMap
都由onMenuShow
使用,如下所示。我们还调用监听器以初始化菜单项的检查状态(工具栏图标不会被勾选):
if (checkListener != nullptr) {
actionPtr->setCheckable(true);
QWidget* widgetPtr = centralWidget();
m_checkMap[actionPtr] =
QPair<QObject*,Listener>(widgetPtr, checkListener);
actionPtr->setChecked(checkListener(widgetPtr));
}
最后,如果组指针不为空,我们将动作添加到其中。这样,菜单项将通过单选按钮而不是复选框进行标注。框架也会跟踪组,并确保每个组中只有一个单选按钮被同时标记:
if (groupPtr != nullptr) {
groupPtr->addAction(actionPtr);
}
}
onMenuShow
方法在菜单或工具栏图标变得可见之前被调用。它确保每个项目都被启用或禁用,并且项目被标注为复选框或单选按钮。
我们首先遍历启用映射。对于映射中的每个条目,我们查找小部件和启用函数。我们调用该函数,它返回true
或false
,然后通过在动作对象指针上调用setEnabled
来使用结果启用或禁用项目:
void MainWindow::onMenuShow() {
for (QMap<QAction*,QPair<QObject*,Listener>>::iterator i =
m_enableMap.begin(); i != m_enableMap.end(); ++i) {
QAction* actionPtr = i.key();
QPair<QObject*,Listener> pair = i.value();
QObject* baseObjectPtr = pair.first;
Listener enableFunction = pair.second;
actionPtr->setEnabled(enableFunction(baseObjectPtr));
}
同样地,我们遍历检查映射。对于映射中的每个条目,我们查找小部件和检查函数。我们调用该函数,并使用结果通过在动作对象指针上调用 setCheckable
和 setChecked
来检查项目。Qt 框架确保如果项目属于一个组,则通过单选按钮进行注释,如果不属于,则通过复选框进行注释:
for (QMap<QAction*,QPair<QObject*,Listener>>::iterator i =
m_checkMap.begin(); i != m_checkMap.end(); ++i) {
QAction* actionPtr = i.key();
QPair<QObject*,Listener> pair = i.value();
QObject* baseObjectPtr = pair.first;
Listener checkFunction = pair.second;
actionPtr->setCheckable(true);
actionPtr->setChecked(checkFunction(baseObjectPtr));
}
}
基础小部件类
DocumentWidget
是处理文档的应用程序的骨架框架。它处理文档的加载和保存,并为子类提供覆盖 Cut
、Copy
、Paste
和 Delete
菜单项的方法。
当前的 MainWindow
类处理窗口框架,包括其菜单和工具栏,而 DocumentWidget
类则负责绘制窗口内容。其理念是 MainWindow
的子类创建一个 DocumentWidget
子类的对象,并将其放置在窗口的中心。请参阅下一节中 DrawingWindow
和 EditorWindow
的构造函数。
DocumentWidget.h:
#ifndef DOCUMENTWIDGET_H
#define DOCUMENTWIDGET_H
#include "Listener.h"
#include <QWidget>
#include <QtWidgets>
#include <FStream>
using namespace std;
class DocumentWidget : public QWidget {
Q_OBJECT
构造函数接受要显示在窗口顶部横幅中的应用程序名称,用于加载和存储文档的标准文件对话框的文件名掩码,以及指向潜在父小部件的指针(通常是包含的主窗口):
public:
DocumentWidget(const QString& name, const QString& fileMask,
QWidget* parentWidgetPtr);
~DocumentWidget();
setFilePath
方法设置当前文档的路径。路径在窗口的顶部横幅中显示,并在标准加载和保存对话框中作为默认路径给出:
protected:
void setFilePath(QString filePath);
当文档被修改时,会设置修改标志(有时称为脏标志)。这会导致在窗口顶部横幅的文件路径旁边出现一个星号(*
),并启用 Save
和 SaveAs
菜单项:
public:
void setModifiedFlag(bool flag);
setMainWindowTitle
方法是一个辅助方法,用于组合窗口的标题。它由文件路径和一个潜在的星号(*
)组成,以指示是否设置了修改标志:
private:
void setMainWindowTitle();
closeEvent
方法是从 QWidget
重写的,当用户关闭窗口时被调用。通过设置 eventPtr
参数的字段,可以阻止关闭。例如,如果文档尚未保存,可以询问用户是否要保存文档或取消窗口的关闭:
public:
virtual void closeEvent(QCloseEvent* eventPtr);
isClearOk
方法是一个辅助方法,如果用户尝试在不保存文档的情况下关闭窗口或退出应用程序,则会显示消息框:
private:
bool isClearOk(QString title);
当用户选择菜单项或点击工具栏图标时,框架会调用以下方法。为了使其工作,我们将这些方法标记为槽,这是在 connect
调用中的 SLOT
宏所必需的:
public slots:
virtual void onNew();
virtual void onOpen();
virtual bool onSave();
virtual bool onSaveAs();
virtual void onExit();
当文档没有更改时,没有必要保存它。在这种情况下,Save
和 SaveAs
菜单项和工具栏图像应禁用。isSaveEnabled
方法在 File
菜单可见之前由 onMenuShow
调用。它仅在文档已更改且需要保存时返回 true:
virtual bool isSaveEnabled();
tryWriteFile
方法是一个辅助方法,它尝试写入文件。如果失败,将显示一个消息框显示错误信息:
private:
bool tryWriteFile(QString filePath);
以下方法是虚拟方法,旨在由子类重写。当用户选择 New
、Save
、SaveAs
和 Open
菜单项时,会调用这些方法:
protected:
virtual void newDocument() = 0;
virtual bool writeFile(const QString& filePath) = 0;
virtual bool readFile(const QString& filePath) = 0;
在编辑菜单可见之前,会调用以下方法,并决定是否启用 Cut
、Copy
、Paste
和 Delete
项目:
public:
virtual bool isCutEnabled();
virtual bool isCopyEnabled();
virtual bool isPasteEnabled();
virtual bool isDeleteEnabled();
当用户选择 Cut
、Copy
、Paste
和 Delete
项目或工具栏图标时,会调用以下方法:
public slots:
virtual void onCut();
virtual void onCopy();
virtual void onPaste();
virtual void onDelete();
m_applicationName
字段包含应用程序的名称,而不是文档的名称。在下一节中,名称将是 Drawing 和 Editor。m_fileMask
字段包含在标准对话框中加载和保存文档时使用的掩码。例如,假设我们有以 .abc
结尾的文档。那么掩码可以是 Abc files (.abc)
。m_filePath
字段包含当前文档的路径。当文档是新的且尚未保存时,该字段包含空字符串。
最后,当文档已被修改且在应用程序退出之前需要保存时,m_modifiedFlag
为真:
private:
QString m_applicationName, m_fileMask, m_filePath;
bool m_modifiedFlag = false;
};
最后,还有一些重载的辅助运算符。加法和减法运算符将一个点与一个大小相加或相减,以及一个具有大小的矩形:
QPoint& operator+=(QPoint& point, const QSize& size);
QPoint& operator-=(QPoint& point, const QSize& size);
QRect& operator+=(QRect& rect, int size);
QRect& operator-=(QRect& rect, int size);
writePoint
和 readPoint
方法从输入流中写入和读取一个点:
void writePoint(ofstream& outStream, const QPoint& point);
void readPoint(ifstream& inStream, QPoint& point);
writeColor
和 readColor
方法从输入流中写入和读取一个颜色:
void writeColor(ofstream& outStream, const QColor& color);
void readColor(ifstream& inStream, QColor& color);
makeRect
方法创建一个以 point
为中心,size
为大小的矩形:
QRect makeRect(const QPoint& centerPoint, int halfSide);
#endif // DOCUMENTWIDGET_H
DocumentWidget.cpp:
#include <QtWidgets>
#include <QMessageBox>
#include "MainWindow.h"
#include "DocumentWidget.h"
构造函数设置应用程序的名称、保存和加载标准对话框的文件掩码,以及指向封装父小部件的指针(通常是封装的主窗口):
DocumentWidget::DocumentWidget(const QString& name,
const QString& fileMask, QWidget* parentWidgetPtr)
:m_applicationName(name),
m_fileMask(fileMask),
QWidget(parentWidgetPtr) {
setMainWindowTitle();
}
析构函数不执行任何操作,仅为了完整性而包含:
DocumentWidget::~DocumentWidget() {
// Empty.
}
setFilePath
方法调用 setMainWindowTitle
来更新窗口顶部横幅上的文本:
void DocumentWidget::setFilePath(QString filePath) {
m_filePath = filePath;
setMainWindowTitle();
}
setModifiedFlag
方法还会调用 setMainWindowTitle
来更新窗口顶部横幅上的文本。此外,它还会在父小部件上调用 onMenuShow
以更新工具栏的图标:
void DocumentWidget::setModifiedFlag(bool modifiedFlag) {
m_modifiedFlag = modifiedFlag;
setMainWindowTitle();
((MainWindow*) parentWidget())->onMenuShow();
}
工具栏顶部横幅上显示的标题是应用程序名称、文档文件路径(如果非空),以及如果文档未经保存而修改,则显示一个星号:
void DocumentWidget::setMainWindowTitle() {
QString title= m_applicationName +
(m_filePath.isEmpty() ? "" : (" [" + m_filePath + "]"))+
(m_modifiedFlag ? " *" : "");
this->parentWidget()->setWindowTitle(title);
}
isClearOk
方法会在文档未经保存而修改时显示一个消息框。用户可以选择以下按钮之一:
-
是:保存文档并退出应用程序。但是,如果保存失败,将显示错误消息,并且应用程序不会退出。
-
否:应用程序退出而不保存文档。
-
取消:取消应用程序的关闭。文档不会被保存。
bool DocumentWidget::isClearOk(QString title) {
if (m_modifiedFlag) {
QMessageBox messageBox(QMessageBox::Warning,
title, QString());
messageBox.setText(tr("The document has been modified."));
messageBox.setInformativeText(
tr("Do you want to save your changes?"));
messageBox.setStandardButtons(QMessageBox::Yes |
QMessageBox::No | QMessageBox::Cancel);
messageBox.setDefaultButton(QMessageBox::Yes);
switch (messageBox.exec()) {
case QMessageBox::Yes:
return onSave();
case QMessageBox::No:
return true;
case QMessageBox::Cancel:
return false;
}
}
return true;
}
如果文档被清除,则会调用newDocument
,该函数旨在被子类覆盖以执行特定于应用程序的初始化。此外,修改标志和文件路径也会被清除。最后,调用 Qt 的 update
方法来强制重绘窗口内容:
void DocumentWidget::onNew() {
if (isClearOk(tr("New File"))) {
newDocument();
setModifiedFlag(false);
setFilePath(QString());
update();
}
}
如果文档被清除,onOpen
会使用标准打开对话框来获取文档的文件路径:
void DocumentWidget::onOpen() {
if (isClearOk(tr("Open File"))) {
QString file =
QFileDialog::getOpenFileName(this, tr("Open File"),
tr("C:\Users\Stefan\Documents\"
"A A_Cpp_By_Example\Draw"),
m_fileMask + tr(";;Text files (*.txt)"));
如果文件成功读取,则清除修改标志,设置文件路径,并调用 update
来强制重绘窗口内容:
if (!file.isEmpty()) {
if (readFile(file)) {
setModifiedFlag(false);
setFilePath(file);
update();
}
然而,如果读取不成功,将显示一个包含错误信息的消息框:
else {
QMessageBox messageBox;
messageBox.setIcon(QMessageBox::Critical);
messageBox.setText(tr("Read File"));
messageBox.setInformativeText(tr("Could not read "") +
m_filePath + tr("""));
messageBox.setStandardButtons(QMessageBox::Ok);
messageBox.setDefaultButton(QMessageBox::Ok);
messageBox.exec();
}
}
}
}
ifSaveEnabled
方法简单地返回 m_modifiedFlag
的值。但是,我们需要这个方法以便监听器可以工作:
bool DocumentWidget::isSaveEnabled() {
return m_modifiedFlag;
}
当用户选择 Save
或 SaveAs
菜单项或工具栏图标时,会调用 onSave
方法。如果文档已经有一个名称,我们只需尝试写入文件。但是,如果没有给出名称,我们则调用 OnSaveAs
,这会为用户显示标准的保存对话框:
bool DocumentWidget::onSave() {
if (!m_filePath.isEmpty()) {
return tryWriteFile(m_filePath);
}
else {
return onSaveAs();
}
}
当用户选择 SaveAs
菜单项(此项目没有工具栏图标)时,会调用 onSaveAs
方法。它打开标准打开对话框并尝试写入文件。如果写入不成功,则返回 false
。这是因为 isClearOk
只在写入成功时关闭窗口:
bool DocumentWidget::onSaveAs() {
QString filePath =
QFileDialog::getSaveFileName(this, tr("Save File"),
tr("C:\Users\Stefan\Documents\"
"A A_Cpp_By_Example\Draw"),
m_fileMask + tr(";;Text files (*.txt)"));
if (!filePath.isEmpty()) {
return tryWriteFile(filePath);
}
else {
return false;
}
}
tryWriteFile
方法尝试通过调用 write
来写入文件,该函数旨在被子类覆盖。如果成功,则设置修改标志和文件路径。如果文件未能成功写入,将显示一个包含错误信息的消息框:
bool DocumentWidget::tryWriteFile(QString filePath) {
if (writeFile(filePath)) {
setModifiedFlag(false);
setFilePath(filePath);
return true;
}
else {
QMessageBox messageBox;
messageBox.setIcon(QMessageBox::Critical);
messageBox.setText(tr("Write File"));
messageBox.setInformativeText(tr("Could not write "") +
filePath + tr("""));
messageBox.setStandardButtons(QMessageBox::Ok);
messageBox.setDefaultButton(QMessageBox::Ok);
messageBox.exec();
return false;
}
}
当用户选择 Exit
菜单项时,会调用 onExit
方法。它会检查是否可以关闭窗口,如果可以,则退出应用程序:
void DocumentWidget::onExit() {
if (isClearOk(tr("Exit"))) {
qApp->exit(0);
}
}
isCutEnabled
和 isDeleteEnabled
的默认行为是调用 isCopyEnabled
,因为它们通常在相同的条件下被启用:
bool DocumentWidget::isCutEnabled() {
return isCopyEnabled();
}
bool DocumentWidget::isDeleteEnabled() {
return isCopyEnabled();
}
onCut
的默认行为是简单地调用 onCopy
和 onDelete
:
void DocumentWidget::onCut() {
onCopy();
onDelete();
}
其他剪切和复制方法的默认行为是返回 false
并不执行任何操作,这将使菜单项处于禁用状态,除非子类覆盖这些方法:
bool DocumentWidget::isCopyEnabled() {
return false;
}
void DocumentWidget::onCopy() {
// Empty.
}
bool DocumentWidget::isPasteEnabled() {
return false;
}
void DocumentWidget::onPaste() {
// Empty.
}
void DocumentWidget::onDelete() {
// Empty.
}
最后,当用户尝试关闭窗口时,会调用 closeEvent
。如果窗口准备就绪可以清除,则会在 eventPtr
上调用 accept
,这将导致窗口关闭,并在全局 qApp
对象上调用 exit
,导致应用程序退出:
void DocumentWidget::closeEvent(QCloseEvent* eventPtr) {
if (isClearOk(tr("Close Window"))) {
eventPtr->accept();
qApp->exit(0);
}
然而,如果窗口尚未准备好清除,则会在 eventPtr
上调用 ignore
,这将导致窗口保持打开状态(并且应用程序继续运行):
else {
eventPtr->ignore();
}
}
此外,还有一组处理点、大小、矩形和颜色的辅助函数。以下运算符将一个点与一个大小相加或相减,并返回结果点:
QPoint& operator+=(QPoint& point, const QSize& size) {
point.setX(point.x() + size.width());
point.setY(point.y() + size.height());
return point;
}
QPoint& operator-=(QPoint& point, const QSize& size) {
point.setX(point.x() - size.width());
point.setY(point.y() - size.height());
return point;
}
以下运算符将一个整数加到或从矩形中减去,并返回结果矩形。加法运算符在所有方向上扩展矩形的大小,而减法运算符在所有方向上缩小矩形的大小:
QRect& operator+=(QRect& rect, int size) {
rect.setLeft(rect.left() - size);
rect.setTop(rect.top() - size);
rect.setWidth(rect.width() + size);
rect.setHeight(rect.height() + size);
return rect;
}
QRect& operator-=(QRect& rect, int size) {
rect.setLeft(rect.left() + size);
rect.setTop(rect.top() + size);
rect.setWidth(rect.width() - size);
rect.setHeight(rect.height() - size);
return rect;
}
writePoint
和 readPoint
函数用于从文件写入和读取一个点。它们分别写入和读取 x 和 y 坐标:
void writePoint(ofstream& outStream, const QPoint& point) {
int x = point.x(), y = point.y();
outStream.write((char*) &x, sizeof x);
outStream.write((char*) &y, sizeof y);
}
void readPoint(ifstream& inStream, QPoint& point) {
int x, y;
inStream.read((char*) &x, sizeof x);
inStream.read((char*) &y, sizeof y);
point = QPoint(x, y);
}
writeColor
和 readColor
函数用于从文件写入和读取一个颜色。一个颜色由 red
(红色)、green
(绿色)和 blue
(蓝色)三个分量组成。每个分量是一个介于 0
和 255
(包含)之间的整数。这些方法从文件流中写入和读取分量:
void writeColor(ofstream& outStream, const QColor& color) {
int red = color.red(), green = color.green(),
blue = color.blue();
outStream.write((char*) &red, sizeof red);
outStream.write((char*) &green, sizeof green);
outStream.write((char*) &blue, sizeof blue);
}
void readColor(ifstream& inStream, QColor& color) {
int red, green, blue;
inStream.read((char*) &red, sizeof red);
inStream.read((char*) &green, sizeof green);
inStream.read((char*) &blue, sizeof blue);
当组件被读取后,我们创建一个 QColor
对象,并将其分配给 color
参数:
color = QColor(red, green, blue);
}
makeRect
函数创建一个以点为中心的矩形:
QRect makeRect(const QPoint& centerPoint, int halfSide) {
return QRect(centerPoint.x() - halfSide,
centerPoint.y() - halfSide,
2 * halfSide, 2 * halfSide);
}
构建绘图程序
现在我们开始一个新的项目,利用上一节中提到的主窗口和文档小部件类——绘图程序。我们将在本章中从基本版本开始,并在下一章中继续构建更高级的版本。使用本章的绘图程序,我们可以用不同的颜色绘制线条、矩形和椭圆。我们还可以保存和加载我们的绘图。请注意,在这个项目中,窗口和小部件类继承自上一节中的 MainWindow
和 DocumentWidget
类。
图形基类
应用程序中的图形构成一个类层次结构,其中 Figure
是基类。其子类是 Line
、RectangleX
和 EllipseX
,这些将在后面进行描述。我们不能使用 Rectangle 和 Ellipse 作为我们类的名称,因为这会与具有相同名称的 Qt 方法冲突。我选择简单地在名称中添加一个 'X
'。
Figure
类是抽象的,这意味着我们不能创建该类的对象。我们只能将其用作基类,子类从中继承。
Figure.h:
#ifndef FIGURE_H
#define FIGURE_H
enum FigureId {LineId, RectangleId, EllipseId};
#include <QtWidgets>
#include <FStream>
using namespace std;
class Figure {
public:
Figure();
以下方法都是纯虚的,这意味着它们不需要被定义。包含至少一个纯虚方法的一个类变成抽象类。子类必须定义其所有基类的所有纯虚方法,或者它们自己也成为抽象类。这样,可以保证所有非抽象类的所有方法都被定义。
每个子类定义 getId
并返回其类的身份枚举:
virtual FigureId getId() const = 0;
每个图形都有一个起始点和结束点,具体由每个子类来定义:
virtual void initializePoints(QPoint point) = 0;
virtual void setLastPoint(QPoint point) = 0;
isClick
方法如果图形被点击则返回 true
:
virtual bool isClick(QPoint mousePoint) = 0;
move
方法将图形移动一定距离:
virtual void move(QSize distance) = 0;
draw
方法在绘图区域上绘制图形:
virtual void draw(QPainter &painter) const = 0;
write
和 read
方法将图形从文件中写入和读取;write
是常量,因为它不会改变图形:
virtual bool write(ofstream& outStream) const;
virtual bool read(ifstream& inStream);
color
方法返回图形的颜色。它有两种版本,其中第一种是常量版本,返回一个常量 QColor
对象的引用,而第二种是非常量版本,返回一个非常量对象的引用:
const QColor& color() const {return m_color;}
QColor& color() {return m_color;}
filled
方法仅适用于二维图形(矩形和椭圆)。如果图形被填充,则返回 true
。请注意,第二个版本返回 m_filled
字段的引用,允许方法调用者修改 m_filled
的值:
virtual bool filled() const {return m_filled;}
virtual bool& filled() {return m_filled;}
当图形被标记时,它会在其角落绘制小正方形。正方形的边长由静态字段 Tolerance
定义:
static const int Tolerance;
writeColor
和 readColor
方法是辅助方法,用于读取和写入颜色。由于它们由 Figure
类层次结构之外的方法调用,因此它们是静态的:
static void writeColor(ofstream& outStream,
const QColor& color);
static void readColor(ifstream& inStream, QColor& color);
每个图形都有一个颜色,它可以被标记或填充:
private:
QColor m_color;
bool m_marked = false, m_filled = false;
};
#endif
Figure.cpp
文件包含了 Figure
类的定义。它定义了 Tolerance
字段以及 write
和 read
方法。
Figure.cpp:
#include "..\MainWindow\DocumentWidget.h"
#include "Figure.h"
由于 Tolerance
是静态的,必须在全局空间中定义和初始化。我们定义标记正方形的尺寸为 6
像素:
const int Figure::Tolerance(6);
仅当从文件读取图形时才调用默认构造函数:
Figure::Figure() {
// Empty.
}
write
和 read
方法写入和读取图形的颜色以及图形是否被填充:
bool Figure::write(ofstream& outStream) const {
writeColor(outStream, m_color);
outStream.write((char*) &m_filled, sizeof m_filled);
return ((bool) outStream);
}
bool Figure::read(ifstream& inStream) {
readColor(inStream, m_color);
inStream.read((char*) &m_filled, sizeof m_filled);
return ((bool) inStream);
}
Line
子类
Line
类是 Figure
的子类。通过定义 Figure
的每个纯虚方法,它变得非抽象。一条线通过 Line
中的 m_firstPoint
到 m_lastPoint
字段在两个端点之间绘制:
Line.h:
#ifndef LINE_H
#define LINE_H
#include <FStream>
using namespace std;
#include "Figure.h"
class Line : public Figure {
public:
默认构造函数仅在从文件读取 Line
对象时调用;getId
简单地返回线的身份枚举:
Line();
FigureId getId() const {return LineId;}
一条线有两个端点。当创建线时,这两个点都被设置,当用户移动它时,第二个点被修改:
void initializePoints(QPoint point);
void setLastPoint(QPoint point);
isClick
方法如果鼠标点击位于线上(带有一些容差),则返回 true
:
bool isClick(QPoint mousePoint);
move
方法将线(及其两个端点)移动给定的距离:
void move(QSize distance);
draw
方法在 QPainter
对象上绘制线:
void draw(QPainter& painter) const;
write
和 read
方法将线的端点从文件流中写入和读取:
bool write(ofstream& outStream) const;
bool read(ifstream& inStream);
线的第一个和最后一个点存储在 Line
对象中:
private:
QPoint m_firstPoint, m_lastPoint;
};
#endif
Line.cpp
文件定义了 Line
类的方法。
Line.cpp:
#include "..\MainWindow\DocumentWidget.h"
#include "Line.h"
Line::Line() {
// Empty.
}
当用户向绘图添加新线时,会调用 initializePoints
方法。它设置其两个端点:
void Line::initializePoints(QPoint point) {
m_firstPoint = point;
m_lastPoint = point;
}
当用户添加线并修改其形状时,会调用 setLastPoint
方法。它设置最后一个点:
void Line::setLastPoint(QPoint point) {
m_lastPoint = point;
}
isClick
方法测试用户是否在线条上用鼠标点击。我们需要考虑两种情况。第一种情况是当线条完全垂直时发生的特殊情况,此时端点的x
坐标相等。我们使用 Qt 的QRect
类创建一个围绕线条的矩形,并测试该点是否在矩形内:
bool Line::isClick(QPoint mousePoint) {
if (m_firstPoint.x() == m_lastPoint.x()) {
QRect lineRect(m_firstPoint, m_lastPoint);
lineRect.normalized();
lineRect += Tolerance;
return lineRect.contains(mousePoint);
}
在一般情况中,即线条不是垂直的情况下,我们首先创建一个包含的矩形并测试鼠标指针是否在其中。如果是,我们将leftPoint
设置为firstPoint
和lastPoint
的最左端点,将rightPoint
设置为最右端点。然后我们计算包含矩形的宽度(lineWidth
)和高度(lineHeight
),以及rightPoint
和mousePoint
在x
和y
方向上的距离(diffWidth
和diffHeight
)。
由于一致性,如果鼠标指针击中线条,以下等式是正确的:
然而,为了使左手表达式正好为零,用户必须精确地点击在线条上。因此,我们可以允许有一定的容差。让我们使用Tolerance
字段:
else {
QPoint leftPoint = (m_firstPoint.x() < m_lastPoint.x())
? m_firstPoint : m_lastPoint,
rightPoint = (m_firstPoint.x() < m_lastPoint.x())
? m_lastPoint : m_firstPoint;
if ((leftPoint.x() <= mousePoint.x()) &&
(mousePoint.x() <= rightPoint.x())) {
int lineWidth = rightPoint.x() - leftPoint.x(),
lineHeight = rightPoint.y() - leftPoint.y();
int diffWidth = mousePoint.x() - leftPoint.x(),
diffHeight = mousePoint.y() - leftPoint.y();
我们必须将lineHeight
转换为双精度浮点数,以便执行非整数除法:
return (fabs(diffHeight - (((double) lineHeight) /
lineWidth) * diffWidth) <= Tolerance);
}
如果鼠标指针位于包含线条的矩形外部,我们直接返回false
:
return false;
}
}
move
方法简单地移动线条的两个端点:
void Line::move(QSize distance) {
m_firstPoint += distance;
m_lastPoint += distance;
}
当绘制线条时,我们设置画笔颜色并绘制线条。Figure
类的color
方法返回线条的颜色:
void Line::draw(QPainter& painter) const {
painter.setPen(color());
painter.drawLine(m_firstPoint, m_lastPoint);
}
当绘制线条时,我们首先在Figure
中调用write
来绘制图形的颜色。然后我们写入线条的端点。最后,我们返回输出流的布尔值,如果写入成功则为true
:
bool Line::write(ofstream& outStream) const {
Figure::write(outStream);
writePoint(outStream, m_firstPoint);
writePoint(outStream, m_lastPoint);
return ((bool) outStream);
}
以同样的方式,当读取线条时,我们首先在Figure
中调用read
来读取线条的颜色。然后我们读取线条的端点并返回输入流的布尔值:
bool Line::read(ifstream& inStream) {
Figure::read(inStream);
readPoint(inStream, m_firstPoint);
readPoint(inStream, m_lastPoint);
return ((bool) inStream);
}
Rectangle
子类
RectangleX
是Figure
的子类,用于处理矩形。与Line
类似,它持有两个点,这两个点持有矩形的对角线:
Rectangle.h
#ifndef RECTANGLE_H
#define RECTANGLE_H
#include <FStream>
using namespace std;
#include "Figure.h"
class RectangleX : public Figure {
public:
与前面的Line
类类似,RectangleX
有一个默认构造函数,用于从文件中读取对象时使用:
RectangleX();
virtual FigureId getId() const {return RectangleId;}
RectangleX(const RectangleX& rectangle);
virtual void initializePoints(QPoint point);
virtual void setLastPoint(QPoint point);
virtual bool isClick(QPoint mousePoint);
virtual void move(QSize distance);
virtual void draw(QPainter& painter) const;
virtual bool write(ofstream& outStream) const;
virtual bool read(ifstream& inStream);
protected:
QPoint m_topLeft, m_bottomRight;
};
#endif
Rectangle.cpp
#include "..\MainWindow\DocumentWidget.h"
#include "Rectangle.h"
RectangleX::RectangleX() {
// Empty.
}
initializePoints
和setLastPoint
方法的工作方式与Line
中的对应方法类似:initializePoints
设置两个角点,而setLastPoint
设置最后一个角点:
void RectangleX::initializePoints(QPoint point) {
m_topLeft = point;
m_bottomRight = point;
}
void RectangleX::setLastPoint(QPoint point) {
m_bottomRight = point;
}
isClick
方法比其在Line
中的对应方法简单:
bool RectangleX::isClick(QPoint mousePoint) {
QRect areaRect(m_topLeft, m_bottomRight);
如果矩形被填充,我们简单地通过在QRect
中调用contains
来检查鼠标点击是否击中了矩形:
if (filled()) {
return areaRect.contains(mousePoint);
}
如果矩形没有被填充,我们需要检查鼠标是否点击了矩形的边界。为此,我们创建两个稍微小一些和大一些的矩形。如果鼠标点击击中了较大的矩形,但没有击中较小的矩形,我们认为矩形边界被击中:
else {
QRect largeAreaRect(areaRect), smallAreaRect(areaRect);
largeAreaRect += Tolerance;
smallAreaRect -= Tolerance;
return largeAreaRect.contains(mousePoint) &&
!smallAreaRect.contains(mousePoint);
}
return false;
}
当移动矩形时,我们只需移动第一个和最后一个角:
void RectangleX::move(QSize distance) {
addSizeToPoint(m_topLeft, distance);
addSizeToPoint(m_bottomRight, distance);
}
当绘制矩形时,我们首先通过调用Figure
中的color
来设置笔的颜色:
void RectangleX::draw(QPainter& painter) const {
painter.setPen(color());
如果矩形被填充,我们只需在QPainter
对象上调用fillRect
:
if (filled()) {
painter.fillRect(QRect(m_topLeft, m_bottomRight), color());
}
如果矩形没有被填充,我们禁用画笔使矩形空心,然后调用QPainter
对象的drawRect
来绘制矩形的边界:
else {
painter.setBrush(Qt::NoBrush);
painter.drawRect(QRect(m_topLeft, m_bottomRight));
}
}
write
方法首先在Figure
中调用write
,然后写入矩形的第一个和最后一个角:
bool RectangleX::write(ofstream& outStream) const {
Figure::write(outStream);
writePoint(outStream, m_topLeft);
writePoint(outStream, m_bottomRight);
return ((bool) outStream);
}
同样地,read
首先在Figure
中调用read
,然后读取矩形的第一个和最后一个角:
bool RectangleX::read (ifstream& inStream) {
Figure::read(inStream);
readPoint(inStream, m_topLeft);
readPoint(inStream, m_bottomRight);
return ((bool) inStream);
}
椭圆子类
EllipseX
是处理椭圆的RectangleX
子类。RectangleX
的部分功能在EllipseX
中被重用。更具体地说,initializePoints
、setLastPoint
、move
、write
和read
是从RectangleX
中重写的。
Ellipse.h:
#ifndef ELLIPSE_H
#define ELLIPSE_H
#include "Rectangle.h"
class EllipseX : public RectangleX {
public:
EllipseX();
FigureId getId() const {return EllipseId;}
EllipseX(const EllipseX& ellipse);
bool isClick(QPoint mousePoint);
void draw(QPainter& painter) const;
};
#endif
Ellipse.cpp:
#include "..\MainWindow\DocumentWidget.h"
#include "Ellipse.h"
EllipseX::EllipseX() {
// Empty.
}
EllipseX
类的isClick
方法与其在RectangleX
中的对应方法类似。我们使用 Qt 的QRegion
类创建椭圆对象,并将其与鼠标点击进行比较:
bool EllipseX::isClick(QPoint mousePoint) {
QRect normalRect(m_topLeft, m_bottomRight);
normalRect.normalized();
如果椭圆被填充,我们创建一个椭圆区域并测试鼠标点击是否击中了该区域:
if (filled()) {
QRegion normalEllipse(normalRect, QRegion::Ellipse);
return normalEllipse.contains(mousePoint);
}
如果椭圆没有被填充,我们创建稍微小一些和大一些的椭圆区域。如果鼠标点击击中了较小的区域,但没有击中较大的区域,我们认为椭圆的边界被击中:
else {
QRect largeRect(normalRect), smallRect(normalRect);
largeRect += Tolerance;
smallRect -= Tolerance;
QRegion largeEllipse(largeRect, QRegion::Ellipse),
smallEllipse(smallRect, QRegion::Ellipse);
return (largeEllipse.contains(mousePoint) &&
!smallEllipse.contains(mousePoint));
}
}
当绘制椭圆时,我们首先通过调用Figure
中的color
来设置笔的颜色:
void EllipseX::draw(QPainter& painter) const {
painter.setPen(color());
如果椭圆被填充,我们设置画笔并绘制椭圆:
if (filled()) {
painter.setBrush(color());
painter.drawEllipse(QRect(m_topLeft, m_bottomRight));
}
如果椭圆没有被填充,我们设置画笔为空心并绘制椭圆边界:
else {
painter.setBrush(Qt::NoBrush);
painter.drawEllipse(QRect(m_topLeft, m_bottomRight));
}
}
绘制窗口
DrawingWindow
类是上一节中MainWindow
类的子类。
DrawingWindow.h:
#ifndef DRAWINGWINDOW_H
#define DRAWINGWINDOW_H
#include <QMainWindow>
#include <QActionGroup>
#include "..\MainWindow\MainWindow.h"
#include "DrawingWidget.h"
class DrawingWindow : public MainWindow {
Q_OBJECT
public:
DrawingWindow(QWidget* parentWidgetPtr = nullptr);
~DrawingWindow();
public:
void closeEvent(QCloseEvent *eventPtr)
{ m_drawingWidgetPtr->closeEvent(eventPtr); }
private:
DrawingWidget* m_drawingWidgetPtr;
QActionGroup* m_figureGroupPtr;
};
#endif // DRAWINGWINDOW_H
DrawingWindow.cpp:
#include "..\MainWindow\DocumentWidget.h"
#include "DrawingWindow.h"
构造函数将窗口的大小设置为1000
* 500
像素:
DrawingWindow::DrawingWindow(QWidget* parentWidgetPtr
/* = nullptr */)
:MainWindow(parentWidgetPtr) {
resize(1000, 500);
m_drawingWidgetPtr
字段被初始化为指向DrawingWidget
类的一个对象,然后将其设置为窗口的中心部分:
m_drawingWidgetPtr = new DrawingWidget(this);
setCentralWidget(m_drawingWidgetPtr);
标准文件菜单被添加到窗口菜单栏:
addFileMenu();
然后我们添加应用程序特定的格式菜单。它连接到上一节中DocumentWidget
类的onMenuShow
方法:
{ QMenu* formatMenuPtr = menuBar()->addMenu(tr("F&ormat"));
connect(formatMenuPtr, SIGNAL(aboutToShow()),
this, SLOT(onMenuShow()));
格式菜单包含颜色和填充项:
addAction(formatMenuPtr, tr("&Color"),
SLOT(onColor()), QKeySequence(Qt::ALT + Qt::Key_C),
QString(), nullptr, tr("Figure Color"));
当绘图程序的下一个图形是一个二维图形(矩形或椭圆)时,填充项将被启用:
addAction(formatMenuPtr, tr("&Fill"),
SLOT(onFill()), QKeySequence(Qt::CTRL + Qt::Key_F),
QString(), nullptr, tr("Figure Fill"),
LISTENER(isFillEnabled));
}
对于图形菜单,我们为线、矩形和椭圆项创建一个新的动作组。它们中只能同时标记一个:
{ m_figureGroupPtr = new QActionGroup(this);
QMenu* figureMenuPtr = menuBar()->addMenu(tr("F&igure"));
connect(figureMenuPtr, SIGNAL(aboutToShow()),
this, SLOT(onMenuShow()));
当前选中的项应使用单选按钮标记:
addAction(figureMenuPtr, tr("&Line"),
SLOT(onLine()), QKeySequence(Qt::CTRL + Qt::Key_L),
QString(), nullptr, tr("Line Figure"), nullptr,
LISTENER(isLineChecked), m_figureGroupPtr);
addAction(figureMenuPtr, tr("&Rectangle"),
SLOT(onRectangle()),
QKeySequence(Qt::CTRL + Qt::Key_R),
QString(), nullptr, tr("Rectangle Figure"), nullptr,
LISTENER(isRectangleChecked), m_figureGroupPtr);
addAction(figureMenuPtr, tr("&Ellipse"),
SLOT(onEllipse()),
QKeySequence(Qt::CTRL + Qt::Key_E),
QString(), nullptr, tr("Ellipse Figure"), nullptr,
LISTENER(isEllipseChecked), m_figureGroupPtr);
}
}
析构函数释放了在构造函数中动态分配的图形组:
DrawingWindow::~DrawingWindow() {
delete m_figureGroupPtr;
}
绘制小部件
DrawingWidget
是上一节中DocumentWidget
的子类。它处理鼠标输入、图形的绘制以及绘图的保存和加载。它还提供了决定何时标记和启用菜单项的方法。
DrawingWidget.h:
#ifndef DRAWINGWIDGET_H
#define DRAWINGWIDGET_H
#include "..\MainWindow\MainWindow.h"
#include "..\MainWindow\DocumentWidget.h"
#include "Figure.h"
class DrawingWidget : public DocumentWidget {
Q_OBJECT
public:
DrawingWidget(QWidget* parentWidgetPtr);
~DrawingWidget();
当用户按下或释放鼠标键或移动鼠标时,会调用重写的mousePressEvent
、mouseReleaseEvent
和mouseMoveEvent
方法:
public:
void mousePressEvent(QMouseEvent *eventPtr);
void mouseReleaseEvent(QMouseEvent *eventPtr);
void mouseMoveEvent(QMouseEvent *eventPtr);
当窗口需要重新绘制时,会调用paintEvent
方法。这可以由几个原因引起。例如,用户可以修改窗口的大小。重新绘制也可以通过调用update
方法强制执行,这最终会导致调用paintEvent
:
void paintEvent(QPaintEvent *eventPtr);
当用户选择新菜单项时,会调用newDocument
方法,当用户选择保存或另存为项时,会调用writeFile
,当用户选择打开项时,会调用readFile
:
private:
void newDocument() override;
bool writeFile(const QString& filePath);
bool readFile(const QString& filePath);
Figure* createFigure(FigureId figureId);
当用户选择颜色和填充菜单项时,会调用onColor
和onFill
方法:
public slots:
void onColor();
void onFill();
在用户选择格式菜单之前会调用isFillEnabled
方法。如果它返回true
,则填充项变为启用:
DEFINE_LISTENER(DrawingWidget, isFillEnabled);
在图形菜单可见之前也会调用isLineChecked
、isRectangleChecked
和isEllipseChecked
方法。如果方法返回true
,则项目会带有单选按钮:
DEFINE_LISTENER(DrawingWidget, isLineChecked);
DEFINE_LISTENER(DrawingWidget, isRectangleChecked);
DEFINE_LISTENER(DrawingWidget, isEllipseChecked);
当用户选择线条、矩形和椭圆菜单项时,会调用onLine
、onRectangle
和isEllipse
方法:
void onLine();
void onRectangle();
void onEllipse();
当应用程序运行时,它可以保持Idle
、Create
或Move
模式:
-
Idle
:当应用程序等待用户输入时。 -
Create
:当用户向绘图添加新图形时。发生在用户按下左鼠标按钮而没有击中图形时。添加一个新图形,并修改其端点,直到用户释放鼠标按钮。 -
Move
:当用户移动图形时。发生在用户按下左鼠标按钮并击中图形时。图形被移动,直到用户释放鼠标按钮。
private:
enum ApplicationMode {Idle, Create, Move};
ApplicationMode m_applicationMode = Idle;
void setApplicationMode(ApplicationMode mode);
m_currColor
字段保存用户将要添加的下一个图形的颜色;m_currFilled
决定下一个图形(如果是矩形或椭圆)是否应该填充。m_addFigureId
方法保存用户将要添加的下一个图形类型(线条、矩形或椭圆)的标识整数:
QColor m_currColor = Qt::black;
bool m_currFilled = false;
FigureId m_addFigureId = LineId;
当用户按下鼠标按钮并移动图形时,我们需要存储上一个鼠标点,以便计算图形自上次鼠标事件以来移动的距离:
QPoint m_mousePoint;
最后,m_figurePtrList
保存指向绘图图形的指针。绘图中最顶层的图形位于列表的末尾:
QList<Figure*> m_figurePtrList;
};
#endif // DRAWINGWIDGET_H
DrawingWidget.cpp:
#include "..\MainWindow\DocumentWidget.h"
#include "DrawingWidget.h"
#include "Line.h"
#include "Rectangle.h"
#include "Ellipse.h"
构造函数调用基类DocumentWidget
的构造函数,标题为Drawing
。它还将保存和加载掩码设置为Drawing files (*.drw)
,这意味着标准保存和加载对话框中默认选择的文件具有drw
后缀:
DrawingWidget::DrawingWidget(QWidget* parentWidgetPtr)
:DocumentWidget(tr("Drawing"), tr("Drawing files (*.drw)"),
parentWidgetPtr) {
// Empty.
}
析构函数释放图形指针列表中的图形指针:
DrawingWidget::~DrawingWidget() {
for (Figure* figurePtr : m_figurePtrList) {
delete figurePtr;
}
}
setApplicationMode
方法设置应用程序模式,并在主窗口中调用onMenuShow
以正确启用工具栏图标:
void DrawingWidget::setApplicationMode(ApplicationMode mode) {
m_applicationMode = mode;
((MainWindow*) parent())->onMenuShow();
}
当用户选择新菜单项时,会调用newDocument
。图形指针列表中的图形被释放,列表本身被清除:
void DrawingWidget::newDocument() {
for (Figure* figurePtr : m_figurePtrList) {
delete figurePtr;
}
m_figurePtrList.clear();
用户将要添加的下一个图形是一条黑色线条,并且填充状态为false
:
m_currColor = Qt::black;
m_addFigureId = LineId;
m_currFilled = false;
}
当用户选择保存或另存为菜单项时,会调用writeFile
方法:
bool DrawingWidget::writeFile(const QString& filePath) {
ofstream outStream(filePath.toStdString());
我们首先写入当前颜色和填充状态。然后继续写入图形指针列表的大小,以及图形本身:
if (outStream) {
writeColor(outStream, m_currColor);
outStream.write((char*) &m_currFilled, sizeof m_currFilled);
int size = m_figurePtrList.size();
outStream.write((char*) &size, sizeof size);
对于每个图形,我们首先写入其身份编号,然后写入图形本身:
for (Figure* figurePtr : m_figurePtrList) {
FigureId figureId = figurePtr->getId();
outStream.write((char*) &figureId, sizeof figureId);
figurePtr->write(outStream);
}
return ((bool) outStream);
}
如果文件无法打开,则返回false
:
return false;
}
当用户选择打开菜单项时,会调用readFile
方法。与之前的writeFile
方法相同,我们读取颜色和填充状态,图形指针列表的大小,然后是图形本身:
bool DrawingWidget::readFile(const QString& filePath) {
ifstream inStream(filePath.toStdString());
if (inStream) {
readColor(inStream, m_currColor);
inStream.read((char*) &m_currFilled, sizeof m_currFilled);
int size;
inStream.read((char*) &size, sizeof size);
在读取图形时,我们首先读取其身份编号,并调用createFigure
来创建与图形身份编号对应的类的对象。然后通过调用其指针上的read
来读取图形的字段。请注意,我们实际上并不真正知道(或关心)它是哪种图形。我们只是调用图形指针的read
,该指针实际上指向Line
、RectangleX
或EllipseX
类的对象:
for (int count = 0; count < size; ++count) {
FigureId figureId = (FigureId) 0;
inStream.read((char*) &figureId, sizeof figureId);
Figure* figurePtr = createFigure(figureId);
figurePtr->read(inStream);
m_figurePtrList.push_back(figurePtr);
}
return ((bool) inStream);
}
return false;
}
根据figureId
参数的值,createFigure
方法动态创建Line
、RectangleX
或EllipseX
类的对象:
Figure* DrawingWidget::createFigure(FigureId figureId) {
Figure* figurePtr = nullptr;
switch (figureId) {
case LineId:
figurePtr = new Line();
break;
case RectangleId:
figurePtr = new RectangleX();
break;
case EllipseId:
figurePtr = new EllipseX();
break;
}
return figurePtr;
}
当用户选择颜色菜单项时,会调用onColor
方法。它设置用户将要添加的下一个图形的颜色:
void DrawingWidget::onColor() {
QColor newColor = QColorDialog::getColor(m_currColor, this);
if (newColor.isValid() && (m_currColor != newColor)) {
m_currColor = newColor;
setModifiedFlag(true);
}
}
在格式菜单可见之前,会调用isFillEnabled
方法,如果用户将要添加的下一个图形是矩形或椭圆,则返回true
:
bool DrawingWidget::isFillEnabled() {
return (m_addFigureId == RectangleId) ||
(m_addFigureId == EllipseId);
}
当用户选择填充菜单项时,会调用onFill
方法。它反转m_currFilled
字段。它还设置修改标志,因为文档已被影响:
void DrawingWidget::onFill() {
m_currFilled = !m_currFilled;
setModifiedFlag(true);
}
在图形菜单可见之前,会调用isLineChecked
、isRectangleChecked
和isEllipseChecked
方法。如果它们返回true
,则如果下一个要添加的图形是所涉及的图形,则项目会通过单选按钮被选中:
bool DrawingWidget::isLineChecked() {
return (m_addFigureId == LineId);
}
bool DrawingWidget::isRectangleChecked() {
return (m_addFigureId == RectangleId);
}
bool DrawingWidget::isEllipseChecked() {
return (m_addFigureId == EllipseId);
}
当用户选择图形菜单中的项目时,会调用onLine
、onRectangle
和onEllipse
方法。它们将用户将要添加的下一个图形设置为所涉及的图形:
void DrawingWidget::onLine() {
m_addFigureId = LineId;
}
void DrawingWidget::onRectangle() {
m_addFigureId = RectangleId;
}
void DrawingWidget::onEllipse() {
m_addFigureId = EllipseId;
}
每次用户按下鼠标键时,都会调用mousePressEvent
方法。首先,我们需要检查他们是否按下了左鼠标键:
void DrawingWidget::mousePressEvent(QMouseEvent* eventPtr) {
if (eventPtr->buttons() == Qt::LeftButton) {
在以下代码片段中对mouseMoveEvent
的调用中,我们需要跟踪最新的鼠标点,以便计算鼠标移动之间的距离。因此,我们将m_mousePoint
设置为鼠标点:
m_mousePoint = eventPtr->pos();
我们遍历图形指针列表,并对每个图形,我们通过调用isClick
来检查图形是否被鼠标点击。我们需要以相当尴尬的方式反向迭代,以便首先找到最顶部的图形。我们使用reverse_iterator
类和rbegin
和rend
方法来反向迭代:
for (QList<Figure*>::reverse_iterator iterator =
m_figurePtrList.rbegin();
iterator != m_figurePtrList.rend(); ++iterator) {
我们使用解引用运算符(*
)来获取列表中的图形指针:
Figure* figurePtr = *iterator;
如果图形被鼠标点击,我们将应用程序模式设置为移动。我们还通过在列表上调用removeOne
和push_back
来将图形放置在列表的末尾,使其看起来是绘图的顶层。最后,我们中断循环,因为我们已经找到了我们正在寻找的图形:
if (figurePtr->isClick(m_mousePoint)) {
setApplicationMode(Move);
m_figurePtrList.removeOne(figurePtr);
m_figurePtrList.push_back(figurePtr);
break;
}
}
如果应用程序模式仍然是空闲状态(没有移动),我们没有找到被鼠标点击的图形。在这种情况下,我们将应用程序模式设置为创建,并调用createFigure
来找到一个要复制的图形。然后,我们设置图形的颜色和填充状态以及图形的点。最后,通过调用push_back
(为了使其出现在绘图的顶部,将其添加到列表的末尾)并将修改标志设置为true
,因为绘图已被修改:
if (m_applicationMode == Idle) {
setApplicationMode(Create);
Figure* newFigurePtr = createFigure(m_addFigureId);
newFigurePtr->color() = m_currColor;
newFigurePtr->filled() = m_currFilled;
newFigurePtr->initializePoints(m_mousePoint);
m_figurePtrList.push_back(newFigurePtr);
setModifiedFlag(true);
}
}
}
每当用户移动鼠标时,都会调用mouseMoveEvent
。首先,我们需要检查用户在移动鼠标时是否按下了鼠标左键:
void DrawingWidget::mouseMoveEvent(QMouseEvent* eventPtr) {
if (eventPtr->buttons() == Qt::LeftButton) {
QPoint newMousePoint = eventPtr->pos();
然后,我们检查应用程序模式。如果我们正在将新图形添加到绘图的过程中,我们修改其最后一个点:
switch (m_applicationMode) {
case Create:
m_figurePtrList.back()->setLastPoint(m_mousePoint);
break;
如果我们在移动一个图形的过程中,我们将计算自上次鼠标事件以来的距离,并将位于图形指针列表末尾的图形移动。记住,被鼠标点击的图形是在前一个mousePressEvent
中放置在图形指针列表末尾的:
case Move: {
QSize distance(newMousePoint.x() - m_mousePoint.x(),
newMousePoint.y() - m_mousePoint.y());
m_figurePtrList.back()->move(distance);
setModifiedFlag(true);
}
break;
}
最后,我们更新当前鼠标点,以便下一次调用mouseMoveEvent
。我们还调用更新方法来强制窗口重新绘制:
m_mousePoint = newMousePoint;
update();
}
}
当用户释放鼠标按钮之一时,会调用mouseReleaseEvent
方法。我们将应用程序模式设置为空闲:
void DrawingWidget::mouseReleaseEvent(QMouseEvent* eventPtr) {
if (eventPtr->buttons() == Qt::LeftButton) {
setApplicationMode(Idle);
}
}
每当窗口需要重新绘制时,都会调用paintEvent
方法。这可能是由于几个原因。例如,用户可能已经改变了窗口的大小。这也可能是由于在 Qt 的QWidget
类中调用update
的结果,这强制窗口重新绘制,并最终调用paintEvent
。
我们首先创建一个QPainter
对象,这可以被视为绘画的画布,并设置合适的渲染。然后,我们遍历图形指针列表,并绘制每个图形。这样,列表中的最后一个图形就会绘制在绘图的顶部:
void DrawingWidget::paintEvent(QPaintEvent* /* eventPtr */) {
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
painter.setRenderHint(QPainter::TextAntialiasing);
for (Figure* figurePtr : m_figurePtrList) {
figurePtr->draw(painter);
}
}
主函数
最后,我们在 main
函数中通过创建应用程序对象、显示主窗口并执行应用程序来启动应用程序。
Main.cpp:
#include "DrawingWindow.h"
#include <QApplication>
int main(int argc, char *argv[]) {
QApplication application(argc, argv);
DrawingWindow drawingWindow;
drawingWindow.show();
return application.exec();
}
接收到的输出如下:
构建编辑器
下一个应用程序是一个编辑器,用户可以在其中输入和编辑文本。当前输入位置由光标指示。可以使用箭头键和鼠标点击来移动光标。
光标类
Caret
类处理光标;即标记下一个要输入的字符位置的闪烁垂直线。
Caret.h:
#ifndef CARET_H
#define CARET_H
#include <QObject>
#include <QWidget>
#include <QTimer>
class Caret : public QObject {
Q_OBJECT
public:
Caret(QWidget* parentWidgetPtr = nullptr);
show
和 hide
方法用于显示和隐藏光标。在本应用中,光标永远不会被隐藏。然而,在下一章的高级版本中,在某些情况下光标将被隐藏:
void show();
void hide();
set
方法设置光标的当前尺寸和位置,而 paint
方法将其绘制在 QPainter
对象上:
void set(QRect rect);
void paint(QPainter& painter);
每次光标闪烁时都会调用 onTimer
方法:
public slots:
void onTimer(void);
private:
QWidget* m_parentWidgetPtr;
当光标可见时,m_visible
字段为真:
bool m_visible, m_blink;
m_rect
字段处理使光标闪烁的计时器:
QRect m_rect;
m_timer
字段处理使光标闪烁的计时器:
QTimer m_timer;
};
#endif // CARET_H
Caret.cpp
文件包含 Caret
类方法的定义。
Caret.cpp:
#include "Caret.h"
#include <QPainter>
构造函数将计时器信号连接到 onTimer
,结果为每次超时都会调用 onTimer
。然后计时器初始化为 500
毫秒。也就是说,onTimer
将每 500
毫秒被调用一次,光标每 500
毫秒显示和隐藏:
Caret::Caret(QWidget* parentWidgetPtr)
:m_parentWidgetPtr(parentWidgetPtr) {
m_timer.setParent(this);
connect(&m_timer, SIGNAL(timeout()), this, SLOT(onTimer()));
m_timer.start(500);
}
show
和 hide
方法设置 m_visible
字段并通过在父窗口上调用 update
强制重绘光标区域:
void Caret::show() {
m_visible = true;
m_parentWidgetPtr->update(m_rect);
}
void Caret::hide() {
m_visible = false;
m_parentWidgetPtr->update(m_rect);
}
set
方法设置光标的尺寸和位置。然而,光标的宽度始终设置为 1,这使得它看起来像一条细长的垂直线:
void Caret::set(QRect rect) {
m_rect = rect;
m_rect.setWidth(1);
m_parentWidgetPtr->update(m_rect);
}
onTimer
方法每 500 毫秒被调用一次。它反转 m_blink
并强制重绘光标。这导致光标以一秒的间隔闪烁:
void Caret::onTimer(void) {
m_blink = !m_blink;
m_parentWidgetPtr->update(m_rect);
}
每次需要重绘光标时都会调用 paint
方法。如果 m_visible
和 m_blink
都为真,则绘制光标;如果光标被设置为可见且光标正在闪烁,即光标在闪烁间隔内可见,则它们为真。在调用 paint
之前清除光标区域,以便如果没有发生绘制,则清除光标:
void Caret::paint(QPainter& painter) {
if (m_visible && m_blink) {
painter.save();
painter.setPen(Qt::NoPen);
painter.setBrush(Qt::black);
painter.drawRect(m_rect);
painter.restore();
}
}
绘制编辑器窗口
EditorWindow
是上一节中 MainWindow
的子类。它处理窗口的关闭操作。此外,它还处理按键事件。
EditorWindow.h:
#ifndef EDITORWINDOW_H
#define EDITORWINDOW_H
#include <QMainWindow>
#include <QActionGroup>
#include <QPair>
#include <QMap>
#include "..\MainWindow\MainWindow.h"
#include "EditorWidget.h"
class EditorWindow : public MainWindow {
Q_OBJECT
public:
EditorWindow(QWidget* parentWidgetPtr = nullptr);
~EditorWindow();
每次用户按下键时都会调用 keyPressEvent
方法,当用户尝试关闭窗口时调用 closeEvent
:
protected:
void keyPressEvent(QKeyEvent* eventPtr);
void closeEvent(QCloseEvent* eventPtr);
private:
EditorWidget* m_editorWidgetPtr;
};
#endif // EDITORWINDOW_H
EditorWindow
类实际上相当小。它只定义了构造函数和析构函数,以及 keyPressEvent
和 closePressEvent
方法。
EditorWindow.cpp:
#include "EditorWindow.h"
#include <QtWidgets>
构造函数将窗口大小设置为 1000
* 500
像素,并将标准文件菜单添加到菜单栏:
EditorWindow::EditorWindow(QWidget* parentWidgetPtr /*= nullptr*/)
:MainWindow(parentWidgetPtr) {
resize(1000, 500);
m_editorWidgetPtr = new EditorWidget(this);
setCentralWidget(m_editorWidgetPtr);
addFileMenu();
}
EditorWindow::~EditorWindow() {
// Empty.
}
keyPressEvent
和 closeEvent
方法只是将消息传递给编辑器小部件中的对应方法,该小部件位于窗口中心:
void EditorWindow::keyPressEvent(QKeyEvent* eventPtr) {
m_editorWidgetPtr->keyPressEvent(eventPtr);
}
void EditorWindow::closeEvent(QCloseEvent* eventPtr) {
m_editorWidgetPtr->closeEvent(eventPtr);
}
绘制编辑器小部件
EditorWidget
类是上一节中 DocumentWidget
的子类。它捕获键、鼠标、调整大小和关闭事件。它还重写了保存和加载文档的方法。
EditorWidget.h:
#ifndef EDITORWIDGET_H
#define EDITORWIDGET_H
#include <QWidget>
#include <QMap>
#include <QMenu>
#include <QToolBar>
#include <QPair>
#include "Caret.h"
#include "..\MainWindow\DocumentWidget.h"
class EditorWidget : public DocumentWidget {
Q_OBJECT
public:
EditorWidget(QWidget* parentWidgetPtr);
当用户按下键时调用 keyPressEvent
,当用户用鼠标点击时调用 mousePressEvent
:
void keyPressEvent(QKeyEvent* eventPtr);
void mousePressEvent(QMouseEvent* eventPtr);
mouseToIndex
方法是一个辅助方法,用于计算用户用鼠标点击的字符的索引:
private:
int mouseToIndex(QPoint point);
当窗口需要重绘时调用 paintEvent
方法,当用户调整窗口大小时调用 resizeEvent
。我们在此应用程序中捕获调整大小事件,因为我们想要重新计算每行可以容纳的字符数:
public:
void paintEvent(QPaintEvent* eventPtr);
void resizeEvent(QResizeEvent* eventPtr);
与上一节中的绘图程序类似,当用户选择“新建”菜单项时调用 newDocument
,当用户选择“保存”或“另存为”项时调用 writeFile
,当用户选择“打开”项时调用 readFile
:
private:
void newDocument(void);
bool writeFile(const QString& filePath);
bool readFile(const QString& filePath);
调用 setCaret
方法以响应用户输入或鼠标点击来设置光标:
private:
void setCaret();
当用户移动光标上下时,我们需要找到光标上方或下方的字符索引。完成此操作的最简单方法是模拟鼠标点击:
void simulateMouseClick(int x, int y);
calculate
方法是一个辅助方法,用于计算行数以及每行中每个字符的位置:
private:
void calculate();
m_editIndex
字段持有用户输入文本的位置索引。该位置也是光标可见的位置:
int m_editIndex = 0;
m_caret
字段持有应用程序的光标:
Caret m_caret;
编辑器的文本存储在 m_editorText
中:
QString m_editorText;
编辑器的文本可能分布在多行;m_lineList
跟踪每行的第一个和最后一个索引:
QList<QPair<int,int>> m_lineList;
之前的 calculate
方法计算编辑器文本中每个字符的矩形,并将它们放置在 m_rectList
中:
QList<QRect> m_rectList;
在本章的应用中,所有字符都使用相同的字体,该字体存储在 TextFont
中:
static const QFont TextFont;
FontWidth
和 FontHeight
持有 TextFont
中字符的宽度和高度:
int FontWidth, FontHeight;
};
#endif // EDITORWIDGET_H
EditorWidget
类相当大。它定义了编辑器的功能。
EditorWidget.cpp:
#include "EditorWidget.h"
#include <QtWidgets>
using namespace std;
我们将文本字体初始化为 12 点的 Courier New
:
const QFont EditorWidget::TextFont("Courier New", 12);
构造函数将标题设置为 Editor
,并将标准加载和保存对话框的文件后缀设置为 edi
。使用 Qt QMetrics
类设置文本字体中字符的高度和平均宽度(以像素为单位)。计算每个字符的矩形,并将光标设置为文本中的第一个字符:
EditorWidget::EditorWidget(QWidget* parentWidgetPtr)
:DocumentWidget(tr("Editor"), tr("Editor files (*.edi)"),
parentWidgetPtr),
m_caret(this),
m_editorText(tr("Hello World")) {
QFontMetrics metrics(TextFont);
FontHeight = metrics.height();
FontWidth = metrics.averageCharWidth();
calculate();
setCaret();
m_caret.show();
}
当用户选择新菜单项时,会调用newDocument
方法。它会清除文本,设置光标,并重新计算字符矩形:
void EditorWidget::newDocument(void) {
m_editIndex = 0;
m_editorText.clear();
calculate();
setCaret();
}
当用户选择保存或另存为菜单项时,会调用writeFile
方法。它简单地写入编辑器的当前文本:
bool EditorWidget::writeFile(const QString& filePath) {
QFile file(filePath);
if (file.open(QIODevice::WriteOnly | QIODevice::Text)) {
QTextStream outStream(&file);
outStream << m_editorText;
我们使用输入流的Ok
字段来决定写入是否成功:
return ((bool) outStream.Ok);
}
如果无法打开文件进行写入,则返回false
:
return false;
}
当用户选择加载菜单项时,会调用readFile
方法。它通过在输入流上调用readAll
来读取编辑器的所有文本:
bool EditorWidget::readFile(const QString& filePath) {
QFile file(filePath);
if (file.open(QIODevice::ReadOnly | QIODevice::Text)) {
QTextStream inStream(&file);
m_editorText = inStream.readAll();
当文本被读取后,计算字符矩形,并设置光标:
calculate();
setCaret();
我们使用输入流的Ok
字段来决定读取是否成功:
return ((bool) inStream.Ok);
}
如果无法打开文件进行读取,则返回false
:
return false;
}
当用户按下鼠标按钮之一时,会调用mousePressEvent
。如果用户按下左键,我们调用mouseToIndex
来计算点击的字符索引,并将光标设置到该索引:
void EditorWidget::mousePressEvent(QMouseEvent* eventPtr) {
if (eventPtr->buttons() == Qt::LeftButton) {
m_editIndex = mouseToIndex(eventPtr->pos());
setCaret();
}
}
当用户按下键时,会调用keyPressEvent
。首先,我们检查它是否是箭头键、删除键、退格键或回车键。如果不是,我们将在光标指示的位置插入字符:
void EditorWidget::keyPressEvent(QKeyEvent* eventPtr) {
switch (eventPtr->key()) {
如果键是向左箭头键,并且如果编辑光标尚未位于文本开头,我们减少编辑索引:
case Qt::Key_Left:
if (m_editIndex > 0) {
--m_editIndex;
}
break;
如果键是向右箭头键,并且如果编辑光标尚未位于文本末尾,我们增加编辑索引:
case Qt::Key_Right:
if (m_editIndex < m_editorText.size()) {
++m_editIndex;
}
break;
如果键是向上箭头键,并且如果编辑光标尚未位于编辑器的顶部,我们调用similateMouseClick
来模拟用户在当前索引稍上方点击鼠标。这样,新的编辑索引将位于当前行的上方行:
case Qt::Key_Up: {
QRect charRect = m_rectList[m_editIndex];
if (charRect.top() > 0) {
int x = charRect.left() + (charRect.width() / 2),
y = charRect.top() - 1;
simulateMouseClick(x, y);
}
}
break;
如果键是向下箭头键,我们调用similateMouseClick
来模拟用户在当前索引稍下方点击鼠标。这样,编辑光标将位于当前字符直接下方的字符。注意,如果索引已经在底部行,则不会发生任何操作:
case Qt::Key_Down: {
QRect charRect = m_rectList[m_editIndex];
int x = charRect.left() + (charRect.width() / 2),
y = charRect.bottom() + 1;
simulateMouseClick(x, y);
}
break;
如果用户按下删除键,并且编辑索引尚未超出文本末尾,则移除当前字符:
case Qt::Key_Delete:
if (m_editIndex < m_editorText.size()) {
m_editorText.remove(m_editIndex, 1);
setModifiedFlag(true);
}
break;
如果用户按下退格键,并且编辑索引尚未位于文本开头,则移除当前字符之前的字符:
case Qt::Key_Backspace:
if (m_editIndex > 0) {
m_editorText.remove(--m_editIndex, 1);
setModifiedFlag(true);
}
break;
如果用户按下回车键,则插入换行字符(n
):
case Qt::Key_Return:
m_editorText.insert(m_editIndex++, 'n');
setModifiedFlag(true);
break;
如果用户按下可读字符,它由text
方法提供,我们将它的第一个字符插入到编辑索引处:
default: {
QString text = eventPtr->text();
if (!text.isEmpty()) {
m_editorText.insert(m_editIndex++, text[0]);
setModifiedFlag(true);
}
}
break;
}
当文本被修改后,我们需要计算字符矩形,设置光标,并通过调用update
强制重绘:
calculate();
setCaret();
update();
}
similateMouseClick
方法通过调用mousePressEvent
和mousePressRelease
以及给定的点来模拟鼠标点击:
void EditorWidget::simulateMouseClick(int x, int y) {
QMouseEvent pressEvent(QEvent::MouseButtonPress, QPointF(x, y),
Qt::LeftButton, Qt::NoButton, Qt::NoModifier);
mousePressEvent(&pressEvent);
QMouseEvent releaseEvent(QEvent::MouseButtonRelease,
QPointF(x, y), Qt::LeftButton,
Qt::NoButton, Qt::NoModifier);
mousePressEvent(&releaseEvent);
}
setCaret
方法创建一个包含光标大小和位置的矩形,然后隐藏、设置并显示光标:
void EditorWidget::setCaret() {
QRect charRect = m_rectList[m_editIndex];
QRect caretRect(charRect.left(), charRect.top(),
1, charRect.height());
m_caret.hide();
m_caret.set(caretRect);
m_caret.show();
}
mouseToIndex
方法计算给定鼠标点的编辑索引:
int EditorWidget::mouseToIndex(QPoint mousePoint) {
int x = mousePoint.x(), y = mousePoint.y();
首先,我们将 y
坐标设置为文本,以防它在文本下方:
if (y > (FontHeight * m_lineList.size())) {
y = ((FontHeight * m_lineList.size()) - 1);
}
我们计算鼠标点的行:
int lineIndex = y / FontHeight;
QPair<int,int> lineInfo = m_lineList[lineIndex];
int firstIndex = lineInfo.first, lastIndex = lineInfo.second;
我们在该行找到索引:
if (x > ((lastIndex - firstIndex + 1) * FontWidth)) {
return (lineIndex == (m_lineList.size() - 1))
? (lineInfo.second + 1) : lineInfo.second;
}
else {
return firstIndex + (x / FontWidth);
}
return 0;
}
当用户更改窗口大小时,会调用 resizeEvent
方法。由于线条可能变短或变长,因此会重新计算字符矩形:
void EditorWidget::resizeEvent(QResizeEvent* eventPtr) {
calculate();
DocumentWidget::resizeEvent(eventPtr);
}
每当文本发生变化或窗口大小发生变化时,都会调用 calculate
方法。它会遍历文本并为每个字符计算矩形:
void EditorWidget::calculate() {
m_lineList.clear();
m_rectList.clear();
int windowWidth = width();
首先,我们需要将文本分成行。每行继续直到它不适合窗口,直到我们达到一个新行,或者直到文本结束:
{ int firstIndex = 0, lineWidth = 0;
for (int charIndex = 0; charIndex < m_editorText.size();
++charIndex) {
QChar c = m_editorText[charIndex];
if (c == 'n') {
m_lineList.push_back
(QPair<int,int>(firstIndex, charIndex));
firstIndex = charIndex + 1;
lineWidth = 0;
}
else {
if ((lineWidth + FontWidth) > windowWidth) {
if (firstIndex == charIndex) {
m_lineList.push_back
(QPair<int,int>(firstIndex, charIndex));
firstIndex = charIndex + 1;
}
else {
m_lineList.push_back(QPair<int,int>(firstIndex,
charIndex - 1));
firstIndex = charIndex;
}
lineWidth = 0;
}
else {
lineWidth += FontWidth;
}
}
}
m_lineList.push_back(QPair<int,int>(firstIndex,
m_editorText.size() - 1));
}
然后,我们遍历这些行,并对每一行计算每个字符的矩形:
{ int top = 0;
for (int lineIndex = 0; lineIndex < m_lineList.size();
++lineIndex) {
QPair<int,int> lineInfo = m_lineList[lineIndex];
int firstIndex = lineInfo.first,
lastIndex = lineInfo.second, left = 0;
for (int charIndex = firstIndex;
charIndex <= lastIndex; ++charIndex){
QRect charRect(left, top, FontWidth, FontHeight);
m_rectList.push_back(charRect);
left += FontWidth;
}
if (lastIndex == (m_editorText.size() - 1)) {
QRect lastRect(left, top, 1, FontHeight);
m_rectList.push_back(lastRect);
}
top += FontHeight;
}
}
}
当窗口需要重绘时,会调用 paintEvent
方法:
void EditorWidget::paintEvent(QPaintEvent* /*eventPtr*/) {
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
painter.setRenderHint(QPainter::TextAntialiasing);
painter.setFont(TextFont);
painter.setPen(Qt::black);
painter.setBrush(Qt::white);
我们遍历编辑器的文本,并对每个字符(除了换行符)在其适当的位置写入:
for (int index = 0; index < m_editorText.length(); ++index) {
QChar c = m_editorText[index];
if (c != 'n') {
QRect rect = m_rectList[index];
painter.drawText(rect, c);
}
}
m_caret.paint(painter);
}
main
函数
最后,main
函数的工作方式与本章之前的应用类似——我们创建一个应用程序,创建一个编辑窗口,并执行该应用程序。
Main.cpp:
#include "EditorWindow.h"
#include <QApplication>
int main(int argc, char *argv[]) {
QApplication application(argc, argv);
EditorWindow editorWindow;
editorWindow.show();
return application.exec();
}
得到以下输出:
概述
在本章中,我们使用 Qt 库开发了三个图形应用程序——一个模拟时钟、一个绘图程序和一个编辑器。时钟显示当前的小时、分钟和秒。在绘图程序中,我们可以绘制线条、矩形和椭圆,在编辑器中,我们可以输入和编辑文本。
在下一章中,我们将继续与这些应用程序一起工作,并开发更高级的版本。
第六章:提升 Qt 图形应用程序
在第五章,Qt 图形应用程序中,我们开发了包含模拟时钟、绘图程序和编辑器的图形 Qt 应用程序。在本章中,我们将继续对第五章中提到的三个图形应用程序进行工作,即第五章,Qt 图形应用程序。然而,我们将进行以下改进:
-
时钟: 我们将向时钟表盘添加数字
-
绘图程序: 我们将添加移动和修改图形、剪切和粘贴它们以及标记一个或多个图形的能力
-
编辑器: 我们将添加更改字体和对齐方式以及标记文本块的能力
在本章中,我们将继续使用 Qt 库:
-
窗口和小部件
-
菜单和工具栏
-
鼠标和键盘事件
改进时钟
在本章中,我们将替换时钟表盘标记的版本,使用数字。
Clock
类
Clock
类的定义与第五章,Qt 图形应用程序中的定义类似。计时器每秒更新窗口 10 次。构造函数初始化时钟,每当窗口需要重绘时,都会调用paintEvent
。
Clock.h:
#ifndef CLOCK_H
#define CLOCK_H
#include <QWidget>
#include <QTimer>
class Clock : public QWidget {
Q_OBJECT
public:
Clock(QWidget *parentWidget = nullptr);
void paintEvent(QPaintEvent *eventPtr);
private:
QTimer m_timer;
};
#endif // CLOCK_H
Clock.cpp:
#include <QtWidgets>
#include "Clock.h"
与第五章,Qt 图形应用程序类似,构造函数将窗口标题设置为Clock Advanced
,窗口大小设置为1000 x 500像素,初始化计时器以每100
毫秒发送一个超时消息,并将timeout
消息连接到update
方法,这将强制窗口在每次超时时重绘:
Clock::Clock(QWidget *parentWidget /*= nullptr*/)
:QWidget(parentWidget) {
setWindowTitle(tr("Clock Advanced"));
resize(1000, 500);
m_timer.setParent(this);
connect(&m_timer, SIGNAL(timeout()), this, SLOT(update()));
m_timer.start(100);
}
每当窗口需要重绘时,都会调用paintEvent
方法。我们首先计算时钟的边长并获取当前时间:
void Clock::paintEvent(QPaintEvent* /*event*/) {
int side = qMin(width(), height());
QTime time = QTime::currentTime();
我们随后创建并初始化一个QPainter
对象。我们调用translate
和scale
来匹配物理大小(像素)与逻辑大小(200 x 200单位):
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
painter.setRenderHint(QPainter::TextAntialiasing);
painter.translate(width() / 2, height() / 2);
painter.scale(side / 200.0, side / 200.0);
在本章的这个版本中,我们向画家添加了Times New Roman
字体,12
点,以写入时钟的数字:
painter.setFont(QFont(tr("Times New Roman"), 12));
我们将时钟的数字1
到12
写入,如下所示:
for (int hour = 1; hour <= 12; ++hour) {
QString text;
text.setNum(hour);
一个完整的跳跃是 360°,两个连续数字之间的角度是 30°,因为 360 除以 12 等于 30:
double angle = (30.0 * hour) - 90;
double radius = 90.0;
数字x
和y
坐标通过正弦和余弦函数计算得出。然而,首先我们需要将度数转换为弧度,因为正弦和余弦函数只接受弧度。以下代码展示了这一过程:
double x = radius * qCos(qDegreesToRadians(angle)),
y = radius * qSin(qDegreesToRadians(angle));
drawText
方法将数字写入,如下所示:
QRect rect(x - 100, y - 100, 200, 200);
painter.drawText(rect, Qt::AlignHCenter |
Qt::AlignVCenter, text);
}
当数字被写入后,我们将以与第五章,Qt 图形应用程序中相同的方式绘制hour
、minute
和second
指针:
double hours = time.hour(), minutes = time.minute(),
seconds = time.second(), milliseconds = time.msec();
painter.setPen(Qt::black);
painter.setBrush(Qt::gray);
{ static const QPoint hourHand[3] =
{QPoint(8, 8), QPoint(-8, 8), QPoint(0, -60)};
painter.save();
double hour = hours + (minutes / 60.0) + (seconds / 3600.0) +
(milliseconds / 3600000.0);
painter.rotate(30.0 * hour);
painter.drawConvexPolygon(hourHand, 3);
painter.restore();
}
{ static const QPoint minuteHand[3] =
{QPoint(6, 8), QPoint(-6, 8), QPoint(0, -70)};
painter.save();
double minute = minutes + (seconds / 60.0) +
(milliseconds / 60000.0);
painter.rotate(6.0 * minute);
painter.drawConvexPolygon(minuteHand, 3);
painter.restore();
}
{ static const QPoint secondHand[3] =
{QPoint(4, 8), QPoint(-4, 8), QPoint(0, -80)};
painter.save();
double second = seconds + (milliseconds / 1000);
painter.rotate(6.0 * second);
painter.drawConvexPolygon(secondHand, 3);
painter.restore();
}
}
主要功能
main
函数与 第五章 中的类似,Qt 图形应用程序。它创建一个应用程序对象,初始化时钟,并执行应用程序。
Main.cpp:
#include <QApplication>
#include "Clock.h"
int main(int argc, char *argv[]) {
QApplication application(argc, argv);
Clock Clock;
Clock.show();
return application.exec();
}
输出:
提高绘图程序
本章的绘图程序是 第五章 中 Qt 图形应用程序 绘图程序的更高级版本。在这个版本中,可以修改图形,包围一个或多个图形然后改变它们的颜色,以及剪切和粘贴图形。
图形类
Figure
类与 第五章 中的类似,Qt 图形应用程序。然而,增加了 isInside
、doubleClick
、modify
和 marked
。
Figure.h:
#ifndef FIGURE_H
#define FIGURE_H
enum FigureId {LineId, RectangleId, EllipseId};
#include <QtWidgets>
#include <FStream>
using namespace std;
class Figure {
public:
Figure();
在这个版本中,增加了纯虚 clone
方法。这是由于剪切和粘贴。当粘贴图形时,我们希望创建它的一个副本,而不必实际知道该对象属于哪个类。我们只能通过复制构造函数来做这件事。这实际上是本节的主要点:如何使用纯虚方法和如何利用动态绑定。我们需要 clone
,它调用其类的复制构造函数以返回新对象的指针:
virtual Figure* clone() const = 0;
virtual FigureId getId() const = 0;
virtual void initializePoints(QPoint point) = 0;
在这个版本的绘图程序中,onClick
设置字段以指示图形是否应该被修改或移动。如果用户抓住图形的标记点之一(不同类型的图形之间有所不同),则修改图形。否则,应移动图形。当用户抓住图形的一个角时调用 modify
方法。在这种情况下,应修改图形而不是移动它:
virtual bool isClick(QPoint mousePoint) = 0;
virtual void modify(QSize distance) = 0;
isInside
方法返回 true
如果图形完全包含在区域内。当用户用鼠标包围图形时调用:
virtual bool isInside(QRect area) = 0;
当用户在图形上双击时调用 doubleClick
方法,每个图形执行一些合适的操作:
virtual void doubleClick(QPoint mousePoint) = 0;
virtual void move(QSize distance) = 0;
virtual void draw(QPainter &painter) const = 0;
virtual bool write(ofstream& outStream) const;
virtual bool read(ifstream& inStream);
marked
方法返回和设置 m_marked
字段。当一个图形被标记时,它会被小方块注释:
bool marked() const {return m_marked;}
bool& marked() {return m_marked;}
const QColor& color() const {return m_color;}
QColor& color() {return m_color;}
virtual bool filled() const {return m_filled;}
virtual bool& filled() {return m_filled;}
static const int Tolerance;
private:
QColor m_color;
bool m_marked = false, m_filled = false;
};
#endif
Figure.cpp:
#include "..\MainWindow\DocumentWidget.h"
#include "Figure.h"
const int Figure::Tolerance(6);
Figure::Figure() {
// Empty.
}
write
和 read
方法写入和读取图形的颜色以及它是否被填充。然而,它们不写入或读取标记状态。图形在写入或读取时总是未标记的:
bool Figure::write(ofstream& outStream) const {
writeColor(outStream, m_color);
outStream.write((char*) &m_filled, sizeof m_filled);
return ((bool) outStream);
}
bool Figure::read(ifstream& inStream) {
readColor(inStream, m_color);
inStream.read((char*) &m_filled, sizeof m_filled);
return ((bool) inStream);
}
线类
Line
类是 Figure
的子类。
Line.h:
#ifndef LINE_H
#define LINE_H
#include <FStream>
using namespace std;
#include "Figure.h"
class Line : public Figure {
public:
Line();
FigureId getId() const {return LineId;}
In addition to the
Line(const Line& line);
Figure* clone() const;
void initializePoints(QPoint point);
如前文 Figure
部分所述,isClick
决定线是否应该被修改或移动。如果用户抓住其端点之一,则仅移动该端点。如果用户抓住端点之间的线,则移动整条线。也就是说,线的两个端点都将移动:
bool isClick(QPoint mousePoint);
isInside
方法检查线是否完全被区域包围:
bool isInside(QRect area);
在 Line
类中,doubleClick
方法不执行任何操作。然而,我们仍然需要定义它,因为它是 Figure
中的纯虚函数。如果我们没有定义它,Line
将会是抽象的:
void doubleClick(QPoint /* mousePoint */) {/* Empty. */}
modify
方法根据前一个 isClick
的设置修改线。如果用户抓取了一个端点,则该端点被移动。否则,整个线(包括两个端点)被移动:
void modify(QSize distance);
void move(QSize distance);
如果线被标记,area
方法返回一个稍微大一点的区域,以便包括标记的正方形:
QRect area() const;
void draw(QPainter& painter) const;
bool write(ofstream& outStream) const;
bool read(ifstream& inStream);
m_lineMode
字段跟踪线的移动或修改。当线被创建时,m_lineMode
被设置为 LastPoint
。当用户抓取线的第一个或最后一个端点时,m_lineMode
被设置为 FirstPoint
或 LastPoint
。当用户抓取端点之间的线时,m_lineMode
被设置为 MoveLine
:
private:
enum {FirstPoint, LastPoint, MoveLine} m_lineMode;
QPoint m_firstPoint, m_lastPoint;
isPointInLine
方法决定用户是否点击了线,并有一定的容差:
static bool isPointInLine(QPoint m_firstPoint,
QPoint m_lastPoint, QPoint point);
};
#endif
Line.cpp:
#include "..\MainWindow\DocumentWidget.h"
#include "Line.h"
当一条线被创建时,线模式被设置为最后一个点。这意味着当用户移动鼠标时,线的最后一个点将会改变:
Line::Line()
:m_lineMode(LastPoint) {
// Empty.
}
当粘贴线时,会调用 clone
方法。调用 Figure
的拷贝构造函数来设置图形的颜色。请注意,我们使用 Line
对象作为参数调用 Figure
构造函数,尽管它接受一个 Figure
对象的引用作为参数。我们允许这样做,因为 Line
是 Figure
的子类,并且在调用过程中 Line
对象将被转换成 Figure
对象。此外,第一个和最后一个端点被复制。请注意,我们确实需要复制 m_lineMode
的值,因为它的值是在用户创建、修改或移动线时设置的:
Line::Line(const Line& line)
:Figure(line),
m_firstPoint(line.m_firstPoint),
m_lastPoint(line.m_lastPoint) {
// Empty.
}
clone
方法使用拷贝构造函数来创建一个新的对象,然后返回:
Figure* Line::clone() const {
Line* linePtr = new Line(*this);
return linePtr;
}
在线被创建后不久,会调用 initializePoints
方法。调用这个方法的原因是我们没有直接创建 Line
对象。相反,我们通过调用 clone
间接创建线。然后我们需要通过调用 initializePoints
来初始化端点:
void Line::initializePoints(QPoint point) {
m_firstPoint = point;
m_lastPoint = point;
}
当用户用鼠标点击时,会调用 isClick
方法。首先,我们检查他们是否点击了第一个端点。我们使用 Tolerance
字段创建一个以第一个端点为中心的小正方形。如果用户点击了这个正方形,m_lineMode
被设置为 FirstPoint
并返回 true
:
bool Line::isClick(QPoint mousePoint) {
QRect firstSquare(makeRect(m_firstPoint, Tolerance));
if (firstSquare.contains(mousePoint)) {
m_lineMode = FirstPoint;
return true;
}
同样地,我们在最后一个端点的中心创建一个小正方形。如果用户点击这个正方形,m_lineMode
被设置为 LastPoint
并返回 true
:
QRect lastSquare(makeRect(m_lastPoint, Tolerance));
if (lastSquare.contains(mousePoint)) {
m_lineMode = LastPoint;
return true;
}
如果用户没有点击任一端点,我们将检查他们是否点击了线本身。如果他们点击了,m_lineMode
被设置为 ModeLine
并返回 true
:
if (isPointInLine(m_firstPoint, m_lastPoint, mousePoint)) {
m_lineMode = MoveLine;
return true;
}
最后,如果用户没有点击线的一个端点或线本身,他们完全错过了线,并返回 false
:
return false;
}
isInside
方法如果线完全被区域包围则返回 true
。这相当简单,我们只需检查两个端点是否位于区域内:
bool Line::isInside(QRect area) {
return area.contains(m_firstPoint) &&
area.contains(m_lastPoint);
}
isPointInLine
方法与 第五章 的版本,Qt 图形应用程序 中的 isClick
方法相同:
bool Line::isPointInLine(QPoint m_firstPoint, QPoint m_lastPoint,
QPoint point) {
if (m_firstPoint.x() == m_lastPoint.x()) {
QRect lineRect(m_firstPoint, m_lastPoint);
lineRect.normalized();
lineRect += Tolerance;
return lineRect.contains(point);
}
else {
QPoint leftPoint = (m_firstPoint.x() < m_lastPoint.x())
? m_firstPoint : m_lastPoint,
rightPoint = (m_firstPoint.x() < m_lastPoint.x())
? m_lastPoint : m_firstPoint;
if ((leftPoint.x() <= point.x()) &&
(point.x() <= rightPoint.x())) {
int lineWidth = rightPoint.x() - leftPoint.x(),
lineHeight = rightPoint.y() - leftPoint.y();
int diffWidth = point.x() - leftPoint.x(),
diffHeight = point.y() - leftPoint.y();
double delta = fabs(diffHeight -
(diffWidth * ((double) lineHeight) / lineWidth));
return (delta <= Tolerance);
}
return false;
}
}
modify
方法根据前面 isClick
方法中 m_lineMode
的设置移动第一个或最后一个端点,或两者都移动:
void Line::modify(QSize distance) {
switch (m_lineMode) {
case FirstPoint:
m_firstPoint += distance;
break;
case LastPoint:
m_lastPoint += distance;
break;
case MoveLine:
move(distance);
break;
}
}
move
方法简单地移动线的两个端点:
void Line::move(QSize distance) {
m_firstPoint += distance;
m_lastPoint += distance;
}
draw
方法绘制线。与 第五章 的版本,Qt 图形应用程序 相比,它还绘制了如果被标记的线端点的正方形:
void Line::draw(QPainter& painter) const {
painter.setPen(color());
painter.drawLine(m_firstPoint, m_lastPoint);
if (marked()) {
painter.fillRect(makeRect(m_firstPoint, Tolerance),
Qt::black);
painter.fillRect(makeRect(m_lastPoint, Tolerance),
Qt::black);
}
}
area
方法返回覆盖线的区域。如果线被标记,区域会略微扩展以覆盖标记端点的正方形:
QRect Line::area() const {
QRect lineArea(m_firstPoint, m_lastPoint);
lineArea.normalized();
if (marked()) {
lineArea += Tolerance;
}
return lineArea;
}
与 第五章 的版本类似,Qt 图形应用程序,write
和 read
调用 Figure
中的对应方法,然后写入和读取线的两个端点:
bool Line::write(ofstream& outStream) const {
Figure::write(outStream);
writePoint(outStream, m_firstPoint);
writePoint(outStream, m_lastPoint);
return ((bool) outStream);
}
bool Line::read(ifstream& inStream) {
Figure::read(inStream);
readPoint(inStream, m_firstPoint);
readPoint(inStream, m_lastPoint);
return ((bool) inStream);
}
矩形类
RectangleX
是 Figure
的子类。它是 第五章 的扩展版本,Qt 图形应用程序。isClick
方法已被修改,增加了 doubleClick
和 modify
:
Rectangle.h:
#ifndef RECTANGLE_H
#define RECTANGLE_H
#include <FStream>
using namespace std;
#include "Figure.h"
class RectangleX : public Figure {
public:
RectangleX();
virtual FigureId getId() const {return RectangleId;}
RectangleX(const RectangleX& rectangle);
Figure* clone() const;
virtual void initializePoints(QPoint point);
virtual bool isClick(QPoint mousePoint);
virtual void modify(QSize distance);
virtual bool isInside(QRect area);
virtual void doubleClick(QPoint mousePoint);
virtual void move(QSize distance);
virtual QRect area() const;
virtual void draw(QPainter& painter) const;
virtual bool write(ofstream& outStream) const;
virtual bool read(ifstream& inStream);
private:
enum {TopLeftPoint, TopRightPoint, BottomRightPoint,
BottomLeftPoint, MoveRectangle} m_rectangleMode;
protected:
QPoint m_topLeft, m_bottomRight;
};
#endif
Rectangle.cpp:
#include <CAssert>
#include "..\MainWindow\DocumentWidget.h"
#include "Rectangle.h"
当用户添加矩形时,其模式是 BottomRightPoint
。这意味着当用户移动鼠标时,矩形的右下角将被移动:
RectangleX::RectangleX()
:m_rectangleMode(BottomRightPoint) {
// Empty.
}
复制构造函数复制矩形。更具体地说,首先它调用 Figure
类的复制构造函数,然后复制左上角和右下角。请注意,它不会复制 m_rectangleMode
字段,因为它仅在用户移动鼠标时使用:
RectangleX::RectangleX(const RectangleX& rectangle)
:Figure(rectangle),
m_topLeft(rectangle.m_topLeft),
m_bottomRight(rectangle.m_bottomRight) {
// Empty.
}
clone
方法通过调用复制构造函数创建并返回一个指向新对象的指针:
Figure* RectangleX::clone() const {
RectangleX* rectanglePtr = new RectangleX(*this);
return rectanglePtr;
}
void RectangleX::initializePoints(QPoint point) {
m_topLeft = point;
m_bottomRight = point;
}
当用户用鼠标点击时调用 isClick
方法。与前面的布尔 Line
类似,我们首先检查他们是否点击了任何角落。如果没有,我们检查他们是否点击了矩形边框或矩形内部,这取决于它是否被填充:
我们首先定义一个覆盖左上角的小正方形。如果用户点击它,我们将 m_rectangleMode
字段设置为 TopLeftPoint
并返回 true
:
bool RectangleX::isClick(QPoint mousePoint) {
QRect topLeftRect(makeRect(m_topLeft, Tolerance));
if (topLeftRect.contains(mousePoint)) {
m_rectangleMode = TopLeftPoint;
return true;
}
我们继续定义一个覆盖右上角的正方形。如果用户点击它,我们将 m_rectangleMode
设置为 TopRightPoint
并返回 true
:
QPoint topRightPoint(m_bottomRight.x(), m_topLeft.y());
QRect topRectRight(makeRect(topRightPoint, Tolerance));
if (topRectRight.contains(mousePoint)) {
m_rectangleMode = TopRightPoint;
return true;
}
如果用户点击在覆盖右下角的正方形上,我们将 m_rectangleMode
设置为 BottomRightPoint
并返回 true
:
QRect m_bottomRightRect(makeRect(m_bottomRight, Tolerance));
if (m_bottomRightRect.contains(mousePoint)) {
m_rectangleMode = BottomRightPoint;
return true;
}
如果用户点击在覆盖左下角的正方形上,我们将 m_rectangleMode
设置为 BottomLeftPoint
并返回 true
:
QPoint bottomLeftPoint(m_topLeft.x(), m_bottomRight.y());
QRect bottomLeftRect(makeRect(bottomLeftPoint, Tolerance));
if (bottomLeftRect.contains(mousePoint)) {
m_rectangleMode = BottomLeftPoint;
return true;
}
如果用户没有点击在矩形的任何一个角落,我们检查矩形本身。如果它是填充的,我们检查鼠标指针是否位于矩形本身内部。如果是,我们将 m_rectangleMode
设置为 MoveRectangle
并返回 true
:
QRect areaRect(m_topLeft, m_bottomRight);
if (filled()) {
if (areaRect.contains(mousePoint)) {
m_rectangleMode = MoveRectangle;
return true;
}
}
如果矩形没有被填充,我们定义稍微大一些和稍微小一些的矩形。如果鼠标点击位于较大的矩形内部,但不在较小的矩形内,我们将 m_rectangleMode
设置为 MoveRectangle
并返回 true
:
else {
QRect largeAreaRect(areaRect), smallAreaRect(areaRect);
largeAreaRect += Tolerance;
smallAreaRect -= Tolerance;
if (largeAreaRect.contains(mousePoint) &&
!smallAreaRect.contains(mousePoint)) {
m_rectangleMode = MoveRectangle;
return true;
}
}
最后,如果用户没有点击在任何一个角落或矩形本身,他们错过了矩形,我们返回 false
:
return false;
}
isInside
方法相当简单。我们只需检查左上角和右下角是否位于矩形内部:
bool RectangleX::isInside(QRect area) {
return area.contains(m_topLeft) &&
area.contains(m_bottomRight);
}
当用户用鼠标双击时,会调用 doubleClick
方法。如果 onClick
的调用返回 true
,则调用 doubleClick
。在矩形的情况下,填充状态会改变——填充的矩形变为未填充,未填充的矩形变为填充:
void RectangleX::doubleClick(QPoint mousePoint) {
if (isClick(mousePoint)) {
第一次调用 filled
是调用返回对 m_filled
字段引用的版本,这允许我们更改返回的值:
filled() = !filled();
}
}
modify
方法根据前一个 isClick
中设置的 m_rectangleMode
字段来修改矩形。如果它设置为四个角落之一,我们修改那个角落。如果不是,我们移动整个矩形:
void RectangleX::modify(QSize distance) {
switch (m_rectangleMode) {
case TopLeftPoint:
m_topLeft += distance;
break;
case TopRightPoint:
m_topLeft.setY(m_topLeft.y() + distance.height());
m_bottomRight.setX(m_bottomRight.x() + distance.width());
break;
case BottomRightPoint:
m_bottomRight += distance;
break;
case BottomLeftPoint:
m_topLeft.setX(m_topLeft.x() + distance.width());
m_bottomRight.setY(m_bottomRight.y() + distance.height());
break;
case MoveRectangle:
move(distance);
break;
}
}
move
方法相当简单。它只是改变左上角和右下角:
void RectangleX::move(QSize distance) {
m_topLeft += distance;
m_bottomRight += distance;
}
area
方法返回覆盖矩形的面积。如果它被标记,我们稍微扩大面积,以便它覆盖标记的正方形:
QRect RectangleX::area() const {
QRect areaRect(m_topLeft, m_bottomRight);
areaRect.normalized();
if (marked()) {
areaRect += Tolerance;
}
return areaRect;
}
draw
方法绘制矩形;使用全画笔时填充,使用空心画笔时未填充:
void RectangleX::draw(QPainter& painter) const {
painter.setPen(color());
if (filled()) {
painter.fillRect(QRect(m_topLeft, m_bottomRight), color());
}
else {
painter.setBrush(Qt::NoBrush);
painter.drawRect(QRect(m_topLeft, m_bottomRight));
}
如果矩形被标记,覆盖矩形四个角落的四个正方形也会被绘制:
if (marked()) {
painter.fillRect(makeRect(m_topLeft, Tolerance), Qt::black);
QPoint topRight(m_bottomRight.x(), m_topLeft.y());
painter.fillRect(makeRect(topRight, Tolerance), Qt::black);
painter.fillRect(makeRect(m_bottomRight, Tolerance),
Qt::black);
QPoint bottomLeft(m_topLeft.x(), m_bottomRight.y());
painter.fillRect(makeRect(bottomLeft, Tolerance), Qt::black);
}
}
write
和 read
方法首先调用 Figure
中的对应方法来写入和读取矩形的颜色。然后写入和读取左上角和右下角:
bool RectangleX::write(ofstream& outStream) const {
Figure::write(outStream);
writePoint(outStream, m_topLeft);
writePoint(outStream, m_bottomRight);
return ((bool) outStream);
}
bool RectangleX::read (ifstream& inStream) {
Figure::read(inStream);
readPoint(inStream, m_topLeft);
readPoint(inStream, m_bottomRight);
return ((bool) inStream);
}
Ellipse 类
EllipseX
是 RectangleX
的直接子类,也是 Figure
的间接子类,它绘制一个填充或未填充的椭圆:
EllipseX.h:
#ifndef ELLIPSE_H
#define ELLIPSE_H
#include "Rectangle.h"
class EllipseX : public RectangleX {
public:
EllipseX();
FigureId getId() const {return EllipseId;}
EllipseX(const EllipseX& ellipse);
Figure* clone() const;
与前面的矩形情况类似,isClick
检查用户是否在椭圆的四个角落之一抓取椭圆,或者椭圆本身是否应该移动:
bool isClick(QPoint mousePoint);
modify
方法根据前一个 isClick
中设置的 m_ellipseMode
的设置来修改椭圆:
void modify(QSize distance);
void draw(QPainter& painter) const;
与前面的矩形可以通过其四个角抓取不同,椭圆可以通过其左、上、右和底部点抓取。因此,我们需要添加 CreateEllipse
枚举值,它修改覆盖椭圆的区域右下角:
private:
enum {CreateEllipse, LeftPoint, TopPoint, RightPoint,
BottomPoint, MoveEllipse} m_ellipseMode;
};
#endif
EllipseX.cpp:
#include <CAssert>
#include "..\MainWindow\DocumentWidget.h"
#include "Ellipse.h"
与前面的行和矩形情况相比,我们将 m_ellipseMode
字段设置为 CreateEllipse
,这在创建椭圆时是有效的:
EllipseX::EllipseX()
:m_ellipseMode(CreateEllipse) {
// Empty.
}
复制构造函数不需要设置 m_topLeft
和 m_bottomRight
字段,因为这是由 RectangleX
的复制构造函数处理的,而 RectangleX
的复制构造函数是由 EllipseX
的复制构造函数调用的:
EllipseX::EllipseX(const EllipseX& ellipse)
:RectangleX(ellipse) {
// Empty.
}
Figure* EllipseX::clone() const {
EllipseX* ellipsePtr = new EllipseX(*this);
return ellipsePtr;
}
与前面的矩形情况类似,isClick
检查用户是否通过椭圆的四个点之一抓取椭圆。然而,在椭圆的情况下,我们不检查矩形的角。相反,我们检查椭圆的左、上、右和底部位置。我们为这些位置中的每一个创建一个小正方形,并检查用户是否点击了它们。如果点击了,我们将 m_ellipseMode
字段设置为适当的值并返回 true
:
bool EllipseX::isClick(QPoint mousePoint) {
QPoint leftPoint(m_topLeft.x(),
(m_topLeft.y() + m_bottomRight.y()) / 2);
QRect leftRect(makeRect(leftPoint, Tolerance));
if (leftRect.contains(mousePoint)) {
m_ellipseMode = LeftPoint;
return true;
}
QPoint topPoint((m_topLeft.x() + m_bottomRight.x()) / 2,
m_topLeft.y());
QRect topRect(makeRect(topPoint, Tolerance));
if (topRect.contains(mousePoint)) {
m_ellipseMode = TopPoint;
return true;
}
QPoint rightPoint(m_bottomRight.x(),
(m_topLeft.y() + m_bottomRight.y()) / 2);
QRect rightRect(makeRect(rightPoint, Tolerance));
if (rightRect.contains(mousePoint)) {
m_ellipseMode = RightPoint;
return true;
}
QPoint bottomPoint((m_topLeft.x() + m_bottomRight.x()) / 2,
m_bottomRight.y());
QRect bottomRect(makeRect(bottomPoint, Tolerance));
if (bottomRect.contains(mousePoint)) {
m_ellipseMode = BottomPoint;
return true;
}
如果用户没有点击四个位置中的任何一个,我们检查他们是否点击了椭圆本身。如果它是填充的,我们使用 Qt 的 QRegion
类创建一个椭圆区域,并检查鼠标点是否位于该区域内:
QRect normalRect(m_topLeft, m_bottomRight);
normalRect.normalized();
if (filled()) {
QRegion normalEllipse(normalRect, QRegion::Ellipse);
if (normalEllipse.contains(mousePoint)) {
m_ellipseMode = MoveEllipse;
return true;
}
}
如果椭圆未填充,我们创建稍大和稍小的椭圆区域,然后检查鼠标点是否位于较大的区域内,同时也位于较小的区域内:
else {
QRect largeRect(normalRect), smallRect(normalRect);
largeRect += Tolerance;
smallRect -= Tolerance;
QRegion largeEllipse(largeRect, QRegion::Ellipse),
smallEllipse(smallRect, QRegion::Ellipse);
if (largeEllipse.contains(mousePoint) &&
!smallEllipse.contains(mousePoint)) {
m_ellipseMode = MoveEllipse;
return true;
}
}
最后,如果用户没有在任何抓取位置或椭圆本身上点击,我们返回 false
:
return false;
}
modify
方法根据 onClick
中 m_ellipseMode
的设置修改椭圆:
void EllipseX::modify(QSize distance) {
switch (m_ellipseMode) {
case CreateEllipse:
m_bottomRight += distance;
break;
case LeftPoint:
m_topLeft.setX(m_topLeft.x() + distance.width());
break;
case RightPoint:
m_bottomRight.setX(m_bottomRight.x() + distance.width());
break;
case TopPoint:
m_topLeft.setY(m_topLeft.y() + distance.height());
break;
case BottomPoint:
m_bottomRight.setY(m_bottomRight.y() + distance.height());
break;
case MoveEllipse:
move(distance);
break;
}
}
draw
方法根据椭圆是否填充,使用实心画笔绘制椭圆,如果未填充则使用空心画笔:
void EllipseX::draw(QPainter& painter) const {
painter.setPen(color());
if (filled()) {
painter.setBrush(color());
painter.drawEllipse(QRect(m_topLeft, m_bottomRight));
}
else {
painter.setBrush(Qt::NoBrush);
painter.drawEllipse(QRect(m_topLeft, m_bottomRight));
}
如果椭圆被标记,则绘制覆盖椭圆顶部、左侧、右侧和底部点的四个正方形:
if (marked()) {
QPoint leftPoint(m_topLeft.x(),
(m_topLeft.y() + m_bottomRight.y())/2);
painter.fillRect(makeRect(leftPoint, Tolerance), Qt::black);
QPoint topPoint((m_topLeft.x() + m_bottomRight.x()) / 2,
m_topLeft.y());
painter.fillRect(makeRect(topPoint, Tolerance), Qt::black);
QPoint rightPoint(m_bottomRight.x(),
(m_topLeft.y() + m_bottomRight.y()) / 2);
painter.fillRect(makeRect(rightPoint, Tolerance), Qt::black);
QPoint bottomPoint((m_topLeft.x() + m_bottomRight.x()) / 2,
m_bottomRight.y());
painter.fillRect(makeRect(bottomPoint, Tolerance), Qt::black);
}
}
DrawingWindow
类
DrawingWindow
类与上一章的版本类似。它重写了 closeEvent
方法。
DrawingWindow.h:
#ifndef DRAWINGWINDOW_H
#define DRAWINGWINDOW_H
#include <QMainWindow>
#include <QActionGroup>
#include "..\MainWindow\MainWindow.h"
#include "DrawingWidget.h"
class DrawingWindow : public MainWindow {
Q_OBJECT
public:
DrawingWindow(QWidget *parentWidget = nullptr);
~DrawingWindow();
public:
void closeEvent(QCloseEvent *eventPtr)
{ m_drawingWidgetPtr->closeEvent(eventPtr); }
private:
DrawingWidget* m_drawingWidgetPtr;
QActionGroup* m_figureGroupPtr;
};
#endif // DRAWINGWINDOW_H
DrawingWindow.cpp:
#include "..\MainWindow\DocumentWidget.h"
#include "DrawingWindow.h"
构造函数将窗口大小初始化为 1000 x 500 像素,将绘图小部件放置在窗口中间,添加标准文件和编辑菜单,并添加应用程序特定的格式和图形菜单:
DrawingWindow::DrawingWindow(QWidget *parentWidget /*= nullptr*/)
:MainWindow(parentWidget) {
resize(1000, 500);
m_drawingWidgetPtr = new DrawingWidget(this);
setCentralWidget(m_drawingWidgetPtr);
addFileMenu();
addEditMenu();
格式菜单包含 Color
、Fill
和 Modify
项以及图形子菜单:
{ QMenu* formatMenuPtr = menuBar()->addMenu(tr("F&ormat"));
connect(formatMenuPtr, SIGNAL(aboutToShow()),
this, SLOT(onMenuShow()));
addAction(formatMenuPtr, tr("&Color"),
SLOT(onColor()), QKeySequence(Qt::ALT + Qt::Key_C),
QString(), nullptr, tr("Figure Color"));
addAction(formatMenuPtr, tr("&Fill"),
SLOT(onFill()), QKeySequence(Qt::CTRL + Qt::Key_F),
QString(), nullptr, tr("Figure Fill"),
LISTENER(isFillEnabled));
当用户想要标记或修改现有图形而不是添加新图形时,他们选择修改项:
m_figureGroupPtr = new QActionGroup(this);
addAction(formatMenuPtr, tr("&Modify"),
SLOT(onModify()),
QKeySequence(Qt::CTRL + Qt::Key_M),
QString(), nullptr, tr("Modify Figure"), nullptr,
LISTENER(isModifyChecked), m_figureGroupPtr);
图形菜单是一个包含 Line
、Rectangle
和 Ellipse
项的子菜单。当我们将其添加到格式菜单时,它成为一个子菜单:
{ QMenu* figureMenuPtr =
formatMenuPtr->addMenu(tr("&Figure"));
connect(figureMenuPtr, SIGNAL(aboutToShow()),
this, SLOT(onMenuShow()));
addAction(figureMenuPtr, tr("&Line"),
SLOT(onLine()),
QKeySequence(Qt::CTRL + Qt::Key_L),
QString(), nullptr, tr("Line Figure"), nullptr,
LISTENER(isLineChecked), m_figureGroupPtr);
addAction(figureMenuPtr, tr("&Rectangle"),
SLOT(onRectangle()),
QKeySequence(Qt::CTRL + Qt::Key_R),
QString(), nullptr, tr("Rectangle Figure"),
nullptr, LISTENER(isRectangleChecked),
m_figureGroupPtr);
addAction(figureMenuPtr, tr("&Ellipse"),
SLOT(onEllipse()),
QKeySequence(Qt::CTRL + Qt::Key_E),
QString(), nullptr, tr("Ellipse Figure"), nullptr,
LISTENER(isEllipseChecked), m_figureGroupPtr);
}
}
}
DrawingWindow::~DrawingWindow() {
delete m_figureGroupPtr;
}
DrawingWidget
类
DrawingWidget
类是应用程序的主要类。它捕获鼠标和绘图事件。它还捕获文件、编辑和图形菜单项的选择。
DrawingWidget.h:
#ifndef DRAWINGWIDGET_H
#define DRAWINGWIDGET_H
#include "..\MainWindow\MainWindow.h"
#include "..\MainWindow\DocumentWidget.h"
#include "Figure.h"
class DrawingWidget : public DocumentWidget {
Q_OBJECT
public:
DrawingWidget(QWidget* parentWidget);
~DrawingWidget();
public:
void mousePressEvent(QMouseEvent *eventPtr);
void mouseMoveEvent(QMouseEvent *eventPtr);
void mouseReleaseEvent(QMouseEvent *eventPtr);
void mouseDoubleClickEvent(QMouseEvent *eventPtr);
void paintEvent(QPaintEvent *eventPtr);
private:
void newDocument(void);
bool writeFile(const QString& filePath);
bool readFile(const QString& filePath);
Figure* createFigure(FigureId figureId);
与 第五章 的版本(Qt 图形应用程序)不同,这个版本覆盖了剪切和复制事件方法:
public slots:
bool isCopyEnabled();
void onCopy(void);
bool isPasteEnabled();
void onPaste(void);
void onDelete(void);
void onColor(void);
DEFINE_LISTENER(DrawingWidget, isFillEnabled);
void onFill(void);
DEFINE_LISTENER(DrawingWidget, isModifyChecked);
void onModify(void);
DEFINE_LISTENER(DrawingWidget, isLineChecked);
void onLine(void);
DEFINE_LISTENER(DrawingWidget, isRectangleChecked);
void onRectangle(void);
DEFINE_LISTENER(DrawingWidget, isEllipseChecked);
void onEllipse(void);
m_applicationMode
字段包含 Idle
、ModifySingle
或 ModifyRectangle
的值。当用户没有按鼠标时,Idle
模式是活动的。当用户抓住一个图形并修改或移动它(取决于用户抓住图形的哪个部分)时,ModifySingle
模式变为活动状态。最后,当用户在窗口中点击而没有点击图形时,ModifyRectangle
模式变为活动状态。在这种情况下,显示一个矩形,当用户释放鼠标按钮时,矩形内的每个图形都会被标记。用户可以删除或剪切粘贴标记的图形,或更改其颜色或填充状态。当用户释放鼠标按钮时,Application
模式再次变为 Idle
:
private:
enum ApplicationMode {Idle, ModifySingle, ModifyRectangle};
ApplicationMode m_applicationMode = Idle;
void setApplicationMode(ApplicationMode mode);
m_actionMode
字段包含 Modify
或 Add
的值。在 Modify
模式下,当用户用鼠标点击时,m_applicationMode
被设置为 ModifySingle
或 ModifyRectangle
,具体取决于是否点击了图形。在 Add
模式下,无论用户是否点击了图形,都会添加一个新的图形。要添加的图形类型由 m_addFigureId
设置,它包含 LineId
、RectangleId
或 EllipseId
的值:
enum ActionMode {Modify, Add};
ActionMode m_actionMode = Add;
FigureId m_addFigureId = LineId;
要添加到绘图中的下一个图形的颜色初始化为黑色,填充状态初始化为 false(未填充)。在两种情况下,用户都可以稍后更改它们:
QColor m_nextColor = Qt::black;
bool m_nextFilled = false;
我们需要保存最新的鼠标点,以便计算鼠标移动之间的距离:
QPoint m_mousePoint;
绘图图形的指针存储在 m_figurePtrList
中。最顶层的图形存储在列表的末尾。当用户剪切或复制一个或多个图形时,图形被复制,复制的指针存储在 m_copyPtrList
中:
QList<Figure*> m_figurePtrList, m_copyPtrList;
当 m_actionMode
包含 Modify
且用户按下鼠标按钮而没有点击图形时,窗口中会出现一个矩形。这个矩形存储在 m_insideRectangle
中:
QRect m_insideRectangle;
};
#endif // DRAWINGWIDGET_H
DrawingWidget.cpp:
#include <CAssert>
#include "..\MainWindow\DocumentWidget.h"
#include "DrawingWidget.h"
#include "Line.h"
#include "Rectangle.h"
#include "Ellipse.h"
构造函数调用基类 DocumentWidget
的构造函数,将窗口标题设置为 Drawing Advanced
,并将绘图文件的文件后缀设置为 drw
:
DrawingWidget::DrawingWidget(QWidget* parentWidget)
:DocumentWidget(tr("Drawing Advanced"),
tr("Drawing files (*.drw)"),
parentWidget) {
// Empty.
}
析构函数不执行任何操作,仅为了完整性而包含:
DrawingWidget::~DrawingWidget() {
// Empty.
}
setApplicationMode
方法设置应用程序模式,并在主窗口中调用 onMenuShow
以确保工具栏图标正确启用:
void DrawingWidget::setApplicationMode(ApplicationMode mode) {
m_applicationMode = mode;
((MainWindow*) parent())->onMenuShow();
}
当用户选择 New
菜单项时调用 newDocument
方法。我们首先在图形和复制指针列表中释放每个图形,然后它们自己清除列表:
void DrawingWidget::newDocument(void) {
for (Figure* figurePtr : m_figurePtrList) {
delete figurePtr;
}
for (Figure* copyPtr : m_copyPtrList) {
delete copyPtr;
}
m_figurePtrList.clear();
m_copyPtrList.clear();
当前颜色和填充状态设置为黑色和 false(未填充)。操作模式设置为 Add
,添加图形标识符设置为 LineId
,这意味着当用户按下鼠标按钮时,会在绘图上添加一条黑色线条:
m_nextColor = Qt::black;
m_nextFilled = false;
m_actionMode = Add;
m_addFigureId = LineId;
}
当用户选择保存
或另存为
菜单项时,会调用writeFile
方法:
bool DrawingWidget::writeFile(const QString& filePath) {
ofstream outStream(filePath.toStdString());
如果文件成功打开,我们首先写入下一个颜色和填充状态:
if (outStream) {
writeColor(outStream, m_nextColor);
outStream.write((char*) &m_nextFilled, sizeof m_nextFilled);
然后我们写入绘图中的图形数量,然后写入图形本身:
int size = m_figurePtrList.size();
outStream.write((char*) &size, sizeof size);
对于每个图形,首先我们写入其身份值,然后通过在其指针上调用write
来写入图形本身。注意,我们不知道图形指针指向哪个类。我们不需要知道这一点,因为write
是基类Figure
中的纯虚方法:
for (Figure* figurePtr : m_figurePtrList) {
FigureId figureId = figurePtr->getId();
outStream.write((char*) &figureId, sizeof figureId);
figurePtr->write(outStream);
}
我们返回输出流转换为bool
的结果,如果写入成功则为true
:
return ((bool) outStream);
}
如果文件未能成功打开,我们返回false
:
return false;
}
当用户选择打开菜单项时,会调用readFile
方法。我们按照与之前writeFile
中写入相同的顺序读取文件的部分:
bool DrawingWidget::readFile(const QString& filePath) {
ifstream inStream(filePath.toStdString());
如果文件成功打开,我们首先读取下一个颜色和填充状态:
if (inStream) {
readColor(inStream, m_nextColor);
inStream.read((char*) &m_nextFilled, sizeof m_nextFilled);
然后我们写入绘图中的图形数量,然后写入图形本身:
int size;
inStream.read((char*) &size, sizeof size);
对于每个图形,首先我们读取其身份值,然后通过调用createFigure
创建由身份值指示的图形类。最后,通过在其指针上调用write
来读取图形本身:
for (int count = 0; count < size; ++count) {
FigureId figureId = (FigureId) 0;
inStream.read((char*) &figureId, sizeof figureId);
Figure* figurePtr = createFigure(figureId);
figurePtr->read(inStream);
m_figurePtrList.push_back(figurePtr);
}
我们返回输入流转换为bool
的结果,如果读取成功则为true
:
return ((bool) inStream);
}
如果文件未能成功打开,我们返回false
:
return false;
}
createFigure
方法根据figureId
参数的值动态创建Line
、RectangleX
或EllipseX
类的一个对象:
Figure* DrawingWidget::createFigure(FigureId figureId) {
Figure* figurePtr = nullptr;
switch (figureId) {
case LineId:
figurePtr = new Line();
break;
case RectangleId:
figurePtr = new RectangleX();
break;
case EllipseId:
figurePtr = new EllipseX();
break;
}
return figurePtr;
}
在编辑菜单可见之前调用isCopyEnable
方法以启用复制项。框架也会调用它来启用复制工具栏图标。如果至少有一个图形被标记,它返回true
,此时它就准备好被复制。如果它返回true
,复制项和工具栏图标将变为启用状态:
bool DrawingWidget::isCopyEnabled() {
for (Figure* figurePtr : m_figurePtrList) {
if (figurePtr->marked()) {
return true;
}
}
return false;
}
当用户选择复制菜单项时,会调用onCopy
方法。首先,它释放复制指针列表中的每个图形并清除列表本身:
void DrawingWidget::onCopy(void) {
for (Figure* copyPtr : m_copyPtrList) {
delete copyPtr;
}
m_copyPtrList.clear();
然后,我们遍历图形指针列表,并将每个标记图形的指针添加到复制指针列表中。我们对每个图形指针调用clone
以提供复制:
for (Figure* figurePtr : m_figurePtrList) {
if (figurePtr->marked()) {
m_copyPtrList.push_back(figurePtr->clone());
}
}
}
在编辑菜单可见之前调用isPasteEnabled
方法以启用粘贴项。框架也会调用它来启用粘贴工具栏图标。如果复制指针列表不为空,它返回true
,从而启用粘贴项和图像。也就是说,如果有图形准备好粘贴,它返回true
:
bool DrawingWidget::isPasteEnabled() {
return !m_copyPtrList.isEmpty();
}
当用户在编辑菜单中选择粘贴项,或者在选择编辑工具栏中的粘贴图像时,会调用onPaste
方法。我们遍历复制指针列表,并在将其向下和向右移动 10 像素后,将图形的复制(通过调用clone
获得)添加到图形指针列表中:
void DrawingWidget::onPaste(void) {
for (Figure* copyPtr : m_copyPtrList) {
Figure* pastePtr = copyPtr->clone();
pastePtr->move(QSize(10, 10));
m_figurePtrList.push_back(pastePtr);
}
最后,当图形被添加到列表中时,我们通过调用update
强制最终调用paintEvent
:
update();
}
每次用户选择删除菜单项或工具栏图标时,都会调用onDelete
方法。我们遍历图形指针列表并删除每个标记的图形:
void DrawingWidget::onDelete(void) {
for (Figure* figurePtr : m_figurePtrList) {
if (figurePtr->marked()) {
m_figurePtrList.removeOne(figurePtr);
delete figurePtr;
}
}
此外,在这种情况下,我们在图形被删除后通过调用update
方法强制最终调用paintEvent
。
update();
}
每次用户在格式菜单中选择“颜色”项时,都会调用onColor
方法。我们首先通过调用 Qt QColorDialog
类的静态方法getColor
来获取新的颜色:
void DrawingWidget::onColor(void) {
QColor newColor = QColorDialog::getColor(m_nextColor, this);
如果颜色有效,即用户通过按下“确定”按钮而不是“取消”按钮关闭了对话框,并且他们选择了一种新的颜色,我们将下一个颜色设置为新的颜色并设置修改标志。我们还会遍历图形指针列表,并为每个标记的图形设置图形颜色:
if (newColor.isValid() && (m_nextColor != newColor)) {
m_nextColor = newColor;
setModifiedFlag(true);
for (Figure* figurePtr : m_figurePtrList) {
if (figurePtr->marked()) {
figurePtr->color() = m_nextColor;
如果至少有一个图形被标记,我们将通过调用update
强制最终调用paintEvent
:
update();
}
}
}
}
在格式菜单中的“填充”项变得可见之前,会调用isFillEnabled
方法:
bool DrawingWidget::isFillEnabled(void) {
switch (m_actionMode) {
在“修改”模式下,我们遍历图形指针列表。如果至少有一个矩形或椭圆被标记,我们返回true
并使项目启用:
case Modify:
for (Figure* figurePtr : m_figurePtrList) {
if (figurePtr->marked() &&
((figurePtr->getId() == RectangleId) ||
(figurePtr->getId() == EllipseId))) {
return true;
}
}
如果没有标记矩形或椭圆,我们返回false
并使项目禁用:
return false;
在“添加”模式下,如果用户将要添加的下一个图形是矩形或椭圆,我们返回true
:
case Add:
return (m_addFigureId == RectangleId) ||
(m_addFigureId == EllipseId);
}
我们不应该到达这一点。assert
宏调用仅用于调试目的。然而,我们仍然必须在方法末尾返回一个值:
assert(false);
return true;
}
当用户在格式菜单中选择“填充”项时,会调用onFill
方法:
void DrawingWidget::onFill(void) {
switch (m_actionMode) {
在“修改”模式下,我们遍历图形指针列表并反转所有标记图形的填充状态。如果至少有一个图形发生变化,我们将通过调用update
强制最终调用paintEvent
:
case Modify:
for (Figure* figurePtr : m_figurePtrList) {
if (figurePtr->marked()) {
figurePtr->filled() = !figurePtr->filled();
update();
}
}
我们还反转用户将要添加的下一个图形的填充状态:
m_nextFilled = !m_nextFilled;
break;
在“添加”模式下,我们反转用户将要添加的下一个图形的填充状态:
case Add:
m_nextFilled = !m_nextFilled;
break;
}
}
在格式菜单中的“修改”项变得可见之前,会调用isModifyChecked
方法。在“修改”模式下,它返回true
并启用项目:
bool DrawingWidget::isModifyChecked(void) {
return (m_actionMode == Modify);
}
当用户在格式菜单中选择“修改”项时,会调用onModify
方法。它将操作模式设置为“修改”:
void DrawingWidget::onModify(void) {
m_actionMode = Modify;
}
在“添加”子菜单中的“线条”项变得可见之前,会调用isLineChecked
方法。它返回true
,并在添加操作模式下,如果下一个要添加的图形是线条,则项目(由于项目属于一组,因此带有单选按钮)变为选中状态:
bool DrawingWidget::isLineChecked(void) {
return (m_actionMode == Add) && (m_addFigureId == LineId);
}
当用户在“添加”子菜单中选择“线条”项时,会调用onLine
方法。它将操作模式设置为“添加”,并将用户将要添加到线条中的下一个图形设置为:
void DrawingWidget::onLine(void) {
m_actionMode = Add;
m_addFigureId = LineId;
}
在Add
子菜单中的Rectangle
项变得可见之前,会调用isRectangleChecked
方法。在Add
动作模式下,如果下一个要添加的图形是矩形,它将返回true
:
bool DrawingWidget::isRectangleChecked(void) {
return (m_actionMode == Add) && (m_addFigureId == RectangleId);
}
当用户选择Rectangle
项时,会调用onRectangle
方法。它将动作模式设置为Add
,并将用户将要添加的下一个图形设置为矩形:
void DrawingWidget::onRectangle(void) {
m_actionMode = Add;
m_addFigureId = RectangleId;
}
在Add
子菜单中的Ellipse
项变得可见之前,会调用isEllipseChecked
方法。在Add
动作模式下,如果下一个要添加的图形是椭圆,它将返回true
:
bool DrawingWidget::isEllipseEnabled(void) {
return !isEllipseChecked();
}
当用户选择Ellipse
项时,会调用onEllipse
方法。它将动作模式设置为Add
,并将用户将要添加的下一个图形设置为椭圆:
void DrawingWidget::onEllipse(void) {
m_actionMode = Add;
m_addFigureId = EllipseId;
}
当用户按下鼠标按钮之一时,会调用mousePressEvent
方法。我们将鼠标点存储在m_mousePoint
中,以便在mouseMoveEvent
中使用,如下所示:
void DrawingWidget::mousePressEvent(QMouseEvent* eventPtr) {
if (eventPtr->buttons() == Qt::LeftButton) {
m_mousePoint = eventPtr->pos();
在Modify
模式下,我们首先遍历图形指针列表,并取消标记每个图形:
switch (m_actionMode) {
case Modify: {
for (Figure* figurePtr : m_figurePtrList) {
figurePtr->marked() = false;
}
我们然后再次遍历列表,以查找用户是否击中了图形。由于最顶部的图形被放置在列表的末尾,我们需要从列表的末尾开始遍历。我们通过使用 Qt QList
类的reverse_iterator
类型来实现这一点:
m_clickedFigurePtr = nullptr;
for (QList<Figure*>::reverse_iterator iterator =
m_figurePtrList.rbegin();
iterator != m_figurePtrList.rend(); ++iterator) {
Figure* figurePtr = *iterator;
如果我们通过在图形上调用isClick
发现用户点击了图形,我们将应用程序模式设置为ModifySingle
并标记该图形。我们还将它从列表中移除,并将其添加到列表的末尾,以便它在绘图中最先显示。最后,我们中断循环,因为我们已经找到了一个图形:
if (figurePtr->isClick(m_mousePoint)) {
setApplicationMode(ModifySingle);
m_clickedFigurePtr = figurePtr;
figurePtr->marked() = true;
m_figurePtrList.removeOne(figurePtr);
m_figurePtrList.push_back(figurePtr);
break;
}
}
如果我们没有找到图形,我们将应用程序模式设置为ModifyRectangle
,并将包含矩形的顶部和底部右角初始化为鼠标点:
if (m_clickedFigurePtr == nullptr) {
setApplicationMode(ModifyRectangle);
m_insideRectangle = QRect(m_mousePoint, m_mousePoint);
}
}
break;
在Add
动作模式下,我们通过调用createFigure
并传递用户将要添加的下一个图形的标识符作为参数来创建一个新的图形。然后我们设置新图形的颜色、填充状态,并初始化其端点:
case Add: {
Figure* newFigurePtr = createFigure(m_addFigureId);
newFigurePtr->color() = m_nextColor;
newFigurePtr->filled() = m_nextFilled;
newFigurePtr->initializePoints(m_mousePoint);
当新图形被创建并初始化后,我们将它添加到图形指针列表的末尾,并将应用程序模式设置为ModifySingle
,因为mouseMoveEvent
方法将继续修改列表中的最后一个图形,就像用户在Modify
模式下击中了图形一样。我们还设置了修改标志,因为我们已经向绘图添加了一个图形:
m_figurePtrList.push_back(newFigurePtr);
setApplicationMode(ModifySingle);
setModifiedFlag(true);
}
break;
}
最后,我们通过调用update
强制调用paintEvent
:
update();
}
}
当用户移动鼠标时,会调用mouseMoveEvent
方法。如果他们同时按下鼠标左键,我们将鼠标点保存到未来的mouseMoveEvent
调用中,并计算自上次调用mousePressEvent
或mouseMoveEvent
以来的距离:
void DrawingWidget::mouseMoveEvent(QMouseEvent* eventPtr) {
if (eventPtr->buttons() == Qt::LeftButton) {
QPoint newMousePoint = eventPtr->pos();
QSize distance(newMousePoint.x() - m_mousePoint.x(),
newMousePoint.y() - m_mousePoint.y());
m_mousePoint = newMousePoint;
在Modify
模式下,我们通过调用modify
来修改当前图形(位于图形指针列表末尾的图形)。请记住,图形可以是修改的,也可以是移动的,这取决于之前在onMousePress
中调用isClick
时的设置。我们还设置了修改标志,因为图形已经被更改:
switch (m_applicationMode) {
case ModifySingle:
m_figurePtrList.back()->modify(distance);
setModifiedFlag(true);
break;
在包含矩形的情况下,我们只需更新其右下角。请注意,我们并没有设置修改标志,因为还没有任何图形被更改:
case ModifyRectangle:
m_insideRectangle.setBottomRight(m_mousePoint);
break;
}
最后,通过调用update
强制进行可能的paintEvent
调用:
update();
}
}
当用户释放鼠标按钮时,会调用mouseReleaseEvent
方法。如果是左键,我们会检查应用程序模式。我们唯一感兴趣的模式是包含矩形模式:
void DrawingWidget::mouseReleaseEvent(QMouseEvent* eventPtr) {
if (eventPtr->buttons() == Qt::LeftButton) {
switch (m_applicationMode) {
case ModifyRectangle: {
QList<Figure*> insidePtrList;
我们遍历图形指针列表,并对每个图形调用isInside
。每个完全被矩形包含的图形被标记,从列表中移除,并添加到insidePtrList
中,稍后将其添加到图形指针列表的末尾:
for (Figure* figurePtr : m_figurePtrList) {
if (figurePtr->isInside(m_insideRectangle)) {
figurePtr->marked() = true;
m_figurePtrList.removeOne(figurePtr);
insidePtrList.push_back(figurePtr);
}
}
每个完全被矩形包含的图形都会从图形指针列表中移除:
for (Figure* figurePtr : insidePtrList) {
m_figurePtrList.removeOne(figurePtr);
}
最后,将所有包含的图形添加到列表的末尾,以便在绘图中最先显示:
m_figurePtrList.append(insidePtrList);
}
break;
}
当用户释放鼠标按钮时,应用程序模式设置为空闲,并通过调用update
强制进行可能的paintEvent
调用:
setApplicationMode(Idle);
update();
}
}
当用户双击按钮之一时,会调用mouseDoubleClick
方法。然而,mouseClickEvent
总是在mouseDoubleClickEvent
之前被调用。如果先前的mouseClickEvent
调用使m_clickedFigurePtr
指向被点击的图形,我们会在该图形上调用doubleClick
。这可能会根据图形的类型引起一些变化:
void DrawingWidget::mouseDoubleClickEvent(QMouseEvent
*eventPtr) {
if ((eventPtr->buttons() == Qt::LeftButton) &&
(m_clickedFigurePtr != nullptr)) {
m_clickedFigurePtr->doubleClick(eventPtr->pos());
update();
}
}
最后,当窗口内容需要重绘时,会调用paintEvent
。在调用之前,框架会清除窗口:
void DrawingWidget::paintEvent(QPaintEvent* /*
eventPtr */) {
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
painter.setRenderHint(QPainter::TextAntialiasing);
我们遍历图形指针列表,并绘制每个图形。列表中的最后一个图形被放置在列表的末尾,以便在绘图的最上方显示:
for (Figure* figurePtr : m_figurePtrList) {
figurePtr->draw(painter);
}
在包含矩形模式下,我们用浅灰色边框绘制一个空心矩形:
if (m_applicationMode == ModifyRectangle) {
painter.setPen(Qt::lightGray);
painter.setBrush(Qt::NoBrush);
painter.drawRect(m_insideRectangle);
}
}
主函数
main
函数与之前应用程序的main
函数类似——创建一个应用程序,显示绘图窗口,并开始应用程序的执行。
Main.cpp:
#include "DrawingWindow.h"
#include <QApplication>
int main(int argc, char *argv[]) {
QApplication application(argc, argv);
DrawingWindow drawingWindow;
drawingWindow.show();
return application.exec();
}
下面的截图显示了输出:
改进编辑器
本章的编辑器是第五章中编辑器的更高级版本,Qt 图形应用程序。在这个版本中,可以更改文本的字体和对齐方式,标记文本,以及剪切和粘贴文本。
EditorWindow 类
本章的EditorWindow
类与第五章,Qt 图形应用程序中的类相似。它捕获按键事件和窗口关闭事件。
EditorWindow.h:
#ifndef EDITORWINDOW_H
#define EDITORWINDOW_H
#include <QMainWindow>
#include <QActionGroup>
#include <QPair>
#include <QMap>
#include "..\MainWindow\MainWindow.h"
#include "EditorWidget.h"
class EditorWindow : public MainWindow {
Q_OBJECT
public:
EditorWindow(QWidget *parentWidgetPtr = nullptr);
~EditorWindow();
protected:
void keyPressEvent(QKeyEvent* eventPtr);
void closeEvent(QCloseEvent* eventPtr);
private:
EditorWidget* m_editorWidgetPtr;
QActionGroup* m_alignmentGroupPtr;
};
#endif // EDITORWINDOW_H
EditorWindow.cpp:
#include "EditorWindow.h"
#include <QtWidgets>
构造函数初始化编辑器窗口。它将窗口大小设置为 1000 x 500 像素。它还动态创建了一个编辑器小部件,并添加了标准的文件和编辑菜单:
EditorWindow::EditorWindow(QWidget *parentWidgetPtr /*= nullptr*/)
:MainWindow(parentWidgetPtr) {
resize(1000, 500);
m_editorWidgetPtr = new EditorWidget(this);
setCentralWidget(m_editorWidgetPtr);
addFileMenu();
addEditMenu();
与第五章,Qt 图形应用程序相比,图形单菜单有所不同。我们添加了字体
项和子菜单对齐,然后我们依次添加了三个项:左对齐、居中对齐和右对齐:
{ QMenu* formatMenuPtr = menuBar()->addMenu(tr("F&ormat"));
connect(formatMenuPtr, SIGNAL(aboutToShow()), this,
SLOT(onMenuShow()));
addAction(formatMenuPtr, tr("&Font"), SLOT(onFont()),
0, QString(), nullptr, QString(),
LISTENER(isFontEnabled));
{ QMenu* alignmentMenuPtr =
formatMenuPtr->addMenu(tr("&Alignment"));
connect(alignmentMenuPtr, SIGNAL(aboutToShow()),
this, SLOT(onMenuShow()));
我们还为Alignment
菜单添加了一个工具栏:
QToolBar* alignmentToolBarPtr = addToolBar(tr("Alignment"));
m_alignmentGroupPtr = new QActionGroup(this);
addAction(alignmentMenuPtr, tr("&Left"), SLOT(onLeft()),
QKeySequence(Qt::ALT + Qt::Key_L), tr("left"),
alignmentToolBarPtr, tr("Left-aligned text"),
nullptr, LISTENER(isLeftChecked));
addAction(alignmentMenuPtr, tr("&Center"),
SLOT(onCenter()),
QKeySequence(Qt::ALT + Qt::Key_C),
tr("center"), alignmentToolBarPtr,
tr("Center-aligned text"), nullptr,
LISTENER(isCenterChecked));
addAction(alignmentMenuPtr, tr("&Right"),
SLOT(onRight()),
QKeySequence(Qt::ALT + Qt::Key_R),
tr("right"), alignmentToolBarPtr,
tr("Right-aligned text"), nullptr,
LISTENER(isRightChecked));
}
}
m_editorWidgetPtr->setModifiedFlag(false);
}
EditorWindow::~EditorWindow() {
delete m_alignmentGroupPtr;
}
关键按键事件和窗口关闭事件被传递给编辑器小部件:
void EditorWindow::keyPressEvent(QKeyEvent* eventPtr) {
m_editorWidgetPtr->keyPressEvent(eventPtr);
}
void EditorWindow::closeEvent(QCloseEvent* eventPtr) {
m_editorWidgetPtr->closeEvent(eventPtr);
}
EditorWidget
类
EditorWidget
类与第五章,Qt 图形应用程序中的版本相似。然而,已经添加了处理字体和对齐的方法和监听器。
EditorWidget.h:
#ifndef EDITORWIDGET_H
#define EDITORWIDGET_H
#include <QWidget>
#include <QMap>
#include <QMenu>
#include <QToolBar>
#include <QPair>
#include "Caret.h"
#include "..\MainWindow\Listener.h"
#include "..\MainWindow\DocumentWidget.h"
class EditorWidget : public DocumentWidget {
Q_OBJECT
public:
EditorWidget(QWidget* parentWidgetPtr);
void keyPressEvent(QKeyEvent* eventPtr);
private:
void keyEditPressEvent(QKeyEvent* eventPtr);
void keyMarkPressEvent(QKeyEvent* eventPtr);
当用户按下鼠标按钮、移动鼠标和释放鼠标按钮时,会调用mousePressEvent
、mouseMoveEvent
和mouseReleaseEvent
:
public:
void mousePressEvent(QMouseEvent* eventPtr);
void mouseMoveEvent(QMouseEvent* eventPtr);
void mouseReleaseEvent(QMouseEvent* eventPtr);
private:
int mouseToIndex(QPoint point);
public:
void paintEvent(QPaintEvent* eventPtr);
void resizeEvent(QResizeEvent* eventPtr);
当用户选择新建菜单项时,会调用newDocument
方法,当选择保存或另存为时,会调用writeFile
,当选择打开菜单项时,会调用readFile
:
private:
void newDocument(void);
bool writeFile(const QString& filePath);
bool readFile(const QString& filePath);
public slots:
bool isCopyEnabled();
void onCopy(void);
bool isPasteEnabled();
void onPaste(void);
void onDelete(void);
DEFINE_LISTENER(EditorWidget, isFontEnabled);
void onFont(void);
在Alignment
子菜单可见之前,会调用isLeftChecked
、isCenterChecked
和isRightChecked
方法。然后它们会对选定的对齐方式标注一个单选按钮:
DEFINE_LISTENER(EditorWidget, isLeftChecked);
DEFINE_LISTENER(EditorWidget, isCenterChecked);
DEFINE_LISTENER(EditorWidget, isRightChecked);
当用户选择对齐子菜单的任一项时,会调用onLeft
、onCenter
和onRight
方法:
void onLeft(void);
void onCenter(void);
void onRight(void);
private:
void setCaret();
void simulateMouseClick(int x, int y);
在这个版本的编辑器中,我们有两种模式——编辑和标记。当用户输入文本或使用箭头键移动光标时,编辑标记是活动的,而当用户用鼠标标记代码块时,标记模式是活动的。光标在编辑模式下可见,但在标记模式下不可见:
private:
enum Mode {Edit, Mark} m_mode;
文本可以沿左、中、右方向对齐:
enum Alignment {Left, Center, Right} m_alignment;
在编辑模式下,m_editIndex
持有用户将要输入的下一个字符的索引,这同时也是光标的位置。在标记模式下,m_firstIndex
和m_lastIndex
持有第一个和最后一个标记字符的索引:
int m_editIndex, m_firstIndex, m_lastIndex;
m_caret
对象持有编辑器的光标。光标在编辑模式下可见,但在标记模式下不可见:
Caret m_caret;
m_editorText
字段持有编辑器的文本,而m_copyText
持有用户剪切或粘贴的文本:
QString m_editorText, m_copyText;
编辑器的文本被分成行;每行的第一个和最后一个字符的索引存储在m_lineList
中:
QList<QPair<int,int>> m_lineList;
当前文本的字体存储在m_textFont
中。当前字体中字符的高度(以像素为单位)存储在m_fontHeight
中:
QFont m_textFont;
int m_fontHeight;
mousePressEvent
和 mouseMoveEvent
方法存储最后一个鼠标点,以便计算鼠标事件之间的距离:
Qt::MouseButton m_button;
与第五章方法中的方法类似,Qt 图形应用程序,calculate
是一个辅助方法,用于计算文本中每个字符的包含矩形。然而,本章的版本更复杂,因为它必须考虑文本是左对齐、居中对齐还是右对齐:
void calculate();
包含矩形存储在 m_rectList
中,然后由光标和 paintEvent
使用:
QList<QRect> m_rectList;
};
#endif // EDITORWIDGET_H
EditorWidget.cpp:
#include "EditorWidget.h"
#include <QtWidgets>
#include <CAssert>
using namespace std;
构造函数将窗口标题设置为 Editor Advanced
并将文件后缀设置为 edi
:
EditorWidget::EditorWidget(QWidget* parentWidgetPtr)
:DocumentWidget(tr("Editor Advanced"),
tr("Editor files (*.edi)"), parentWidgetPtr),
文本字体初始化为 12
点 Times New Roman
。将应用程序模式设置为编辑,将用户下一个要输入的字符的索引设置为零,并从开始将文本设置为左对齐:
m_textFont(tr("Times New Roman"), 12),
m_mode(Edit),
m_editIndex(0),
m_alignment(Left),
m_caret(this) {
通过 calculate
计算包含字符的矩形,初始化并显示光标,因为应用程序从开始就持有编辑模式:
calculate();
setCaret();
m_caret.show();
}
当用户选择“新建”菜单项时调用 newDocument
方法。我们首先将应用程序模式设置为编辑并将编辑索引设置为零。文本字体设置为 12
点 Times New Roman。清除编辑器的文本,通过 calculate
计算包含字符的矩形,并设置光标:
void EditorWidget::newDocument(void) {
m_mode = Edit;
m_editIndex = 0;
m_textFont = QFont(tr("Times New Roman"), 12);
m_editorText.clear();
calculate();
setCaret();
}
当用户选择“保存”或“另存为”菜单项时调用 writeFile
方法。文件格式相当简单:我们在第一行写入字体,然后在以下行写入编辑器的文本:
bool EditorWidget::writeFile(const QString& filePath) {
QFile file(filePath);
if (file.open(QIODevice::WriteOnly | QIODevice::Text)) {
QTextStream outStream(&file);
outStream << m_textFont.toString() << endl << m_editorText;
我们使用输入流的 Ok
字段来决定写入是否成功:
return ((bool) outStream.Ok);
}
如果我们无法打开文件进行写入,我们返回 false
:
return false;
}
当用户选择“打开”菜单项时调用 readFile
方法。类似于之前的 writeFile
,我们读取第一行并用文本初始化文本字体。然后读取编辑器文本:
bool EditorWidget::readFile(const QString& filePath) {
QFile file(filePath);
if (file.open(QIODevice::ReadOnly | QIODevice::Text)) {
QTextStream inStream(&file);
m_textFont.fromString(inStream.readLine());
m_editorText = inStream.readAll();
当文本被读取时,我们调用 calculate
来计算包含文本字符的矩形。然后设置光标并返回 true
,因为读取是成功的:
calculate();
setCaret();
我们使用输入流的 Ok
字段来决定读取是否成功:
return ((bool) inStream.Ok);
}
如果我们无法打开文件进行读取,我们 return false
:
return false;
}
在编辑菜单可见之前调用 isCopyEnabled
方法。框架也会调用它来决定是否启用复制工具栏图标。如果应用程序处于标记模式(意味着用户已标记文本的一部分,可以复制),则返回 true
(并且项目变为启用状态):
bool EditorWidget::isCopyEnabled() {
return (m_mode == Mark);
}
当用户选择“复制”菜单项时调用 onCopy
方法。我们将标记的文本复制到 m_EditorText
:
void EditorWidget::onCopy(void) {
int minIndex = qMin(m_firstIndex, m_lastIndex),
maxIndex = qMax(m_firstIndex, m_lastIndex);
m_copyText =
m_editorText.mid(minIndex, maxIndex - minIndex + 1);
}
在编辑菜单可见之前也会调用 isPasteEnabled
方法。如果复制的文本不为空,它返回 true
(并且项目变为可见)。也就是说,如果有一个已复制的文本块准备好粘贴:
bool EditorWidget::isPasteEnabled() {
return !m_copyText.isEmpty();
}
当用户选择粘贴菜单项时,会调用 onPaste
方法。在标记模式下,我们调用 onDelete
,这将导致标记的文本被删除:
void EditorWidget::onPaste(void) {
if (m_mode == Mark) {
onDelete();
}
然后,我们将复制的文本插入到编辑器文本中。我们还更新 m_editIndex
,因为文本被复制后,编辑索引应该是插入文本后的位置:
m_editorText.insert(m_editIndex, m_copyText);
m_editIndex += m_copyText.size();
最后,我们计算包含文本字符的矩形,将光标设置到新索引,设置修改标志,因为文本已被更改,并通过调用 update
强制调用 paintEvent
以显示新文本:
calculate();
setCaret();
setModifiedFlag(true);
update();
}
当用户选择删除菜单项或删除工具栏图标时,会调用 onDelete
方法。效果类似于用户按下 Delete 键时的事件。因此,我们准备一个带有 Delete 键的按键事件,将其用作 keyPressEvent
调用的参数:
注意,没有 isDeleteEnabled
方法,因为用户始终可以使用删除项。在编辑模式下,下一个字符被删除。在标记模式下,标记的文本被删除:
void EditorWidget::onDelete(void) {
QKeyEvent event(QEvent::KeyPress, Qt::Key_Delete,
Qt::NoModifier);
keyPressEvent(&event);
}
在格式菜单可见之前调用 isCopyEnabled
。在编辑模式下它返回 true
,因为当只有一部分字符被标记时,改变所有字符的字体是不合逻辑的:
bool EditorWidget::isFontEnabled() {
return (m_mode == Edit);
}
当用户选择 Font
菜单项时,会调用 onFont
方法。我们让用户使用 Qt 的 QFontDialog
类选择新的字体:
void EditorWidget::onFont(void) {
bool pressedOkButton;
QFont newFont =
QFontDialog::getFont(&pressedOkButton, m_textFont, this);
如果用户通过按下 Ok 按钮关闭对话框,我们将编辑器(m_textFont
)字段的字体和修改标志设置:
if (pressedOkButton) {
m_textFont = newFont;
setModifiedFlag(true);
我们通过调用 calculate
计算新包含的矩形,设置光标,并通过调用 update
强制调用 paintEvent
:
calculate();
m_caret.set(m_rectList[m_editIndex]);
update();
}
}
isLeftChecked
, isCenterChecked
, 和 isRightChecked
方法在对齐子菜单可见之前被调用。它们返回 true
给当前的对齐方式:
bool EditorWidget::isLeftChecked(void) {
return (m_alignment == Left);
}
bool EditorWidget::isCenterChecked(void) {
return (m_alignment == Center);
}
bool EditorWidget::isRightChecked(void) {
return (m_alignment == Right);
}
当用户选择 Left
、Center
和 Right
菜单项时,会调用 onLeft
、onCenter
和 onRight
方法。它们设置对齐方式和修改标志。
它们也会计算新的包含矩形,设置光标,并通过调用 update
强制调用 paintEvent
:
void EditorWidget::onLeft(void) {
m_alignment = Left;
setModifiedFlag(true);
calculate();
setCaret();
update();
}
void EditorWidget::onCenter(void) {
m_alignment = Center;
setModifiedFlag(true);
calculate();
setCaret();
update();
}
void EditorWidget::onRight(void) {
m_alignment = Right;
setModifiedFlag(true);
calculate();
setCaret();
update();
}
当用户按下鼠标按钮之一时,会调用 mousePressEvent
方法。我们调用 mouseToIndex
来找到用户点击的字符索引。暂时,第一个和最后一个标记索引被设置为鼠标索引。最后一个索引可能稍后通过以下片段中的 mouseMoveEvent
调用而改变。最后,模式被设置为标记,光标被隐藏:
void EditorWidget::mousePressEvent(QMouseEvent* eventPtr) {
if (eventPtr->buttons() == Qt::LeftButton) {
m_firstIndex = m_lastIndex = mouseToIndex(eventPtr->pos());
m_mode = Mark;
m_caret.hide();
}
}
当用户移动鼠标时,会调用 mouseMoveEvent
方法。我们将最后一个标记索引设置为鼠标索引,并通过调用 update
强制调用 paintEvent
:
void EditorWidget::mouseMoveEvent(QMouseEvent* eventPtr) {
if (eventPtr->buttons() == Qt::LeftButton) {
m_lastIndex = mouseToIndex(eventPtr->pos());
update();
}
}
当用户释放鼠标按钮时,会调用 mouseReleaseEvent
方法。如果用户将鼠标移动到鼠标移动的原始起始位置,则没有标记要做,并将应用程序设置为编辑模式。在这种情况下,我们将编辑索引设置为第一个标记索引,并设置和显示插入点(因为它应该在编辑模式下可见)。最后,我们通过调用 update
强制调用 paintEvent
:
void EditorWidget::mouseReleaseEvent(QMouseEvent* eventPtr) {
if (eventPtr->buttons() == Qt::LeftButton) {
if (m_firstIndex == m_lastIndex) {
m_mode = Edit;
m_editIndex = m_firstIndex;
setCaret();
m_caret.show();
update();
}
}
}
当用户在键盘上按下键时,会调用 keyPressEvent
。根据应用程序模式(编辑或标记),我们调用 keyEditPressEvent
或以下 keyMarkPressEvent
以进一步处理按键事件:
void EditorWidget::keyPressEvent(QKeyEvent* eventPtr) {
switch (m_mode) {
case Edit:
keyEditPressEvent(eventPtr);
break;
case Mark:
keyMarkPressEvent(eventPtr);
break;
}
}
keyEditPressEvent
处理编辑模式下的按键。首先,我们检查键是否是箭头键、页面上下、删除、退格或回车键:
void EditorWidget::keyEditPressEvent(QKeyEvent* eventPtr) {
switch (eventPtr->key()) {
在左箭头键的情况下,我们将编辑索引向后移动一步,除非它已经位于文本的开头:
case Qt::Key_Left:
if (m_editIndex > 0) {
--m_editIndex;
}
break;
在右箭头键的情况下,我们将编辑索引向前移动一步,除非它已经位于文本的末尾:
case Qt::Key_Right:
if (m_editIndex < m_editorText.size()) {
++m_editIndex;
}
break;
在上箭头键的情况下,我们计算上一行字符的适当 x
和 y
位置,除非它已经在文本顶部。然后我们调用 simulateMouseClick
,这会产生与用户点击行上方的字符相同的效果:
case Qt::Key_Up: {
QRect charRect = m_rectList[m_editIndex];
if (charRect.top() > 0) {
int x = charRect.left() + (charRect.width() / 2),
y = charRect.top() - 1;
simulateMouseClick(x, y);
}
}
break;
同样,在下箭头键的情况下,我们将编辑索引向下移动一行,除非它已经位于文本底部。
我们计算下一行字符的适当 x
和 y
位置,并调用 simulateMouseClick
,这会产生与用户在点击点相同的效果:
case Qt::Key_Down: {
QRect charRect = m_rectList[m_editIndex];
int x = charRect.left() + (charRect.width() / 2),
y = charRect.bottom() + 1;
simulateMouseClick(x, y);
}
break;
在 删除 键的情况下,我们删除当前键,除非我们位于文本末尾。也就是说,如果我们比最后一个字符多一步:
case Qt::Key_Delete:
if (m_editIndex < m_editorText.size()) {
m_editorText.remove(m_editIndex, 1);
setModifiedFlag(true);
}
break;
在退格键的情况下,我们将编辑索引向后移动一步,除非它已经位于文本的开头,并调用 onDelete
。这样,我们就删除了前面的字符,并将编辑索引向后移动一步:
case Qt::Key_Backspace:
if (m_editIndex > 0) {
--m_editIndex;
onDelete();
}
break;
在回车键的情况下,我们简单地将在文本中插入新行字符:
case Qt::Key_Return:
m_editorText.insert(m_editIndex++, 'n');
setModifiedFlag(true);
break;
如果键不是特殊键,我们通过在按键事件指针上调用 text
来检查它是否是常规字符。如果文本不为空,则将其第一个字符添加到文本中:
default: {
QString text = eventPtr->text();
if (!text.isEmpty()) {
m_editorText.insert(m_editIndex++, text[0]);
setModifiedFlag(true);
}
}
break;
}
最后,我们计算包含的矩形,设置插入点,并通过调用 update
强制调用 paintEvent
:
calculate();
setCaret();
update();
}
当用户在标记模式下按下键时,会调用 keyMarkPressEvent
:
void EditorWidget::keyMarkPressEvent(QKeyEvent* eventPtr) {
switch (eventPtr->key()) {
在左箭头键的情况下,我们将应用程序设置为编辑模式,并将编辑索引设置为第一个和最后一个标记索引的最小值。然而,如果最小索引位于文本开头,我们不做任何操作:
case Qt::Key_Left: {
int minIndex = qMin(m_firstIndex, m_lastIndex);
if (minIndex > 0) {
m_mode = Edit;
m_caret.show();
m_editIndex = minIndex;
}
}
break;
另一方面,在右箭头键的情况下,我们将应用程序设置为编辑模式,并将编辑索引设置为第一个和最后一个标记索引的最大值。然而,如果最大索引位于文本末尾,我们不做任何操作:
case Qt::Key_Right: {
int maxIndex = qMax(m_firstIndex, m_lastIndex);
if (maxIndex < m_editorText.size()) {
m_mode = Edit;
m_caret.show();
m_editIndex = maxIndex;
}
}
break;
在上下箭头的情况下,我们模拟在当前行上方或下方一行处的鼠标点击,就像在之前的编辑情况中一样:
case Qt::Key_Up: {
QRect charRect = m_rectList[m_editIndex];
if (charRect.top() > 0) {
int x = charRect.left() + (charRect.width() / 2),
y = charRect.top() - 1;
simulateMouseClick(x, y);
}
}
break;
case Qt::Key_Down: {
QRect charRect = m_rectList[m_editIndex];
int x = charRect.left() + (charRect.width() / 2),
y = charRect.bottom() + 1;
simulateMouseClick(x, y);
}
break;
在标记模式下,删除键和退格键执行相同的任务——它们删除标记的文本:
case Qt::Key_Delete:
case Qt::Key_Backspace: {
int minIndex = qMin(m_firstIndex, m_lastIndex),
maxIndex = qMax(m_firstIndex, m_lastIndex);
我们从编辑文本中移除标记的文本,设置修改标志,将应用程序设置为编辑模式,将编辑索引设置为第一个和最后一个标记索引的最小值,并显示光标:
m_editorText.remove(minIndex, maxIndex - minIndex);
setModifiedFlag(true);
m_mode = Edit;
m_editIndex = minIndex;
m_caret.show();
}
break;
回车键的情况与之前的编辑模式情况类似,不同之处在于我们首先删除标记的文本。然后我们在编辑器文本中添加一个新行:
case Qt::Key_Return:
onDelete();
m_editorText.insert(m_editIndex++, 'n');
setModifiedFlag(true);
break;
如果键不是特殊键,我们通过在键事件指针上调用 text
来检查它是否是常规键。如果文本不为空,则用户打印了一个常规键,并且我们在编辑器文本中插入第一个字符:
default: {
QString text = eventPtr->text();
if (!text.isEmpty()) {
onDelete();
m_editorText.insert(m_editIndex++, text[0]);
setModifiedFlag(true);
}
}
break;
}
最后,我们计算包围字符的新矩形,设置光标,并通过调用 update
强制调用 paintEvent
:
calculate();
setCaret();
update();
}
当用户移动光标上下时调用 simulateMouseClick
方法。它通过调用 mousePressEvent
和 mouseReleaseEvent
并使用适当准备的事件对象来模拟鼠标点击:
void EditorWidget::simulateMouseClick(int x, int y) {
QMouseEvent pressEvent(QEvent::MouseButtonPress, QPointF(x, y),
Qt::LeftButton, Qt::NoButton, Qt::NoModifier);
mousePressEvent(&pressEvent);
QMouseEvent releaseEvent(QEvent::MouseButtonRelease,
QPointF(x, y), Qt::LeftButton,
Qt::NoButton, Qt::NoModifier);
mousePressEvent(&releaseEvent);
}
setCaret
方法在编辑模式下设置光标到适当的大小和位置。首先,我们使用 m_editIndex
找到正确字符的矩形。然后我们创建一个只有一像素宽的新矩形,以便光标看起来像一条细的垂直线:
void EditorWidget::setCaret() {
QRect charRect = m_rectList[m_editIndex];
QRect caretRect(charRect.left(), charRect.top(),
1, charRect.height());
m_caret.set(caretRect);
}
mouseToIndex
方法接受一个鼠标点并返回该点的字符索引。与 第五章 的版本 Qt 图形应用程序 不同,我们需要考虑文本可能是居中或右对齐的:
int EditorWidget::mouseToIndex(QPoint point) {
int x = point.x(), y = point.y();
如果鼠标指针位于编辑器文本下方,则返回最后一个字符的索引:
if (y > (m_fontHeight * m_lineList.size())) {
return m_editorText.size();
}
否则,我们首先找到鼠标指针所在的行,并获取该行第一个和最后一个字符的索引:
else {
int lineIndex = y / m_fontHeight;
QPair<int,int> lineInfo = m_lineList[lineIndex];
int firstIndex = lineInfo.first, lastIndex = lineInfo.second;
如果鼠标指针位于行上第一个字符的左侧(如果文本是居中或右对齐,则可能如此),我们返回行的第一个字符的索引:
if (x < m_rectList[firstIndex].left()) {
return firstIndex;
}
如果鼠标指针位于行的右侧,我们返回行中最后一个字符旁边的字符的索引:
else if (x >= m_rectList[lastIndex].right()) {
return (lastIndex + 1);
}
否则,我们遍历行上的字符,并对每个字符检查鼠标指针是否位于该字符的包围矩形内:
else {
for (int charIndex = firstIndex + 1;
charIndex <= lastIndex; ++charIndex){
int left = m_rectList[charIndex].left();
如果鼠标指针位于矩形内,我们检查它是否最接近矩形的左边界或右边界。如果它最接近左边界,我们返回字符的索引。如果它最接近右边界,我们则返回下一个字符的索引:
if (x < left) {
int last = m_rectList[charIndex - 1].left();
int leftSize = x - last, rightSize = left - x;
return (leftSize < rightSize) ? (charIndex - 1)
: charIndex;
}
}
}
}
我们不应该到达这个点。添加 assert
宏仅用于调试目的:
assert(false);
return 0;
}
当用户调整窗口大小时,会调用resizeEvent
方法。我们计算包含字符的矩形,因为窗口的宽度可能已更改,这可能导致行包含的字符数量减少或增加:
void EditorWidget::resizeEvent(QResizeEvent* eventPtr) {
calculate();
DocumentWidget::resizeEvent(eventPtr);
}
calculate
方法将文本分成行,并计算包含文本中每个字符的矩形。每行的第一个和最后一个字符的索引存储在m_lineList
中,包围的矩形存储在m_rectList
中:
void EditorWidget::calculate() {
m_lineList.clear();
m_rectList.clear();
我们使用 Qt QFontMetrics
类来获取编辑器字体中字符的高度。高度存储在m_fontHeight
中。width
方法给出窗口内容的宽度,以像素为单位:
QFontMetrics metrics(m_textFont);
m_fontHeight = metrics.height();
QList<int> charWidthList, lineWidthList;
int windowWidth = width();
我们首先遍历编辑器文本,以便将文本分成行:
{ int firstIndex = 0, lineWidth = 0;
for (int charIndex = 0; charIndex < m_editorText.size();
++charIndex) {
QChar c = m_editorText[charIndex];
当我们遇到新行时,我们将当前行的第一个和最后一个索引添加到m_lineList
中:
if (c == 'n') {
charWidthList.push_back(1);
lineWidthList.push_back(lineWidth);
m_lineList.push_back
(QPair<int,int>(firstIndex, charIndex));
firstIndex = charIndex + 1;
lineWidth = 0;
}
否则,我们调用 Qt QMetrics
对象的width
方法来获取字符的宽度,以像素为单位:
else {
int charWidth = metrics.width(c);
charWidthList.push_back(charWidth);
如果字符使行的宽度超过了窗口内容的宽度,我们将第一个和最后一个索引添加到m_lineList
中,并开始新的一行。
然而,我们需要考虑两种不同的情况。如果当前字符是行的第一个字符,那么会出现(相当不可能)这种情况,即该字符的宽度超过了窗口内容的宽度。在这种情况下,我们将该字符的索引添加到m_lineList
中作为第一个和最后一个索引。下一行的第一个索引是那个字符旁边的字符:
if ((lineWidth + charWidth) > windowWidth) {
if (firstIndex == charIndex) {
lineWidthList.push_back(windowWidth);
m_lineList.push_back
(QPair<int,int>(firstIndex, charIndex));
firstIndex = charIndex + 1;
}
如果当前字符不是行的第一个字符,我们将第一个字符和当前字符之前的字符的索引添加到m_lineList
中。下一行的索引变为当前字符的索引:
else {
lineWidthList.push_back(lineWidth);
m_lineList.push_back(QPair<int,int>(firstIndex,
charIndex - 1));
firstIndex = charIndex;
}
lineWidth = 0;
}
如果字符没有使行的宽度超过窗口内容的宽度,我们只需将字符的宽度添加到行的宽度中:
else {
lineWidth += charWidth;
}
}
}
最后,我们需要将最后一行添加到m_lineList
中:
m_lineList.push_back(QPair<int,int>(firstIndex,
m_editorText.size() - 1));
lineWidthList.push_back(lineWidth);
}
当我们将文本分成行后,我们继续计算单个字符的包围矩形。我们首先将top
设置为零,因为它持有行的顶部位置。对于每一行,它将增加行高:
{ int top = 0, left;
for (int lineIndex = 0; lineIndex < m_lineList.size();
++lineIndex) {
QPair<int,int> lineInfo = m_lineList[lineIndex];
int lineWidth = lineWidthList[lineIndex];
int firstIndex = lineInfo.first,
lastIndex = lineInfo.second;
根据文本的对齐方式,我们需要决定行从哪里开始。在左对齐的情况下,我们将行的左位置设置为零:
switch (m_alignment) {
case Left:
left = 0;
break;
在居中对齐的情况下,我们将左位置设置为窗口内容宽度与行宽度的差的一半。这样,行将出现在窗口的中心:
case Center:
left = (windowWidth - lineWidth) / 2;
break;
在右对齐的情况下,我们将左位置设置为窗口内容宽度与行宽度的差。这样,行将看起来在窗口的右侧:
case Right:
left = windowWidth - lineWidth;
break;
}
最后,当我们确定了文本行的起始左位置和每个文本字符的宽度后,我们遍历该行并计算每个字符的包围矩形:
for (int charIndex = firstIndex;
charIndex <= lastIndex;++charIndex){
int charWidth = charWidthList[charIndex];
QRect charRect(left, top, charWidth, m_fontHeight);
m_rectList.push_back(charRect);
left += charWidth;
}
对于文本的最后一行,我们添加一个包含超出最后一个字符位置的矩形:
if (lastIndex == (m_editorText.size() - 1)) {
QRect lastRect(left, top, 1, m_fontHeight);
m_rectList.push_back(lastRect);
}
每行新行时,顶部字段增加行高:
top += m_fontHeight;
}
}
}
框架在每次需要重绘窗口或通过调用update
强制重绘时都会调用paintEvent
方法。在调用paintEvent
之前,框架会清除窗口的内容:
首先,我们创建一个QPinter
对象,然后使用它来书写。我们设置了某些渲染和文本字体:
void EditorWidget::paintEvent(QPaintEvent* /* eventPtr */) {
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
painter.setRenderHint(QPainter::TextAntialiasing);
painter.setFont(m_textFont);
我们计算被标记文本的最小和最大索引(即使我们尚不知道应用程序是否处于标记模式):
int minIndex = qMin(m_firstIndex, m_lastIndex),
maxIndex = qMax(m_firstIndex, m_lastIndex);
我们遍历编辑器的文本。我们书写除换行符外的每个字符:
for (int index = 0; index < m_editorText.length(); ++index) {
QChar c = m_editorText[index];
如果字符被标记,我们使用白色文本在黑色背景上书写:
if (c != 'n') {
if ((m_mode == Mark) &&
(index >= minIndex) && (index < maxIndex)) {
painter.setPen(Qt::white);
painter.setBackground(Qt::black);
}
如果字符没有被标记,我们使用黑色文本在白色背景上书写:
else {
painter.setPen(Qt::black);
painter.setBrush(Qt::white);
}
当文本和背景的颜色设置好后,我们查找包含字符的矩形并书写字符本身:
QRect rect = m_rectList[index];
painter.drawText(rect, c);
}
}
最后,我们还绘制了光标:
m_caret.paint(&painter);
}
主函数
main
函数与之前应用程序的main
函数类似:它创建一个应用程序,显示绘图窗口,并开始应用程序的执行。
Main.cpp:
#include "EditorWindow.h"
#include <QApplication>
int main(int argc, char *argv[]) {
QApplication application(argc, argv);
EditorWindow editorWindow;
editorWindow.show();
return application.exec();
}
下面的截图显示了输出:
摘要
在本章中,我们开发了更高级的模拟时钟、绘图程序和编辑器版本。时钟显示当前的小时、分钟和秒。绘图程序允许用户绘制线条、矩形和椭圆。编辑器允许用户输入和编辑文本。时钟面使用数字而不是线条。在绘图程序中,我们可以标记、修改,以及剪切和粘贴图形,而在编辑器中,我们可以更改字体和段落对齐方式,并标记文本块。
在第七章“游戏”中,我们将开始开发奥赛罗和井字棋游戏。
第七章:游戏
在 第六章,“增强 QT 图形应用”中,我们使用 Qt 图形库开发了一个模拟时钟、一个绘图程序和一个编辑器。在本章中,我们继续开发 Othello 和井字棋游戏,使用 Qt 库。您将在本介绍之后找到这些游戏的描述。我们从这个章节的基本版本开始,其中两名玩家相互对战。在第八章 计算机游戏中,我们将游戏改进为计算机与人类对战。
本章我们将涵盖的主题包括:
-
博弈论简介。我们开发了一个游戏网格,玩家轮流将他们的标记添加到游戏网格中。
-
我们宣布获胜者。在国际象棋中,每走一步棋后,我们计算有多少个对手的标记可以被改变。当游戏网格的每个位置都被占据后,我们宣布获胜者或平局。
-
在井字棋中,我们计算一行中的标记数量。如果一行中有五个标记,我们宣布获胜者。
-
我们继续使用 C++ 特性,如类、字段和方法。我们还继续使用 Qt 特性,如窗口和小部件。
国际象棋
在国际象棋中,游戏网格在游戏开始时是空的。在游戏过程中,两名玩家轮流将黑白标记添加到游戏网格中。每次玩家添加标记时,我们查看其他标记,看看新的标记是否会导致对手的任何标记被包围。如果是这样,我们就交换对手被包围标记的颜色。
例如,如果黑子在三个白标记的左侧添加一个黑标记,第四个标记是黑,那么这三个白标记被两个黑标记包围,它们被交换成黑标记。当游戏网格上的每个位置都被黑白标记占据后,我们计算标记数量,标记数量最多的玩家获胜。如果黑白标记数量相等,则为平局。
这是我们游戏应该看起来像的:
游戏小部件
首先,我们需要一个游戏网格。GameWidget
类是本章以及 第八章,“计算机游戏”中所有应用的通用类。在第五章 Qt 图形应用和第六章 增强 QT 图形应用中,由于我们处理的是基于文档的应用,我们开发了 DocumentWidget
类。在本章和 第八章,“计算机游戏”中,我们则开发了 GameWidget
类。
上一章的DocumentWidget
类和本章以及下一章的GameWidget
类既有相似之处也有不同之处。它们都是 Qt 类QWidget
的子类,并且都旨在嵌入到窗口中。然而,DocumentWidget
旨在包含文档,而GameWidget
旨在包含游戏网格。它绘制网格并捕获网格位置的鼠标点击。GameWidget
是一个抽象类,允许其子类定义当用户点击鼠标或游戏网格中某个位置的标记需要重绘时调用的方法。
然而,我们重用了上一章的MainWindow
类来包含应用程序的主窗口及其菜单栏。
GameWidget.h
#ifndef GAMEWIDGET_H
#define GAMEWIDGET_H
#include <QPainter>
#include <QMouseEvent>
#include <QMessageBox>
#include "..\MainWindow\MainWindow.h"
class GameWidget : public QWidget {
Q_OBJECT
构造函数初始化游戏网格的行数和列数:
public:
GameWidget(int rows, int columns, QWidget* parentWidget);
clearGrid
方法将游戏网格中的每个位置都设置为零,这被假定为表示一个空位置。因此,继承GameWidget
的每个类都应该让零值代表一个空位置:
void clearGrid();
当用户改变窗口大小时会调用resizeEvent
方法。由于行数和列数是固定的,每个位置的宽度和高度会根据窗口的新大小进行改变:
void resizeEvent(QResizeEvent *eventPtr);
当用户按下鼠标按钮时调用mousePressEvent
,当窗口需要重绘时调用paintEvent
,当用户点击窗口右上角的关闭框时调用closeEvent
:
void mousePressEvent(QMouseEvent *eventPtr);
void paintEvent(QPaintEvent *eventPtr);
void closeEvent(QCloseEvent *eventPtr);
mouseMark
和drawMark
方法是纯虚方法,旨在被子类覆盖;当用户在网格中的某个位置点击时调用mouseMark
,当需要重绘某个位置时调用drawMark
。它们是纯虚方法,而GameWidget
是抽象的,这意味着只能将其用作基类。GameWidget
的子类必须覆盖这些方法以使其非抽象:
virtual void mouseMark(int row, int column) = 0;
virtual void drawMark(QPainter& painter,
const QRect& markRect, int mark) = 0;
isQuitOk
方法显示一个消息框,询问用户是否真的想要退出游戏:
private:
bool isQuitOk();
在Game
菜单可见之前调用isQuitEnabled
方法。当游戏正在进行时,Quit
项被启用:
public slots:
DEFINE_LISTENER(GameWidget, isQuitEnabled);
当用户选择退出或退出菜单项时调用onQuit
和onExit
方法:
void onQuit();
void onExit();
isGameInProgress
和setGameInProgress
方法返回和设置m_gameInProgress
字段的值:
protected:
bool isGameInProgress() const {return m_gameInProgress;}
void setGameInProgress(bool active)
{m_gameInProgress = active;}
get
和set
方法用于在游戏网格中的某个位置获取和设置一个值。该值是一个整数;记住,一个空位置被假定为包含值零:
protected:
int get(int row, int column) const;
void set(int row, int column, int value);
m_gameInProgress
字段在游戏进行中时为真。m_rows
和 m_columns
字段分别存储游戏网格的行数和列数;m_rowHeight
和 m_columnWidth
分别存储游戏网格中每个位置的高度和宽度(以像素为单位)。最后,m_gameGrid
是一个指向缓冲区的指针,该缓冲区包含游戏网格中位置值:
private:
bool m_gameInProgress = false;
int m_rows, m_columns;
int m_rowHeight, m_columnWidth;
int* m_gameGrid;
};
#endif // GAMEWIDGET_H
GameWidget.cpp
文件包含 GameWidget
类的方法定义、鼠标事件方法、菜单方法,以及标记的绘制和设置。
GameWidget.cpp
#include "GameWidget.h"
#include <QApplication>
#include <CAssert>
构造函数初始化网格的行数和列数,动态分配其内存,并调用 clearGrid
以清除网格:
GameWidget::GameWidget(int rows, int columns,
QWidget* parentWidget)
:QWidget(parentWidget),
m_rows(rows),
m_columns(columns),
m_gameGrid(new int[rows * columns]) {
assert(rows > 0);
assert(columns > 0);
clearGrid();
}
get
方法返回由行和列指示的位置的值,而 set
设置该值。包含值的缓冲区按行组织。也就是说,缓冲区的第一部分包含第一行,然后是第二行,依此类推:
int GameWidget::get(int row, int column) const {
return m_gameGrid[(row * m_columns) + column];
}
void GameWidget::set(int row, int column, int value) {
m_gameGrid[(row * m_columns) + column] = value;
}
clearGrid
方法将每个位置设置为零,因为零假设表示一个空位置:
void GameWidget::clearGrid() {
for (int row = 0; row < m_rows; ++row) {
for (int column = 0; column < m_columns; ++column) {
set(row, column, 0);
}
}
}
只要游戏在进行中,Quit
菜单项就处于启用状态:
bool GameWidget::isQuitEnabled() {
return m_gameInProgress;
}
如果用户在游戏进行中选择退出游戏,则显示一个带有确认问题的消息框:
bool GameWidget::isQuitOk() {
if (m_gameInProgress) {
QMessageBox messageBox(QMessageBox::Warning,
tr("Quit"), QString());
messageBox.setText(tr("Quit the Game."));
messageBox.setInformativeText
(tr("Do you really want to quit the game?"));
messageBox.setStandardButtons(QMessageBox::Yes |
QMessageBox::No);
messageBox.setDefaultButton(QMessageBox::No);
如果用户按下 Yes
按钮,则返回 true
:
return (messageBox.exec() == QMessageBox::Yes);
}
return true;
}
当用户选择退出菜单项时,会调用 onQuit
方法。如果 isQuitOk
的调用返回 true
,则将 m_gameInProgress
设置为 false
并调用更新,这最终强制重新绘制游戏网格所在的窗口,并清除网格。
void GameWidget::onQuit() {
if (isQuitOk()) {
m_gameInProgress = false;
update();
}
}
当用户选择退出菜单项时,会调用 onExit
方法。如果 isQuitOk
的调用返回 true
,则应用程序退出。这在上面的代码中显示:
void GameWidget::onExit() {
if (isQuitOk()) {
qApp->exit(0);
}
}
当用户调整窗口大小时,会调用 resizeEvent
方法。由于行数和列数是固定的,无论窗口大小如何,都会重新计算行高和列宽。我们将窗口的高度和宽度除以行数和列数加二,因为我们添加了额外的行和列作为边距。考虑以下代码:
void GameWidget::resizeEvent(QResizeEvent* eventPtr) {
m_rowHeight = height() / (m_rows + 2);
m_columnWidth = width() / (m_columns + 2);
QWidget::resizeEvent(eventPtr);
update();
}
当用户点击窗口时,会调用 mousePressEvent
方法:
void GameWidget::mousePressEvent(QMouseEvent* eventPtr) {
if (m_gameInProgress &&
(eventPtr->button() == Qt::LeftButton)) {
QPoint mousePoint = eventPtr->pos();
由于游戏网格被边距包围,因此从鼠标点中减去列宽和行高:
mousePoint.setX(mousePoint.x() - m_columnWidth);
mousePoint.setY(mousePoint.y() - m_rowHeight);
如果鼠标点位于游戏网格中的一个位置内,并且该位置为空(零),则调用纯虚方法 mouseMark
,该方法负责处理鼠标点击的实际操作。在下一节中,将在游戏网格中添加黑白标记,并在稍后的井字棋应用中添加。井字棋被添加到游戏网格中:
int row = mousePoint.y() / m_rowHeight,
column = mousePoint.x() / m_columnWidth;
如果点击的行和列位于游戏网格内(而不是游戏网格外的边距中)并且位置为空(零),我们调用 mouseMark
,这是一个纯虚方法,并带有行和列:
if ((row < m_rows) && (column < m_columns) &&
(get(row, column) == 0)) {
mouseMark(row, column);
update();
}
}
}
当窗口需要重绘时,会调用 paintEvent
方法。如果游戏正在进行中(m_gameInProgress
为 true),则写入行和列,然后对游戏网格中的每个位置调用纯虚方法 drawMark
,该方法负责实际绘制每个位置:
void GameWidget::paintEvent(QPaintEvent* /*eventPtr*/) {
if (m_gameInProgress) {
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
painter.setRenderHint(QPainter::TextAntialiasing);
首先,我们遍历行,并为每一行写入一个从 A
到 Z
的字母。字母表中有 26 个字母,我们假设没有超过 26 行:
for (int row = 0; row < m_rows; ++row) {
QString text;
text.sprintf("%c", (char) (((int) 'A') + row));
QRect charRect(0, (row + 1) * m_rowHeight,
m_columnWidth, m_rowHeight);
painter.drawText(charRect, Qt::AlignCenter |
Qt::AlignHCenter, text);
}
然后我们遍历列,并为每一列写入其编号:
for (int column = 0; column < m_columns; ++column) {
QString text;
text.sprintf("%i", column);
QRect charRect((column + 1) * m_columnWidth, 0,
m_columnWidth, m_rowHeight);
painter.drawText(charRect, Qt::AlignCenter |
Qt::AlignHCenter, text);
}
painter.save();
painter.translate(m_columnWidth, m_rowHeight);
纯虚方法是那些不打算在类中定义,而只在其子类中定义的方法。包含至少一个纯虚方法的类成为抽象类,这意味着无法创建该类的对象。该类只能作为类层次结构中的基类使用。继承自抽象类的类必须定义基类中的每个纯虚方法,或者自身也成为抽象类。
最后,我们遍历游戏网格,并对每个位置调用纯虚方法 drawMark
,该方法使用位置的矩形及其当前标记:
for (int row = 0; row < m_rows; ++row) {
for (int column = 0; column < m_columns; ++column) {
QRect markRect(column * m_columnWidth, row * m_rowHeight,
m_columnWidth, m_rowHeight);
painter.setPen(Qt::black);
painter.drawRect(markRect);
painter.fillRect(markRect, Qt::lightGray);
drawMark(painter, markRect, get(row, column));
}
}
painter.restore();
}
}
当用户点击窗口右上角的关闭按钮时,会调用 closeEvent
方法。如果 isQuitOk
的调用返回 true,则窗口关闭,应用程序退出:
void GameWidget::closeEvent(QCloseEvent* eventPtr) {
if (isQuitOk()) {
eventPtr->accept();
qApp->exit(0);
}
else {
eventPtr->ignore();
}
}
OthelloWindow 类
Othello
类是来自 第六章,增强 QT 图形应用程序 的 MainWindow
子类。它向窗口添加菜单,并将 OthelloWidget
类(它是 GameWidget
的子类)设置为中央小部件。
OthelloWindow.h
#ifndef OTHELLOWINDOW_H
#define OTHELLOWINDOW_H
#include "..\MainWindow\MainWindow.h"
#include "OthelloWidget.h"
class OthelloWindow : public MainWindow {
Q_OBJECT
public:
OthelloWindow(QWidget *parentWidget = nullptr);
~OthelloWindow();
void closeEvent(QCloseEvent *eventPtr)
{m_othelloWidgetPtr->closeEvent(eventPtr);}
m_othelloWidgetPtr
字段持有指向窗口中心小部件的指针。它指向 OthelloWidget
类的对象。以下代码展示了这一点:
private:
OthelloWidget* m_othelloWidgetPtr;
};
#endif // OTHELLOWINDOW_H
OthelloWindow.cpp
文件定义了 OthelloWindow
类的方法。
OthelloWindow.cpp
#include "OthelloWidget.h"
#include "OthelloWindow.h"
#include <QtWidgets>
构造函数将窗口标题设置为 Othello
并将大小设置为 1000 x 500 像素:
OthelloWindow::OthelloWindow(QWidget *parentWidget /*= nullptr*/)
:MainWindow(parentWidget) {
setWindowTitle(tr("Othello"));
resize(1000, 500);
动态创建 OthelloWidget
对象并将其放置在窗口中心:
m_othelloWidgetPtr = new OthelloWidget(this);
setCentralWidget(m_othelloWidgetPtr);
我们将菜单 Game
添加到菜单栏,并将 onMenuShow
方法连接到菜单,这样在菜单可见之前就会调用它:
{ QMenu* gameMenuPtr = menuBar()->addMenu(tr("&Game"));
connect(gameMenuPtr, SIGNAL(aboutToShow()),
this, SLOT(onMenuShow()));
用户可以选择黑色或白色来开始第一步。在项目可见之前会调用 isBlackStartsEnabled
和 isWhiteStartsEnabled
方法。当游戏进行时,项目会变为不可用:
addAction(gameMenuPtr, tr("&Black Starts"),
SLOT(onBlackStarts()), 0,
tr("Black Starts"), nullptr,tr("Black Starts"),
LISTENER(isBlackStartsEnabled));
addAction(gameMenuPtr, tr("&White Starts"),
SLOT(onWhiteStarts()), 0,
tr("White Starts"), nullptr, tr("White Starts"),
LISTENER(isWhiteStartsEnabled));
gameMenuPtr->addSeparator();
当游戏进行时,用户可以退出游戏。如果没有游戏进行,项目会变为不可用:
addAction(gameMenuPtr, tr("&Quit the Game"),
SLOT(onQuit()),
QKeySequence(Qt::CTRL + Qt::Key_Q),
tr("Quit Game"), nullptr, tr("Quit the Game"),
LISTENER(isQuitEnabled));
用户可以随时退出应用程序:
addAction(gameMenuPtr, tr("E&xit"),
SLOT(onExit()), QKeySequence::Quit);
}
}
析构函数释放窗口中心 Othello
小部件的资源:
OthelloWindow::~OthelloWindow() {
delete m_othelloWidgetPtr;
}
OthelloWidget 类
OthelloWidget
是我们在本章开头定义的GameWidget
类的子类。通过重写mouseMark
和drawMark
,它成为一个非抽象类,这两个方法分别在用户点击游戏网格中的位置和需要重绘位置时被调用。
OthelloWidget.h
#ifndef OTHELLOWIDGET_H
#define OTHELLOWIDGET_H
#include "..\MainWindow\GameWidget.h"
#define ROWS 8
#define COLUMNS 8
在奥赛罗游戏中,标记可以是黑色或白色。我们使用Mark
枚举来存储游戏网格上的值。Empty
项持有零值,它被假定为GameWidget
以表示空位:
enum Mark {Empty = 0, Black, White};
class OthelloWidget : public GameWidget {
Q_OBJECT
public:
OthelloWidget(QWidget* parentWidget);
void mouseMark(int row, int column);
void drawMark(QPainter& painter,
const QRect& markRect, int mark);
在BlackStarts
和WhiteStarts
菜单项可见之前,会调用isBlackStartsEnabled
和isWhiteStartsEnabled
监听器以启用它们。请注意,监听器和方法必须被标记为公共槽,以便菜单框架允许它们作为监听器:
public slots:
DEFINE_LISTENER(OthelloWidget, isBlackStartsEnabled);
DEFINE_LISTENER(OthelloWidget, isWhiteStartsEnabled);
当用户选择BlackStarts
和WhiteStarts
菜单项时,会调用onBlackStarts
和onWhiteStarts
方法:
void onBlackStarts();
void onWhiteStarts();
如果游戏网格上的每个位置都被黑白标记占据,checkWinner
方法会检查。如果是这样,就会计算标记,并宣布获胜者,除非是平局:
private:
void checkWinner();
当一位玩家移动时,会调用turn
方法。它计算移动结果导致的翻转位置:
void turn(int row, int column, Mark mark);
calculateMark
方法计算如果玩家在由行和列给出的位置放置标记,则要翻转的标记集合:
void calculateMark(int row, int column, Mark mark,
QSet<QPair<int,int>>& resultSet);
m_nextMark
字段交替地赋予前一个Mark
枚举的Black
和White
值,这取决于哪个玩家即将进行下一步移动。
它由onBlackStarts
或onWhiteStarts
初始化,如前述代码所示:
Mark m_nextMark;
};
#endif // OTHELLOWIDGET_H
OthelloWidget
类包含游戏的功能。它允许玩家将黑白标记添加到游戏网格,翻转标记,并宣布获胜者。
OthelloWidget.cpp
#include "OthelloWidget.h"
#include "OthelloWindow.h"
#include <QTime>
#include <CTime>
#include <CAssert>
using namespace std;
OthelloWidget::OthelloWidget(QWidget* parentWidget)
:GameWidget(ROWS, COLUMNS, parentWidget) {
// Empty.
}
当没有正在进行的游戏时,BlackStarts
和WhiteStarts
菜单项被启用:
bool OthelloWidget::isBlackStartsEnabled() {
return !isGameInProgress();
}
bool OthelloWidget::isWhiteStartsEnabled() {
return !isGameInProgress();
}
onBlackStarts
和onWhiteStarts
方法设置一个新游戏开始,设置第一个移动的标记(黑色或白色),清除网格,并更新窗口以绘制一个空的游戏网格:
void OthelloWidget::onBlackStarts() {
setGameInProgress(true);
m_nextMark = Black;
update();
}
void OthelloWidget::onWhiteStarts() {
setGameInProgress(true);
m_nextMark = White;
update();
}
当玩家在游戏网格上点击一个空位时,会调用onMouseMark
。我们使用下一个标记设置位置,翻转所有受移动影响的标记,并更新窗口以反映变化:
void OthelloWidget::mouseMark(int row, int column) {
set(row, column, m_nextMark);
turn(row, column, m_nextMark);
update();
我们检查移动是否使游戏网格变得满载,并切换下一个标记:
checkWinner();
m_nextMark = (m_nextMark == Black) ? White : Black;
}
当游戏网格中的某个位置需要重绘时,会调用drawMark
方法。如果位置不为空,我们用黑色边框绘制一个黑色或白色的椭圆。如果位置为空,我们不做任何操作。请注意,框架在调用重绘之前清除窗口:
void OthelloWidget::drawMark(QPainter& painter,
const QRect& markRect, int mark) {
painter.setPen(Qt::black);
painter.drawRect(markRect);
painter.fillRect(markRect, Qt::lightGray);
switch (mark) {
case Black:
painter.setPen(Qt::black);
painter.setBrush(Qt::black);
painter.drawEllipse(markRect);
break;
case White:
painter.setPen(Qt::white);
painter.setBrush(Qt::white);
painter.drawEllipse(markRect);
break;
case Empty:
break;
}
}
checkWinner
方法计算被黑白标记占据或为空的格子的数量:
void OthelloWidget::checkWinner() {
int blacks = 0, whites = 0, empties = 0;
for (int row = 0; row < ROWS; ++row) {
for (int column = 0; column < COLUMNS; ++column) {
switch (get(row, column)) {
case Black:
++blacks;
break;
case White:
++whites;
break;
case Empty:
++empties;
break;
}
}
}
如果没有剩余的空位置,游戏结束,我们宣布获胜者,除非是平局。获胜者是拥有最多标记的玩家:
if (empties == 0) {
QMessageBox messageBox(QMessageBox::Information,
tr("Victory"), QString());
QString text;
if (blacks == whites) {
text.sprintf("A Draw.");
}
else if (blacks > whites) {
text.sprintf("The Winner: Black");
}
else {
text.sprintf("The Winner: White");
}
messageBox.setText(text);
messageBox.setStandardButtons(QMessageBox::Ok);
messageBox.exec();
setGameInProgress(false);
clearGrid();
update();
}
}
turn
方法调用 calculateMark
来获取标记应翻转的位置集合。然后,将集合中的每个位置都设置为相应的标记。
在这个应用程序中,turn
是唯一调用 calculateMark
的方法。然而,在第八章,《计算机对战》中,calculateMark
也会被调用以计算计算机玩家的移动。因此,turn
和 calculateMark
的功能被分为两个方法:
void OthelloWidget::turn(int row, int column, Mark mark) {
QSet<QPair<int,int>> totalSet;
calculateMark(row, column, mark, totalSet);
for (QPair<int,int> pair : totalSet) {
int row = pair.first, column = pair.second;
set(row, column, mark);
}
}
calculateMark
方法计算游戏网格上每个位置将翻转的标记数量,包括八个方向:
void OthelloWidget::calculateMark(int row, int column,
Mark playerMark, QSet<QPair<int,int>>& totalSet){
directionArray
中的每个整数对都根据罗盘上升的方向指代一个方向:
QPair<int,int> directionArray[] =
{QPair<int,int>(-1, 0), // North
QPair<int,int>(-1, 1), // Northeast
QPair<int,int>(0, 1), // East
QPair<int,int>(1, 1), // Southeast
QPair<int,int>(1, 0), // South
QPair<int,int>(1, -1), // Southwest
QPair<int,int>(0, -1), // West
QPair<int,int>(-1, -1)}; // Northwest
数组的大小可以通过将其总大小(以字节为单位)除以第一个值的大小来确定:
int arraySize =
(sizeof directionArray) / (sizeof directionArray[0]);
我们遍历方向,并且对于每个方向,只要我们找到对手的标记就继续移动:
for (int index = 0; index < arraySize; ++index) {
QPair<int,int> pair = directionArray[index];
row
和 column
字段在迭代该方向时保持当前行和列:
int rowStep = pair.first, columnStep = pair.second,
currRow = row, currColumn = column;
我们在迭代过程中找到的标记收集到 directionSet
中:
QSet<QPair<int,int>> directionSet;
while (true) {
currRow += rowStep;
currColumn += columnStep;
如果我们到达游戏网格的边界之一,或者如果我们找到一个空位置,我们就会中断迭代:
if ((currRow < 0) || (currRow == ROWS) ||
(currColumn < 0) || (currColumn == COLUMNS) ||
(get(currRow, currColumn) == Empty)) {
break;
}
如果我们找到玩家的标记,我们将方向集合添加到总集合中,并中断迭代:
else if (get(currRow, currColumn) == playerMark) {
totalSet += directionSet;
break;
}
如果我们没有找到玩家的标记或空位置,我们就找到了对手的标记,并将其位置添加到方向集合中:
else {
directionSet.insert(QPair<int,int>(row, column));
}
}
}
}
主函数
main
函数与之前 Qt 应用程序中的方式相同。它创建一个应用程序,显示 Othello 窗口,并执行应用程序。执行将继续,直到调用 exit
方法,这通常发生在用户关闭窗口或选择退出菜单项时。
Main.cpp
#include "OthelloWidget.h"
#include "OthelloWindow.h"
#include <QApplication>
int main(int argc, char *argv[]) {
QApplication application(argc, argv);
OthelloWindow othelloWindow;
othelloWindow.show();
return application.exec();
}
井字棋
Noughts and Crosses 应用程序设置了一个游戏网格,并允许两名玩家相互对战。在井字棋游戏中,两名玩家轮流在游戏网格上添加圆圈和叉号。首先成功将五个标记连成一线的玩家赢得游戏。标记可以水平、垂直或对角放置。虽然每个玩家都试图将五个自己的标记连成一线,但他们也必须尝试阻止对手将五个标记连成一线。
在第八章,《计算机对战》中,计算机与人类玩家对战。
NaCWindow 类
我们重用了游戏部件部分的 GameWidget
。NaCWindow
类与 OthelloWindow
类类似。它将 Nought Begins
和 Cross Begins
菜单项添加到窗口的菜单栏中。
NaCWindow.h
#ifndef NACWINDOW_H
#define NACWINDOW_H
#include "..\MainWindow\MainWindow.h"
#include "NaCWidget.h"
class NaCWindow : public MainWindow {
Q_OBJECT
public:
NaCWindow(QWidget *parentWidget = nullptr);
~NaCWindow();
public:
void closeEvent(QCloseEvent *eventPtr) override
{m_nacWidgetPtr->closeEvent(eventPtr);}
private:
NaCWidget* m_nacWidgetPtr;
};
#endif // NACWINDOW_H
NaCWindow.cpp
文件包含 NacWindow
类的方法定义。
NaCWindow.cpp
#include "NaCWindow.h"
#include <QtWidgets>
NaCWindow::NaCWindow(QWidget *parentWidget /*= nullptr*/)
:MainWindow(parentWidget) {
setWindowTitle(tr("Noughts and Crosses"));
resize(1000, 500);
m_nacWidgetPtr = new NaCWidget(this);
setCentralWidget(m_nacWidgetPtr);
{ QMenu* gameMenuPtr = menuBar()->addMenu(tr("&Game"));
connect(gameMenuPtr, SIGNAL(aboutToShow()),
this, SLOT(onMenuShow()));
addAction(gameMenuPtr, tr("&Nought Starts"),
SLOT(onNoughtStarts()), 0,
tr("Nought Starts"), nullptr, tr("Nought Starts"),
LISTENER(isNoughtStartsEnabled));
addAction(gameMenuPtr, tr("&Cross Starts"),
SLOT(onCrossStarts()), 0,
tr("Cross Starts"), nullptr, tr("Cross Starts"),
LISTENER(isCrossStartsEnabled));
gameMenuPtr->addSeparator();
addAction(gameMenuPtr, tr("&Quit the Game"),
SLOT(onQuit()),
QKeySequence(Qt::CTRL + Qt::Key_Q), tr("Quit Game"),
nullptr, tr("Quit the Game"),
LISTENER(isQuitEnabled));
addAction(gameMenuPtr, tr("E&xit"),
SLOT(onExit()), QKeySequence::Quit);
}
}
NaCWindow::~NaCWindow() {
delete m_nacWidgetPtr;
}
NaCWidget 类
NaCWidget
类处理井字棋的功能。它允许两个玩家相互对战。在第八章,计算机对战中,我们将编写一个计算机对战人类的游戏:
NaCWidget.h
#ifndef NACWIDGET_H
#define NACWIDGET_H
#include "..\MainWindow\GameWidget.h"
#define ROWS 26
#define COLUMNS 26
与奥赛罗应用类似,游戏网格中的位置可以持有三个值之一:
-
Empty
(即零) -
Nought
-
Cross
Mark
枚举对应于 Empty
、Nought
和 Cross
值:
enum Mark {Empty = 0, Nought, Cross};
class NaCWidget : public GameWidget {
Q_OBJECT
public:
NaCWidget(QWidget* parentWidget);
void mouseMark(int row, int column);
void drawMark(QPainter& painter,
const QRect& markRect, int mark);
public slots:
DEFINE_LISTENER(NaCWidget, isNoughtStartsEnabled);
void onNoughtStarts();
DEFINE_LISTENER(NaCWidget, isCrossStartsEnabled);
void onCrossStarts();
private:
void checkWinner(int row, int column, Mark mark);
int countMarks(int row, int column, int rowStep,
int columnStep, Mark mark);
Mark m_nextMark;
};
#endif // NACWIDGET_H
NaCWidget.cpp
文件包含 NaCWidget
类的方法定义:
NaCWidget.cpp
#include "NaCWidget.h"
#include <CTime>
NaCWidget::NaCWidget(QWidget* parentWidget)
:GameWidget(ROWS, COLUMNS, parentWidget) {
// Empty.
}
在 Game
菜单可见之前,会调用 isNoughtStartsEnabled
和 isCrossStartsEnabled
方法。如果没有正在进行的游戏,则启用 Noughts Begins
和 Cross Begins
菜单项:
bool NaCWidget::isCrossStartsEnabled() {
return !isGameInProgress();
}
bool NaCWidget::isNoughtStartsEnabled() {
return !isGameInProgress();
}
当用户选择 Nought Begins
和 Cross Begins
菜单项时,会调用 onNoughtBegins
和 onCrossBegins
方法。它们设置正在进行的游戏,设置第一个标记以进行第一次移动(m_nextMark
),并通过调用 update
强制重绘游戏网格:
void NaCWidget::onNoughtStarts() {
setGameInProgress(true);
m_nextMark = Nought;
update();
}
void NaCWidget::onCrossStarts() {
setGameInProgress(true);
m_nextMark = Cross;
update();
}
当玩家在游戏网格中点击一个位置时,会调用 mouseMark
方法。我们在该位置设置下一个标记,检查是否有玩家赢得了游戏,交换下一个移动,并通过调用 update
来重绘窗口:
void NaCWidget::mouseMark(int row, int column) {
set(row, column, m_nextMark);
checkWinner(row, column, m_nextMark);
m_nextMark = (m_nextMark == Nought) ? Cross : Nought;
update();
}
当游戏网格中的位置需要重绘时,会调用 drawMark
方法:
void NaCWidget::drawMark(QPainter& painter,
const QRect& markRect, int mark) {
我们将笔的颜色设置为黑色,在井的情况下,我们画一个椭圆,如下所示:
painter.setPen(Qt::black);
switch (mark) {
case Nought:
painter.drawEllipse(markRect);
break;
在十字的情况下,我们在左上角和右下角以及右上角和左下角之间画两条线:
case Cross:
painter.drawLine(markRect.topLeft(),
markRect.bottomRight());
painter.drawLine(markRect.topRight(),
markRect.bottomLeft());
break;
在空位的情况下,我们不做任何操作。请记住,框架在重绘之前会清除窗口:
case Empty:
break;
}
}
当玩家移动时,我们检查该移动是否导致了胜利。我们调用 countMarks
在四个方向上到 checkWinner
,看看移动是否导致了五个标记连成一线:
void NaCWidget::checkWinner(int row, int column, Mark mark) {
对于北和南方向,代码如下:
if ((countMarks(row, column, -1, 0, mark) >= 5) ||
对于西和东方向,代码如下:
(countMarks(row, column, 0, -1, mark) >= 5) ||
对于西北和东南方向,代码如下:
(countMarks(row, column, -1, 1, mark) >=5)||
对于东南和西北方向,代码如下:
(countMarks(row, column, 1, 1, mark) >= 5)) {
如果移动导致一行有五个标记,我们将显示一个包含获胜者(黑色或白色)的消息框。在井字棋中,不可能平局:
QMessageBox messageBox(QMessageBox::Information,
tr("Victory"), QString());
QString text;
text.sprintf("The Winner: %s.",
(mark == Nought) ? "Nought" : "Cross");
messageBox.setText(text);
messageBox.setStandardButtons(QMessageBox::Ok);
messageBox.exec();
setGameInProgress(false);
游戏网格被清除,因此准备好进行另一场比赛:
clearGrid();
update();
}
}
countMarks
方法计算一行中的标记数量。我们在两个方向上计算标记的数量。例如,如果 rowStep
和 columnStep
都是减一,那么我们每次迭代都减少当前行和列。这意味着我们在第一次迭代中在东北方向调用 countMarks
。在第二次迭代中,我们在相反的方向调用 countMarks
,即西南方向:
int NaCWidget::countMarks(int row, int column, int rowStep,
int columnStep, Mark mark) {
int countMarks = 0;
我们继续计数,直到遇到游戏网格的边界,或者我们找到的不是我们正在计数的标记,即对手的标记或空标记:
{ int currentRow = row, currentColumn = column;
while ((currentRow >= 0) && (currentRow < ROWS) &&
(currentColumn >= 0) && (currentColumn < COLUMNS) &&
(get(currentRow, currentColumn) == mark)) {
++countMarks;
currentRow += rowStep;
currentColumn += columnStep;
}
}
在第二次迭代中,我们减去行和列的步长而不是增加它们。这样,我们以相反的方向调用countMarks
。我们还通过顺序添加步长来初始化当前行和列,这样我们就不会对中间的标记进行两次countMarks
:
{ int currentRow = row + rowStep,
currentColumn = column + columnStep;
while ((currentRow >= 0) && (currentRow < ROWS) &&
(currentColumn >= 0) && (currentColumn < COLUMNS) &&
(get(currentRow, currentColumn) == mark)) {
++countMarks;
currentRow -= rowStep;
currentColumn -= columnStep;
}
}
return countMarks;
}
主函数
main
函数创建应用程序,显示窗口,并在用户关闭窗口或选择退出菜单项之前执行应用程序。
Main.cpp
#include "NaCWidget.h"
#include "NaCWindow.h"
#include <QApplication>
int main(int argc, char *argv[]) {
QApplication application(argc, argv);
NaCWindow mainWindow;
mainWindow.show();
return application.exec();
}
上述代码的输出如下:
摘要
在本章中,我们开发了两个游戏,黑白棋和井字棋。我们介绍了博弈论,并开发了一个游戏网格,玩家轮流在其上添加标记。在黑白棋中,我们开发了计算每步需要更改的标记数量的方法,在井字棋中,我们开发了识别玩家是否成功在一行中放置了五个标记的方法——如果他们做到了,我们宣布他们为胜者。
在第八章,《计算机游戏》,我们将开发这些游戏的更高级版本,其中计算机与人类玩家对战。
第八章:计算机走棋
在本章中,我们继续开发 Othello 和井字棋游戏。本章的新内容是计算机与人类对弈;而不是两个人类玩家,计算机与人类对弈。
本章我们将涵盖的主题包括:
-
博弈论推理。在这两个游戏中,人类或计算机可以首先走棋,我们为计算机添加了与人类对弈的代码。
-
在 Othello 中,对于每一步,我们扫描游戏网格并试图找到导致人类标记交换数量最多的走法。
-
在井字棋游戏中,我们试图找到游戏网格中能给我们带来最高行数标记的位置,或者如果人类即将在行中获得五个标记,我们必须放置计算机的标记在防止这种情况发生的位置。
-
随机数生成简介。如果计算机可以在几个等效的走法之间选择,它应随机选择其中一个走法。
-
我们继续使用 C++ 特性,如类、字段和方法。我们还继续使用 Qt 特性,如窗口和小部件。
Othello
在本章的 Othello 应用程序中,我们重用了上一章的 MainWindow
和 GameWidget
类。
OthelloWindow 类
OthelloWindow
类与上一章的对应类相当相似。然而,除了菜单和选项外,这个版本的窗口还包含子菜单。子菜单将通过在 OthelloWindow.cpp
文件中调用 addAction
方法来添加。
OthelloWindow.h
#ifndef OTHELLOWINDOW_H
#define OTHELLOWINDOW_H
#include "..\MainWindow\MainWindow.h"
#include "OthelloWidget.h"
class OthelloWindow : public MainWindow {
Q_OBJECT
public:
OthelloWindow(QWidget *parentWidget = nullptr);
~OthelloWindow();
void closeEvent(QCloseEvent *eventPtr)
{m_othelloWidgetPtr->closeEvent(eventPtr);}
private:
OthelloWidget* m_othelloWidgetPtr;
};
#endif // OTHELLOWINDOW_H
OthelloWindow.cpp
文件包含 OthelloWindow
类的方法定义。
OthelloWindow.cpp
#include "OthelloWidget.h"
#include "OthelloWindow.h"
#include <QtWidgets>
窗口的标题已更改为 Othello Advanced
:
OthelloWindow::OthelloWindow(QWidget *parentWidget /*= nullptr*/)
:MainWindow(parentWidget) {
setWindowTitle(tr("Othello Advanced"));
resize(1000, 500);
m_othelloWidgetPtr = new OthelloWidget(this);
setCentralWidget(m_othelloWidgetPtr);
{ QMenu* gameMenuPtr = menuBar()->addMenu(tr("&Game"));
connect(gameMenuPtr, SIGNAL(aboutToShow()),
this, SLOT(onMenuShow()));
游戏菜单中有两个子菜单,Computer Starts
和 Human Starts
:
{ QMenu* computerStartsMenuPtr =
gameMenuPtr->addMenu(tr("&Computer Starts"));
connect(computerStartsMenuPtr, SIGNAL(aboutToShow()),
this, SLOT(onMenuShow()));
Computer Starts
子菜单包含两个选项 Computer Black
和 Computer White
:
addAction(computerStartsMenuPtr, tr("Computer &Black"),
SLOT(onComputerStartsBlack()), 0,
tr("Computer Black"), nullptr,
tr("Computer Black"),
LISTENER(isComputerStartsBlackEnabled));
addAction(computerStartsMenuPtr, tr("Computer &White"),
SLOT(onComputerStartsWhite()), 0,
tr("Computer White"), nullptr,
tr("Computer White"),
LISTENER(isComputerStartsWhiteEnabled));
}
Human Starts
子菜单包含两个选项,Human Black
和 Human White
:
{ QMenu* humanStartsMenuPtr =
gameMenuPtr->addMenu(tr("&Human Starts"));
connect(humanStartsMenuPtr, SIGNAL(aboutToShow()),
this, SLOT(onMenuShow()));
addAction(humanStartsMenuPtr, tr("Human &Black"),
SLOT(onHumanStartsBlack()), 0, tr("Human Black"),
nullptr, tr("Human Black"),
LISTENER(isHumanStartsBlackEnabled));
addAction(humanStartsMenuPtr, tr("Human &White"),
SLOT(onHumanStartsWhite()), 0, tr("Human White"),
nullptr, tr("Human White"),
LISTENER(isHumanStartsWhiteEnabled));
}
gameMenuPtr->addSeparator();
addAction(gameMenuPtr, tr("&Quit the Game"),
SLOT(onQuit()),
QKeySequence(Qt::CTRL + Qt::Key_Q), tr("Quit Game"),
nullptr, tr("Quit the Game"),
LISTENER(isQuitEnabled));
addAction(gameMenuPtr, tr("E&xit"),i
SLOT(onExit()), QKeySequence::Quit);
}
}
OthelloWindow::~OthelloWindow() {
delete m_othelloWidgetPtr;
}
OthelloWidget 类
OthelloWidget
类包含 Othello 的功能。它允许计算机与人类对弈:
OthelloWidget.h
#ifndef OTHELLOWIDGET_H
#define OTHELLOWIDGET_H
#include "..\MainWindow\GameWidget.h"
#define ROWS 8
#define COLUMNS 8
enum Mark {Empty = 0, Black, White};
class OthelloWidget : public GameWidget {
Q_OBJECT
public:
OthelloWidget(QWidget* parentWidget);
void mouseMark(int row, int column);
void drawMark(QPainter& painter,
const QRect& markRect, int mark);
public slots:
DEFINE_LISTENER(OthelloWidget, isComputerStartsBlackEnabled);
DEFINE_LISTENER(OthelloWidget, isComputerStartsWhiteEnabled);
DEFINE_LISTENER(OthelloWidget, isHumanStartsBlackEnabled);
DEFINE_LISTENER(OthelloWidget, isHumanStartsWhiteEnabled);
void onComputerStartsBlack();
void onComputerStartsWhite();
void onHumanStartsBlack();
void onHumanStartsWhite();
private:
bool checkWinner();
void turn(int row, int column, Mark mark);
void calculateComputerMove();
void calculateTurns(int row, int column, Mark mark,
QSet<QPair<int,int>>& totalSet,
int& neighbours);
Mark m_humanMark, m_computerMark;
};
#endif // OTHELLOWIDGET_H
OthelloWidget.cpp
文件包含 OthelloWidget
类的方法定义:
OthelloWidget.cpp
#include "OthelloWidget.h"
#include "OthelloWindow.h"
#include <QTime>
#include <CTime>
#include <CAssert>
using namespace std;
OthelloWidget::OthelloWidget(QWidget* parentWidget)
:GameWidget(ROWS, COLUMNS, parentWidget) {
// Empty.
}
在调用 Computer Starts
和 Human Starts
子菜单之前,会调用 isComputerStartsBlackEnabled
、isComputerStartsWhiteEnabled
、isHumanStartsBlackEnabled
和 isHumanStartsWhiteEnabled
方法。如果没有进行游戏,它们将变为可用状态:
bool OthelloWidget::isComputerStartsBlackEnabled() {
return !isGameInProgress();
}
bool OthelloWidget::isComputerStartsWhiteEnabled() {
return !isGameInProgress();
}
bool OthelloWidget::isHumanStartsBlackEnabled() {
return !isGameInProgress();
}
bool OthelloWidget::isHumanStartsWhiteEnabled() {
return !isGameInProgress();
}
当用户选择 Computer Starts
子菜单中的一个选项时,会调用 onComputerStartsBlack
和 onComputerStartsWhite
方法。它们将计算机标记设置为黑色或白色,通过在游戏网格中间设置标记来开始游戏,并更新窗口:
void OthelloWidget::onComputerStartsBlack() {
setGameInProgress(true);
set(ROWS / 2, COLUMNS / 2, m_computerMark = Black);
m_humanMark = White;
update();
}
void OthelloWidget::onComputerStartsWhite() {
setGameInProgress(true);
set(ROWS / 2, COLUMNS / 2, m_computerMark = White);
m_humanMark = Black;
update();
}
当用户在Human Starts
子菜单中选择一个项目时,会调用onHumanStartsBlack
和onHumanStartsWhite
方法。它们将电脑标记设置为黑色或白色并更新窗口。它们在游戏网格中不设置任何标记。相反,人类将首先移动:
void OthelloWidget::onHumanStartsBlack() {
setGameInProgress(true);
m_humanMark = Black;
m_computerMark = White;
update();
}
void OthelloWidget::onHumanStartsWhite() {
setGameInProgress(true);
m_humanMark = White;
m_computerMark = Black;
update();
}
当用户在游戏网格中点击一个空位置时,会调用mouseMark
方法。我们首先设置下一个标记在位置上,然后根据移动翻转标记:
void OthelloWidget::mouseMark(int row, int column) {
set(row, column, m_humanMark);
turn(row, column, m_humanMark);
update();
如果人类的移动没有使游戏网格变满,我们调用calculateComputerMove
来设置电脑标记到该位置,从而翻转最大数量的相反标记。然后我们更新窗口并再次调用checkWinner
以决定电脑的移动是否使游戏网格变满:
if (!checkWinner()) {
calculateComputerMove();
update();
checkWinner();
}
}
当游戏网格中的某个位置需要重新绘制时,会调用drawMark
方法。它以与上一章相同的方式绘制标记:
void OthelloWidget::drawMark(QPainter& painter,
const QRect& markRect, int mark) {
painter.setPen(Qt::black);
painter.drawRect(markRect);
painter.fillRect(markRect, Qt::lightGray);
switch (mark) {
case Black:
painter.setPen(Qt::black);
painter.setBrush(Qt::black);
painter.drawEllipse(markRect);
break;
case White:
painter.setPen(Qt::white);
painter.setBrush(Qt::white);
painter.drawEllipse(markRect);
break;
case Empty:
break;
}
}
本章的checkWinner
方法与上一章的对应方法类似。它检查游戏网格是否已满。如果已满,则宣布获胜者,否则为平局:
bool OthelloWidget::checkWinner() {
int blacks = 0, whites = 0, empties = 0;
for (int row = 0; row < ROWS; ++row) {
for (int column = 0; column < COLUMNS; ++column) {
switch (get(row, column)) {
case Black:
++blacks;
break;
case White:
++whites;
break;
case Empty:
++empties;
break;
}
}
}
if (empties == 0) {
QMessageBox messageBox(QMessageBox::Information,
tr("Victory"), QString());
QString text;
if (blacks > whites) {
text.sprintf("The Winner: %s.", (m_computerMark == Black)
? "Computer" : "Human");
}
else if (whites > blacks) {
text.sprintf("The Winner: %s.", (m_computerMark == White)
? "Computer" : "Human");
}
else {
text.sprintf("A Draw.");
}
messageBox.setText(text);
messageBox.setStandardButtons(QMessageBox::Ok);
messageBox.exec();
setGameInProgress(false);
clearGrid();
update();
return true;
}
return false;
}
calculateComputerMove
方法计算电脑的移动,该移动生成最多的翻转相反标记。我们遍历电脑标记,并对每个标记调用calculateTurns
以获取如果我们把标记放在那个位置,将翻转的相反标记的最大数量。对于每个标记,我们还获取邻居的数量,这在找不到任何标记翻转时很有价值。
maxTurnSetSize
和maxNeighbours
字段保存可翻转标记和邻居的最大数量;maxTurnSetList
保存可翻转标记位置的最大集合列表,而maxNeighboursList
保存邻居数量的最大列表:
void OthelloWidget::calculateComputerMove() {
int maxTurnSetSize = 0, maxNeighbours = 0;
QList<QSet<QPair<int,int>>> maxTurnSetList;
QList<QPair<int,int>> maxNeighboursList;
我们遍历游戏网格中的所有位置。对于每个空位置,我们获取如果我们把标记放在那个位置,将翻转的相反标记的数量。我们还获取相反邻居的数量:
for (int row = 0; row < ROWS; ++row) {
for (int column = 0; column < COLUMNS; ++column) {
if (get(row, column) == Empty) {
QSet<QPair<int,int>> turnSet;
int neighbours = 0;
calculateTurns(row, column, m_computerMark,
turnSet, neighbours);
int turnSetSize = turnSet.size();
如果我们发现一个可翻转标记的集合大于当前的最大集合,我们将maxTurnSetSize
字段设置为新的可翻转集合的大小,将当前位置插入集合中,清除maxTurnSetList
(因为我们不希望保留其之前的较小集合),并添加新的集合。
我们添加当前集合是为了简化;将其添加到集合中比以其他方式存储它更容易:
if (turnSetSize > maxTurnSetSize) {
maxTurnSetSize = turnSetSize;
turnSet.insert(QPair<int,int>(row, column));
maxTurnSetList.clear();
maxTurnSetList.append(turnSet);
}
如果新集合不为空且与最大集合大小相等,我们只需将其添加到maxTurnSetList
:
else if ((turnSetSize > 0) &&
(turnSetSize == maxTurnSetSize)) {
turnSet.insert(QPair<int,int>(row, column));
maxTurnSetList.append(turnSet);
}
我们还检查当前位置的邻居数量。我们以与turnable
集合案例相同的方式进行工作。如果邻居数量超过最大邻居数量,我们清除maxNeighboursList
并添加新位置:
if (neighbours > maxNeighbours) {
maxNeighbours = neighbours;
maxNeighboursList.clear();
maxNeighboursList.append(QPair<int,int>(row, column));
}
如果至少有一个邻居,并且邻居数量等于最大邻居数量,我们将它添加到maxNeighboursList
列表中:
else if ((neighbours > 0) &&
(neighbours == maxNeighbours)) {
maxNeighboursList.append(QPair<int,int>(row, column));
}
}
}
}
如果至少有一个位置我们将转动至少一个相反标记,我们就选择它。如果有几个位置将转动相同数量的相反标记,我们就随机选择其中一个。我们使用 C 标准函数srand
、rand
和time
来获取一个随机整数。
随机数生成算法接受一个起始值,然后生成一系列随机数。srand
函数使用起始值初始化生成器,然后rand
被反复调用以获取新的随机数。为了不在每次调用srand
时使用相同的起始值(这将导致相同的随机数序列),我们使用调用time
标准 C 函数的结果来调用srand
,该函数返回自 1970 年 1 月 1 日以来的秒数。这样,随机数生成器为每场比赛初始化一个新的值,我们通过反复调用rand
来获得新的随机数序列:
if (maxTurnSetSize > 0) {
srand(time(NULL));
int index = rand() % maxTurnSetList.size();
QSet<QPair<int,int>> maxTurnSet = maxTurnSetList[index];
当我们获得了要转动的位置集合后,我们遍历集合并将电脑标记设置到所有这些位置:
for (QPair<int,int> position : maxTurnSet) {
int row = position.first, column = position.second;
set(row, column, m_computerMark);
}
}
如果没有位置会导致相反标记转动,我们就查看邻居。同样地,我们随机选择具有最大邻居数量的位置之一。请注意,我们不需要迭代任何集合;在这种情况下,我们只设置一个标记:
else {
assert(!maxNeighboursList.empty());
srand(time(NULL));
int index = rand() % maxNeighboursList.size();
QPair<int,int> position = maxNeighboursList[index];
int row = position.first, column = position.second;
set(row, column, m_computerMark);
}
}
当人类玩家移动时调用turn
方法。它调用calculateMark
以获取可转动相反标记的集合,然后遍历集合并在游戏网格中设置每个位置:
void OthelloWidget::turn(int row, int column, Mark mark) {
QSet<QPair<int,int>> turnSet;
calculateMark(row, column, mark, turnSet);
for (QPair<int,int> pair : turnSet) {
int row = pair.first, column = pair.second;
set(row, column, mark);
}
}
calculateTurns
方法计算给定位置的可转动相反标记的集合和邻居的数量:
void OthelloWidget::calculateTurns(int row, int column,
Mark playerMark,QSet<QPair<int,int>>& totalSet,
int& neighbours) {
directionArray
中的每个整数对都指代根据罗盘上升的方向:
QPair<int,int> directionArray[] =
{QPair<int,int>(-1, 0), // North
QPair<int,int>(-1, 1), // Northeast
QPair<int,int>(0, 1), // East
QPair<int,int>(1, 1), // Southeast
QPair<int,int>(1, 0), // South
QPair<int,int>(1, -1), // Southwest
QPair<int,int>(0, -1), // West
QPair<int,int>(-1, -1)}; // Northwest
数组的大小可以通过将其总大小(以字节为单位)除以第一个值的尺寸来决定:
int arraySize =
(sizeof directionArray) / (sizeof directionArray[0]);
neighbours = 0;
int opponentMark = (playerMark == Black) ? White : Black;
我们遍历方向,并对每个方向,只要我们找到对手的标记就继续移动:
for (int index = 0; index < arraySize; ++index) {
QPair<int,int> pair = directionArray[index];
row
和column
字段在遍历方向时保持当前行和列:
int rowStep = pair.first, columnStep = pair.second,
currRow = row, currColumn = column;
首先,我们检查我们是否在最近的位置有一个对手标记的邻居。如果我们没有到达游戏网格的边界之一,并且在该位置有一个对手标记,我们就增加neighbours
:
if (((row + rowStep) >= 0) && ((row + rowStep) < ROWS) &&
((column + rowStep) >= 0) &&
((column + columnStep) < COLUMNS) &&
(get(row + rowStep, column + rowStep) == opponentMark)) {
++neighbours;
}
我们在迭代过程中找到的标记收集到directionSet
中:
QSet<QPair<int,int>> directionSet;
while (true) {
currRow += rowStep;
currColumn += columnStep;
如果我们到达游戏网格的边界之一,或者如果我们找到一个空位,我们就会中断迭代:
if ((currRow < 0) || (currRow == ROWS) ||
(currColumn < 0) || (currColumn == COLUMNS) ||
(get(currRow, currColumn) == Empty)) {
break;
}
如果我们找到了玩家的标记,我们将directionSet
添加到总集合中,并中断迭代:
else if (get(currRow, currColumn) == playerMark) {
totalSet += directionSet;
break;
}
如果我们确实找到了玩家的标记或空位,我们就找到了对手的标记,并将它的位置添加到方向集合中:
else {
directionSet.insert(QPair<int,int>(row, column));
}
}
}
}
主函数
和往常一样,main
函数创建一个应用程序,显示窗口,并执行应用程序直到用户关闭窗口或选择退出菜单项。
Main.cpp
#include "OthelloWidget.h"
#include "OthelloWindow.h"
#include <QApplication>
int main(int argc, char *argv[]) {
QApplication application(argc, argv);
OthelloWindow othelloWindow;
othelloWindow.show();
return application.exec();
}
奥运十字
本章的 Noughts and Crosses 应用程序基于上一章的版本。不同之处在于,在这个版本中,计算机与人类进行对战:
NaCWindow
类
NaCWindow
类与上一节中的OthelloWindow
类类似(NaC 是 Noughts and Crosses 的缩写)。它向游戏菜单中添加了两个子菜单,其中计算机或人类先手,并选择零或叉:
NaCWindow.h
#ifndef NACWINDOW_H
#define NACWINDOW_H
#include "..\MainWindow\MainWindow.h"
#include "NaCWidget.h"
class NaCWindow : public MainWindow {
Q_OBJECT
public:
NaCWindow(QWidget *parentWidget = nullptr);
~NaCWindow();
public:
void closeEvent(QCloseEvent *eventPtr)
{m_nacWidgetPtr->closeEvent(eventPtr);}
private:
NaCWidget* m_nacWidgetPtr;
};
#endif // NACWINDOW_H
NaCWindow.cpp
文件包含了NaCWindow
类的方法定义:
NaCWindow.cpp
#include "NaCWindow.h"
#include <QtWidgets>
标题已更改为Noughts and Crosses Advanced
:
NaCWindow::NaCWindow(QWidget *parentWidget /*= nullptr*/)
:MainWindow(parentWidget) {
setWindowTitle(tr("Noughts and Crosses Advanced"));
resize(1000, 500);
m_nacWidgetPtr = new NaCWidget(this);
setCentralWidget(m_nacWidgetPtr);
{ QMenu* gameMenuPtr = menuBar()->addMenu(tr("&Game"));
connect(gameMenuPtr, SIGNAL(aboutToShow()),
this, SLOT(onMenuShow()));
{ QMenu* computerStartsMenuPtr =
gameMenuPtr->addMenu(tr("&Computer Starts"));
connect(computerStartsMenuPtr, SIGNAL(aboutToShow()),
this, SLOT(onMenuShow()));
addAction(computerStartsMenuPtr, tr("Computer &Nought"),
SLOT(onComputerStartsNought()), 0,
tr("Computer Nought"), nullptr,
tr("Computer Nought"),
LISTENER(isComputerStartsNoughtEnabled));
addAction(computerStartsMenuPtr, tr("Computer &Cross"),
SLOT(onComputerStartsCross()), 0,
tr("Computer Cross"), nullptr,
tr("Computer Cross"),
LISTENER(isComputerStartsCrossEnabled));
}
{ QMenu* humanStartsMenuPtr =
gameMenuPtr->addMenu(tr("&Human Starts"));
connect(humanStartsMenuPtr, SIGNAL(aboutToShow()),
this, SLOT(onMenuShow()));
addAction(humanStartsMenuPtr, tr("Human &Nought"),
SLOT(onHumanNought()), 0, tr("Human Nought"),
nullptr, tr("Human Nought"),
LISTENER(isHumanNoughtEnabled));
addAction(humanStartsMenuPtr, tr("Human &Cross"),
SLOT(onHumanCross()), 0, tr("Human Cross"),
nullptr, tr("Human Cross"),
LISTENER(isHumanCrossEnabled));
}
gameMenuPtr->addSeparator();
addAction(gameMenuPtr, tr("&Quit the Game"),
SLOT(onQuit()),
QKeySequence(Qt::CTRL + Qt::Key_Q), tr("Quit Game"),
nullptr, tr("Quit the Game"),
LISTENER(isQuitEnabled));
addAction(gameMenuPtr, tr("E&xit"),
SLOT(onExit()), QKeySequence::Quit);
}
}
NaCWindow::~NaCWindow() {
delete m_nacWidgetPtr;
}
NaCWidget
类
与上一章中的版本相比,NaCWidget
类得到了改进。它包含了计算机对抗人类时使用的calculateComputerMove
和calculateMarkValue
方法:
NaCWidget.h
#ifndef NACWIDGET_H
#define NACWIDGET_H
#include "..\MainWindow\GameWidget.h"
#define ROWS 26
#define COLUMNS 26
enum Mark {Empty = 0, Nought, Cross};
class NaCWidget : public GameWidget {
Q_OBJECT
public:
NaCWidget(QWidget* parentWidget);
void mouseMark(int row, int column);
void drawMark(QPainter& painter,
const QRect& markRect, int mark);
public slots:
DEFINE_LISTENER(NaCWidget, isComputerStartsNoughtEnabled);
DEFINE_LISTENER(NaCWidget, isComputerStartsCrossEnabled);
DEFINE_LISTENER(NaCWidget, isHumanStartsNoughtEnabled);
DEFINE_LISTENER(NaCWidget, isHumanStartsCrossEnabled);
void onComputerStartsNought();
void onComputerStartsCross();
void onHumanStartsNought();
void onHumanStartsCross();
private:
bool checkWinner(int row, int column, Mark mark);
int countMarks(int row, int column, int rowStep,
int columnStep, Mark mark);
void calculateComputerMove(int& row, int &column);
double calculateMarkValue(int row, int column, Mark mark);
Mark m_humanMark, m_computerMark;
};
#endif // NACWIDGET_H
NaCWidget.cpp
文件包含了NaCWidget
类的方法定义:
NaCWidget.cpp
#include "NaCWidget.h"
#include <CTime>
#include <CAssert>
NaCWidget::NaCWidget(QWidget* parentWidget)
:GameWidget(ROWS, COLUMNS, parentWidget) {
// Empty.
}
isComputerStartsNoughtEnabled
、isComputerStartsCrossEnabled
、isHumanStartsNoughtEnabled
和isHumanStartsCrossEnabled
方法决定是否启用计算机零
、计算机叉
、人类零
和人类叉
菜单项。在没有进行游戏时,它们都是启用的:
bool NaCWidget::isComputerStartsNoughtEnabled() {
return !isGameInProgress();
}
bool NaCWidget::isComputerStartsCrossEnabled() {
return !isGameInProgress();
}
bool NaCWidget::isHumanStartsNoughtEnabled() {
return !isGameInProgress();
}
bool NaCWidget::isHumanStartsCrossEnabled() {
return !isGameInProgress();
}
当用户选择计算机零
、计算机叉
、人类零
和人类叉
菜单项时,会调用onComputerStartsNought
、onComputerStartsCross
、onHumanStartsNought
和onHumanStartsCross
。它们设置游戏状态,将计算机和人类的标记设置为零和叉,并更新窗口。如果计算机先手,它将被放置在游戏网格的中间,以便尽可能有效地使用游戏网格:
void NaCWidget::onComputerStartsNought() {
setGameInProgress(true);
set(ROWS /2, COLUMNS / 2, m_computerMark = Nought);
m_humanMark = Cross;
update();
}
void NaCWidget::onComputerStartsCross() {
setGameInProgress(true);
set(ROWS /2, COLUMNS / 2, m_computerMark = Cross);
m_humanMark = Nought;
update();
}
void NaCWidget::onHumanStartsNought() {
setGameInProgress(true);
m_computerMark = Cross;
m_humanMark = Nought;
update();
}
void NaCWidget::onHumanStartsCross() {
setGameInProgress(true);
m_computerMark = Nought;
m_humanMark = Cross;
update();
}
当人类玩家在游戏网格中点击一个空位时,会调用mouseMark
方法。我们首先将标记设置到该位置并更新窗口:
void NaCWidget::mouseMark(int row, int column) {
set(row, column, m_humanMark);
update();
如果人类的移动没有让他们赢得游戏,我们计算计算机的下一步移动,设置位置,检查移动是否让计算机赢得了游戏,并更新窗口:
if (!checkWinner(row, column, m_humanMark)) {
calculateComputerMove(row, column);
set(row, column, m_computerMark);
checkWinner(row, column, m_computerMark);
update();
}
}
当需要重新绘制位置时,会调用drawMark
方法。它与上一章中的对应方法类似。它绘制一个零或一个叉:
void NaCWidget::drawMark(QPainter& painter,
const QRect& markRect, int mark) {
painter.setPen(Qt::black);
switch (mark) {
case Nought:
painter.drawEllipse(markRect);
break;
case Cross:
painter.drawLine(markRect.topLeft(),
markRect.bottomRight());
painter.drawLine(markRect.topRight(),
markRect.bottomLeft());
break;
case Empty:
break;
}
}
checkWinner
方法与上一章中的对应方法类似。它决定最新的移动是否导致了五连珠。如果是,则宣布获胜者:
bool NaCWidget::checkWinner(int row, int column, Mark mark) {
if ((countMarks(row, column, -1, 0, mark) >= 5) ||
(countMarks(row, column, 0, -1, mark) >= 5) ||
(countMarks(row, column, -1, 1, mark) >= 5) ||
(countMarks(row, column, 1, 1, mark) >= 5)) {
QMessageBox messageBox(QMessageBox::Information,
tr("Victory"), QString());
QString text;
text.sprintf("The Winner: %s.",
(mark == m_computerMark) ? "Computer" : "Human");
messageBox.setText(text);
messageBox.setStandardButtons(QMessageBox::Ok);
messageBox.exec();
setGameInProgress(false);
clearGrid();
update();
return true;
}
return false;
}
countMarks
方法用于计算一行中的标记数量。与上一章中的对应方法相比,它已经得到了改进。在这个版本中,我们还计算了移动可以导致的一行中可能出现的最高标记数量。由于countMarks
方法是由calculateComputerMove
调用的,我们需要知道移动可能导致的一行中的标记数量:
double NaCWidget::countMarks(int row, int column, int rowStep,
int columnStep, Mark mark) {
markCount
字段保存了如果我们把标记放在给定位置,我们将得到的连续标记的数量;freeCount
保存了如果我们继续在那个行添加标记,我们可能得到的连续标记的数量。原因是电脑不会在无法形成连续五个标记的行上添加标记:
double markCount = 0;
int freeCount = 0;
我们以给定方向遍历游戏网格:
{ bool marked = true;
int currentRow = row, currentColumn = column;
while ((currentRow >= 0) && (currentRow < ROWS) &&
(currentColumn >= 0) && (currentColumn < COLUMNS)) {
只要我们找到标记,我们就增加markCount
和freeCount
:
if (get(currentRow, currentColumn) == mark) {
if (marked) {
++markCount;
}
++freeCount;
}
如果我们找到一个空位,我们将0.4
(因为空闲行比封闭行好)加到markCount
上,并继续增加freeCount
:
else if (get(currentRow, currentColumn) == Empty) {
if (marked) {
markCount += 0.4;
}
marked = false;
++freeCount;
}
如果我们既没有找到电脑的标记也没有找到空位,那么我们一定找到了人类的标记,然后我们中断迭代:
else {
break;
}
在每次迭代的末尾,我们将行和列的步数添加到当前行和列:
currentRow += rowStep;
currentColumn += columnStep;
}
}
我们在相反方向上执行类似的迭代。唯一的区别是在每次迭代的末尾,我们减去行和列的步数,而不是将它们加到上面:
{ bool marked = true;
int currentRow = row + rowStep,
currentColumn = column + columnStep;
while ((currentRow >= 0) && (currentRow < ROWS) &&
(currentColumn >= 0) && (currentColumn < COLUMNS)) {
if (get(currentRow, currentColumn) == mark) {
if (marked) {
++markCount;
}
}
else if (get(currentRow, currentColumn) == Empty) {
if (marked) {
markCount += 0.4;
}
marked = false;
++freeCount;
}
else {
break;
}
currentRow -= rowStep;
currentColumn -= columnStep;
}
}
如果空闲计数至少为五,我们返回标记计数。如果它少于五,我们返回零,因为我们不能在这个方向上获得连续五个标记:
return (freeCount >= 5) ? markCount : 0;
}
calculateComputerMove
方法计算导致最大连续标记数量的电脑移动。我们计算电脑和人类的行,因为我们可能面临需要阻止人类获胜而不是最大化电脑获胜机会的情况。
maxComputerValue
和maxHumanValue
字段保存了我们迄今为止找到的连续标记的最大数量。maxComputerList
和maxHumanList
保存了导致电脑和人类连续标记最大数量的位置:
void NaCWidget::calculateComputerMove(int& maxRow,int &maxColumn){
double maxComputerValue = 0, maxHumanValue = 0;
QList<QPair<int,int>> maxComputerList, maxHumanList;
我们遍历游戏网格。对于每个空位,我们尝试设置电脑和人类的标记,并查看这将导致多少个连续标记:
for (int row = 0; row < ROWS; ++row) {
for (int column = 0; column < COLUMNS; ++column) {
if (get(row, column) == Empty) {
set(row, column, m_computerMark);
我们获得电脑和人类标记的连续标记的最大数量。如果它大于之前的最大数量,我们清除列表并将位置添加到列表中:
{ double computerValue =
calculateMarkValue(row, column, m_computerMark);
if (computerValue > maxComputerValue) {
maxComputerValue = computerValue;
maxComputerList.clear();
maxComputerList.append(QPair<int,int>(row, column));
}
如果新的连续标记数量大于零或等于最大数量,我们只需添加位置:
else if ((computerValue > 0) &&
(computerValue == maxComputerValue)) {
maxComputerList.append(QPair<int,int>(row, column));
}
}
我们对电脑标记和人类标记做同样的处理:
set(row, column, m_humanMark);
{ double humanValue =
calculateMarkValue(row, column, m_humanMark);
if (humanValue > maxHumanValue) {
maxHumanValue = humanValue;
maxHumanList.clear();
maxHumanList.append(QPair<int,int>(row, column));
}
else if ((humanValue > 0) &&
(humanValue == maxHumanValue)) {
maxHumanList.append(QPair<int,int>(row, column));
}
}
最后,我们将位置重置为空值:
set(row, column, Empty);
}
}
}
电脑或人类必须至少有一个在行的位置:
assert(!maxComputerList.empty() && !maxHumanList.empty());
如果电脑的值至少为两个且大于人类值,或者如果人类值小于四个,我们将随机选择电脑的最大移动之一:
if ((maxComputerValue >= 2) &&
((maxComputerValue >= maxHumanValue) ||
(maxHumanValue < 3.8))) {
srand(time(NULL));
QPair<int,int> pair =
maxComputerList[rand() % maxComputerList.size()];
maxRow = pair.first;
maxColumn = pair.second;
}
然而,如果电脑无法连续放置至少两个标记,或者如果人类即将连续放置五个标记,我们将随机选择人类最大移动中的一个:
else {
srand(time(NULL));
QPair<int,int> pair =
maxHumanList[rand() % maxHumanList.size()];
maxRow = pair.first;
maxColumn = pair.second;
}
}
calculateMarkValue
方法通过计算其四个方向中的较大值来计算给定位置可能导致的连续标记的最大数量:
double NaCWidget::calculateMarkValue(int row, int column,
Mark mark) {
return qMax(qMax(countMarks(row, column, -1, 0, mark),
countMarks(row, column, 0, -1, mark)),
qMax(countMarks(row, column, -1, 1, mark),
countMarks(row, column, 1, 1, mark)));
}
主函数
最后,main
函数在 Qt 应用程序中总是这样工作:
Main.cpp
#include "NaCWidget.h"
#include "NaCWindow.h"
#include <QApplication>
int main(int argc, char *argv[]) {
QApplication application(argc, argv);
NaCWindow mainWindow;
mainWindow.show();
return application.exec();
}
摘要
在本章中,我们开发了上一章游戏的高级版本。在奥赛罗和井字棋游戏中,我们添加了代码,使计算机能够与人类对弈。在奥赛罗游戏中,我们寻找游戏网格中能够导致对手标记数量变化最大的位置。在井字棋游戏中,我们寻找能够使计算机获得尽可能多的连续标记的走法,最好是连续五条。然而,我们还得寻找对手可能的连续标记数量,并阻止他们的下一步棋,如果这会导致胜利。现在,我建议你在继续到下一章之前,先坐下来和计算机玩几局,享受一下。
在下一章中,我们将开始开发一种领域特定语言(DSL),这是一种针对特定领域设计的语言。我们将开发一个 DSL 来指定图形对象的绘制,例如线条、矩形、椭圆和文本,以及颜色、字体、笔和刷的样式以及对齐设置。我们还将编写一个查看器来显示图形对象。
第九章:领域特定语言
在前面的章节中,我们使用 Qt 库开发了 Othello 和井字棋游戏。在本章中,我们将开始开发一个领域特定语言(DSL),这是一种针对特定领域的语言。更具体地说,我们将开发一种用于在 Qt 小部件中编写图形对象的语言。该语言允许我们绘制线条、矩形、椭圆,并写入文本。此外,它还允许我们为图形对象选择颜色以及笔和画笔样式。它还允许我们为文本选择字体和对齐方式。
本章我们将涵盖的主题包括:
-
首先,我们将通过查看一个示例来非正式地研究我们的领域特定语言(DSL)的源代码。我们将绘制图形对象并设置它们的颜色、样式和字体。
-
我们将使用语法正式定义我们的语言。
-
当我们定义了语法后,我们编写扫描器。扫描器读取源代码并识别有意义的字符序列,称为标记。
-
当我们编写了扫描器后,我们编写解析器。解析器可以被认为是我们的领域特定语言(DSL)的核心。当需要时,它会从扫描器请求新的标记。它检查源代码是否符合语法,并生成一系列动作。每个动作都包含一个指令,例如设置颜色或绘制线条。
-
最后,我们编写一个查看器,该查看器读取解析器生成的动作序列,并在 Qt 小部件中显示图形对象。
介绍源语言——一个简单的例子
我们的领域特定语言(DSL)的源语言由一系列指令组成。有用于绘制图形对象的指令,如线条、矩形、椭圆和文本。我们还有设置对象颜色和样式以及文本字体和对齐方式的指令。最后,还有为名称分配值的指令。
让我们来看一个例子。以下代码绘制了一个矩形并写入了文本。请注意,该语言不区分大小写,也就是说,我们代码中使用小写或大写字母无关紧要。我们首先定义矩形的左上角:
topLeft = point(100, 100);
我们使用坐标运算符来提取左上点的x和y坐标,并定义右下角:
left = xCoordinate(topleft);
top = yCoordinate(topLeft);
bottomRight = point(left + 100, top + 100);
我们使用预定义的值DashLine
和CrossPatterns
来设置笔和画笔的样式:
SetPenStyle(DashLine);
SetBrushStyle(CrossPattern);
我们使用预定义的颜色Black
作为笔的颜色,并为画笔创建自己的颜色Purple
。我们可以使用三个值来创建一个新颜色,这三个值分别对应它们的红色、绿色和蓝色分量。每个分量可以持有介于 0 到 255 之间的值,包括:
SetPenColor(Black);
PurpleColor = color(128, 0, 128);
SetBrushColor(PurpleColor);
DrawRectangle(topLeft, bottomRight);
我们继续添加文本,包括字体和对齐方式。我们选择12
点的Times New Roman
字体,左对齐水平方向和顶对齐垂直方向:
SetFont(font("Times New Roman", 12));
SetHorizontalAlignment(AlignLeft);
SetVerticalAlignment(AlignTop);
DrawText(point(300, 150), "Hello, DSL!");
此示例中的指令将由扫描器分成有意义的部分;解析器将检查指令是否符合语法,并生成一系列由观众读取的动作,并显示以下 Qt 小部件:
源语言的语法
我们需要精确地定义我们的领域特定语言(DSL)的源语言。我们通过定义语言的语法来实现这一点。语法由规则(以 斜体 风格呈现)、关键字(以 *粗体 风格呈现)、分隔符和标点符号组成。
program
规则是起始规则。箭头 (->
) 表示程序由指令列表组成。箭头可以读作:
program -> instructionList
在语法中,星号 (*
) 表示 零个或多个。因此,指令列表由零个或多个指令组成:
instructionList -> instruction*
赋值指令包含一个名称后跟赋值运算符 (=
),一个表达式和一个分号。设置笔和画笔颜色和样式的指令以及字体和对齐的设置都只需要一个表达式。绘制线条、矩形和文本的指令需要两个表达式。请注意,每个指令都以分号 (;
) 结尾。
竖线 (|
) 可以读作 或。指令是一个赋值或设置笔色、设置画笔色等:
instruction -> name = expression;
| SetPenColor(expression);
| SetPenStyle(expression);
| SetBrushColor(expression);
| SetBrushStyle(expression);
| SetFont(expression);
| SetHorizontalAlignment(expression);
| SetVerticalAlignment(expression);
| DrawLine(expression, expression);
| DrawRectangle(expression, expression);
| DrawEllipse(expression, expression);
| DrawText(expression, expression);
下一步要定义的是表达式。首先,我们查看表达式的运算符。我们还需要考虑运算符的优先级。例如,乘法和除法的优先级高于加法和减法。语法中的运算符具有以下优先级:
表达式 | 运算符 | 优先级 |
---|---|---|
加减 | + - |
最低 |
乘除 | * / |
|
基本表达式 | point xCoordinate yCoordinate color font (expression) name value |
最高 |
我们为加法和减法、乘法和除法各自定义了两条规则。我们首先从优先级最低的开始,即加法和减法。在 expression
规则中,我们调用 mulDivExpression
规则来处理乘除表达式,并调用 expressionRest
规则来检查表达式的其余部分:
expression -> mulDivExpression expressionRest
在 expressionRest
规则中,我们查看下一个标记。如果它是加号或减号,我们有一个加法或减法表达式。我们调用 mulDivExpression
来处理优先级更高的表达式。最后,如果还有另一个加号或减号,我们再次调用 expressionRest
规则。然而,如果第一个标记既不是加号也不是减号,我们就不做任何事情:
expressionRest -> + mulDivExpression expressionRest
| - mulDivExpression expressionRest
| /* empty */
mulDivExpression
和 mulDivExpressionRest
的工作方式与之前展示的 expression
和 expressionRest
相同:
mulDivExpression -> primaryExpression mulDivExpressionRest
mulDivExpressionRest -> * primaryExpression mulDivExpressionRest
| / primaryExpression mulDivExpressionRest
| /* empty */
基本表达式是一个点,一个 x 或 y 坐标,一个颜色,一个字体,一个名字或一个值。一个点由两个表达式组成,分别持有点的 x 和 y 坐标。一个坐标接受一个包含点的表达式,并给它一个 x 或 y 坐标:
primaryExpression -> point(expression, expression)
| xCoordinate(expression)
| yCoordinate(expression)
颜色表达式由其红色、绿色和蓝色分量组成,而字体表达式由字体名称和大小组成:
| color(expression, expression, expression)
| font(expression, expression)
表达式可以用括号括起来以改变表达式的优先级。例如,在表达式 2 + 3 x 4 中,乘法比加法有更高的优先级,但在表达式 (2 + 3) x 4 中,加法比乘法有更高的优先级:
| (expression)
最后,一个表达式可以是一个之前与值关联的名字,或者简单地是一个值:
| name
| value
目标语言
目标语言由一系列动作定义。非正式地说,动作对应于语法的指令。我们有设置画笔或刷子的颜色或样式的动作,以及设置文本的水平或垂直对齐,以及实际绘制线条、矩形、椭圆和绘图文本的动作。在本章的后面,我们将编写一个生成一系列动作的解析器,以及一个读取动作并在 Qt 小部件中显示图形对象的查看器。
一个 Action
对象持有动作的标识符(由 Token
类中的 TokenId
枚举定义,如下所示)以及最多两个值。
Action.h:
#ifndef ACTION_H
#define ACTION_H
#include "Token.h"
#include "Value.h"
class Action {
public:
Action(TokenId actionId, const Value& value1 = Value(),
const Value& value2 = Value());
Action(const Action& action);
Action operator=(const Action& action);
TokenId id() const {return m_actionId;}
const Value& value1() const {return m_value1;}
const Value& value2() const {return m_value2;}
private:
TokenId m_actionId;
Value m_value1, m_value2;
};
#endif // ACTION_H
Action.cpp
文件包含了 Action
类的方法定义。
Action.cpp:
#include "Action.h"
构造函数接受动作标识符和最多两个值:
Action::Action(TokenId actionId,
const Value& value1 /*= Value()*/,
const Value& value2 /*= Value()*/ )
:m_actionId(actionId),
m_value1(value1),
m_value2(value2) {
// Empty.
}
颜色
当设置画笔或刷子的颜色时,我们需要提交带有指令的颜色。我们可以使用前面语法中的颜色规则来创建自己的颜色。然而,Qt 类 QColor
有一个预定义的颜色集。以下扫描器定义了一个预定义的 QColor
对象集(Aqua
、Black
、...)并将它们映射到它们的名称。例如,用户可以在源代码中编写以下指令:
SetPenColor(Aqua);
在那种情况下,由于名字 Aqua
与 QColor
对象 Aqua
相关联,画笔颜色被设置为 Aqua
。
Colors.h:
#ifndef COLOR_H
#define COLOR_H
#include <QWidget>
extern QColor
Aqua, Black, Blue, Brown, Cyan, Gray, Green, Lime, Magenta,
Navyblue, Orange, Orchid, Pink, Purple, Red, Silver, Snow,
SteelBlue, SystemColor, Turquoise, Violet, White, Yellow;
#endif // COLOR_H
Colors.cpp
文件包含了 Colors.h
文件中颜色的定义。
Colors.cpp:
#include "Colors.h"
每个颜色由其红色、绿色和蓝色分量定义。每个分量持有从 0 到 255 的值,包括 255。例如,Blue
颜色持有蓝色分量的最大值和其它分量的零值,而 Yellow
是红色和绿色的混合:
QColor
Aqua(0, 255, 255), Black(0, 0, 0), Blue(0, 0, 255),
Brown(165, 42, 42), Cyan(0, 255, 255), Gray(127, 127, 127),
Green(0, 128, 0), Lime(0, 255, 0), Magenta(255, 0, 255),
Navyblue(159, 175, 223), Orange(255, 165, 0),
Orchid(218, 112, 214), Pink(255, 192, 203),
Purple(128, 0, 128), Red(255, 0, 0), Silver(192, 192, 192),
Snow(255, 250, 250), SteelBlue(70, 130, 180),
SystemColor(0, 0, 0), Turquoise(64, 224, 208),
Violet(238, 130, 238), White(255, 255, 255),
Yellow(255, 255, 0);
错误处理
存在一些用于错误处理的函数:check
检查一个条件是否为真,如果不为真则报告错误。syntaxError
和 semanticError
函数报告语法和语义错误,而 error
抛出一个异常,该异常被 main
函数捕获并报告。
Error.h:
#ifndef ERROR_H
#define ERROR_H
#include <QString>
void error(const QString& message);
void syntaxError();
void syntaxError(const QString& message);
void semanticError(const QString& message);
void check(bool condition, const QString& message);
#endif // ERROR_H
Error.cpp
文件包含了 Error.h
文件的定义。
Error.cpp:
#include <SStream>
#include <Exception>
using namespace std;
#include "Error.h"
extern int g_lineNo = 1;
void error(const QString& message) {
throw exception(message.toStdString().c_str());
}
我们使用 C++的stringstream
标准类来组合错误信息:
void syntaxError() {
stringstream stringStream;
stringStream << "Syntax error at line " << g_lineNo << ".";
str
方法返回 C++ string
标准类的一个对象,而c_str
返回一个字符指针,在error
调用中转换为QString
对象:
error(stringStream.str().c_str());
}
当扫描器发现不构成标记的字符序列,或者当解析器检测到标记序列不符合语法时,会发生语法错误。我们将在不久的将来介绍这个主题;现在,只需记住扫描器也可以报告错误:
void syntaxError(const QString& message) {
stringstream stringStream;
stringStream << "Syntax error at line " << g_lineNo
<< ": " << message.toStdString() << ".";
error(stringStream.str().c_str());
}
当发现未知名称,或者当表达式的类型不匹配时,会发生语义错误:
void semanticError(const QString& message) {
stringstream stringStream;
stringStream << "Sematic error: "
<< message.toStdString() << ".";
error(stringStream.str().c_str());
}
check
方法与assert
宏有类似的效果。它检查条件是否为真。如果不为真,则调用semanticError
,最终抛出错误异常:
void check(bool condition, const QString& message) {
if (!condition) {
semanticError(message);
}
}
值
语言中有几种类型的值,用于设置画笔或画刷的颜色或样式,或设置线的端点,或设置字体名称,或文本的对齐方式:数值(double
)、字符串(QString
)、颜色(QColor
)、字体(QFont
)、点(QPoint
)、画笔样式(Qt::PenStyle
)、画刷样式(Qt
::BrushStyle
)以及水平或垂直对齐(Qt
::AlignmentFlag
)。
Value.h:
#ifndef VALUE_H
#define VALUE_H
#include <IOStream>
using namespace std;
#include <QtWidgets>
enum TypeId {NumericalTypeId, StringTypeId, ColorTypeId,
PenStyleTypeId, BrushStyleId, AlignmentTypeId,
FontTypeId, PointTypeId};
class Value {
public:
Value();
Value(double numericalValue);
Value(const QString& stringValue);
Value(const QPoint& pointValue);
Value(const QColor& colorValue);
Value(const QFont& fontValue);
Value(const Qt::PenStyle& penStyleValue);
Value(const Qt::BrushStyle& brushStyleValue);
Value(const Qt::AlignmentFlag& alignment);
Value(const Value& value);
Value& operator=(const Value& value);
bool isNumerical() const {return (m_typeId==NumericalTypeId);}
bool isString() const { return (m_typeId == StringTypeId); }
bool isColor() const { return (m_typeId == ColorTypeId); }
bool isFont() const { return (m_typeId == FontTypeId); }
bool isPoint() const { return (m_typeId == PointTypeId); }
bool isPenStyle() const {return (m_typeId == PenStyleTypeId);}
bool isBrushStyle() const {return (m_typeId == BrushStyleId);}
bool isAlignment() const {return (m_typeId==AlignmentTypeId);}
double numericalValue() const { return m_numericalValue; }
const QString& stringValue() const { return m_stringValue; }
const QColor& colorValue() const { return m_colorValue; }
const QFont& fontValue() const { return m_fontValue; }
const QPoint& pointValue() const { return m_pointValue; }
const Qt::PenStyle& penStyleValue() const
{ return m_penStyleValue; }
const Qt::BrushStyle& brushStyleValue() const
{ return m_brushStyleValue; }
const Qt::AlignmentFlag& alignmentValue() const
{ return m_alignmentValue; }
private:
TypeId m_typeId;
double m_numericalValue;
QString m_stringValue;
QPoint m_pointValue;
QColor m_colorValue;
QFont m_fontValue;
Qt::PenStyle m_penStyleValue;
Qt::BrushStyle m_brushStyleValue;
Qt::AlignmentFlag m_alignmentValue;
};
#endif // VALUE_H
Value.cpp
文件包含了Value
类的定义方法。
Value.cpp:
#include <CAssert>
using namespace std;
#include "Value.h"
Value::Value() {
// Empty.
}
非默认构造函数使用适当的值初始化Value
对象:
Value::Value(double numericalValue)
:m_typeId(NumericalTypeId),
m_numericalValue(numericalValue) {
// Empty.
}
Value::Value(const QPoint& pointValue)
:m_typeId(PointTypeId),
m_pointValue(pointValue) {
// Empty.
}
扫描器
扫描器是应用程序的一部分,它接受源代码并生成一系列标记。标记是源代码中最小的有意义的部分。例如,字符f、o、n和t组成了关键字font,而字符1、2和3构成了数值123。
然而,首先我们需要Token
类来跟踪标记。m_tokenId
字段被设置为枚举TokenId
的值。在名称的情况下,m_name
字段包含名称,而在值的情况下,m_value
字段包含值。
Token.h:
#ifndef TOKEN_H
#define TOKEN_H
#include <QWidget>
#include "Value.h"
TokenId
枚举包含了扫描器的所有标记。它们被分为关键字、运算符、标点符号和分隔符,以及名称和值。为了避免在不同枚举之间进行转换,扫描器、解析器和查看器都使用TokenId
枚举。TokenId
枚举由扫描器在类型检查和评估表达式时区分不同的标记,由Action
类区分不同的操作。
第一部分(从ColorId
到YCoordinateId
)是语言的关键字:
enum TokenId {ColorId, DrawEllipseId, DrawLineId,
DrawRectangleId, DrawTextId, FontId,
PointId, SetBrushColorId, SetBrushStyleId,
SetFontId, SetHorizontalAlignmentId,
SetPenColorId, SetPenStyleId,
SetVerticalAlignmentId,
XCoordinateId, YCoordinateId,
第二部分(从AddId
到DivideId
)是运算符:
AddId, SubtractId, MultiplyId, DivideId,
下一个部分是括号、赋值(=
)、逗号和分号:
LeftParenthesisId, RightParenthesisId,
AssignId, CommaId, SemicolonId,
最后,最后一部分是名称、值和文件结束标记:
NameId, ValueId, EndOfFileId};
class Token{
public:
Token();
Token(TokenId tokenId);
Token(TokenId tokenId, const QString& name);
Token(TokenId tokenId, const Value& value);
每个标记都可以用名称或值进行标注:
TokenId id() const {return m_tokenId;}
const QString& name() const { return m_name; }
const Value& value() const { return m_value; }
private:
TokenId m_tokenId;
QString m_name;
Value m_value;
};
#endif // TOKEN_H
Token.cpp
文件包含 Token
类的方法定义。
Token.cpp:
#include "Token.h"
默认标记使用文件结束标记初始化:
Token::Token()
:m_tokenId(EndOfFileId) {
// Empty.
}
大多数标记只包含 TokenId
枚举的值:
Token::Token(TokenId tokenId)
:m_tokenId(tokenId) {
// Empty.
}
标记也可以包含名称或值:
Token::Token(TokenId tokenId, const QString& name)
:m_tokenId(tokenId),
m_name(name) {
// Empty.
}
Token::Token(TokenId tokenId, const Value& value)
:m_tokenId(tokenId),
m_value(value) {
// Empty.
}
Scanner
类接收源代码并将其划分为标记。标记也可以通过名称或值关联。
Scanner.h:
#ifndef SCANNER_H
#define SCANNER_H
#include "Token.h"
#include "Colors.h"
init
方法初始化关键字和操作符的名称:
class Scanner {
public:
static void init();
Scanner(QString& buffer);
nextToken
方法扫描缓冲区并返回下一个标记。如果没有可识别的标记,则会抛出一个错误异常,该异常随后被 main
函数捕获:
public:
Token nextToken();
m_buffer
字段包含源代码;m_bufferIndex
包含要检查的缓冲区中下一个字符的索引(索引初始化为零);m_keywordMap
包含关键字的名称;m_valueMap
包含颜色、对齐、笔和画笔样式值的映射,m_operatorList
包含操作符列表:
private:
QString m_buffer;
int m_bufferIndex = 0;
在前面的章节中,我们使用了 C++ 标准类 map
、set
、list
、vector
和 stack
。在本章中,我们将使用 Qt 类 QMap
、QSet
、QList
、QVector
和 QStack
代替。它们的工作方式大致相同:
static QMap<QString,TokenId> m_keywordMap;
static QMap<QString,Value> m_valueMap;
static QList<pair<QString,TokenId>> m_operatorList;
};
#endif // SCANNER_H
Scanner.cpp
文件包含 Scanner
类的方法定义。
Scanner.cpp:
#include <SStream>
#include <IOStream>
#include <Exception>
using namespace std;
#include "Error.h"
#include "Scanner.h"
QMap<QString,Value> Scanner::m_valueMap;
QMap<QString,TokenId> Scanner::m_keywordMap;
QList<pair<QString, TokenId>> Scanner::m_operatorList;
g_lineNo
全局字段跟踪源代码中的当前行,以便错误消息可以显示行号:
extern int g_lineNo;
ADD_TO_OPERATOR_LIST
宏将标记添加到操作符列表中。例如,ADD_TO_OPERATOR_LIST("+", AddId)
将 "+"
和 AddId
对添加到列表中:
#define ADD_TO_OPERATOR_LIST(text, token)
m_operatorList.push_back(pair<QString,TokenId>(text, token));
void Scanner::init() {
ADD_TO_OPERATOR_LIST("+", AddId)
ADD_TO_OPERATOR_LIST("-", SubtractId)
ADD_TO_OPERATOR_LIST("*", MultiplyId)
ADD_TO_OPERATOR_LIST("/", DivideId)
ADD_TO_OPERATOR_LIST("(", LeftParenthesisId)
ADD_TO_OPERATOR_LIST(")", RightParenthesisId)
ADD_TO_OPERATOR_LIST("=", AssignId)
ADD_TO_OPERATOR_LIST(",", CommaId)
ADD_TO_OPERATOR_LIST(";", SemicolonId)
ADD_TO_KEYWORD_MAP
宏将关键字添加到关键字映射中。例如,ADD_TO_KEYWORD_MAP(ColorId)
将 Color
和 ColorId
对添加到映射中。请注意,关键字的部分(最后两个字符)的文本被移除:
#define ADD_TO_KEYWORD_MAP(x) {
QString s(#x);
m_keywordMap[s.toLower().left(s.length() - 2)] = x; }
ADD_TO_KEYWORD_MAP(ColorId)
ADD_TO_KEYWORD_MAP(DrawEllipseId)
ADD_TO_KEYWORD_MAP(DrawLineId)
ADD_TO_KEYWORD_MAP(DrawRectangleId)
ADD_TO_KEYWORD_MAP(DrawTextId)
ADD_TO_KEYWORD_MAP(FontId)
ADD_TO_KEYWORD_MAP(PointId)
ADD_TO_KEYWORD_MAP(SetBrushColorId)
ADD_TO_KEYWORD_MAP(SetBrushStyleId)
ADD_TO_KEYWORD_MAP(SetFontId)
ADD_TO_KEYWORD_MAP(SetHorizontalAlignmentId)
ADD_TO_KEYWORD_MAP(SetPenColorId)
ADD_TO_KEYWORD_MAP(SetPenStyleId)
ADD_TO_KEYWORD_MAP(SetVerticalAlignmentId)
ADD_TO_KEYWORD_MAP(XCoordinateId)
ADD_TO_KEYWORD_MAP(YCoordinateId)
ADD_TO_VALUE_MAP
宏将值添加到值映射中。例如,ADD_TO_VALUE_MAP(Aqua)
将 aqua 和 QColor
对象 Aqua 对添加到映射中。请注意,文本被转换为小写。另外请注意,只包括最后一个可能的对冒号 (::
) 的最后一个部分:
#define ADD_TO_VALUE_MAP(x) {
QString s(#x);
QString t = s.toLower();
int i = t.lastIndexOf("::");
m_valueMap[(i == -1) ? t : t.mid(i + 2)] = Value(x); }
ADD_TO_VALUE_MAP(Qt::AlignLeft)
将对齐左和 Qt::PenStyle
值对添加到映射中。再次注意,只有值名称的最后一个部分被存储为文本:
ADD_TO_VALUE_MAP(Qt::AlignLeft)
ADD_TO_VALUE_MAP(Qt::AlignTop)
ADD_TO_VALUE_MAP(Qt::PenStyle::NoPen)
ADD_TO_VALUE_MAP(Qt::PenStyle::SolidLine)
ADD_TO_VALUE_MAP(Qt::BrushStyle::NoBrush)
ADD_TO_VALUE_MAP(Qt::BrushStyle::SolidPattern)
ADD_TO_VALUE_MAP(Aqua)
ADD_TO_VALUE_MAP(Black)
}
在构造函数中,我们将缓冲区加载到 m_buffer
字段中。我们还添加了空字符 (''
),以便更容易地找到缓冲区的末尾:
Scanner::Scanner(QString& buffer)
:m_buffer(buffer) {
m_buffer.append('');
}
nextToken
方法扫描缓冲区并返回找到的标记。首先,我们迭代,直到找到新行、空白或行注释。如果遇到新行,则增加行数:
Token Scanner::nextToken() {
while (true) {
if (m_buffer[m_bufferIndex] == 'n') {
++g_lineNo;
++m_bufferIndex;
}
空白是常规空格、水平或垂直制表符、回车符或新行。我们使用 isSpace
方法检查字符是否为空白:
else if (m_buffer[m_bufferIndex].isSpace()) {
++m_bufferIndex;
}
如果我们遇到行注释的开始(//
),我们继续直到找到行尾('n'
)或缓冲区结束(''
):
else if (m_buffer.indexOf("//", m_bufferIndex) ==
m_bufferIndex) {
while ((m_buffer[m_bufferIndex] != QChar('n')) &&
(m_buffer[m_bufferIndex] != QChar(''))) {
++m_bufferIndex;
}
}
如果我们没有找到新行、空白或行注释,我们中断迭代并继续寻找下一个标记:
else {
break;
}
}
当我们扫描过潜在的空白和注释后,我们开始寻找真正的标记。我们首先检查缓冲区中的下一个字符是否是空字符(''
)。如果是空字符,我们就找到了源代码的结尾并返回文件结束。记住,我们在构造函数中添加了一个空字符到缓冲区末尾,只是为了能够识别文件结束:
if (m_buffer[m_bufferIndex] == QChar('')) {
return Token(EndOfFileId);
}
如果下一个标记不是文件结束,我们检查它是否是一个运算符。我们遍历运算符列表,并检查缓冲区是否以任何运算符的文本开头。例如,加法运算符包含文本 +
:
for (const pair<QString,TokenId>& pair : m_operatorList) {
const QString& operatorText = pair.first;
TokenId tokenId = pair.second;
当我们找到运算符时,我们增加缓冲区索引,并返回标记:
if (m_buffer.indexOf(operatorText, m_bufferIndex) ==
m_bufferIndex) {
m_bufferIndex += operatorText.length();
return Token(tokenId);
}
}
如果缓冲区不以运算符开头,我们寻找代表关键字、值或简单名称的名称。我们首先检查缓冲区是否以字母或下划线字符('_'
)开头,因为名称可以以字母或下划线开头。然而,除了字母和下划线之外,剩余的字符可以是数字:
if (m_buffer[m_bufferIndex].isLetter() ||
(m_buffer[m_bufferIndex] == '_')) {
int index = m_bufferIndex;
我们遍历直到找到一个不是字母、数字或下划线的字符:
while (m_buffer[index].isLetterOrNumber() ||
(m_buffer[index] == '_')) {
++index;
}
我们提取文本并增加缓冲区索引:
int size = index - m_bufferIndex;
QString text = m_buffer.mid(m_bufferIndex, size).toLower();
m_bufferIndex += size;
文本可以包含一个关键字、一个值或一个名称。首先,我们检查文本是否存在于关键字映射中。如果存在,我们只需返回与关键字文本关联的标记:
if (m_keywordMap.contains(text)) {
return Token(m_keywordMap[text]);
}
然后我们检查文本是否存在于值映射中。如果存在,我们返回一个带有值注释的值标记。值可以在稍后由解析器获取:
else if (m_valueMap.contains(text)) {
return Token(ValueId, m_valueMap[text]);
}
如果文本既不是关键字也不是值,我们假设它是一个名称,并返回一个带有名称注释的名称标记。名称可以在稍后由解析器获取:
else {
return Token(NameId, text);
}
}
当我们没有找到名称时,我们开始寻找字符串。字符串是由双引号('"'
)包围的文本。如果缓冲区中的下一个字符是双引号,那么它是文本的开始。我们从缓冲区中移除双引号,并遍历直到找到文本的结束引号:
if (m_buffer[m_bufferIndex] == '"') {
int index = m_bufferIndex + 1;
while (m_buffer[index] != '"') {
如果我们在文本结束之前找到空字符,由于我们在文本中找到了文件结束,所以报告语法错误:
if (m_buffer[index] == QChar('')) {
syntaxError("unfinished string");
}
++index;
}
当我们找到结束引号时,我们增加缓冲区索引,并返回一个带有文本作为其注释值的值标记。文本可以在稍后由解析器获取:
int size = index - m_bufferIndex + 1;
QString text = m_buffer.mid(m_bufferIndex, size);
m_bufferIndex += size;
return Token(ValueId, Value(text));
}
如果缓冲区中的下一个字符是数字,我们就找到了一个数值,可能带有小数点。首先,我们遍历缓冲区,直到找到数字:
if (m_buffer[m_bufferIndex].isDigit()) {
int index = m_bufferIndex;
while (m_buffer[index].isDigit()) {
++index;
}
当我们不再找到任何数字时,我们检查缓冲区中的下一个字符是否是点('.'
)。如果是点,只要我们找到数字,我们就继续迭代:
if (m_buffer[index] == '.') {
++index;
while (m_buffer[index].isDigit()) {
++index;
}
}
当我们不再找到任何数字时,我们增加缓冲区索引,并返回一个带有注释值的值标记。该值可以稍后被解析器获取:
int size = index - m_bufferIndex;
QString text = m_buffer.mid(m_bufferIndex, size);
m_bufferIndex += size;
return Token(ValueId, Value(text.toDouble()));
}
最后,如果前面的任何情况都不适用,源代码在语法上是错误的,我们报告一个语法错误:
syntaxError();
我们返回一个文件结束标记,仅仅是因为我们必须返回一个值。然而,我们永远不会到达代码的这个点,因为syntaxError
调用抛出了一个异常:
return Token(EndOfFileId);
}
现在我们已经了解了扫描器,我们将在下一节继续了解解析器。
构建解析器
现在我们已经了解了扫描器,是时候转向解析器了。解析器检查源代码是否符合语法。它还执行类型检查并生成动作列表,该列表稍后由查看器显示,如下所示。Parser
类以这种方式反映了语法,即它为每个语法规则持有一个方法。
Parser.h:
#ifndef PARSER_H
#define PARSER_H
#include "Action.h"
#include "Scanner.h"
构造函数接受一个语法对象和动作列表,动作列表最初为空。解析器每次需要新标记时都会调用扫描器:
class Parser {
public:
Parser(Scanner& m_scanner, QList<Action>& actionList);
match
方法检查给定的标记是否等于扫描器获取的下一个标记。如果不相等,则报告语法错误:
private:
void match(TokenId tokenId);
Parser
类的其余方法分为语法中的指令和表达式的方法,以及类型检查和表达式评估的方法:
void instructionList();
void instruction();
我们还为语法中的每个表达式规则添加了一个解析器方法:
Value expression();
Value expressionRest(Value leftValue);
Value mulDivExpression();
Value mulDivExpressionRest(Value leftValue);
Value primaryExpression();
Value primaryExpression();
在评估表达式的值时,我们需要检查值的类型。例如,当添加两个值时,两个操作数都应该是数值:
void checkType(TokenId operatorId, const Value& value);
void checkType(TokenId operatorId, const Value& leftValue,
const Value& rightValue);
Value evaluate(TokenId operatorId, const Value& value);
Value evaluate(TokenId operatorId, const Value& leftValue,
const Value& rightValue);
m_lookAhead
字段持有扫描器获取的下一个标记,m_scanner
持有扫描器本身。m_actionList
字段持有构造函数中给出的动作列表的引用。最后,m_assignMap
持有由赋值规则分配给值的映射:
private:
Token m_lookAHead;
Scanner& m_scanner;
QList<Action>& m_actionList;
QMap<QString,Value> m_assignMap; };
#endif // PARSER_H
Parser.cpp
文件包含Parser
类的定义方法。
Parser.cpp:
#include <CAssert>
using namespace std;
#include "Value.h"
#include "Token.h"
#include "Scanner.h"
#include "Parser.h"
#include "Error.h"
构造函数初始化对扫描器和动作列表的引用,并将m_lookAHead
字段设置为扫描器获取的第一个标记。然后通过调用instructionList
开始解析过程。当指令列表被解析后,唯一剩下的标记应该是文件结束标记:
Parser::Parser(Scanner& m_scanner, QList<Action>& actionList)
:m_scanner(m_scanner),
m_actionList(actionList) {
m_lookAHead = m_scanner.nextToken();
instructionList();
match(EndOfFileId);
}
g_lineNo
字段跟踪源代码的当前行,以便可以报告带有正确行号的语法错误:
extern int g_lineNo;
instructionList
方法会一直迭代,直到遇到文件结束标记:
void Parser::instructionList() {
while (m_lookAHead.id() != EndOfFileId) {
instruction();
}
}
match
方法比较扫描器获取的下一个标记与给定的标记。如果不一致,则报告语法错误。如果一致,则通过扫描器获取下一个标记:
void Parser::match(TokenId tokenId) {
if (m_lookAHead.id() != tokenId) {
syntaxError();
}
m_lookAHead = m_scanner.nextToken();
}
解析语言的指令
instruction
方法包含一系列的 switch 案例序列,每个案例对应于指令的一个类别。我们将查看扫描器获得的下一个标记:
void Parser::instruction() {
TokenId tokenId = m_lookAHead.id();
在名称的情况下,我们解析名称、赋值运算符(=
)、后面的表达式和分号:
switch (tokenId) {
case NameId: {
QString assignName = m_lookAHead.name();
match(NameId);
match(AssignId);
Value assignValue = expression();
match(SemicolonId);
如果名称已经与一个值相关联,则会报告语义错误:
check(!m_assignMap.contains(assignName),
"the name "" + assignName + "" defined twiced");
m_assignMap[assignName] = assignValue;
}
break;
笔和刷的颜色和样式设置,以及字体和对齐方式,稍微复杂一些。我们调用 expression
来解析和评估表达式的值。检查表达式的类型,并将 Action
对象添加到动作列表中:
case SetPenColorId:
case SetPenStyleId:
case SetBrushColorId:
case SetBrushStyleId:
case SetFontId:
case SetHorizontalAlignmentId:
case SetVerticalAlignmentId: {
match(tokenId);
match(LeftParenthesisId);
Value value = expression();
match(RightParenthesisId);
match(SemicolonId);
checkType(tokenId, value);
m_actionList.push_back(Action(tokenId, value));
}
break;
绘制线条、矩形、椭圆和文本需要两种表达式,其值将被评估和类型检查:
case DrawLineId:
case DrawRectangleId:
case DrawEllipseId:
case DrawTextId: {
match(tokenId);
match(LeftParenthesisId);
Value firstValue = expression();
match(CommaId);
Value secondValue = expression();
match(RightParenthesisId);
match(SemicolonId);
checkType(tokenId, firstValue, secondValue);
m_actionList.push_back(Action(tokenId, firstValue,
secondValue));
}
break;
如果前面的任何标记都不适用,则报告语法错误:
default:
syntaxError();
}
}
解析语言的表达式
在最低优先级上,一个表达式由两个乘法或除法表达式组成。首先,我们调用 mulDivExpression
,这是按优先级顺序的下一个表达式,以获得可能的加法或减法表达式的左值,然后调用 expressionRest
,以检查实际上是否存在这样的表达式:
Value Parser::expression() {
Value leftValue = mulDivExpression ();
return expressionRest(leftValue);
}
expressionRest
方法检查下一个标记是否为加号或减号。在这种情况下,我们有一个加法或减法表达式,匹配标记,检查左右值的类型,然后评估并返回结果表达式:
Value Parser::expressionRest(Value leftValue) {
TokenId tokenId = m_lookAHead.id();
switch (tokenId) {
case AddId:
case SubtractId: {
match(tokenId);
Value rightValue = mulDivExpression();
check(leftValue.isNumerical() && rightValue.isNumerical(),
"non-numerical values in arithmetic expression");
Value resultValue =
evaluate(tokenId, leftValue, rightValue);
return expressionRest(resultValue);
}
default:
return leftValue;
}
}
mulDivExpression
方法的工作方式与之前展示的 expression
类似。它调用 primaryExpression
和 mulDivExpressionRest
,寻找乘法和除法。乘法和除法的优先级高于加法和减法。正如之前在 源语言语法 部分所述,我们需要在语法中添加一对新规则,在解析器中有两对方法用于加法/减法和乘法/除法表达式:
Value Parser::mulDivExpression() {
Value leftValue = primaryExpression();
return mulDivExpressionRest(leftValue);
}
Value Parser::mulDivExpressionRest(Value leftValue) {
TokenId tokenId = m_lookAHead.id();
switch (tokenId) {
case MultiplyId:
case DivideId: {
match(tokenId);
Value rightValue = primaryExpression();
check(leftValue.isNumerical() && rightValue.isNumerical(),
"non-numerical values in arithmetic expression");
Value resultValue =
evaluate(tokenId, leftValue, rightValue);
return mulDivExpressionRest (resultValue);
}
default:
return leftValue;
}
}
最后,主表达式由一个点、坐标、颜色或字体表达式组成。它也可以由括号内的表达式、一个名称(在这种情况下我们查找其值)或一个值组成:
Value Parser::primaryExpression() {
TokenId tokenId = m_lookAHead.id();
坐标表达式接受一个点并返回其 x 或 y 坐标。我们匹配关键字和括号,并调用括号之间的表达式。然后我们检查表达式的值是否为点,最后调用 evaluate
以提取 x 或 y 坐标:
switch (tokenId) {
case XCoordinateId:
case YCoordinateId: {
match(tokenId);
match(LeftParenthesisId);
Value value = expression();
match(RightParenthesisId);
check(value.isPoint(),
"not a point in coordinate expression");
checkType(tokenId, value);
return evaluate(tokenId, value);
}
break;
点表达式由关键字 point
和两个数值表达式组成:x 和 y 坐标:
case PointId: {
match(PointId);
match(LeftParenthesisId);
Value xValue = expression();
match(CommaId);
Value yValue = expression();
match(RightParenthesisId);
check(xValue.isNumerical() && yValue.isNumerical(),
"non-numerical values in point expression");
return Value(QPoint(xValue.numericalValue(),
yValue.numericalValue()));
}
颜色表达式由关键字 color
和三个数值表达式组成:红色、绿色和蓝色成分:
case ColorId: {
match(ColorId);
match(LeftParenthesisId);
Value redValue = expression();
match(CommaId);
Value greenValue = expression();
match(CommaId);
Value blueValue = expression();
match(RightParenthesisId);
check(redValue.isNumerical() && greenValue.isNumerical()
&& blueValue.isNumerical(),
"non-numerical values in color expression");
return Value(QColor(redValue.numericalValue(),
greenValue.numericalValue(),
blueValue.numericalValue()));
}
字体表达式由关键字 font
和两个表达式组成:字体的名称(字符串)和其大小(数值):
case FontId: {
match(FontId);
match(LeftParenthesisId);
Value nameValue = expression();
match(CommaId);
Value sizeValue = expression();
match(RightParenthesisId);
check(nameValue.isString() && sizeValue.isNumerical(),
"invalid types in font expression");
return Value(QFont(nameValue.stringValue(),
sizeValue.numericalValue()));
}
表达式可以被括号包围。在这种情况下,我们匹配括号,并在其中调用 expression
以获得表达式的值:
case LeftParenthesisId: {
match(LeftParenthesisId);
Value value = expression();
match(RightParenthesisId);
return value;
}
在名称的情况下,我们在赋值映射中查找其值并返回该值。如果没有值,则报告语义错误:
case NameId: {
QString lookupName = m_lookAHead.name();
match(NameId);
check(m_assignMap.contains(lookupName ),
"unknown name: "" + lookupName + "".");
return m_assignMap[lookupName ];
}
在值的情况下,我们直接返回该值:
case ValueId: {
Value value = m_lookAHead.value();
match(ValueId);
return value;
}
在任何其他情况下,都会报告语法错误:
default:
syntaxError();
return Value();
}
}
表达式类型检查
第一个 checkType
方法检查具有一个值的表达式的类型。当设置笔或画刷样式时,类型必须是笔或画刷样式:
void Parser::checkType(TokenId codeId, const Value& value) {
switch (codeId) {
case SetPenStyleId:
check(value.isPenStyle(), "not a pen-style value");
break;
case SetBrushStyleId:
check(value.isBrushStyle(), "not a brush-style value");
break;
当设置颜色或字体时,值必须是颜色或字体:
case SetPenColorId:
case SetBrushColorId:
check(value.isColor(), "not a color value");
break;
case SetFontId:
check(value.isFont(), "not a font value");
break;
当设置对齐方式时,值必须是对齐方式:
case SetHorizontalAlignmentId:
case SetVerticalAlignmentId:
check(value.isAlignment(), "not an alignment value");
break;
当从一个点中提取 x 或 y 坐标时,值必须是一个点:
case XCoordinateId:
case YCoordinateId:
check(value.isPoint(), "not a point value");
break;
}
}
第二个 checkType
方法接受两个值。绘图指令必须接受两个点:
void Parser::checkType(TokenId codeId, const Value& leftValue,
const Value& rightValue) {
switch (codeId) {
case DrawLineId:
case DrawRectangleId:
case DrawEllipseId:
check(leftValue.isPoint() && rightValue.isPoint(),
"non-point values in draw expression");
break;
文本绘图指令必须接受一个点和字符串:
case DrawTextId:
check(leftValue.isPoint() && rightValue.isString(),
"invalid values in text-drawing expression");
break;
}
}
评估表达式的值
第一个 evaluate
方法返回具有一个值的表达式的值。x 和 y 坐标运算符返回点的 x 或 y 坐标:
Value Parser::evaluate(TokenId codeId, const Value& value) {
switch (codeId) {
case XCoordinateId:
return Value((double) value.pointValue().x());
case YCoordinateId:
return Value((double) value.pointValue().y());
断言仅用于调试目的,我们返回 false 仅因为该方法必须返回一个值:
default:
assert(false);
return false;
}
}
最后,第二个 evaluate
方法评估具有两个值的表达式的值。首先,我们提取数值并评估算术表达式:
Value Parser::evaluate(TokenId codeId, const Value& leftValue,
const Value& rightValue) {
double leftNumericalValue = leftValue.numericalValue(),
rightNumericalValue = rightValue.numericalValue();
switch (codeId) {
case AddId:
return Value(leftNumericalValue + rightNumericalValue);
case SubtractId:
return Value(leftNumericalValue - rightNumericalValue);
case MultiplyId:
return Value(leftNumericalValue * rightNumericalValue);
在除以零的情况下,报告语义错误:
case DivideId:
if (rightNumericalValue == 0) {
semanticError("division by zero");
}
return Value(leftNumericalValue / rightNumericalValue);
最后,在点表达式中,我们返回一个包含其 x 和 y 坐标两个数值的点值:
case PointId:
return Value(QPoint(leftNumericalValue,
rightNumericalValue));
如前所述的第一个评估情况,断言仅用于调试目的,我们返回 false 仅因为该方法必须返回一个值:
default:
assert(false);
return Value();
}
}
查看器
最后,是时候编写查看器,我们 DSL 的最后一部分。查看器遍历动作并显示图形对象。ViewerWidget
类继承自 Qt 类 QWidget
,它在屏幕上显示小部件。
ViewerWidget.h:
#ifndef MAINWIDGET_H
#define MAINWIDGET_H
#include <QWidget>
#include <QtWidgets>
#include "Value.h"
#include "Colors.h"
#include "Action.h"
class ViewerWidget : public QWidget {
Q_OBJECT
构造函数调用基类 QWidget
的构造函数,并存储动作列表的引用:
public:
ViewerWidget(const QList<Action>& actionList,
QWidget *parentWidget = nullptr);
类的主要部分是 paintEvent
方法。每当窗口需要重绘时,它都会被调用,并遍历动作列表:
void paintEvent(QPaintEvent *eventPtr);
调用 QFont
的默认构造函数,将字体初始化为合适的系统字体。水平和垂直对齐都是居中的。最后,m_actionList
持有由解析器生成的动作列表的引用:
private:
Qt::Alignment m_horizontalAlignment = Qt::AlignHCenter,
m_verticalAlignment = Qt::AlignVCenter;
const QList<Action>& m_actionList;
};
#endif // MAINWIDGET_H
ViewerWidget.cpp
文件包含 ViewerWidget
类的方法定义。
ViewerWidget.cpp:
#include <QtWidgets>
#include "ViewerWidget.h"
构造函数调用基类 QWidget
的构造函数,并传入父窗口小部件,初始化 m_actionList
引用,设置窗口标题,并设置一个合适的尺寸:
ViewerWidget::ViewerWidget(const QList<Action>& actionList,
QWidget *parentWidget)
:QWidget(parentWidget),
m_actionList(actionList) {
setWindowTitle(tr("Domain Specific Language"));
resize(500, 300);
}
每次小部件需要重新绘制时,都会调用 paintEvent
方法。首先定义 QPainter
对象 painter
,然后遍历动作列表:
void ViewerWidget::paintEvent(QPaintEvent* /*event*/) {
QPainter painter(this);
for (const Action& action : m_actionList) {
switch (action.id()) {
SetPenColor
动作创建了一个带有新颜色和当前样式的笔,并将其添加到 painter
中。同样,SetPenStyle
动作创建了一个带有新样式和当前颜色的笔:
case SetPenColorId: {
QColor penColor = action.value1().colorValue();
QPen pen(penColor);
pen.setStyle(painter.pen().style());
painter.setPen(pen);
}
break;
case SetPenStyleId: {
Qt::PenStyle penStyle = action.value1().penStyleValue();
QPen pen(penStyle);
pen.setColor(painter.pen().color());
painter.setPen(pen);
}
break;
我们以与之前设置笔颜色和样式相同的方式设置画刷的颜色和样式。唯一的区别是我们创建了一个画刷而不是笔:
case SetBrushColorId: {
QColor brushColor = action.value1().colorValue();
QBrush brush(brushColor);
brush.setStyle(painter.brush().style());
painter.setBrush(brush);
}
break;
case SetBrushStyleId: {
Qt::BrushStyle brushStyle =
action.value1().brushStyleValue();
QBrush brush(brushStyle);
brush.setColor(painter.brush().color());
painter.setBrush(brush);
}
break;
在字体的情况下,我们在 painter
上调用 setFont
。之后,字体与 painter
关联,并在写入文本时使用:
case SetFontId: {
QFont font = action.value1().fontValue();
painter.setFont(font);
}
break;
水平和垂直对齐方式存储在 m_horizontalAlignment
和 m_verticalAlignment
中,这些值在写入文本时会被使用:
case SetHorizontalAlignmentId:
m_horizontalAlignment = action.value1().alignmentValue();
break;
case SetVerticalAlignmentId:
m_verticalAlignment = action.value1().alignmentValue();
break;
现在,是时候实际绘制一些图形对象了。一条线简单地画在两个点之间,而矩形或椭圆有上左和下右角,这些角被放置在一个矩形中,该矩形用作 drawRect
和 drawEllipse
调用的参数:
case DrawLineId:
painter.drawLine(action.value1().pointValue(),
action.value2().pointValue());
break;
case DrawRectangleId: {
QRect rect(action.value1().pointValue(),
action.value2().pointValue());
painter.drawRect(rect);
}
break;
case DrawEllipseId: {
QRect rect(action.value1().pointValue(),
action.value2().pointValue());
painter.drawEllipse(rect);
}
break;
最后,我们写入文本。我们首先提取点以围绕文本中心进行绘制,然后使用 Qt 的 QFontMetrics
类获取文本的大小(以像素为单位):
case DrawTextId: {
QPoint point = action.value1().pointValue();
const QString& text = action.value2().stringValue();
QFontMetrics metrics(painter.font());
QSize size = metrics.size(0, text);
在左对齐的情况下,文本的左侧是点的 x 坐标。在居中对齐的情况下,文本的左侧会向左移动半个文本宽度,而在右对齐的情况下,文本的左侧会向左移动整个文本宽度:
switch (m_horizontalAlignment) {
case Qt::AlignHCenter:
point.rx() -= size.width() / 2;
break;
case Qt::AlignRight:
point.rx() -= size.width();
break;
}
同样:在顶部垂直对齐的情况下,文本的顶部是点的 y 坐标。在居中对齐的情况下,文本的顶部向上移动半个文本高度,而在底部对齐的情况下,文本的顶部向上移动整个文本高度:
switch (m_verticalAlignment) {
case Qt::AlignVCenter:
point.ry() -= size.height() / 2;
break;
case Qt::AlignBottom:
point.ry() -= size.height();
break;
}
painter.drawText(point, text);
}
break;
}
}
}
主函数
最后,主函数调用扫描器的 init
静态方法以初始化其标记、关键字和值。创建了一个 QApplication
对象,读取并解析源代码,并创建了查看器小部件。它执行动作列表并显示图形对象。应用程序会一直执行,直到用户按下右上角的关闭按钮。
Main.cpp:
#include <QApplication>
#include <QMessageBox>
#include "Action.h"
#include "Error.h"
#include "Scanner.h"
#include "Parser.h"
#include "ViewerWidget.h"
int main(int argc, char *argv[]) {
Scanner::init();
QApplication application(argc, argv);
try {
QString path = "C:\Input.dsl";
QFile file(path);
if (!file.open(QIODevice::ReadOnly)) {
error("Cannot open file "" + path + "" for reading.");
}
QString buffer(file.readAll());
Scanner scanner(buffer);
QList<Action> actionList;
Parser(scanner, actionList);
ViewerWidget mainWidget(actionList);
mainWidget.show();
return application.exec();
}
在出现语法或语义错误的情况下,其消息会在消息框中显示:
catch (exception e) {
QMessageBox messageBox(QMessageBox::Information,
QString("Error"), QString(e.what()));
messageBox.exec();
}
}
摘要
在本章中,我们开始开发一个 DSL,该 DSL 生成一系列创建图形对象的动作,这些对象在窗口小部件中查看。我们的 DSL 支持绘制图形对象(如线条、矩形、椭圆和文本)的指令,以及设置对象的颜色、样式和对齐方式。它还支持带有算术运算符的表达式。
我们领域特定语言(DSL)的语法由语法规则定义,并由一个扫描器组成,该扫描器扫描文本以查找有意义的部分,解析器检查源代码是否符合语法,并生成一系列动作,这些动作由查看器读取并执行。
在下一章中,我们将继续开发我们的 DSL。本章的 DSL 仅支持按顺序执行的代码。然而,在下一章中,我们将添加函数调用以及选择和迭代(if
和while
指令)。
第十章:高级领域特定语言
在上一章中,我们开发了一个领域特定语言(DSL)。在本章中,我们将以几种方式改进语言:
-
我们将添加选择和迭代。更具体地说,我们将添加
if
和while
指令。在上一章的语言中,动作以直接的方式执行。在本章中,可以在不同的选择之间进行选择,并遍历代码的一部分。 -
我们将添加变量。在上一章中,我们可以将值赋给一个名称一次。然而,在本章中,值被赋给可以在程序执行过程中重新赋值的名称。
-
我们将添加函数,带有参数和返回值。在上一章中,一个程序由一系列指令组成。在本章中,它是一系列函数。类似于 C++,必须有
main
函数作为执行开始的地方。 -
最后,我们将在从源代码到查看器的过程中添加另一个模块。在上一章中,解析器生成了一系列由查看器显示的动作。在本章中,解析器生成的是一系列指令,这些指令随后由评估器评估为动作。
-
由于本章的语言支持选择、迭代、变量和函数调用,它开始看起来像一种传统的编程语言。
本章我们将涵盖的主题包括:
-
正如上一章一样,我们将通过查看一个示例来非正式地研究我们的领域特定语言(DSL)的源代码。然而,在这个示例中,我们将使用变量和函数调用,我们还将使用
if
和while
指令。 -
我们将正式定义我们的语言,使用语法。这个语法是上一章语法的扩展。我们将添加函数定义、调用和返回的指令,以及选择(
if
)和迭代(while
)的指令。 -
当我们定义了语法后,我们将编写扫描器。本章的扫描器几乎与上一章的扫描器相同。唯一的区别是我们将添加一些关键字。
-
当我们编写了扫描器后,我们将编写解析器。解析器是上一章解析器的扩展,我们添加了用于函数、选择和迭代的函数。然而,上一章的解析器生成了一系列动作,这些动作由查看器读取并执行。然而,在本章中,解析器生成的是一系列由评估器读取的指令。
-
在本章中,下一步是评估者而不是查看者。评估者接收解析器生成的指令序列,并生成一系列动作,这些动作由查看者读取和执行。评估者与将值分配给名称的映射一起工作。有一个值映射栈确保每个被调用的函数都得到它自己的新值映射。还有一个值栈,用于在评估表达式时存储临时值。最后,还有一个调用栈,用于存储函数调用的返回地址。
-
最后,查看者与上一章中的工作方式相同。它遍历评估者生成的动作列表,并在 Qt 小部件中显示图形对象。
改进源语言 – 一个例子
让我们看看一个新的例子,其中我们定义并调用一个名为triangle
的函数,该函数使用不同大小和不同笔触绘制三角形。请注意,函数不必按任何特定顺序出现。
我们首先将left
和length
变量设置为50
。它们持有第一个三角形最左边的x坐标和其底边长度。我们还设置了index
变量为零;其值将在while
循环中使用:
function main() {
left = 50;
length = 50;
index = 0;
我们会一直迭代,直到index
小于四。注意,在本章中,我们向Value
类添加了布尔值。当index
持有偶数值时,我们将画笔样式设置为实线,而当它持有奇数值时,我们将画笔样式设置为虚线。注意,我们已经通过关系表达式和取模运算符(%
)扩展了语言:
while (index < 4) {
if ((index % 2) == 0) {
SetPenStyle(SolidLine);
}
else {
SetPenStyle(DashLine);
}
我们设置三角形的左上角,并调用drawTriangle
函数来执行三角形的实际绘制:
topLeft = point(left, 25);
call drawTriangle(topLeft, length);
在调用triangle
之后,我们增加下一个三角形的底边长度和最左边的角:
length = length + 25;
left = left + length;
index = index + 1;
}
}
在drawTriangle
函数中,我们调用getTopRight
和getBottomMiddle
函数来获取三角形的右上角和底中点。最后,我们通过调用drawLine
来绘制三角形的三条线:
function drawTriangle(topLeft, length) {
topRight = call getTopRight(topLeft, length);
bottomMiddle = call getBottomMiddle(topLeft, length);
drawLine(topLeft, topRight);
drawLine(topRight, bottomMiddle);
drawLine(bottomMiddle, topLeft);
}
getTopRight
函数提取左上角的x和y坐标,并返回一个x坐标增加了三角形底边长度的点:
function getTopRight(topLeft, length) {
return point(xCoordinate(topLeft) + length,
yCoordinate(topLeft));
}
getBottomMiddle
函数也提取左上角的x和y坐标。然后它计算中间底部的x和y坐标,并返回point
:
function getBottomMiddle(topLeft, length) {
left = xCoordinate(topLeft);
top = yCoordinate(topLeft);
middle = left + length / 2;
bottom = top + length;
return point(middle, bottom);
}
代码执行的输出显示在下述屏幕截图:
改进语法
在本章中,我们将改进我们语言的语法。首先,一个程序由一系列函数组成,而不是指令。技术上,一个程序可以包含零个函数。然而,语义错误会报告main
函数缺失:
program -> functionDefinitionList
functionDefinitionList -> functionDefinition*
函数的定义由关键字 function
、括号内的名称列表和括号内的指令列表组成。nameList
由零个或多个名称组成,名称之间用逗号分隔:
functionDefinition -> function name(nameList) { instructionList }
当涉及到指令时,我们添加了函数调用的功能。我们可以直接作为指令调用函数,例如前面的例子中的 call
drawTriangle
,或者作为表达式的一部分(callgetTopRight
和 call
getBottomMiddle
)。
我们还添加了 while
指令和带有或不带有 else
部分的 if
指令。最后,还有块指令:由括号包围的指令列表:
instruction -> callExpression ;
| while (expression) instruction
| if (expression) instruction
| if (expression) instruction else instruction
| { instructionList }
| ...
callInstruction -> callExpression ;
当涉及到表达式时,唯一的区别是我们增加了函数调用。expressionList
是由逗号分隔的零个或多个表达式的列表:
primaryExpression -> call name(expressionList)
|
标记和扫描器
与上一章类似,该语言的最终目标代码是动作,尽管它们是由评估器而不是解析器生成的。Action
类与上一章的类相同。同样,Value
和 ViewerWidget
类,以及颜色和错误处理也是如此。然而,Token
和 Scanner
类已被扩展。TokenId
枚举已被扩展以包含更多的标记标识符。
Token.h:
class Token {
// ...
enum TokenId {BlockId, CallId, ElseId, FunctionId, GotoId,
IfId, IfNotGotoId, ReturnId, WhileId, // ...
};
// ...
};
同样,Scanner
中的 init
已通过关键字扩展。
Scanner.cpp:
void Scanner::init() {
ADD_TO_KEYWORD_MAP(CallId)
ADD_TO_KEYWORD_MAP(ElseId)
ADD_TO_KEYWORD_MAP(FunctionId)
ADD_TO_KEYWORD_MAP(IfId)
ADD_TO_KEYWORD_MAP(ReturnId)
ADD_TO_KEYWORD_MAP(WhileId)
// ...
}
解析器
解析器已通过对应于新语法规则的方法进行扩展。此外,本章的解析器不生成动作;相反,它生成 指令。这是因为,尽管上一章的源代码包含从开始到结束执行的指令,但本章的源代码包含选择、迭代和可以改变指令流程的函数调用。因此,引入一个中间层是有意义的——解析器生成指令,这些指令被评估为动作。
由于本章的语言支持函数,我们需要 Function
类来存储函数。它存储了形式参数的名称和函数的起始地址。
Function.h:
#ifndef FUNCTION_H
#define FUNCTION_H
#include <QtWidgets>
#include "Value.h"
#include "Action.h"
class Function {
public:
Function() {}
Function(const QList<QString>& nameList, int address);
const QList<QString>& nameList() const {return m_nameList;}
int address() {return m_address;}
Function(const Function& function);
Function operator=(const Function& function);
private:
QList<QString> m_nameList;
int m_address;
};
#endif // FUNCTION_H
Function.cpp
文件包含 Function
类方法的定义。
Function.cpp:
#include "Function.h"
Function::Function(const QList<QString>& nameList, int address)
:m_nameList(nameList),
m_address(address) {
// Empty.
}
Function::Function(const Function& function)
:m_nameList(function.m_nameList),
m_address(function.m_address) {
// Empty.
}
Function Function::operator=(const Function& function) {
m_nameList = function.m_nameList;
m_address = function.m_address;
return *this;
}
由于本章中的解析器生成的是一系列指令而不是动作,因此我们还需要 Directive
类来保存指令。在大多数情况下,一个 Directive
对象只保存 TokenId
枚举的身份标识。然而,在函数调用的例子中,我们需要存储函数名称和实际参数的数量。在函数定义的情况下,我们存储对 Function
对象的引用。在由值名称组成的表达式中,我们需要存储名称或值。最后,还有几种跳转指令,在这种情况下,我们需要存储地址。
Directive.h:
#ifndef DIRECTIVE_H
#define DIRECTIVE_H
#include <QtWidgets>
#include "Token.h"
#include "Value.h"
#include "Function.h"
class Directive {
public:
Directive(TokenId tokenId);
Directive(TokenId tokenId, int address);
Directive(TokenId tokenId, const QString& name);
Directive(TokenId tokenId, const QString& name,
int parameters);
Directive(TokenId tokenId, const Value& value);
Directive(TokenId tokenId, const Function& function);
Directive(const Directive& directive);
Directive operator=(const Directive& directive);
TokenId directiveId() {return m_directiveId;}
const QString& name() {return m_name;}
const Value& value() {return m_value;}
const Function& function() {return m_function;}
int parameters() const {return m_parameters;}
int address() const {return m_address;}
void setAddress(int address) {m_address = address;}
private:
TokenId m_directiveId;
QString m_name;
int m_parameters, m_address;
Value m_value;
Function m_function;
};
#endif // DIRECTIVE_H
Directive.cpp
文件包含Directive
类方法的定义。
Directive.cpp:
#include "Directive.h"
在大多数情况下,我们只创建一个带有指令身份的Directive
类对象:
Directive::Directive(TokenId directiveId)
:m_directiveId(directiveId) {
// Empty.
}
跳转指令需要跳转地址:
Directive::Directive(TokenId directiveId, int address)
:m_directiveId(directiveId),
m_address(address) {
// Empty.
}
当给变量赋值时,我们需要变量的名称。然而,我们不需要值,因为它将被存储在栈上。此外,当表达式由一个名称组成时,我们需要存储该名称:
Directive::Directive(TokenId directiveId, const QString& name)
:m_directiveId(directiveId),
m_name(name) {
// Empty.
}
函数调用指令需要函数名称和实际参数的数量:
Directive::Directive(TokenId directiveId, const QString& name,
int parameters)
:m_directiveId(directiveId),
m_name(name),
m_parameters(parameters) {
// Empty.
}
当一个表达式仅由一个值组成时,我们只需将值存储在指令中:
Directive::Directive(TokenId directiveId, const Value& value)
:m_directiveId(directiveId),
m_value(value) {
// Empty.
}
最后,在函数定义中,我们存储一个Function
类的对象:
Directive::Directive(TokenId directiveId,
const Function& function)
:m_directiveId(directiveId),
m_function(function) {
// Empty.
}
Parser
类已经通过新语法规则的方法扩展:函数定义和if
、while
、call
和return
指令。
Parser.h:
// ...
class Parser {
private:
void functionDefinitionList();
void functionDefinition();
nameList
方法收集函数的形式参数,而expressionList
收集函数调用的实际参数:
QList<QString> nameList();
int expressionList();
callExpression
方法也被添加到Parser
类中,因为函数可以作为指令或表达式的一部分显式调用:
void callExpression();
// ...
};
Parser.cpp
文件包含Parser
类方法的定义。
本章解析器的start
方法为functionDefinitionList
。只要没有达到文件末尾,它就会调用functionDefinition
。
Parser.cpp:
void Parser::functionDefinitionList() {
while (m_lookAHead.id() != EndOfFileId) {
functionDefinition();
}
}
functionDefinition
方法解析函数定义。我们首先匹配function
关键字并存储函数的名称:
void Parser::functionDefinition() {
match(FunctionId);
QString name = m_lookAHead.name();
match(NameId);
函数名称后面跟着括号内的参数名称列表。我们将名称列表存储在nList
字段中。我们不能将字段命名为nameList
,因为该名称已经被方法占用:
match(LeftParenthesisId);
QList<QString> nList = nameList();
match(RightParenthesisId);
我们将指令列表的当前大小存储为函数的起始地址,创建一个带有名称列表和起始地址的Function
对象,并将一个带有函数的Directive
对象添加到指令列表中:
int startAddress = (int) m_directiveList.size();
Function function(nList, startAddress);
m_directiveList.push_back(Directive(FunctionId, function));
名称列表后面跟着一个由括号括起来的指令列表:
match(LeftBracketId);
instructionList();
match(RightBracketId);
为了确保函数确实将控制权返回给调用函数,我们添加了一个带有return
标记身份的Directive
对象:
m_directiveList.push_back(Directive(ReturnId));
当函数被定义后,我们检查没有其他函数具有相同的名称:
check(!m_functionMap.contains(name),
"function "" + name + "" already defined");
如果函数名为"main"
,它是程序的开始函数,并且它不能有参数:
check(!((name == "main") && (nList.size() > 0)),
"function "main" cannot have parameters");
最后,我们将函数添加到functionMap
中:
m_functionMap[name] = function;
}
nameList
方法解析括号内逗号分隔的名称列表:
QList<QString> Parser::nameList() {
QList <QString> nameList;
我们会继续,直到遇到右括号:
while (m_lookAHead.id() != RightParenthesisId) {
QString name = m_lookAHead.name();
nameList.push_back(name);
match(NameId);
在匹配名称后,我们检查下一个标记是否为右括号。如果是,则表示名称列表的末尾,并中断迭代:
if (m_lookAHead.id() == RightParenthesisId) {
break;
}
如果下一个标记不是右括号,我们则假设它是一个逗号,匹配它,并继续使用下一个表达式迭代:
match(CommaId);
}
最后,在我们返回名称列表之前,我们需要检查名称列表中没有任何名称重复。我们遍历名称列表并将名称添加到一个集合中:
QSet<QString> nameSet;
for (const QString& name : nameList) {
if (nameSet.contains(name)) {
semanticError("parameter "" + name + "" defined twice");
}
nameSet.insert(name);
}
return nameList;
}
在本章中,instructionList
方法看起来略有不同,因为它被放置在指令块内部。我们迭代,直到遇到右括号为止:
void Parser::instructionList() {
while (m_lookAHead.id() != RightBracketId) {
instruction();
}
}
由于函数可以作为指令显式调用,或者作为表达式的一部分,我们只需调用 callExpression
并在调用指令的情况下匹配分号:
void Parser::instruction() {
switch (m_lookAHead.id()) {
case CallId:
callExpression();
match(SemicolonId);
break;
在返回指令中,我们匹配 return
关键字并检查它是否后面跟着分号。如果没有跟着分号,我们解析一个表达式,然后假设下一个标记是一个分号。注意,我们不会存储表达式的结果。评估器将在处理过程中稍后将其值放置在栈上:
case ReturnId:
match(ReturnId);
if (m_lookAHead.id() != SemicolonId) {
expression();
}
m_directiveList.push_back(Directive(ReturnId));
match(SemicolonId);
break;
在 if
关键字的情况下,我们匹配它并解析括号内的表达式:
case IfId: {
match(IfId);
match(LeftParenthesisId);
expression();
match(RightParenthesisId);
如果表达式评估为假值,我们将跳过 if
表达式之后的指令。因此,我们添加一个 IfNotGoto
指令,目的是跳过 if
关键字之后的指令:
int ifNotIndex = (int) m_directiveList.size();
m_directiveList.push_back(Directive(IfNotGotoId, 0));
instruction();
如果指令后面跟着 else
关键字,我们匹配它并添加一个 Goto
指令,目的是在 if
指令表达式的真值情况下跳过 else
部分:
if (m_lookAHead.id() == ElseId) {
match(ElseId);
int elseIndex = (int) m_directiveList.size();
m_directiveList.push_back(Directive(GotoId, 0));
然后,我们设置前一个 IfNotTrue
指令的跳转地址。如果表达式不为真,程序将跳转到这个点:
m_directiveList[ifNotIndex].
setAddress((int) m_directiveList.size());
instruction();
另一方面,如果 if
指令的表达式为真,程序将跳过 else
部分跳转到这个点:
m_directiveList[elseIndex].
setAddress((int) m_directiveList.size());
}
如果 if
指令后面没有跟 else
关键字,如果表达式不为真,它将跳转到程序的这个点:
else {
m_directiveList[ifNotIndex].
setAddress((int) m_directiveList.size());
}
}
break;
在 while
关键字的情况下,我们匹配它并将指令列表的当前索引存储起来,以便程序在每次迭代后跳回到这个点:
case WhileId: {
match(WhileId);
int whileIndex = (int) m_directiveList.size();
然后,我们解析表达式及其括号:
match(LeftParenthesisId);
expression();
match(RightParenthesisId);
如果表达式不为真,我们添加一个 IfNotGoto
指令,以便程序跳出迭代:
int ifNotIndex = (int) m_directiveList.size();
m_directiveList.push_back(Directive(IfNotGotoId, 0));
instruction();
在 while
表达式之后的指令后添加一个 Goto
指令,这样程序就可以在每次迭代结束时跳回到表达式:
m_directiveList.push_back(Directive(GotoId, whileIndex));
最后,我们将 IfNotTrue
指令的地址设置在 while
指令的开始处,这样如果表达式不为真,程序就可以跳转到这个程序点:
m_directiveList[ifNotIndex].
setAddress((int) m_directiveList.size());
}
break;
在左括号的情况下,我们有一个由括号包围的指令序列。我们解析这对括号并调用 instructionList
:
case LeftBracketId:
match(LeftBracketId);
instructionList();
match(RightBracketId);
break;
最后,在名称的情况下,我们有一个赋值操作。我们匹配 name
关键字和赋值运算符(=
),解析表达式,并匹配分号。然后我们向指令列表添加一个包含要赋予值的名称的 Assign
对象。请注意,我们不会存储表达式的值,因为它将被评估器推入值栈:
case NameId: {
QString name = m_lookAHead.name();
match(NameId);
match(AssignId);
expression();
match(SemicolonId);
m_directiveList.push_back(Directive(AssignId, name));
}
break;
// ...
}
}
callExpression
方法匹配 call
关键字,存储函数的名称,解析参数表达式,并将包含调用的 Directive
对象添加到指令列表中。请注意,在此点我们不会检查函数是否存在或计算参数的数量,因为函数可能尚未定义。所有类型检查都由评估器在后续过程中处理:
void Parser::callExpression() {
match(CallId);
QString name = m_lookAHead.name();
match(NameId);
match(LeftParenthesisId);
int size = expressionList();
match(RightParenthesisId);
m_directiveList.push_back(Directive(CallId, name, size));
}
expressionList
方法解析表达式列表。与前面的名称列表情况不同,我们不返回列表本身,只返回其大小。表达式生成自己的指令,其值在后续过程中由评估器存储在栈上:
int Parser::expressionList() {
int size = 0;
我们会一直迭代,直到遇到右括号:
while (m_lookAHead.id() != RightParenthesisId) {
expression();
++size;
解析表达式后,我们检查下一个标记是否是右括号。如果是,表达式列表就完成了,我们中断迭代:
if (m_lookAHead.id() == RightParenthesisId) {
break;
}
如果下一个标记不是右括号,我们假设它是一个逗号,匹配它,并继续迭代:
match(CommaId);
}
最后,经过迭代后,我们返回表达式的数量:
return size;
}
评估器
评估器评估一系列指令并生成一个列表,该列表稍后由查看器读取和执行。评估从第一行的指令开始,该指令是跳转到 main
函数的起始地址。评估在遇到没有返回地址的 return
指令时停止。在这种情况下,我们已经到达 main
的末尾,执行应该完成。
评估器针对值栈进行操作。每次评估一个值时,它就会被推入栈中,每次需要值来评估表达式时,它们就会从栈中弹出。
Evaluator.h:
#ifndef EVALUATOR_H
#define EVALUATOR_H
#include <QtWidgets>
#include "Error.h"
#include "Directive.h"
#include "Action.h"
#include "Function.h"
Evaluator
类的构造函数使用函数映射评估指令列表:
class Evaluator {
public:
Evaluator(const QList<Directive>& directiveList,
QList<Action>& actionList,
QMap<QString,Function> functionMap);
checkType
和 evaluate
方法与上一章相同。它们已从 Parser
移至 Evaluator
。checkType
方法检查与标记关联的表达式是否具有正确的类型,而 evaluate
方法评估表达式:
private:
void checkType(TokenId tokenId, const Value& value);
void checkType(TokenId tokenId, const Value& leftValue,
const Value& rightValue);
Value evaluate(TokenId tokenId, const Value& value);
Value evaluate(TokenId tokenId, const Value& leftValue,
const Value& rightValue);
当评估一个表达式时,其值会被推入 m_valueStack
。当一个变量被赋予一个值时,其名称和值会被存储在 m_valueMap
中。请注意,在本章中,一个值可以被赋予一个变量多次。当一个函数调用另一个函数时,调用函数的值映射会被推入 m_valueMapStack
以给被调用函数提供一个全新的值映射,并且返回地址会被推入 m_returnAddressStack
:
QStack<Value> m_valueStack;
QMap<QString,Value> m_valueMap;
QStack<QMap<QString,Value>> m_valueMapStack;
QStack<int> m_returnAddressStack;
};
#endif // EVALUATOR_H
Evaluator.cpp
文件包含Evaluator
类的方法定义:
Evaluator.cpp:
#include <CAssert>
using namespace std;
#include "Error.h"
#include "Evaluator.h"
Evaluator
类的构造函数可以被视为评估器的核心。
构造函数中的directiveIndex
字段是指令列表中当前Directive
对象的索引。通常,它会在每次迭代中增加。然而,由于if
或while
指令以及函数调用和返回,它也可以被赋予不同的值:
Evaluator::Evaluator(const QList<Directive>& directiveList,
QList<Action>& actionList,
QMap<QString,Function> functionMap) {
int directiveIndex = 0;
while (true) {
Directive directive = directiveList[directiveIndex];
TokenId directiveId = directive.directiveId();
当调用函数时,我们首先在函数映射中查找函数名,如果没有找到则报告语义错误。然后我们检查实际参数的数量是否等于形式参数的数量(Function
对象中名称列表的大小):
switch (directiveId) {
case CallId: {
QString name = directive.name();
check(functionMap.contains(name),
"missing function: "" + name + """);
Function function = functionMap[name];
check(directive.parameters() ==
function.nameList().size(),
"invalid number of parameters");
当我们调用函数时,我们在返回地址栈上推送下一个指令的索引,以便被调用的函数可以返回到正确的地址。我们在值映射栈上推送调用函数的值映射,以便在调用后检索它。然后我们清除值映射,以便它对被调用的函数是新鲜的。最后,我们将指令索引设置为被调用函数的起始地址,这会将控制权转移到被调用函数的开始处。请注意,我们对实际参数表达式没有做任何事情。它们已经被评估,并且它们的值被推送到值栈上:
m_returnAddressStack.push(directiveIndex + 1);
m_valueMapStack.push(m_valueMap);
m_valueMap.clear();
directiveIndex = function.address();
}
break;
函数开始时,我们为每个参数弹出值栈,并将每个参数名与其值在值映射中关联。记住,在调用函数之前已经评估了参数表达式,并且它们的值被推送到值栈上。还要记住,第一个参数首先被推送到栈上,并且位于其他参数的下方,这就是为什么我们以相反的顺序分配参数。最后,记住调用函数时值映射被推送到值映射栈上,值栈在函数调用期间被清除,因此在函数开始时当前值映射为空:
case FunctionId: {
const Function& function = directive.function();
const QList<QString>& nameList = function.nameList();
for (int listIndex = ((int) nameList.size() - 1);
listIndex >= 0; --listIndex) {
const QString& name = nameList[listIndex];
m_valueMap[name] = m_valueStack.pop();
}
}
++directiveIndex;
break;
当从函数返回时,我们首先检查返回地址栈是否为空。如果不为空,我们执行正常的函数返回。通过弹出值映射栈,我们恢复调用函数的值映射。我们还通过弹出返回地址栈将指令索引设置为函数调用后的地址:
case ReturnId:
if (!m_returnAddressStack.empty()) {
m_valueMap = m_valueMapStack.pop();
directiveIndex = m_returnAddressStack.pop();
}
然而,如果返回地址栈为空,我们有一个特殊情况——我们已经到达了main
函数的末尾。在这种情况下,我们不应返回到调用函数(没有调用函数)。相反,我们应通过调用返回来完成评估器的执行。记住,我们处于Evaluator
类的构造函数中,并且从构造函数返回:
else {
return;
}
break;
IfNotGoto
指令是在解析 if
或 while
指令时由解析器添加的。我们弹出值栈;如果值为假,我们通过调用指令的 address
方法来设置指令索引以执行跳转。记住,在本章中,我们已经向 Value
类添加了布尔值:
case IfNotGotoId: {
Value value = m_valueStack.pop();
if (!value.booleanValue()) {
directiveIndex = directive.address();
}
如果值为真,我们不执行跳转;我们只是简单地增加指令索引:
else {
++directiveIndex;
}
}
break;
Goto
指令执行无条件跳转;我们只需设置新的指令索引。由于 IfNotGoto
和 Goto
指令是由解析器生成的,我们不需要执行任何类型检查:
case GotoId:
directiveIndex = directive.address();
break;
设置指令的工作方式与上一章中的解析器相对应。在早期指令的评估过程中,表达式的值已经推入值栈。我们从值栈中弹出值并检查它是否包含正确的类型。然后我们将带有值的操作添加到操作列表中并增加指令索引:
case SetPenColorId:
case SetPenStyleId:
case SetBrushColorId:
case SetBrushStyleId:
case SetFontId:
case SetHorizontalAlignmentId:
case SetVerticalAlignmentId: {
Value value = m_valueStack.pop();
checkType(directiveId, value);
actionList.push_back(Action(directiveId, value));
++directiveIndex;
}
break;
此外,绘图指令与上一章中的解析器类似。它们的第一个和第二个值以相反的顺序弹出,因为第一个值首先被推入,因此位于栈中的第二个值下方。然后我们检查值是否具有正确的类型,将操作添加到操作列表中,并增加指令索引:
case DrawLineId:
case DrawRectangleId:
case DrawEllipseId:
case DrawTextId: {
Value secondValue = m_valueStack.pop();
Value firstValue = m_valueStack.pop();
checkType(directiveId, firstValue, secondValue);
actionList.push_back(Action(directiveId, firstValue,
secondValue));
++directiveIndex;
}
break;
赋值指令将名称与值映射中的值关联起来。请注意,如果名称已经与一个值关联,则之前的值将被覆盖。另外请注意,值映射是当前函数的局部变量,潜在的调用函数有自己的值映射推入值映射栈:
case AssignId: {
Value value = m_valueStack.pop();
m_valueMap[directive.name()] = value;
++directiveIndex;
}
break;
在包含一个值的表达式中,其值从栈中弹出,检查其类型,并计算表达式的结果值并将其推入值栈。最后,增加指令索引:
case XCoordinateId:
case YCoordinateId: {
Value value = m_valueStack.pop();
checkType(directiveId, value);
Value resultValue = evaluate(directiveId, value);
m_valueStack.push(resultValue);
++directiveIndex;
}
break;
在包含两个值的表达式中,其第一个和第二个值从栈中弹出(顺序相反),检查它们的类型,并计算表达式的结果值并将其推入值栈。最后,增加指令索引:
case AddId:
case SubtractId:
case MultiplyId:
case DivideId:
case PointId: {
Value rightValue = m_valueStack.pop();
Value leftValue = m_valueStack.pop();
checkType(directiveId, leftValue, rightValue);
Value resultValue =
evaluate(directiveId, leftValue, rightValue);
m_valueStack.push(resultValue);
++directiveIndex;
}
break;
在颜色表达式中,红色、绿色和蓝色组件值从值栈中弹出(顺序相反),检查它们的类型,并将结果颜色推入值栈。最后,增加指令索引:
case ColorId: {
Value blueValue = m_valueStack.pop();
Value greenValue = m_valueStack.pop();
Value redValue = m_valueStack.pop();
checkColorType(redValue, greenValue, blueValue);
QColor color(redValue.numericalValue(),
greenValue.numericalValue(),
blueValue.numericalValue());
m_valueStack.push(Value(color));
++directiveIndex;
}
break;
在字体表达式中,名称和大小值从值栈中弹出(顺序相反)并检查它们的类型。然后将结果字体推入值栈并增加指令索引:
case FontId: {
Value sizeValue = m_valueStack.pop();
Value nameValue = m_valueStack.pop();
checkFontType(nameValue, sizeValue,
boldValue, italicValue);
QFont font(nameValue.stringValue(),
sizeValue.numericalValue());
m_valueStack.push(Value(font));
++directiveIndex;
}
break;
在名称的情况下,我们查找其值并将其推入值栈,并增加指令索引。如果没有与名称关联的值,则报告语义错误:
case NameId: {
QString name = directive.name();
check(m_valueMap.contains(name),
"unknown name: "" + name +""");
m_valueStack.push(m_valueMap[name]);
++directiveIndex;
}
break;
最后,当我们有一个值时,我们只需将其推入值栈并增加指令索引:
case ValueId:
m_valueStack.push(directive.value());
++directiveIndex;
break;
}
}
}
主函数
最后,main
函数几乎与上一个函数相同。
Main.cpp:
#include <QApplication>
#include <QMessageBox>
#include <IOStream>
using namespace std;
#include "Action.h"
#include "Error.h"
#include "Scanner.h"
#include "Parser.h"
#include "Evaluator.h"
#include "ViewerWidget.h"
int main(int argc, char *argv[]) {
Scanner::init();
QApplication application(argc, argv);
try {
QString path = "C:\Input.dsl";
QFile file(path);
if (!file.open(QIODevice::ReadOnly)) {
error("Cannot open file "" + path + "" for reading.");
}
QString buffer(file.readAll());
Scanner scanner(buffer);
唯一的不同之处在于,解析器生成一系列指令而不是动作,以及一个函数映射,这些被发送到评估器,评估器生成最终的动作列表,该列表被读取和执行以显示图形对象:
QList<Directive> directiveList;
QMap<QString,Function> functionMap;
Parser(scanner, directiveList, functionMap);
QList<Action> actionList;
Evaluator evaluator(directiveList, actionList, functionMap);
ViewerWidget mainWidget(actionList);
mainWidget.show();
return application.exec();
}
catch (exception e) {
QMessageBox messageBox(QMessageBox::Information,
QString("Error"), QString(e.what()));
messageBox.exec();
}
}
摘要
在本章中,我们改进了我们在上一章开始工作的领域特定语言(DSL)。我们添加了选择、迭代、变量和函数调用。我们还添加了评估器,它接收解析器生成的指令,并生成由查看器读取和执行的动作。当指令正在执行时,表达式的值存储在栈上,分配给名称的值存储在映射中,函数调用的返回地址存储在栈上。
这就是最后一章了,希望你喜欢这本书!