十三、标准 I/O 和文件操作
我们玩得太开心了。是时候认真对待了。
或者,当你不使用图形和游戏库的时候,也许是时候学习如何编程了。毕竟,你通常不是。即使你是,你也可能需要访问文件(比如加载游戏关卡),在 C++ 中,我们处理文件就像我们处理基于文本的用户交互一样——就像我们到目前为止用sin
和sout
所做的那样。
标准 I/O 程序
示例 13-1 是一个使用标准 I/O 的程序,可能看起来很熟悉。
// Hello, world! program
// -- from _C++20 for Lazy Programmers_
// It prints "Hello, world!" on the screen.
// Quite an accomplishment, huh?
#include <iostream>
using namespace std;
int main ( )
{
cout << "Hello, world!" << endl;
return 0;
}
Example 13-1“Hello, world!” using C++ standard I/O
以下是 SSDL 的《你好,世界》的变化。,从底层开始,反向工作:
-
是时候澄清一下了:
ssin
和sout
是编译器自带的内置cin
(读作“C-in”)和cout
(C-out)的廉价仿制品。cin
和cout
与 SDL 窗口不兼容,所以我们需要一个替代品。cin
和cout
就像ssin
和sout
,但是 a)你不能设置光标——你只能向下移动屏幕——b)你不能设置字体或颜色。 -
我们需要
main
拥有与 SDL 兼容的参数(int argc, char** argv
);现在可以省略了。 -
using
namespace std;
:cout
是“标准”名称空间的一部分,你必须告诉编译器使用它,否则它会抱怨它不知道cout
是什么。 -
我们加载的是
<iostream>
,而不是"SSDL.h
,"
,它就像<cmath>
和<cstdlib>
一样是编译器自带的。它定义了cin
、cout
、endl
(输出'\n'
)、 1 等东西。
编译标准 I/O 程序
您可以像以前一样构建和运行 Visual Studio 程序:打开解决方案文件,右键单击要运行的项目,然后选择 Debug ➤ Start New Instance。但是要做自己的项目,不是抄basicSSDLProject
而是抄basicStandardProject
。
在 Unix 或 **MinGW 中,**变得更加简单。进入项目文件夹,输入make
。(再也不用抄Makefile.unix
或者Makefile.mingw
;一个Makefile
现在服务于两个平台——这是不使用库的优势。)要运行,输入./a.out
(Unix)或a.out
(MinGW)。
要创建自己的项目,在资源库的newWork
文件夹中复制一份basicStandardProject
,并以同样的方式使用它:make
和a.out
。
从头开始构建项目(可选)
…在 Microsoft Visual Studio 中
要制作自己的项目而不引用basicStandardProject
,在启动 Visual Studio 时,告诉它创建一个新项目(图 13-1 ,然后选择控制台 App(图 13-2 )。在下一个窗口中(图 13-3 ),Visual Studio 更愿意将你的项目放在一个很难找到的名为“repos”的文件夹中;我用台式机。我也强烈建议您选中“将解决方案和项目放在同一个目录中”——否则,您会得到多个调试文件夹,并且清理文件会更加困难。
图 13-3
在 Visual Studio 中配置新项目
图 13-2
在 Visual Studio 中创建新项目
图 13-1
正在启动 Visual Studio
默认的控制台项目是微软的 Hello,world!(图 13-4 )。你可以删除它,输入你自己的代码。
图 13-4
新项目
如果你想使用 C++20 的最新特性(谁不想呢?),可能需要告诉 Visual Studio。需求很可能会改变,所以请查看本书源代码的newWork
文件夹,了解当前的指令。
现在你已经准备好输入你的程序了,但是在运行它之前,我建议你先阅读一下反欺诈部分。
防错法
以下是您在使用 Microsoft Visual Studio 迁移到标准 I/O 时可能会发现的一些问题:
-
在你有机会看到任何东西之前,程序就关闭了。解决方案:在工具➤选项➤调试下,取消勾选调试停止时自动关闭控制台。这将适用于所有控制台项目,直到你改变它。
之后,如果您正在运行它,并试图运行另一个副本,该后续副本可能会在您看到之前自动关闭。一次只运行一个副本。
-
编译器找不到 _WinMain@16 **,预编译头文件,或者其他让你去“嗯?”**您可能选择了错误的项目类型(图 13-2 )。简单的解决方法是重新开始并选择控制台应用程序。
…使用 g++
要创建自己的程序,为它创建一个文件夹,在该文件夹中创建main.cpp
,并使用以下命令进行编译:
g++ -std=gnu++2a -g main.cpp -o myProgram
就这样。-std=gnu++2a
表示“尽可能使用 g++ 支持的 C++20 标准,加上一些额外的‘GNU’(g++)特性”;-g
表示“支持用gdb
或ddd
调试”;-o myProgram
的意思是“命名可执行文件myProgram
如果您不选择-o
选项,可执行文件将是a.out
(Unix)或a.exe
(MinGW)。
要运行,请输入./myProgram
(Unix)或myProgram
(MinGW)。
要调试,使用ddd myProgram &
2 (Unix)或gdb
myProgram
(Unix 和 MinGW)。在 MinGW,我们习惯用break``SDL_main
;自从 SDL 走了之后,break main
取而代之。
Extra
为什么我们要把?/在 Unix 中的程序名前?
当您键入一个命令时,Unix 会查看一个名为 PATH 的目录列表:它认为可执行程序应该在其中的目录。如果当前目录(在 Unix 中称为.
,单个句点)不在路径中,它不会在那里查找,所以如果您键入当前目录中的程序名,Unix 不会找到它。
我不喜欢那样,所以让我们把.
(“点”)放在路径中。
假设这是它检查的第一个目录。然后,如果一个坏人可以让一个恶意程序进入你的目录,并将其命名为一个普通命令,如ls
,他可以让你做可怕的事情:你输入ls
,它就会删除操作系统或其他东西。
好的,那么我们将使它成为最后一个被检查的目录。现在,如果您键入ls
,Unix 将在/bin
(或任何地方)中查找,找到正确的ls
,并运行它。
但是如果坏人猜出人们犯了什么错别字,并命名他的邪恶程序sl
而你的手指错过了…他就抓住你了。
我不知道最后一种情况发生的可能性有多大,但如果你担心的话,这是一个让你置身事外的理由。
防错法
- **(gdb/ddd)调试器显示没有找到调试符号。**在
ddd
中,它也给出一个空白窗口。使用-g
选项重新编译,或者使用basicStandardProject
并键入make
。
Exercises
-
Write a program which prints all 99 verses of “99 bottles of beer on the wall.” In case you missed this cultural treasure, it goes like this:
99 bottles of beer on the wall 99 bottles of beer; Take one down, pass it around, 98 bottles of beer on the wall!
最后一节是以 0 个瓶子结尾的。
文件 I/O(可选)
除了家庭作业之外,有用的程序通常需要访问文件。因此,让我们看看如何做到这一点:首先是简单的方法(使用cin
和cout
),然后是更普遍适用的方法。
cin
和cout
作为文件
从某种意义上说,我们已经在使用文件了,至少有两样东西 C++ 认为是文件:cin
和cout
。
cin
是一个输入文件。它只是一个输入文件,当你输入的时候从键盘上获取信息。cout
是输出文件:即你电脑屏幕的输出文件。定义的延伸?也许吧,但是很快我们将使用cin
和cout
作为实际的文件。
为此,我们必须知道如何使用命令提示符。Unix 和 MinGW 用户可以跳过下一节;你已经知道了。
在 Windows 中设置命令提示符
打开 Windows 命令提示符(单击开始菜单并键入cmd
)并转到您正在使用的项目的文件夹。这里有一个简单的方法:在该文件夹的窗口中,单击地址栏左侧的文件夹图标,该部分显示类似于... > ch13 > 1-hello
的内容。当你这样做时,它将被一个高亮显示的路径所取代,如图 13-5 所示。
图 13-5
在 Windows 中获取用于命令提示符的路径
如果你在命令窗口提示中看到的驱动器与你刚才复制的地址左边的不同(在我的例子中是C:
),从地址输入驱动器,如图 13-6 。
图 13-6
在cmd
中切换到项目的驱动器和目录。这里,我们从D:
驱动器转到C:
,然后从cd
转到包含1-hello
项目的目录
现在,在命令窗口中,键入cd
并粘贴到您复制的路径中,然后按回车键。
Visual Studio 将您的可执行文件<your project>.exe
放在一个子文件夹中,可能是Debug
、Release
、x64
,或者是它们的某种组合。找到它,并将其复制到与你的.vcxproj
相同的文件夹中,它将能够找到你放在那里的任何文件。
在命令提示符下重定向 I/O
要让您的程序从in.txt
而不是键盘获得输入,请键入
myProgram < in.txt #3The < goes from file to program; makes sense
要让它也将其输出发送到out.txt
,而不是屏幕,请键入
myProgram < in.txt > out.txt
尝试将 Hello 程序的输出发送到一个文件,看看会发生什么。
Online Extra
“通过 I/O 重定向为你自己和你的用户节省一些时间”:在 YouTube 频道“以懒惰的方式编程”,或者在 www.youtube.com/watch?v=zQ3TY6oSAcQ
找到它。
while (cin)
我听说在英语文本中出现最频繁的字母是,以超级频繁的 E 开头,ETAOINSHRDLU。让我们看看这是不是真的,给程序一些巨大的文本,也许是关闭gutenberg.org
,并计算频率:
make an array of frequencies for letters, all initially zero
while there are characters left
read in a character // we won't prompt the user;
// it's all coming from a file
if it's a letter add 1 to frequency for that letter
print all those frequencies
我知道如何创建数组,给int
加 1,并读入字符。但是我怎么知道还有字符呢?
while (cin) ...
会做到的。如果你把cin
放在某个你期望有bool
的地方,它会被评估为类似于“如果cin
没有出错”cin
通常会出错的地方是到达输入文件的末尾。
示例 13-2 是结果程序。
// Program to get the frequencies of letters
// -- from _C++20 for Lazy Programmers_
#include <iostream>
using namespace std;
int main ()
{
// make an array of frequencies for letters, all initially zero
constexpr int LETTERS_IN_ALPHABET = 26;
int frequenciesOfLetters[LETTERS_IN_ALPHABET] = {}; // all zeroes
// read in the letters
while (cin) // while there are letters left
{
char ch; cin >> ch; // read one in
ch = toupper(ch); // capitalize it
if (cin) // Still no problems with cin, right?
if (isalpha(ch)) // and this is an alphabetic letter?
++frequenciesOfLetters[ch - 'A'];
// A's go in slot 0, B's in slot 1...
}
// print all those frequencies
cout << "Frequencies are:\n";
cout << "Letter\tFrequency\n";
for (char ch = 'A'; ch <= 'Z'; ++ch) // for each letter A to Z...
cout << ch << '\t' << frequenciesOfLetters[ch - 'A'] << '\n';
return 0;
}
Example 13-2Counting frequencies of letters in a text file
用a.out < in.txt > out.txt
(g++)或2-frequencies < in.txt > out.txt
(Visual Studio)试试这个,你会得到一个类似于out.txt
的文件
Frequencies are:
Letter Frequency
A 40
B 5
C 9
D 20
E 63
...
如果字母来自一个文件,while (cin)
会在到达文件末尾时停止。但是实际的键盘输入没有文件结尾。您可以通过按 Ctrl-Z 和 Enter (Windows)或 Ctrl-D (Unix)来模拟它。必须是该行的第一个字符,否则可能不起作用。
读入字符,包括空格
新任务:读入一个文件,每个字符,并大写一切。这是我们的输入文件。
这看起来应该可以工作,但却不能:
while (cin) // for each char in file
{
char ch; cin >> ch; // read in char
ch = toupper (ch); // capitalize
if (cin) cout << ch; // cin still OK? Then print
}
使用这个输入-Twinkle, twinkle, little bat! How I wonder what you're at!
–我们得到这个输出:TWINKLE,TWINKLE,LITTLEBAT!HOWIWONDERWHATYOU'REAT!
跳过空白。对于用户交互和 ETAOIN SHRDLU 程序来说很好,但是这里我们需要空白。
解决方法:ch = cin.get();
。cin.get()
返回下一个字符,即使是空格、制表符(\t
,或者行尾(\n
)。
示例 13-3 读入一个文件并生成一个全大写版本。要执行,请键入a.out < in.txt > out.txt
(g++)或3-capitalizeFile < in.txt > out.txt
(Visual Studio)。
// Program to produce an ALL CAPS version of a file
// -- from _C++20 for Lazy Programmers_
#include <iostream>
#include <cctype> // for toupper
using namespace std;
int main ()
{
while (cin) // for each char in file
{
char ch = cin.get(); // read in char
ch = toupper (ch); // capitalize
if (cin) cout << ch; // cin still OK? Then print
}
return 0;
}
Example 13-3Capitalizing a file, character by character
防错法
-
You told it to stop at the end of file , but it goes too far :
/// get an average -- buggy version // double total = 0.0; // initialize total and howMany int howMany = 0; while (cin) // while there are numbers in file { int num; cin >> num; // read one in total += num; // keep running total ++howMany; }
Your
input file is1 2
而你的平均分是…1.6667。啊?
用调试器跟踪它。它读入 1,将其相加,并递增
howMany
。它读入 2,将其相加,并再次递增howMany
。用while (cin)
测试文件结尾;它继续前进。但是我们不是在文件的末尾吗?也许不是。可能还有另一个\n 或者空间什么的。
所以程序继续进行。它读入下一个数字,但是没有,所以它把 num 保持为 2,再加一次(!),并再次递增。一个错误诞生了。
It couldn’t know there wasn’t going to be another number till it tried to read it. So the solution is to test the input file after every attempt to read, to ensure it didn’t run out of input while reading:
int num; cin >> num; // read one in if (cin) // still no problems with cin, right? { total += num; // keep running total ++howMany; }
average
见源代码,ch13
文件夹/解决方案,是本程序的完整正确版本。
Exercises
在所有这些练习中,使用标准 I/O。
-
从文件中读入一系列数字,并以相反的顺序打印出来。你不知道有多少,但你知道不超过,比如说,100。(这样你可以声明一个足够大的数组。)
-
计算文件中的字符数。
-
…不包括空格或标点符号。
使用文件名
一直重定向 I/O 工作量太大。也许我有多个输入文件——它们不可能都是cin
。或者,也许我只是想让程序记住文件名,而不是期望我在命令提示符下键入它。
假设我有一个游戏,游戏中愤怒的机器人四处游荡,试图与我的玩家发生冲突。播放器从屏幕的左边开始,我的工作是让它到右边,没有任何碰撞。
如果我把机器人放在特定的位置,设计一个比一个更难的关卡,可能会让游戏更有趣。我们将从三个机器人开始第一关,所以有三个位置。
如果我从cin
得到这个(太烦人了,但是我们马上会改变它),代码可能看起来像例子 13-4 。要运行这个例子,输入六个整数,它会把它们返回给你。令人兴奋,是的,我知道。
// A (partial) game with killer robots
// meant to demonstrate use of file I/O
// This loads 3 points and prints a report
// -- from _C++20 for Lazy Programmers_
#include <iostream>
using namespace std;
struct Point2D { int x_=0, y_=0; };
int main ()
{
// an array of robot positions
constexpr int MAX_ROBOTS = 3;
Point2D robots[MAX_ROBOTS];
int whichRobot = 0;
// while there's input and array's not full...
while (cin && whichRobot < MAX_ROBOTS)
{
int x, y;
cin >> x >> y; // read in an x, y pair
if (cin) // if we got valid input (not at end of file)
{
robots[whichRobot] = {x, y}; // store what we read
++whichRobot; // and remember there's 1 more robot
}
}
for (int i = 0; i < MAX_ROBOTS; ++i)
cout << robots[i].x_ << ' '
<< robots[i].y_ << endl;
return 0;
}
Example 13-4Code to read in several points from cin
现在,让程序在不重定向 I/O 的情况下获取文件。下面是使用命名输入文件必须做的事情:
-
#include <
fstream
>,
其中有我需要的定义。 -
ifstream
inFile;
声明我的输入文件。ofstream
用于输出文件。 -
inFile.open``("level1.txt");
–打开一个文件会将它与一个文件名相关联,并确保没有问题。 -
验证打开的文件没有错误。如果是输入文件,错误可能是该文件不存在或者不在您认为的文件夹中。如果是输出文件,可能是磁盘有问题或者是只读文件。以下是如何验证:
-
在您想要使用新文件的任何地方,将
cin
更改为inFile
。如果是输出文件,将cout
改为outFile
。 -
完成后,关闭文件
: inFile.close
();
。这告诉操作系统忘记inFile
和input.txt
之间的关联,从而让其他可能需要它的程序使用它。不可否认,当你的程序结束时,它引用的所有文件都将被关闭——但是,当你用完了你的玩具,我指的是文件,养成收起来的习惯是明智的。你妈妈会为你骄傲的。
if (! inFile) // handle error
示例 13-5 是程序的更新版本。
// A (partial) game with killer robots
// meant to demonstrate use of file I/O
// This loads 3 points and prints a report
// -- from _C++20 for Lazy Programmers_
#include <iostream>
#include <fstream> // 1\. include <fstream>
using namespace std;
struct Point2D { int x_=0, y_=0; };
int main ()
{
// an array of robot positions
constexpr int MAX_ROBOTS = 3;
Point2D robots[MAX_ROBOTS];
// 2\. Declare file variables.
// 3\. Open the files.
// Here's two ways to do both; either's fine
ifstream inFile; inFile.open("RobotGameLevel1.txt");
ofstream outFile ("RobotSavedGame1.txt");
// 4\. Verify the files opened without error
if (! inFile)
{
cout << "Can't open RobotGameLevel1.txt!\n"; return 1;
// 1 is a conventional return value for error
}
if (! outFile)
{
cout << "Can't create file RobotSavedGame1.txt!"; return 1;
}
int whichRobot = 0;
// 5\. Change cin to inFile, cout to outFile
// while there's input and array's not full...
while (inFile && whichRobot < MAX_ROBOTS)
{
int x, y;
inFile >> x >> y; // read in an x, y pair
if (inFile) // if we got valid input (not at end of file)
{
robots[whichRobot] = {x, y}; // store what we read
++whichRobot; // and remember there's 1 more robot
}
}
for (int i = 0; i < MAX_ROBOTS; ++i)
outFile << robots[i].x_ << ' '
<< robots[i].y_ << endl;
// 6\. When done, close the files
inFile.close(); outFile.close();
// can still use cout for other things
cout << "Just saved RobotSavedGame1.txt.\n";
return 0;
}
Example 13-5Program that reads an input file and prints to an output file
这奏效了;它将RobotSavedGame1.txt
保存在与.vcxproj
文件相同的文件夹中。
程序启动时会删除RobotSavedGame1.txt
中的所有内容,并用新内容替换它们。
程序中可以有多个输入和输出文件。您也可以将文件传递给函数:
void readFile (ifstream& in, double numbers[], int& howManyWeGot);
void writeFile(ofstream& out, double numbers[], int howMany);
Exercises
-
写一个程序来判断两个文件是否相同。
-
写入和测试函数,以读取和打印到一个
Point2D
s 的文件。 -
掷出两个骰子 100 次,并将结果存储在一个文件中…
-
…然后加载该文件并打印一个柱状图:一个柱状图显示你得到 2 的次数,另一个柱状图显示你得到 3 的次数,以此类推。在 SSDL 这样做(使用
basicSSDLProject
;继续使用文件变量;只是不要指望cin
和cout
管用);或者在屏幕上打印 x,显示每个值出现的次数——类似于:1 : 2 : X 3 : XXXXXXX 4 : XXXXXXXX ...
-
制作你自己的密码:一个字母方案,如 A 代表 R,B 代表 D,等等。然后使用您的加密方案对消息进行编码。还要写一个解密程序,验证一切正常。
-
(用力)地球变暖了吗?
在本章的示例代码中有一个文件
temperature.txt
4 ,它包含给定年份的年份和估计的全球平均温度。(给出的温度是相对于 1910-2000 年估计平均温度的摄氏度数)。那么我们能从中学到什么呢?
The degrees increase per year, which is
)
x 是年份,y 是温度。σx 读作“x 的总和”,意思是“所有 x 的总和”m 是与数据最匹配的直线 y = mx + b 的斜率。
How closely the yearly temperatures actually match this line. This is
)
如果 R 为–1 或 1,则相关性很强。如果 R 接近 0,就很弱。负 R 意味着温度随时间下降(但我们已经从 m 知道了)。
写一个程序,读取文件,并为用户提供每年增加的学位和 r。你需要什么功能?在给出你的答案之前,充分测试他们以确保你信任他们。
当然,相关性并不能证明因果关系。比如喝咖啡的人滑雪多(比方说)。这是否意味着咖啡会导致滑雪?也许是滑雪小屋提供免费咖啡。或者,喜欢找乐子的人更可能去滑雪和喝咖啡。对于因果关系,我们需要更多一点的(人类)思考。
endl
还告诉 C++ 立即将输出发送到屏幕——刷新它。我们通常不在乎,只要它最终到达那里,所以我倾向于使用’\n’
。
2
&
的意思是“马上再给我一个命令提示;不要等待ddd
结束”——这是一个好习惯。
3
Unix 注释标记。在 Windows 中,使用& REM(或者干脆不放注释)。
4
来源:www . ncdc . NOAA . gov/CAG/global/time-series。
十四、字符数组和动态内存
字符数组——也称为“字符串”或文本——对许多任务都很重要。本章展示了如何处理它们,以及如何在事先不知道大小的情况下创建这些或其他数组。在这个过程中,我们将尽可能以最有效的方式学习标准库的字符数组函数:通过构建它们。
字符数组
我们从一开始就使用了char
数组。我们从第一章和第十三章引用的"Hello, world!"
是一个字符数组,内容如图 14-1 所示。
图 14-1
“你好,世界!”字符数组文字
“空字符”是一个标记,告诉 C++ 这是我们的字符串结束的地方。cout
不是在到达分配空间的末端时停止打印——它不知道也不关心分配了多少空间——而是在到达'\0'
时停止打印。
让我们看看除了打印之外,我们还能用char
数组做什么。
这里有两种方法来初始化一个字符数组:
char A[] = {'d','o','g','\0'}; // they both
mean the same thing
char A[] = "dog"; // but this one's easier to read,
// don't you think?
你也可以从cin
或输入文件中把一个单词读入字符数组。我们需要确保我们声明的数组有足够的空间容纳输入的内容。我们通过分配比我们可能需要的更多的字符来做到这一点:
constexpr int MAX_STRING_SIZE = 250;
char name[MAX_STRING_SIZE];
cout << "What's your name? "; cin >> name;
那段代码读起来一个字。如果你想让读取整行(也许你想让用户输入名字和姓氏),你需要cin.
getline
(name, MAX_STRING_SIZE);
。
我们可以将数组传递给函数。在示例 14-1 中,我们有一个函数打印一个问题并得到一个有效的是或否的答案。我们不打算改变数组,所以我们把它作为const
传递。
bool getYorNAnswer (const char question[])
{
char answer;
do
{
cout << question; // print a question
cin >> answer; // ...and get an answer
answer = toupper (answer); // capitalized, so we can compare to Y, N
}
while (answer != 'Y' && answer != 'N');
// keep asking till we get Y or N
return answer == 'Y'; // "true" means "user said Y"
}
Example 14-1A function that takes a char array as a parameter. Since it’s short, it and Example 14-2 are together in source code folder ch14, in project/folder 1-and-2-charArrays
如果我们这样称呼getYorNAnswer
getYorNAnswer ("Ready to rumble (Y/N)? ") // same question, and reasoning,
// as in Chapter 5's
// section on while and do-while
我们的交互可能看起来像这样
Ready to rumble (Y/N)? x
Ready to rumble (Y/N)? 7
Ready to rumble (Y/N)? y
…此时getYorNAnswer
将返回true
。
现在让我们看看一个字符串有多长——不是分配的内存,而是正在使用的部分,直到空字符:
where = 0
while the whereth char isn't the null character (not at end of string)
add 1 to where
例 14-2 是完整版。
unsigned int myStrlen (const char str[]) // "strlen" is the conventional
// name for this function
{
int where = 0;
while (str[where] != '\0') // count the chars
++where;
return where; // length is final "where"
}
Example 14-2The myStrlen function
. Find it and Example 14-2 in source code folder ch14, in project/folder 1-and-2-charArrays
该功能和其他功能已经在包含文件cstring
中提供。表 14-1 列出了最常用的。
表 14-1
一些cstring
功能,为清晰起见进行了简化
Note
如果 Microsoft Visual Studio 看到strcpy
、strcat
等等,它可能会给出警告:
warning C4996: 'strcpy'
:此函数或变量可能不安全。考虑改用strcpy_s
。use _CRT_SECURE_NO_WARNINGS
禁用折旧。有关详细信息,请参见联机帮助。
strcpy_s
和strcat_s
是strcpy
和strcat
的版本,它们试图阻止你写超出数组的界限。听起来很明智,但这通常不会流行起来。我不使用它们,因为我希望代码可以在编译器之间移植。或者我只是喜欢生活在边缘。22
要取消警告,请将这一行放在引用strcpy
的任何文件的顶部,依此类推:
#pragma warning (disable:4996) // disable warning about // strcpy, etc.
防错法
-
在调试器 中,你看到你的
char
**数组看似合理,但到了最后却充满了随机字符。**没事;无论如何都不会打印或使用超过'\0'
的内容。可以忽略。 -
你看到你的
char
数组中合理的字符被打印出来,然后后面跟着多余的垃圾。字符串缺少最后一个'\0'
。解决方案:在末尾插入'\0'
。 -
It acts like it’s gotten some of your input before you had a chance to type it. (If using file I/O, it skips part of the file.) Here’s an example:
do { cout << "Enter a line and I'll tell you how long it is.\n"; cout << "Enter: "; cin.getline (line, MAX_LINE_LENGTH); cout << "That's " << strlen (line) << " characters.\n"; letsRepeat = getYOrNAnswer ("Continue (Y/N)? "); } while (letsRepeat);
第一次,你很棒。之后每次你说要再来一次,它就说你的线长是 0,问你要不要继续。
想象一下
cin
提供了一个从键盘发送到程序的字符序列,如图 14-2 。
图 14-2
cin
缓冲区 3 ,以用户键入的任何内容作为其内容
在(a)中,你已经输入了你的第一行,TO BE
。cin.getline
通过'\n'
获得所有这些。
在(b)中,您已经输入了对"Continue (Y/N)?"
问题的回答。
©中,getYOrNAnswer
做了其 input: cin>>answer. answer
变成了“y
”,getYOrNAnswer
做了。
我们准备再次开始循环并获得更多的输入……但是看看在cin
缓冲区中留下了什么:一个供cin.getline
读取的字符串。这是一个空字符串,就像你什么也没输入就按了 Enter 键,但它仍然是一个字符串。所以cin.getline
不会等你打字;它继续读取那个空字符串…然后我们回到getYOrNAnswer
被询问是否要继续。
我们需要在cin.getline
被愚弄之前甩掉剩下的'\n'
getYOrNAnswer
。这里有两种方法:
-
cin.getline
再来,就是为了摆脱'\n'
。 -
cin.ignore (MAX_LINE_LENGTH, '\n');
。这忽略了从MAX_LINE_LENGTH
到第一个\n
的所有char
,以先出现的为准。
我觉得还是放在getYOrNAnswer
里吧;是提出问题的函数给我们带来了麻烦,所以它应该自己清理:
-
cin >>
读入一个char
数组,但是走得太远,覆盖了其他变量。让你的char
数组变大,希望没人输入超长的东西。C++20 通过只接受最大可用大小来防止这个问题,但是你的编译器可能还不支持这个特性。 -
cin
**不会读入作为参数传入的数组。**见下一节最后一个反欺诈条目。
bool getYOrNAnswer (const char question[])
{
...
cin.ignore (MAX_LINE_LENGTH, '\n'); // dump rest of this line
return answer == 'Y';
}
Online Extra
“当你在字符串末尾发现垃圾的那一刻”:在 YouTube 频道“以懒惰的方式编程”,或者在 www.youtube.com/watch?v=57jkYKwN9hg
。
Exercises
-
编写并测试
myStrcpy
,你自己版本的strcpy
函数,如表 14-1 所述。(本章后面有答案。)为了测试它是否真的把'\0'
放在了末尾,在复制之前用 x 填充目标数组(比如说)。 -
编写并测试你的
strcat
版本。 -
…和
strcmp
。 -
询问用户的姓名,并重复一遍。如果第一个字母是小写的,那就大写。
-
(使用文件 I/O)确定给定文件中的行数。
-
(使用文件 I/O)…以及它们的平均长度。
-
(使用文件 I/O)编写一个程序,找出并打印两个给定文件之间的共同点。假设每个单词在一个文件中最多出现一次。
数组的动态分配
有时直到程序运行时你才知道数组应该有多大。但这是行不通的:
int size;
// calculate size somehow
int A[size];// compiler should complain: size must be a constant value
以下是应该做的事情。
首先,声明数组,但不为其元素分配内存:
int* A;
A
不是一组int
的集合,而是一些int
的“指针”的地址。这就像我们用[]
声明它时一样,但是还没有为那些int
存储。
接下来,给它需要的内存:
A = new int [size];
这就要求 C++ 的一部分,“堆管理器”给我们这么多的int
中的一部分。有一整堆的内存可用于此,堆管理器可以在你需要的任何时候给你其中的一部分。这被称为“动态分配”,因为它是在程序运行时发生的。到目前为止,我们在编译时进行分配的方式是“静态分配”(以这些方式分配的内存称为“动态”或“静态”)
我们像以前一样使用数组。完成后,我们告诉堆管理器它可以取回它,因此:
delete [] myArray;
对堆管理器来说,这是一个提醒,你所返回的是一个数组。如果你忘记了[]
,唉,编译器不会告诉你——你得记住你自己。
总之,要“动态分配”任何<type>
的数组:
-
<type>* myArray = new <type> [size];
。 -
像平常一样使用数组。
-
当你完成后。
很容易忘记delete []
。有关系吗?当然可以。如果你一直分配内存并且从来不归还——如果你一直做****【内存泄漏】——内存最终耗尽,程序崩溃。以后我们会有一种方法让删除变得更容易记忆。
**示例 14-3 展示了如何使用动态分配。
// Program to generate a random passcode of digits
// -- from _C++20 for Lazy Programmers_
#include <iostream>
#include <cstdlib> // for srand, rand
#include <ctime> // for time
using namespace std;
int main ()
{
srand ((unsigned int) time(nullptr));// start random # generator
int codeLength; // get code length
cout<< "I'll make your secret passcode. How long should it be? ";
cin >> codeLength;
int* passcode = new int[codeLength]; // allocate array
for (int i = 0; i <codeLength; ++i) // generate passcode
passcode[i] = rand () % 10; // each entry is a digit
cout << "Here it is:\n"; // print passcode
for (int i = 0; i < codeLength; ++i)
cout << passcode[i];
cout << '\n';
cout << "But I guess it's not secret any more!\n";
delete [] passcode; // deallocate array
return 0;
}
Example 14-3A program that dynamically allocates, uses, and deletes an array of ints
以下是一个会话示例:
I'll make your secret passcode. How long should it be? 5
Here it is:
14524
But I guess it's not secret any more!
Extra
1992 年,人工智能研究员 Edmund Durfee 在全国人工智能会议(AAAI-92)上做了一次受邀演讲,“你的计算机需要知道的,你在幼儿园就学会了”——引用了罗伯特·富尔亨的畅销书我真正需要知道的一切我在幼儿园就学会了。以下是 Durfee 所说的你的电脑从你的早期儿童教育中需要的:
-
分享一切。
-
公平竞争。
-
不要打人。
-
把东西放回原处。
-
收拾你自己的烂摊子。
-
不要拿不属于你的东西。
-
当你伤害别人时,说对不起。
-
同花。
-
当你走向世界时,注意交通,手拉手,团结在一起。
-
最终,一切都会消亡。
其中许多在操作系统中是有用的。也许你有一个占用内存和 CPU 时间的程序,所以如果你想和一个不同的程序交互,你不能。(分享一切。)也可能只有占满整个屏幕才能运行。(公平竞争。)
就记忆而言,我们需要“把东西放回你发现它们的地方。”就像蜡笔和玩具一样,如果我们总是把它们放回去,就更容易找到我们需要的东西。
防错法
动态内存最常见的问题是程序崩溃。可能是什么原因造成的?
- **忘记初始化:**如果你还没有初始化
myArray
,它的地址指向某个随机的位置。它几乎肯定会崩溃。这比得到错误的输出却不知道要好。
防止这种情况的两种方法如下:
int* A = new myArray[size]; // initialize as soon as you // declare
或者
int* A = nullptr; // we'll initialize to something // sensible later
按照惯例,nullptr
的意思是“不指向任何地方,所以不要考虑查看任何元素。”在老的程序中,不是nullptr
而是NULL
。
-
忘记 删除 : 长时间这样做,程序会耗尽内存并崩溃。
-
忘记使用
delete []
中的[]
:这会导致“未定义的行为”,这意味着它可能会崩溃,表现完美,或者引发第三次世界大战。我不会冒这个险。
其他问题包括:
-
错误声明 一行多指针:很奇怪,但是
int* myArray1, myArray2;
没有创建两个指针。它创建了一个
int
指针myArray1
和另一个(单个)int myArray2
。为什么这么困惑?这是 C 标准遗留下来的东西。解决方案:int* myArray1;
int* myArray2;
-
在不必要的时候使用动态内存:这不是错误,但会导致错误。动态记忆有更多可能出错的地方;你必须记住用
new
分配,用delete
解除分配。如果你没有任何收获(比方说,如果你在编译时知道你的数组有多大),给自己省点事;按老办法分配。 -
cin 不会读入你动态分配的数组或者你的数组作为函数参数传入:
void read (char str [])
{
cin >> str; // compile-time // error
}
int main ()
{
char s1 [FIXED_SIZE]; cin >> s1; // No problem
char* s2 = new char[someSize]; cin >> s2; // compile-time // error
...
}
正如前面的反阻塞部分所提到的:当cin
知道它要读入的数组的大小时,就像它对s1
所做的那样,它只读入它有空间的数组。 4 但是如果给它一个动态数组什么的传入作为参数,它不知道;它可能读入太多,并超过数组界限。C++20 标准通过说“不,你不能这样做”来解决这个问题,因此导致了一个新的问题:编译时错误。
公平地说,这是一个巨大的安全漏洞。
我的解决方法是总是读入一个本地声明的固定大小的数组,并从该数组复制到动态数组或传入的数组中:
static char buffer [REALLY_BIG_NUMBER];
if there is room in str to store what we get, then
strcpy (str, buffer);
else
complain
Exercises
-
询问用户的姓名。您需要一个足够长的缓冲区来存储任何合理的名称。然后把它存储在一个足够长的数组里。
-
(使用 SSDL)询问用户要画多少颗星星;生成随机星阵列;画出来。根据需要使用函数。
-
(使用文件 I/O)编写一个程序,计算文件中的行数(参见上一节中的练习 5),动态分配一个数组来存储这些行,并将它们全部读入。提示:您可以打开文件,计算行数,关闭它,然后再打开它。
-
(更难)动态分配一个游戏板,像棋盘但大小可变。我不能只分配一个二维数组,所以我们只能用一维数组来凑合。决定它的大小以及如何访问第行,第列的位置。
-
(使用 SSDL;硬)自己写位图:动态分配的数组,每个数组包含图像中一个像素的颜色。和前面的练习一样,我们需要使用一个一维数组。
提供渲染功能;给定位图、屏幕上的起始位置以及位图的宽度和高度,在该位置显示位图。要绘制像素,请使用
SSDL_SetRenderDrawColor
和SSDL_RenderDrawPoint
。位图作为一个
struct
,包含数组加上宽度和高度,这是一个好主意吗?
使用*符号
我们已经使用了*
来声明动态分配的数组:
double* myArray = new double[sizeOfArray];
我们也可以用它来指代单个元素。*A
的意思是A[0]
,因为*A
的意思是“什么A
指向什么”,而A
指向第 0 个元素。
*(A+1)
的意思是A[1]
。编译器足够聪明,知道A+1
意味着下一个元素的地址。(像这样给指针加上一些东西叫做“指针算术”。)
A[1]
比*(A+1)
更容易阅读——那么为什么要用这种新的符号呢?一个原因是让你为我们稍后将和*
一起做的事情做好准备。
另一个是这个有趣的遍历数组的新方法。考虑本章“字符数组”一节的练习 1 中的myStrcpy
函数。下面的代码片段是在ch14/strcpyVersions
中收集和编译的。
void myStrcpy (char destination [], const char source[])
{
int i = 0;
while (source[i] != '\0')
{
destination[i] = source[i];
++i;
}
destination[i] = '\0'; // put that null character at the end
}
这里有一个不使用[]
的版本:
void myStrcpy (char* destination, const char* source)
{
int i = 0;
while (*(source + i) != '\0')
{
*(destination + i) = *(source+i);
++i;
}
*(destination + i) = '\0'; // put null character at the end
}
没有明显的改善,但它会起作用。接下来,我们取消使用i
,直接更新source
和destination
:
void myStrcpy (char* destination, const char* source)
{
while (*source != '\0')
{
*destination = *source;
++source; ++destination;
}
*destination = '\0'; // put null character at the end
}
有用吗?是的。现在我们每次遍历循环时都要给source
加 1——所以每次,它都指向它的下一个元素(对于destination
也是如此)。当source
到达空字符时,循环停止。
记住测试条件时,0 表示false
,其他都表示true
(第四章)。所以while (*source != '\0')
可以写成
while (*source) // if *source is nonzero -- "true" -- we continue
因此,我们可以将函数写成
void myStrcpy (char* destination, const char* source)
{
while (*source)
{
*destination = *source;
++source; ++destination;
}
*destination = '\0'; // put null character at the end
}
这是我真正应该停止的地方。习惯了就可读了;它很短,给了我们用*符号的练习,我们会在第二十章及以后经常用到。但是现在退出太有趣了。
回忆第五章中的后增量运算符(如X++
)。Y=X++;
真正的意思是Y=X; X=X+1;
。获取值,然后递增。
在这里,我们可以将它用于目的地和源——因为我们使用它们的值进行赋值,然后递增。
void myStrcpy (char* destination, const char* source)
{
while (*source)
*destination++ = *source++;
*destination = '\0'; // put null character at the end
}
还记得,X=Y
的值是分配的值——在*destination++ = *source++
的情况下就是简单的*source
。只要这个非零,我们就想继续下去:
void myStrcpy (char* destination, const char* source)
{
while (*destination++ = *source++); *destination = '\0';
}
这近乎邪恶和粗鲁。我不想这样写我的代码,但是它的确展示了我们通过使用*
获得的灵活性。
Note
被称为“解引用”操作符——因为它接受一个对象的引用(地址)并给出对象本身。
&
是它的反义词:“reference”运算符。它接受一个对象并给你地址:
int x;
int* addressOfX = &x;
我在 C++ 里不经常用;它在 C 中更有用,C 中缺少我们的引用参数,引用参数也使用符号&
。这让生活更加混乱。啊,好吧。如果*
可以表示取整(*addressOfX
) 和相乘(x*y
,我想&
也可以表示不止一个意思。
防错法
-
编译器抱怨你正在用一个字符串常量初始化一个 char* ,就像在
char* str = "some string";
中一样。改为说
char str[] = "some string";
。
Exercises
在所有这些练习中,使用*
符号,而不是[]
符号。
-
写
strcmp
。 -
…对我们的其他基本字符数组函数做同样的事情。
-
(Harder) Write and test a function
contains
which tells if one character string contains another. For example,contains ("'Twas brilling, and the slithy toves" " did gyre and gimble in the wabe", "slithy")
会返回 true。
-
(Harder) Write a function
myStrtok
which, like thestrtok
incstring
, gets the next word (“token”) in a character array. It might be called thus:char myString[] = "Mary Mary\nQuite contrary"; const char* nextWord = myStrtok (myString, " \t\n\r"); // I use space, tab, return, and // the less-used carriage return \r // as "delimiters": separators // between words while (nextWord) { cout << "Token:\t" << nextWord << '\n'; nextWord = myStrtok (nullptr, " \t\n\r"); }
预期产出:
Token: Mary
Token: Mary
Token: Quite
Token: contrary
当你第一次为一个给定的字符串调用它时,它应该返回一个指向第一个单词的指针。(如果没有,则返回
nullptr
。)然后,每次你传入nullptr
,它会给出你之前使用的字符串中的下一个单词——用完时返回nullptr
。您将需要上一个练习中的
contains
函数。您还需要一个static
局部变量。就像编译器的
strtok
一样,您可以随心所欲地修改输入字符串。通常的方法是将'\0'
放在您希望当前返回的令牌结束的地方,覆盖一个空白字符。
这实际上是size_t
:源自unsigned int
的一种类型,用于变量的大小,尤其是数组。它不是内置的,但是包含在各种头文件中——包括cstring
——我们在这里不使用,所以我现在忽略它。
2
还有另一组函数——这是标准的一部分——试图阻止你在字符数组中走得太远:
char* strncpy (char *destination, const char *source, size_t n);
char* strncat (char *destination, const char* source, size_t n);
...
当您复制n
字符时,这些功能会退出。我很愿意使用它们,但是它们也可能会出错:如果source
太长,它会在destination
的末尾加上 no ’\0’
,打印它会得到一些奇怪的结果。
在第十七章,我们将得到一个更好、更安全的处理字符串的方法。
3
缓冲区:临时存储器,尤其是用于 I/O。
4
但是数组不会记录它们的大小,不管它们是如何被分配的,这是真的吗?没错,但是在编译时,编译器知道并且能够设置好,所以cin >>
使用了正确的界限——这是一个高级的话题。
**
十五、类
到目前为止,我们所介绍的基本上都是 C 语言,只是做了一些调整,特别是cin
和cout
。现在是时候添加在 C++ 中放置+的东西了:类。他们不会给我们新的能力,比如cin
/ cout
、SSDL 函数,或者像循环这样的控制结构。他们将做的是帮助我们保持事情有条理,这样我们就不会混淆;尽量减少错误;让表达事情变得更简单——这样我们就可以信任我们写的代码,并将其用于更大更好的项目。
例如,这里有一个存储日历日期的类类型:
class Date
{
int day_;
int month_;
int year_;
};
...
Date appointment; // Variables of a class type are called "objects"
// Using the term makes you sound smart at job interviews
我们可以用一个struct
来完成。就像使用struct
s 一样,我们可以声明这种类型的变量,将它们作为参数传递,使用.
(点号)来获取部分,等等——看起来非常相似。但是我们即将找到重用代码和避免错误的新方法。
第一种方式:我不想让day_
、month_
和year_
在程序的任何部分都可用;他们可能会搞砸。我将允许某些函数访问它们,称为成员函数。
这个安全措施来自一个可能更容易记住的隐喻。考虑物理世界中的物体。一个物体——比如说,一个橡胶球——有一些特征:也许它是红色的,有弹性,有一定的质量和成分。你不能只是在你想要的时候把那些特征设置成你想要的。可以把真球上的色域设置成蓝色吗?叫它轻如鸿毛?相反,对象本身提供了与它交互的方式。不能设置色域,但是可以上色。你不能直接改变它的质量,但是你可以做一些事情来改变它的质量,比如切掉它或者烧掉它。你不能把它的位置设置成 90 公里直上,但你可以扔出去,看它能飞多远。
我们将以同样的方式创建我们的类:用特征(成员变量)和方法(成员函数)与这些特征进行交互。
那么,如何处理Date
才是合适的呢?首先,您可以打印它:
class Date
{
public:
void print (ostream&);1
private:
int day_;
int month_;
int year_;
};
...
Date appointment;
...
appointment.print (cout); // or we could pass in a file variable -- see // Chapter 13
public 部分是为外界(比如main
)可以访问的东西准备的:也就是说,main
可以告诉一个Date
打印自己。私有部分是只有Date
可以直接访问的部分。(如不注明,均为私人。) 2
一个类的 BNF 大致是
class <name>
{
public:
<function declarations, variables, and types;
usually declarations, almost never variables>
private:
<function declarations, variables, types; usually variables>
};
看前面的print
调用。为什么我们不告诉appointment
日、月和年?它已经知道-它包含了它们!它不知道我们是想打印到cout
还是一个文件,所以我们必须告诉它。
我还没说怎么打印呢。以下是如何:
void Date::print (std::ostream& out)
{
out << day_ << '-' << month_ << '-' << year_;
}
“Date
::
”告诉编译器,“这不仅仅是一个名为print
的函数——它是属于Date
的函数。”
当你调用它的时候——appointment.print (cout);
——它会打印谁的day_
?appointment
夏侯惇
如果你正在使用一个友好的编辑器,比如微软的 Visual Studio,当你输入appointment
和一个句号(见图 15-1 ,编辑器会列出可用的成员函数——到目前为止,只有print
,但是我们很快会有更多的。你可以点击一个,它会为你粘贴。加上开头部分,它会提醒你它期望什么样的论点。
图 15-1
Microsoft Visual Studio 提示函数声明信息
Date::
也一样。它会列出可用的成员。
如果没有,不用担心;有时编辑会感到困惑。
构造器
我们已经知道初始化变量是明智的。在类中,我们有一个特殊类型的函数叫做构造器(常见的缩写是“ctor”)来做这件事(见例子 15-1 中突出显示的部分)。
// A program to print an appointment time, and demo the Date class
// ...doesn't do that much (yet)
// -- from _C++20 for Lazy Programmers_
#include <iostream>
using namespace std;
class Date
{
public:
Date (int theDay, int theMonth, int theYear); // constructor declaration
void print (std::ostream& out);
private:
int day_;
int month_;
int year_;
};
Date::Date (int theDay, int theMonth, int theYear) : // ...constructor // body
day_ (theDay), month_ (theMonth), year_ (theYear)
// theDay is the parameter passed into the Date constructor
// function. day_ is the member that it will initialize.
{
}
void Date::print (std::ostream& out)
{
out << day_ << '-' << month_ << '-' << year_;
}
int main ()
{
Date appointment (31, 1, 2595);3
cout << "I'll see in you in the future, on ";
appointment.print (cout);
cout << " . . . pencil me in!\n";
return 0;
}
Example 15-1The Date class, with a constructor, and a program to use it
构造器的名字总是和它的类一样。当你声明一个类Date
的变量时,它调用这个函数来初始化成员变量。(没有返回类型;本质上,构造器“返回”对象本身。)
函数定义的第二行
day_ (theDay), month_ (theMonth), year_ (theYear)
告诉它将day_
初始化为等于theDay
等等。当我们到达{}
的时候,已经没什么可做的了,所以这次{}
是空的。
相反,您可以在函数体中初始化,使用=
:
Date::Date (int theDay, int theMonth, int theYear)
{
day_ = theDay; month_ = theMonth; year_ = theYear;
}
然而,带有()
的成员初始化语法更常见,更不容易出错(参见反调试部分),并且在某些情况下是必要的,所以现在养成这种习惯是比较懒惰的。
为了形象化成员函数如何与数据成员交互,考虑示例 15-1 中发生的情况。main
的第一个动作是为appointment
分配空间,调用其构造器(图 15-2 ),传入参数。我把构造器画在了main
之外,因为它是一个单独的函数……但它是appointment
的一部分,所以我用虚线把它和数据成员统一起来。
图 15-2
调用Date
构造器
构造器将theDay
复制成day_
、theMonth
复制成month_
、theYear
复制成year_
(图 15-3 )。
图 15-3
Date
构造器初始化appointment
的数据成员
完成后,构造器离开(图 15-4 )。
图 15-4
Date
构造器完成
这显示了(例如)成员day_
和构造器参数theDay
的不同角色:day_
是持久的并且记住你的约会的日期部分;theDay
是Date::Date
用来将信息从main
传递到day_
的参数,当构造器Date::Date
完成时,该参数消失。
此后,main
继续,打印"I'll be getting up at "
,然后进入appointment
的print
功能,该功能了解day_
、month_
、year_
(图 15-5 )。
图 15-5
调用appointment
的print
函数
Golden Rule of Member Function Parameters
不要传入对象的数据成员。该函数已经知道它们。
防错法
-
**构造器被调用,但数据成员从未被初始化。**如果我们使用这个构造器
Date::Date (int theDay, int theMonth, int theYear) { theDay = day_; theMonth = month_; theYear = year_; }
也许我们会得到奇怪的输出
I'll see in you in the future, on -858993460--858993460--858993460...pencil me in!
我已经将day_
和theDay
交换了位置,所以我正在从复制我想要初始化的数据成员(显然其中有-858993460——有未初始化的变量,你永远不知道)到有我想要的值的参数(图 15-6 )。
图 15-6
一个完全颠倒的构造器
如果你在{
前使用()
的初始化方法,就不会发生这种情况,例如 15-1 。如果你试图初始化错误的东西,它会报告一个错误。
Exercises
-
写一个
Time
类,用于记住早上什么时候起床,什么时候午睡等等。包括相关的数据成员、打印函数以及适当的构造器。 -
编写并测试一个函数
Time currentTime()
。它会调用time
(就像我们初始化随机数生成器一样),获取 1970 年 1 月 1 日以来的秒数。我们只关心从午夜开始的秒数。将其转换为秒、分和小时,并返回当前的Time
。当前是不是的成员Time
。 -
像前一个问题一样,用调用
time
的函数Date currentDate ()
来扩充Date
程序,并获得当前的Date
。该功能是而不是的Date
成员。我之前假设时间从 1970 年 1 月 1 日开始,在你的机器上正确吗? -
(更难)添加一个函数
Date::totalDays ()
,该函数返回自公元前 1 年 12 月 31 日以来的天数。你需要处理闰年。项目2-date-bestSoFar
中的示例代码中有一个解决方案。 -
(更难)添加一个函数
Date::normalize ()
,如果Date
有一个或多个字段超出范围,该函数将进行修正:例如,Date tooFar (32, 12, 1999);
将使tooFar
成为日期 1-1-2000。它应该由构造器调用。一个解决方案就在本书的样本代码项目2-date-bestSoFar
中。
有没有更简单的方法写normalize
和totalDays
?
const
对象,const
成员函数…
考虑以下代码:
const Date PEARL_HARBOR_DAY (7, 12, 1941);
cout << "A date which will shall live in infamy is ";
PEARL_HARBOR_DAY.print (cout);
cout << ".\n";
认为PEARL_HARBOR_DAY
是一个常量是合理的,因为它永远不会改变(除非你有一台工作的时间机器)。然而,如果我们把它设为const
,代码将不再编译。为什么不呢?
C++ 区分了能改变对象的成员函数和不能改变对象的成员函数。这是防止错误的一种方法。如果print
是那种可以改变Date
的东西,我们就不应该允许它在常量Date
上被调用。
由于print
对于const
对象来说是安全的,我们将这样告诉 C++:
class Date
{
...
void print (std::ostream&) const;
...
};
void Date::print (std::ostream& out) const
{
out << day_ << '-' << month_ << '-' << year_;
}
在()
后面的单词const
告诉编译器这个函数可以用于常量对象。它还告诉编译器,在编译print
时,如果对数据成员做了任何更改,就会产生一个错误。
如果你遇到很多错误,有时很容易将单词const
从你的程序中完全删除。抵制这种诱惑。这个特性确实可以保护我们免受真正的错误。
防错法
-
你会得到关于从转换到
const
的错误。这通常意味着您忘记了成员函数声明末尾的const
(以及其函数体的第一行)。 -
它说你的成员函数体和声明不匹配,但是它们看起来确实一样。检查他们都是或者都不是。
…和const
参数
假设我们想将一个Date
传递给一个函数fancyDisplay
,它以一种可爱的方式打印时间:
void fancyDisplay (Date myDate, ostream& out)
{
cout << "*************\n";
cout << "* "; myDate.print (out); cout << " *\n";
cout << "*************\n";
}
我没有用&
来表示myDate
,所以myDate
本身不是传入的,而是一个副本。
在某种程度上,这很好,因为我们不想改变myDate
。但是复制的成本比仅仅int
要高——三倍多,因为它有三个int
。当我们创建更大的类时,我们可能会发现它会减慢我们的程序。
以下是部分解决方案:
void fancyDisplay (Date& myDate, ostream& out);
这并不完美,因为现在我们允许fancyDisplay
改变myDate
!这更好:
void fancyDisplay
(const Date& myDate, ostream& out);
现在fancyDisplay
不会花时间去复制myDate
也改不了。
*Golden Rule of Objects as Function Parameters
如果你想改变一个作为参数传入的对象,把它作为TheClass& object
传递。
如果没有,作为const TheClass& object
传递。
多个构造器
没有必要将我们自己局限于一个构造器。我们可能想用其他方式来创造Date
s:
Date d (21, 12, 2000); // using our old constructor...
Date e (d); // e is now exactly the same as d
Date f; // now one with no arguments
Date dateArray [MAX_DATES]; // still no arguments
Date g (22000); // 22,000 days -- nearly a lifetime
让我们一个一个来看。
复制构造器
Date
的复制构造器将另一个Date
作为唯一参数。我们称之为这个是因为它复制了(咄):
Date::Date (const Date& other) : // "copy" constructor
day_(other.day_), month_(other.month_), year_(other.year_)
{
}
这份宣言使用了它
Date e (d);
这个也是
Date e {d};
还有这个:
Date e = d; // Looks like =, but it's really calling the copy ctor
这种形式被称为“语法糖”:让代码更具可读性的不必要的东西。
复制构造器还有一些特殊之处。如果 C++ 需要复制一个Date
,它会隐式地调用**,也就是说,不用你告诉它。这里有两个例子:**
void doSomethingWithDate (Date willBeCopied);
// I'd rarely do this, but if I did...
Date currentDate (); // No &, so it returns a copy
不写复制构造器怎么办?C++ 会对如何复制做出最好的猜测,而这种猜测有时是危险的错误。一个好的规则:总是指定复制构造器。
默认构造器
Date::Date () : // "default" ctor
day_ (1), month_ (1), year_ (1) // default is Jan 1, 1 AD
{
}
...
Date f; // or: Date f {};
Date dateArray[MAX_DATES];
如果你不知道如何初始化你的Date
,你什么也不告诉它,它使用它的默认构造器:不带参数的那个。它还需要这个来初始化日期数组的元素。 4 所以我们总是写默认的构造器。
转换构造器
此构造器调用
Date g (22000); // or Date g {22000}; -- or Date g = 22000;
需要将这些太多的日子转换成更传统的日、月、年安排。我将使用前面练习 5 中的函数normalize
;如果我们给它 22,000 天,它会将其转换为 26 天、3 个月和 61 年:
Date::Date (int totalDays) : // conversion ctor from int
day_ (totalDays), month_ (1), year_ (1)
{
normalize ();
}
由这个函数和任何需要它的构造器调用的normalize
函数应该放在私有部分。函数会在需要的时候调用它,所以其他人不需要。 5
一个只有一个参数的构造器(不是一个Date)
)被称为转换构造器,因为它从其他类型(比如int
)转换成我们正在编写的类。
就像复制构造器一样,如果 C++ 需要一个Date
,但是你给了它一个int
,它会隐式地调用它。假设你调用了void fancyDisplay (const Date& myDate, ostream& out);
,但是传入了一个int
: fancyDisplay (22000, cout);
。C++ 会把 22000 转换成一个Date
,把fancyDisplay
转换成那个Date
。很好!
摘要
每个构造器都有责任确保数据成员处于某种可接受的状态。不幸的是,C++ 的基本类型让你不用初始化就可以声明它们。但是我们可以构造我们的类,这样 C++ 可以初始化所有的数据成员。
由于前面提到的问题,我推荐以下准则:
Golden Rule of Constructors
总是指定默认和复制构造器。
Extra
使用{}
来初始化变量和老式的()
的 and =:
Date g (22000.5); // No problem: casts from int to double
Date g {22000.5}; // WRONG: "narrowing" conversion loses data // -- compile-time error
这可能会防止我们无意中做一些愚蠢的事情。
此外,如果你不使用()
的,你不能混淆使用()
的调用构造器(如在Date d (21, 12, 2000);
中)和使用()
的函数声明——参见下面的反调试部分。
让事情变得更复杂的是,还有initializer_list
s,它支持将{}
用于另一种类型的构造器(见第二十三章)。
我倾向于像我们已经做的那样使用{}
——对于struct
和数组初始化——部分是为了清晰。但是其他人,包括 C++ 创始人比雅尼·斯特劳斯特鲁普本人,建议所有初始化都使用{}
,原因如下。
防错法
-
You declared an object, using the default constructor, but the compiler doesn’t recognize it :
Date z (); z.print (); // Error message says z is a Date() (?) // or at least isn't of a class type
使用
()
会让编译器认为你在为返回Date
的函数z
写声明。我是说,它怎么能说不是这个意思呢?The solution is to ditch the parens or replace them with
{}'
s:Date z1; Date z2 {};
-
**“非法复制构造器”或“无效构造器”**带此复制构造器声明:
Date (Date other);
。假设编译器让你调用这个函数。因为没有
&
,它要做的第一件事就是复制other
。怎么做?通过调用复制构造器,这意味着它必须复制一个other, w
,这意味着它调用复制构造器,等等,直到你用完内存。
这就是偶然递归,也就是一个函数在你不希望的时候调用自己。还好编译器(Visual Studio 或 g++)捕捉到了这个问题。解决方法:const &
。
代码重用的默认参数
我们可以通过告诉 C++ 来节省更多的工作,如果我们不给我们的一些函数参数,它应该适当地填充它们。
比如我已经厌倦了在myDate.print (cout);
中指定cout
。一般不都是cout
吗?但是我不想将cout
硬编码到函数中,因为我以后可能想要打印到一个文件中。
所以我改声明:void print (ostream& out = cout) const;
。
现在我可以只说myDate.print();
,编译器会想,他没说,所以他一定要 cout
。
我有一个带三个int
的构造器,一个带一个,一个不带任何东西。如果我使用默认值,我可以将它们合并成一个函数:
class Date
{
public:
Date (int theDay=1, int theMonth=1, int theYear=1);
// Defaults go in the declaration
...
};
...
Date::Date (int theDay, int theMonth, int theYear) :
day_ (theDay), month_ (theMonth), year_ (theYear)
{
normalize ();
}
现在我可以用 0 到 3 个参数来调用它:
Date Jan1_1AD;
Date convertedFromDays (22000);
const Date CINCO_DE_MAYO (5, 5); // Got a new one free that takes // day & month
Date doomsday (21, 12, 2012); // Doomsday? Well, that didn't // happen
默认参数也适用于非成员函数:
void fancyDisplay (const Date& myDate, ostream& out = cout);
//if not specified, print with cout
如果有些参数有默认值,而有些没有,那么那些有默认值的参数会放在最后。编译器不想混淆你打算省略哪些参数。
Date
程序(目前为止)
这里是我们所拥有的(例子 15-2 )。
我添加了一个枚举类型Month
和对isLeapYear
以及另外两个函数的声明。他们与日期有关,但他们不是Date
的成员。
我可以让Month
成为会员。但是为了引用(比如说)JUNE
,我不得不写Date::Month::JUNE
……那已经长得离谱了。
isLeapYear
成为Date
的成员没有意义:不是你对一个Date
做的事,而是你对一年int
做的事。它不需要访问Date
的数据成员day_
、month_
和year_
。它属于与 Date
,但我不会让它成为成员。
Tip
如果一个函数不能被合理地认为是对一个对象做了一些事情(包括“暴露它的秘密”,像一个访问函数),它可能就不应该是一个成员。
main
的目的是测试类和相关的函数。当设计一个新的类时,这是一个基本的实践(在第十七章和第十九章中再次展示,在这两章中,我们在使用新的类来创造一些有趣的东西之前测试它们)。
// A program to test the Date class
// -- from _C++20 for Lazy Programmers_
#include <iostream>
using namespace std;
enum class Month {JANUARY=1, FEBRUARY, MARCH, APRIL, MAY, JUNE,
JULY, AUGUST, SEPTEMBER, OCTOBER, DECEMBER};
bool isLeapYear (int year);
int daysPerYear (int year);
int daysPerMonth (int month, int year);
// We have to specify year in case month
// is FEBRUARY and it's a leap year
class Date
{
public:
Date (int theDay=1, int theMonth=1, int theYear=1);
// Because of default parameters,
// this serves as a ctor taking 3 ints;
// a new one taking days and months;
// the conversion from int constructor;
// and the default constructor
Date (const Date&); // copy constructor
void print (std::ostream& out = std::cout) const;
int totalDays () const; // total days since Dec 31, 1 B.C.
private:
int day_;
int month_;
int year_;
void normalize ();
};
Date::Date (int theDay, int theMonth, int theYear) :
day_ (theDay), month_ (theMonth), year_ (theYear)
{
normalize ();
}
Date::Date (const Date& other) :
day_ (other.day_), month_ (other.month_), year_ (other.year_)
{
}
void Date::print (std::ostream& out) const
{
out << day_ << '-' << month_ << '-' << year_;
}
// Date::totalDays and Date::normalize from earlier exercises
// as well as isLeapYear, daysPerYear, and daysPerMonth
// are omitted here, but they're in the book's sample code
void fancyDisplay (const Date& myDate, ostream& out = std::cout)
{
cout << "*************\n";
cout << "* "; myDate.print (); cout << " *\n";
cout << "*************\n";
}
int main ()
{
constexpr int MAX_DATES = 10;
Date d (21, 12, 2000); // using our old constructor...
Date e = d; // e is now exactly the same as d
Date f; // now one with no arguments
Date dateArray [MAX_DATES]; // still no arguments
Date g (22000); // 22,000 days, nearly a lifetime
cout << "This should print 26-3-61 with lots of *'s:\n";
fancyDisplay (22000); // tests conversion-from-int constructor
return 0;
}
Example 15-2A program using the Date class
Exercises
- 更新
Time
类以使用你在本章剩余部分学到的知识。
是ostream&
,不是ostream
,因为iostream
的设计者禁用了ostreams
的复制,大概理由是没有意义。如果你忘记了,编译器会提醒你。
2
这是struct
和class
的唯一区别:如果你不指定(我们总是这样做,对于类),class
成员是私有的,struct
成员是公共的。我们通常将struct
用于小型、简单的分组,没有成员函数,一切都是公共的。那是因为struct
s 在类被发明之前就已经存在于 C 中了,C 就是这样使用它们的。
3
也可以用{}
表示法:Date appointment {2595, 1, 31};
。它调用同一个构造器。
4
除非你用{}的,比如在Date myDates[] = {{31,1,2595},{1,2,2595}};
里。
5
私有部分中的函数被称为“实用”函数,因为它们执行对其他公共函数有用的任务。我们并不真的需要这个术语,但使用它让我听起来很聪明,所以我当然会使用它。
***
十六、类·续
更多的事情使你的类工作,并且工作得很好,特别是把你的程序的一部分放在多个文件中。
inline
提高效率的功能
考虑一下第八章和第十五章的函数调用图。它们展示了计算机的功能。它创建函数的新副本,即“激活记录”,包含函数实例需要的所有内容,尤其是局部变量。它将参数复制到内存中该函数可以访问的部分。它存储它需要知道的关于它所在函数的信息(CPU 中寄存器的状态——如果你不知道那是什么,不用担心)。最后,它将控制权转移给新功能。
完成后,它反转这个过程:丢弃函数及其变量的副本,并恢复旧函数的状态。
在运行时,计算机上的工作量很大。那我们该怎么办?停止使用函数?
解决方案是内联函数。一个内联函数被写成一个函数,就程序员而言,它的行为就像一个函数,并且看起来像是作为一个函数编译的,但是编译器做了一些偷偷摸摸的事情:它用一段代码代替函数调用来做同样的事情。这里有一种方法可以让一个函数内联——只要在它前面加上inline
:
inline
void Date::print (std::ostream& out) const
{
out << day_ << '-' << month_ << '-' << year_;
}
当你写这个的时候
d.print (cout);
编译器会像你说的那样处理它
cout << d.day_ << '-' << d.month_ << '-' << d.year_;
// but there's no problem with these members being private
从而节省了函数调用的开销。
如果一个函数足够大,那么调用的时间开销与花在函数本身上的时间相比并不显著,所以帮助不大。并且inline
引入了新的开销:内联扩展的大函数的多个副本将占用大量内存。以下是如何知道是否应该内联一个函数。
Golden Rule
inline
功能
**一个函数应该是内联的,如果它
-
适合单行。
-
不包含循环(for、while 或 do-while)。
扩展仅仅是对编译器的一个建议,而不是命令。如果编译器认为函数不应该扩展,它会否决你。我没意见;在这种情况下,编译器最清楚。
这里有一个快速、简单的方法来使成员函数内联——将整个事情放在类定义中:
class Date
{
...
void print (ostream& out) const
// inline, because it's inside the class definition
{
out << day_ << '-' << month_ << '-' << year_;
}
...
};
访问功能
有时我们希望世界上的其他人能够看到我们的数据成员,但不能改变它们。这就像一个时钟:你必须通过适当的控件(成员函数)来设置它,但是你可以随时看到时间。
事情是这样的:
class Date
{
public:
...
// Access functions -- all const, as they don't change data, just // access it
int day () const { return day_; }
int month () const { return month_; }
int year () const { return year_; }
....
};
调用它们的方法与使用print
相同——使用.
:
cout << myBirthday.year () << " is the year of the lion. Fear me.\n";
使用访问函数通常是个好主意,甚至在成员函数中。假设我决定转储day_
、month_
和year_
,并且只有一个数据成员totalDays
,成员函数可以根据需要从中计算日、月和年。引用day_
的函数将不再编译!但是如果它指的是仍然存在的days()
,那就好了。
这就是为什么我们在数据成员名称:day_
和其他之后使用下划线。这就是为什么我用了有趣的名字theDay
、theMonth
等等,作为我写的第一个构造器的参数。如果我这么做了
class Date
{
public:
Date(int day, int month, int year) :
day(day),month(month),year(year)
{
}
int day() const { return day; }
....
private:
int day, months, years;
};
我有太多的东西要命名,我永远也不会把它们分类! 1 也不会编译。名称必须不同。
单独的编译和包含文件
现在我们的程序足够长了,我们应该把它们分成多个文件。这里有一些通用指南,这样你就知道在哪里可以找到东西了:
-
我们通常给每个类一个自己的文件。
-
…让每一组明确相关的函数共享一个文件。例如,如果你在写三角函数正弦、余弦、正切等等,你可以把它们放在一起。
-
我们给
main
它自己的文件,可能与main
调用的函数共享,这些函数对其他程序没有用。比方说,如果你正在为扑克写一个程序,与叫牌相关的函数可能会放在main
的文件中(因为只有扑克才进行扑克式的叫牌),但是与洗牌和发牌相关的函数会放在其他地方(因为许多游戏都涉及到牌)。
然而,就让这一切发挥作用而言,还有一个问题。这个新文件需要知道某些事情(比如类定义!)—main
也会如此。这些信息必须共享。
幸运的是,我们已经知道如何做到这一点:包含文件。
在单独编译中会发生什么
假设我创建了这些文件:myclass.h
(如“header”中的“h”),包含类定义;myclass.cpp
,包含成员功能;和main.cpp
,包含主程序。(我给.h
和.cpp
文件起了和类一样的名字,小写。习俗各不相同;保持一致。)我是这样收录的:
#include "myclass.h" // <-- Use "" not <>; and let the file end in .h
下面是编译器构建程序的几个阶段。
首先,它编译c++“源”文件(你的.cpp
文件;见图 16-1 。当它遇到一个#include
指令时,它停止读取源文件,读取你包含的.h
文件,然后返回源文件。
图 16-1
构建程序的编译阶段
编译器用机器语言为每个源文件生成一个“目标”文件。
如果没有错误,编译器准备好链接(图 16-2 )。目标文件知道如何做它们要做的事情,但是它们不知道在哪里可以找到函数引用,无论是从彼此之间还是从系统库中。链接阶段通过解析引用将这些文件“链接”在一起,并生成一个可执行文件。如果您使用的是 Visual Studio,可执行文件将以.exe
结尾;g++ 是灵活的。
图 16-2
构建程序的链接阶段
看到这个过程使我们能够准确地理解什么应该和不应该进入包含文件。
写你的。h 文件
以下是包含文件中可以包含的内容(到目前为止):
-
类型,包括类定义和枚举类型
-
函数声明
-
任何事情
以下是不应该的:
-
功能
-
常量或变量声明(除了
inline
–继续阅读)
原因如下。如果你把一个函数(或变量声明)放在一个包含文件中,它将被包含到不同的.cpp
文件中。当你编译这些文件时,你会得到同一个函数的多个副本。当你调用这个函数的时候,编译器不知道使用哪个副本,也不知道它们是相同的。你会得到一个错误,说它有个重复的定义。
如果你想让你的函数出现在包含文件中…就把它设为inline
。
如果您想在包含文件中包含变量、const
s 或constexpr
s…也让它们成为inline
:
inline constexpr int DAY_PER_WEEK = 7;
inline const SSDL_Color BABY_BLUE = SSDL_CreateColor (137, 207, 240);2
不仅仅是为了提高函数调用的效率——它还防止了重复定义的问题。也许不是最清晰的关键字,但是allowInIncludeFilesWithoutDuplicateDefinitionError
太难输入了。
只包含一次. h 文件
假设time.h (
来自第十五章的习题;它定义了新的类Stopwatch
需要类Time
。我们需要#include "time.h"
,这样我们就可以声明start_
和stop_
:
// stopwatch.h: defines class Stopwatch
// -- from _C++20 for Lazy Programmers_
#include "time.h" // trouble ahead...
class Stopwatch
{
public:
Stopwatch () {}
private:
Time start_, stop_;
};
然后我们到了main.cpp
。
// Program that uses Stopwatches and Times
// -- from _C++20 for Lazy Programmers_
#include "time.h"
#include "stopwatch.h"
int main (int argc, char** argv)
{
Time duration;
Stopwatch myStopwatch
;
// ...
return 0;
}
Example 16-1A program that includes time.h and stopwatch.h. Since this and Example 16-2 work together, they’re in the same project in source code’s ch16 folder; it’s named 1-2-stopwatch
编译时main.cpp
首先,编译器包含了time.h
,它定义了类Time
。
然后包括stopwatch.h
。做的第一件事*是#include "time.h"
,它定义了类Time
。*又来了。编译器抱怨:类Time
的重复定义!
解决方案是告诉编译器只在还没有被读取的时候才读取一个.h
文件。有一个常用的技巧:在.h
文件中定义一些东西;然后在整个文件周围放些东西,说“如果你从来没听说过,就只看这个。”
// time.h: defines class Time
// -- from _C++20 for Lazy Programmers_
#ifndef TIME_H // If TIME_H is not defined...
#define TIME_H
class Time
{
// ...
};
#endif //TIME_H
Example 16-2time.h, written so it will only be processed once. Part of the 1-2-stopwatch project in source code’s ch16
第一次通过时,它从未听说过TIME_H
,所以它读取了.h
文件。这定义了类别Time
和TIME_H
。
下一次,它听说过TIME_H
,所以它跳到#endif
。阶级Time
没有被重新定义。任务完成。
为了防止这个问题,我对所有的包含文件都这样做。对常量(MYFILE_H
)使用相同的形式意味着我总是记得我如何拼写它,并防止名称冲突。
避免包含文件中的using namespace std;
using
namespace std
;
不应该在你的包含文件里。如果有人包含了您的文件,但不想使用std
名称空间怎么办?为了避免强加给他们,跳过using
宣言。为了让 C++ 仍然能够识别cin
和cout
以及std
名称空间中的其他东西,在它们前面加上std::
,就像在std::cin >> x;
中一样。
备份多文件项目
在 Unix 中,要备份目录myproject
,请输入以下命令:cp -R myproject myprojectBackup1
。
在 Windows 中,复制并粘贴整个文件夹,忽略不会复制的内容。
防错法
循环包括制造一个奇怪的错误。让我们修改time.h
,这样它就需要Stopwatch
:
#include "stopwatch.h"
class Time
{
void doSomethingWithStopwatch (const Stopwatch&);
};
假设某个.cpp
文件包含了time.h
。这定义了TIME_H
,然后包括stopwatch.h
(图 16-3 ,左)。
图 16-3
time.h
包括stopwatch.h
,其中包括time.h
——会有麻烦的
所以它暂时停止读取time.h
,读取stopwatch.h
(图 16-3 ,中间)。这定义了STOPWATCH_H
,然后又包含了time.h
(图 16-3 ,右图)。
因为已经定义了TIME_H
,所以#ifndef
让我们跳过内容。我们回到stopwatch.h
,当它到达第Time start_, stop_;
行时,它对我们大喊它从来没有读过Time
的定义,这是真的。所以程序不会编译。
一个包含文件可以包含另一个——但是它们不能包含彼此的*。*
*一些修复:
-
重新考虑这个功能是否应该在
Time
中。Time
真的要靠Stopwatch
吗?难道不应该反过来吗?(那是这个代码的最佳答案。) -
如果那样不行…只要代码不需要细节,你可以在不知道是什么的情况下参考
Time
中的Stopwatch
。只告诉time.h
说Stopwatch
是一个班:class Stopwatch; class Time { void doSomethingWithStopwatch (const Stopwatch&); };
问题解决了。
下一个问题:如果你有很多文件,却不记得把其中一个函数放在哪里,该怎么办?
-
Visual Studio:右击函数名。“转到申报”将带您到申报;“转到定义”将带你到函数本身,如果它可用的话。
-
Unix:尽管有一些软件包可以帮助解决这个问题(emacs 的 ggtags 就是其中之一),但不能保证它们就在您的系统上。这个命令是一个快速的 3 的方法来找到函数和对它的所有引用:
grep
functionIWant *
。 -
MinGW:我用 Windows Grep——在网上找——来搜索函数名。
Microsoft Visual Studio 中的多文件项目
**要添加新文件,**进入项目菜单,选择添加新项目。您可以通过这种方式添加任何您需要的.h
或.cpp
文件;它会把它们放在正确的地方。
然后像往常一样构建并运行您的项目。
Extra
现在您已经有了多个源文件,您可能想要一种更简单的方法来清理 Visual Studio 创建的额外文件。 4
在项目所在的文件夹中,使用记事本或其他编辑器创建一个文件clean.txt
。把它放在一个文件夹里,里面只有你的工作(也许还有我的),没有任何不可替代的东西。这里面应该有些什么:
REM Erase folders you don't want -- here's my picks
for /r . %%d in (Debug,.vs) do @if exist "%%d" rd /s/q "%%d"
REM Erase other files -- here's my picks.
REM /s means "in subfolders too"
del /s *.obj REM Not needed, but now you know how to
REM erase all files with a particular extension
保存您的文本文件,并将其名称从clean.txt
改为clean.bat
。(看不到.txt
?取消选中隐藏已知文件类型的扩展名–参见第一章。)在图 16-4 的警告对话框中点击是。
图 16-4
Microsoft Windows 关于更改文件扩展名的警告
…并获取新的clean.bat
文件。
每当您想要抹掉额外的文件时,您可以双击这个“批处理”文件,即一个命令文件。被警告:del
永久删除东西。 5
g++ 中的多文件项目
要在 g++ 项目中使用多个文件,只需将它们添加到从basicStandardProject
(或basicSSDLProject
)复制的项目文件夹中。make
将照常建造一切。
不管有没有自己的 Makefiles,都要自己做,请继续阅读。
命令行:多打字,少思考
您可以使用以下命令构建程序:
g++ -g -std=gnu++2a -o myprogram myprogram.cpp myclass.cpp
您可以将编译和链接阶段分开:
g++ -g -std=gnu++2a -c myprogram.cpp #-c means "compile only -- don't link
g++ -g -std=gnu++2a -c myclass.cpp
g++ -g -std=gnu++2a6 -o myprogram myprogram.o myclass.o #now link
Makefiles:多思考,少打字(可选)
Makefiles 跟踪项目中已更改的文件。当你make
的时候,它只会重建它需要的部分。这减少了编译时间。输入make
比输入g++ -g -o myprogram file1.cpp file2.cpp...
更好
Makefiles 并不容易,但对于大型项目或使用大量库的项目来说,它们是必不可少的。本节将向您展示如何制作它们,从最简单的版本(例如 16-3 )到最复杂但也是最常用的版本(例如 16-5 )。
简单的版本
#This is a basic Makefile, producing one program from 2 source files
myprogram: myclass.o main.o #link object files to get myprogram
g++ -std=gnu++2a -g -o myprogram myclass.o main.o
main.o: main.cpp myclass.h #create main.o
g++ -std=gnu++2a -g -c main.cpp
myclass.o: myclass.cpp myclass.h #create myclass.o
g++ -std=gnu++2a -g -c myclass.cpp
clean:
rm -f myprogram # for Unix; with MinGW, rm -f myprogram.exe
rm -f *.o
Example 16-3A simple Makefile. It’s in source code, ch16/3-4-5-makefiles, as Makefile.Ex-16.3\. To use it, copy it to Makefile and type make
第一行是注释,因为它以#开头。
为了简单起见,我将把事情打乱。这条线
main.o: main.cpp myclass.h
说要编译 main 的.o
(对象)文件,你需要main.cpp
和myclass.h
。如果其中任何一个发生变化,make
就会重建main.o
。(make
根据文件的修改时间检测更改。)
下一行g++ -std=gnu++2a -g -c main.cpp
是编译它的命令。如果失败,make
会停止,以便您可以纠正错误。
对myclass.o
的理解是一样的。
让我们回到顶部:
myprogram: myclass.o main.o
g++ -std=gnu++2a -g -o myprogram myclass.o main.o
这确定了myprogram
依赖于myclass.o
和main.o
,并告诉如何创建它。
因为这是 Makefile 中的第一件事,所以当你键入make
时,这就是计算机试图构建的东西。
clean
很好:如果你说make clean
,它会清除可执行文件和所有的.o
文件。-f
选项是这样的,如果没有错误,它不会报告错误,因为这不是问题。注意,Windows 将.exe
附加到它的可执行文件中,所以 MinGW 版本的clean
需要删除myprogram.exe
。
更好的版本
Makefile 的工作量太大了:我们必须指定每个.cpp
文件以及它所依赖的.h
文件。我们现在将创建一个 Makefile 文件,它应该适用于您将在本文剩余部分和其他地方遇到的大多数项目。
# Makefile for a program with multiple .cpp files
PROG = myprogram # What program am I building?
# MinGW: make this myprogram.exe
SRCS = $(wildcard *.cpp) # What .cpp files do I have?
OBJS = ${SRCS:.cpp=.o} # What .o files do I build?
$(PROG): $(OBJS) # Build the program
g++ -std=gnu++2a -g -o $@ $^
%.o: %.cpp # Make the .o files
g++ -std=gnu++2a -g -o $@ -c $<
clean: # Clean up files
rm -f $(PROG)
rm -f *.o
Example 16-4A Makefile for any project of .cpp source files – first attempt. It’s in source code, ch16/3-4-5-makefiles, as Makefile.Ex-16-4.unix and Makefile.Ex-16.4.mingw. To test, copy the one for your platform to Makefile and make
首先,我们定义一些变量。
之后,我们看到我们的程序依赖于目标文件(和以前一样)。注意变量用$()
括起来。然后第一个 g++ 命令告诉我们如何从目标文件创建程序。
$@
表示“上面的:
左边的东西”,$^
表示“:
右边的一切”——即所有的对象文件。
产生.o
文件的部分为每个.cpp
文件制作一个。$<
的意思是“下一个文件右边有什么*,在这种情况下,下一个.cpp
文件。*
(如果你想知道如何使用这些看起来很奇怪的结构的一切,互联网是你的。如果你只是想找点有用的……互联网还是你的。我就是这么做的:查阅教程,看看什么能解决我的问题。)
要查看这些变量是如何被翻译成实际命令的,输入make
——它会在执行命令时打印出命令。
完整的版本
Makefile 仍然有一个大错误(除了看起来像是用埃及象形文字写的)。它不引用任何.h
文件。如果你改变了一个.h
文件,make
不会知道在此基础上重新编译——它应该知道。
所以我们在 Makefile 的末尾添加了一个神奇的咒语:
%.dep: %.cpp # Make the .dep files
g++ -MM -MT "$*.o $@" $< > $@
ifneq ($(MAKECMDGOALS),clean) # If not cleaning up...
-include $(DEPS) # bring in the .dep files
endif
第一部分说对于我们拥有的每个.cpp
文件,我们需要一个.dep
文件,它将包含依赖关系的信息。g++ -MM
行生成它。main.dep
文件可能看起来像这样
main.o main.dep: main.cpp myclass.h
意思是“每当main.cpp
或myclass.h
改变时,重新制作main.o
和main.dep
”
-MT "$*.o $@"
选项指定了:
左边的内容——它应该包含相关的.o
文件main.o
,加上main.dep
,这里指定为$@
。我们放置main.dep
的原因是,如果main.cpp
或myclass.h
中有任何变化(比方说,我们添加另一个#include
), main.dep
也会更新。
$<
是相关的.cpp
文件。
>
表示将输出存储在一个文件中,特别是$@
,这是我们正在创建的.dep
文件。
include $(DEPS)
表示将这些规则包含到 Makefile 中。最初的-
表示如果有错误就不要报告,比如文件不存在,这将在clean
之后第一次运行make
时发生。而ifneq...
说,如果你无论如何都要更新.dep
文件,那就不要担心它们。
是的,这很复杂,但这是突破口。
这是我们的结果。它应该不加改变地为新项目工作;如果您想要不同的可执行文件名称,请更改myprogram
。
# Makefile for a program with multiple .cpp files
PROG = myprogram #What program am I building?
# MinGW: make this myprogram.exe
SRCS = $(wildcard *.cpp) #What .cpp files do I have?
OBJS = ${SRCS:.cpp=.o} #What .o files do I build?
DEPS = $(OBJS:.o=.dep) #What .dep files do I make?
##########################################################
all: $(PROG)
$(PROG): $(OBJS) # Build the program
g++ -std=gnu++2a -o $@ -g $^
clean: # Clean up files
rm -f $(PROG)
rm -f *.o
rm -f *.dep
%.o: %.cpp # Make the .o files
g++ -std=gnu++2a -g -o $@ -c $<
%.dep: %.cpp # Make the .dep files
g++ -MM -MT "$*.o $@" $< > $@
ifneq ($(MAKECMDGOALS),clean) # If not cleaning up...
-include $(DEPS) # bring in the .dep files
endif
Example 16-5A complete Makefile. It’s in source code, ch16/3-4-5-makefiles, as Makefile.Ex16-5.unix and Makefile.Ex16-5.mingw. Copy the version for your platform into any folder where you have a project, naming it Makefile, and run by typing make
防错法
-
**Makefile:16: ***** 缺少分隔符 。停下来。
不开玩笑:这是因为在指定的行上,你用空格而不是制表符缩进。解决方案:哼哼,翻白眼,或者其他什么,使用制表符。
最终Date
程序
示例 16-6 至 16-8 显示了Date
的完成程序,如前所述,该程序被分成文件。main
是驱动程序,也就是设计用来测试类的程序。
// A "driver" program to test the Date class
// -- from _C++20 for Lazy Programmers_
#include <iostream>
#include "date.h"
using namespace std;
int main ()
{
Date t (5,11,1955); // Test the 3-int constructor
// ... and print
cout << "This should print 5-11-1995:\t";
t.print (cout);
cout << endl;
// Test access functions
if (t.day () != 5 || t.month () != 11 || t.year () != 1955)
{
cout << "Date t should have been 5-11-1955, but was ";
t.print ();
cout << endl;
}
Date u = t; // ...the copy constructor
if (u.day () != 5 || u.month () != 11 || u.year () != 1955)
{
cout << "Date u should have been 5-11-1955, but was ";
u.print ();
cout << endl;
}
const Date DEFAULT; // ...and the default constructor
// I do consts to test const functions
if (DEFAULT.day () != 1 || DEFAULT.month () != 1 ||
DEFAULT.year () != 1)
{
cout << "Date v should have been 1-1-1, but was ";
DEFAULT.print ();
cout << endl;
}
// ...and total days
constexpr int DAYS_FOR_JAN1_5AD = 1462; // I found this number myself
// with a calculator
Date Jan1_5AD (1, 1, 5);
if (Jan1_5AD.totalDays () != DAYS_FOR_JAN1_5AD)
cout << "Date Jan1_5AD should have had 1462 days, but had "
<< DAYS_FOR_JAN1_5AD << endl;
// Test normalization
const Date JAN1_2000 (32, 12, 1999);
if (JAN1_2000.day () != 1 || JAN1_2000.month () != 1 ||
JAN1_2000.year () != 2000)
{
cout << "Date JAN1_2000 should have been 1-1-2000, but was ";
JAN1_2000.print ();
cout << endl;
}
cout << "If no errors were reported, "
<< " it looks like class Date works!\n";
return 0;
}
Example 16-8A driver program for class Date. It’s in source code, ch16, as part of the 6-7-8-date project/folder
// class Date -- functions
// -- from _C++20 for Lazy Programmers_
#include "date.h"
bool isLeapYear (int year)
{
//...
}
int daysPerYear (int year)
{
//...
}
int daysPerMonth (int month, int year)
{
//...
}
void Date::normalize ()
{
//...
}
int Date::totalDays () const
{
//...
}
Example 16-7date.cpp, abbreviated for brevity. A complete version is in source code, ch16 folder, as part of the 6-7-8-date project/folder
// class Date
// -- from _C++20 for Lazy Programmers_
#ifndef DATE_H
#define DATE_H
#include <iostream>
enum class Month { JANUARY=1, FEBRUARY, MARCH, APRIL, MAY, JUNE,
JULY, AUGUST, SEPTEMBER, OCTOBER, DECEMBER};
bool isLeapYear (int year);
int daysPerYear (int year);
int daysPerMonth (int month, int year);// Have to specify year,
// in case month is FEBRUARY
// and we're in a leap year
class Date
{
public:
Date(int theDay=1, int theMonth=1, int theYear=1) :
day_(theDay), month_(theMonth), year_(theYear)
{
normalize();
}
// Because of its default parameters, this 3-param
// ctor also serves as a conversion ctor
// (when you give it one int)
// and the default ctor (when you give it nothing)
// Default is chosen so that the default day
// is Jan 1, 1 A.D.
Date(const Date& otherDate) : // copy ctor
day_ (otherDate.day_ ),
month_(otherDate.month_),
year_ (otherDate.year_ )
{
}
// Access functions
int days () const { return day_; }
int months () const { return month_; }
int years () const { return year_; }
int totalDays () const; // convert to total days since Dec 31, 1 BC
void print (std::ostream& out = std::cout) const
{
out << day_ << '-' << month_ << '-' << year_;
}
private:
int day_;
int month_;
int year_;
void normalize ();
};
#endif //DATE_H
Example 16-6date.h. It’s in source code, ch16, as part of the 6-7-8-date project/folder
输出是
This should print 5-11-1995: 5-11-1955
If no errors were reported, looks like class Date works!
为了对用户友好,如果一切正常,驱动程序给出尽可能少的输出——测试时需要费力处理的输出也越少。
Exercises
在这些练习中,使用单独的编译;为您的类提供适当的构造器,在有用的地方使用默认参数;以及相关的写访问函数和内联函数。
如果结果表明.cpp
文件中没有任何内容(可能发生!),不需要写一个:
-
更新
Time
类以使用本章中所涉及的内容。添加一个常量
Time MIDNIGHT
,如果你的编译器支持的话,使用inline
让它对main.cpp,
可用。 -
添加
Time
函数sum
和difference
,返回这个Time
与另一个Time
的和/差。它的返回值也是一个Time
。 -
你想在你的墓碑上刻什么?创建一个
Tombstone
类,包含出生日期、死亡日期、姓名和墓志铭。除了一个成员函数print
之外,给它一个lifespan
函数,它返回这个人生命的持续时间作为一个Date
。 -
将示例 16-1 和 16-2 中的
Stopwatch
具体化,以使用本章涵盖的内容。还要添加函数start
和stop
,它们启动和停止Stopwatch
(您可能已经完成了第十五章第一组练习中的练习 2:将Time
设置为当前系统时间),以及duration
,它们返回差值。然后使用Stopwatch
记录用户按 Enter 键的速度。 -
创建一个类
Track
,它包含一段音乐的标题、艺术家和持续时间(aTime
)。现在创建一个类
Album
,它包含一个标题和一个Track
的数组。在你知道你将需要的其他函数中,包括一个函数duration()
,它是所有Track
持续时间的总和。
或者我可以有一个数据成员days
和一个访问函数getDays ()
;有些人使用这个惯例。我觉得《??》太难读了——你说了算。
2
如果由于某种原因(可能是不兼容的编译器)这种方法对您不起作用,您可以采用这种更麻烦的形式:
extern const 类型 myConstant//在中。h 文件
和
常量类型 myConstant =…;//在相应的。cpp 文件
extern 的意思是“这个变量将在别处被声明——你会在链接时找到它的位置。”
3
轻松,不优雅。
4
Visual Studio 中还有一个选项:构建➤清洁解决方案。不疼,但是在我的机器上留下了一个文件Browse.VC.db
,可能超过 5 MB。所以我不依赖它。
5
如果使用来自教科书源代码的clean.bat
,Windows 可能会给出“运行此应用程序可能会使你的电脑面临风险”的警告,并建议你不要这样做。Windows 是对的:clean.bat
在错误的地方可能会擦除错误的东西。我知道没关系,但是……自己做吧。
6
可能不需要在这个“link”命令中放入-STD = gnu++ 2a——但是万一发生变化,我会把它留在这里。
***