本文属于「现代C++学习实践」系列文章之一,这一系列正式开始于2021/09/04,着重于现代C++(即C++11、14、17、20、23等新标准)和Linux C++服务端开发的学习与实践。众所周知,「C++难就难在:在C++中你找不到任何一件简单的事」。因此,本系列将至少持续到作者本人「精通C++」为止(笑)。由于文章内容随时可能发生更新变动,欢迎关注和收藏现代C++系列文章汇总目录一文以作备忘。 为了方便在PC上运行调试、分享代码文件,我还建立了相关的仓库:[https://github.com/memcpy0/]。在这一仓库中,你可以看到本人学习C++的全过程,包括C++书籍源码、练习实现、小型项目等。 需要特别说明的是,为了透彻理解和全面掌握现代C++,本系列文章中参考了诸多博客、教程、文档、书籍等资料,限于时间精力有限,这里无法一一列出。部分重要资料的不完全参考目录如下所示,在后续学习整理中还会逐渐补充:
- C++ Primer 中文版(第5版),Stanley B. Lippman、Barbara E. Moo等著,王刚、杨巨峰译,叶劲峰、李云、刘未鹏等审校,电子工业出版社;
- 侯捷老师的公开课;
- C++面向对象高级开发上、下:正确理解面向对象的精神和实现手法,涵盖对象模型、关键机制、编程风格、动态分配;
- STL标准库与范型编程:深入剖析STL标准库之六大部件、及其之间的体系结构,并分析其源码,引导高阶泛型编程。
- C++新标准C++11/14:在短时间内深刻理解C++2.0的诸多新特性,涵盖语言和标准库两层
- C++内存管理机制:学习由语言基本构件到高级分配器的设计与实作,并探及最低阶
malloc 的内部实现。 - C++ Startup揭密:C++程序的生前和死后。认识Windows平台下的Startup Code(启动码),完全通透C++程序的整个运行过程。
本章中将编写一个小的C++程序,来解决简单的书店问题——书店保存所有销售记录的档案,每条记录都保存了某本书的一次销售信息:ISBN 售出册数 书的单价 ,此外老板还需要查询此档案,计算每本书的销售量、销售额、平均售价。
1.1 编写一个简单的C++程序
每个C++程序都包含许多函数,其中一个必须命名为 main ,操作系统通过调用 main 来运行C++程序。最简单的 main 函数如下,它只返回给操作系统一个值:
int main()
{
return 0;
}
每个函数的定义都包括四部分,即使是特殊的 main 函数也不例外:返回类型 return type 、函数名 function name 、一个括号包围的形参列表 parameter list (允许为空)、函数体 function body ——一个以左花括号 curly brace 开始、以右花括号结束的语句块 block of statements 。
6.2.5节讨论 main 函数的形参列表和其他形参类型。
main 函数的返回类型必须为整数类型 int ,这是一种内置类型 built-in type ,即语言自身定义的类型。类型是程序设计的基本概念之一,不仅定义了数据元素的内容,还定义了在这类数据上可以进行的运算。程序处理的数据都保存在变量中,每个变量都有自己的类型。
此处 main 函数体语句块中只有一条 return 语句,它结束函数的执行,还会向调用者返回一个值——注意,return 语句返回值的类型必须与函数返回类型相容。大多数系统中,main 函数的返回值被用来指示状态,零表示成功执行,非零返回值的含义由系统定义,用来指出错误类型。
1.1.1 编译、运行程序
编写程序后,就要编译它,如何编译程序取决于使用的操作系统和编译器。在【VS Code】Windows10下VS Code配置C/C++语言环境这篇文章中,为了配置语言学习环境,我稍微介绍了一下GCC、Clang、MSCV三个编译器:
常见的C/C++编译工具有GCC(GNU Compiler Collection ,即GNU编译器套件,GCC过去代表 GNU C Compiler ,但是由于编译器支持除C之外的其他几种语言,它现在代表 GNU Compiler Collections ,GCC官网在此处)、Clang(C language family frontend for LLVM ,提供兼容GCC的编译器驱动程序 clang.exe 和兼容MSVC的编译器驱动程序 clang-cl.exe ,Clang官网在此)、MSVC(Microsoft C++ 编译器工具集)三巨头,个人觉得Clang的架构更优雅、优化更惊艳,然而姜还是老的辣……在对C++ 20的语核支持“比赛”中,GCC首先接近了终点——虽然MSVC一个月前宣布自己冲过了终点。
编译器包含许多选项,其中有些参数能对有问题的程序结构发出警告,总是打开这些选项是一个好习惯,如在GNU编译器中使用 -Wall 选项,在MSCV中使用 /W4 。更多参数可以查阅编译器的参考手册。
此外,还需要为编译器配备编辑器,比如我正在学习的VS Code和Vim/Emacs,或者使用集成开发环境,比如Jetbrains全家桶的CLion(跨平台的C/C++ IDE,支持现代C++、libc++以及Boost):
程序源文件命名约定
多数编译器要求程序源码存储在一个或多个文本文件中——程序文件常被称为源文件 source file ;源文件的名字以一个后缀名(一个句点接一个或多个字符组成)结尾,后缀名告知系统这个文件是一个C++源文件;不同的编译器使用不同的后缀命名约定,包括 .cc, .cxx, .cpp, .cp 和 .c 。
从命令行运行编译器、编译运行程序、查看 main 返回值
命令行如Unix/Linux的Shell、Windows的命令提示符或Powershell中,可以编译程序。假设 $ 是系统提示符,CC 是编译器程序的名字,prog1.cc 保存了我们的 main 程序:
$ CC prog1.cc
执行命令后,编译器生成一个可执行文件——Windows中将其命名为 prog1.exe ,Unix/Linux中的编译器如GCC通常将其命名为 a.out (个人尝试,我在Windows中使用GCC编译,默认生成的可执行文件是 a.exe )。
要运行可执行文件,需要提供可执行文件的文件名:
- Windows中可以忽略其扩展名
.exe ,执行 prog1 (我执行的是 a ),但是加上扩展名也无妨。 - 某些系统中,即使文件就在当前工作目录中,也必须显式指出文件的位置,此时在
$ 提示符后键入 .\prog1 即可(Windows CMD中,键入 .\prog1 也可以执行程序,但是不可使用 ./prog1 ),. 后跟着反斜线 \ 指出该文件在当前目录中。 - Unix系统中运行一个可执行文件,需要使用全文件名,包括文件扩展名,如
$ a.out (书上说的,没有试过)。 - Linux系统中运行一个可执行文件,要显式指出文件位置,用一个
. 后跟着斜线 / 来指出可执行文件位于当前目录中,如 ./a.out 。
访问 main 函数返回值的方法依赖于系统。Unix和Windows系统中,执行完一个程序后,都可以通过 echo 命令获取其返回值:
- Unix中,通过
$ echo $? 获取状态; - Windows中,查看状态需要键入
$ echo %ERRORLEVEL% 。
1.1节练习*
练习1.1:查阅你使用的编译器的文档,确定它使用的文件命名约定。编译并运行第2页的 main 程序。 答:文中已经编译运行。我使用的是GCC 8.1.0: 点击查看GCC官方文档: 练习1.2:改写程序,让它返回-1。返回值-1通常被当作程序错误的标识。重新编译并运行你的程序,观察系统如何处理 main 返回的错误标识。 答:在CMD中编译运行,系统没有处理返回的错误标识: 在DEV C++中编译运行:
1.2 初识输入输出
C++语言没有定义任何输入输出语句,替代的是用一个全面的?标准库 standard library 来提供IO机制(和很多其他设施)。不过,我们只需了解IO库中一部分基本概念和操作。
其中之一是 iostream 库,它包含两个基础类型 istream, ostream ,分别表示输入流和输出流——所谓的流 stream ,实际就是一个从IO设备中读出或写入IO设备的字符序列,随着时间的推移,字符顺序生成或消耗。
1.2.1 标准输入输出对象
标准库定义了4个IO对象:
- 处理输入,使用名为
cin 的 istream 类型对象,此对象也被称为标准输入 standard input ; - 处理一般输出,使用名为
cout 的 ostream 类型对象,此对象也被称为标准输出 standard output ; - 处理警告和错误消息的输出,使用名为
cerr 的ostream 类型对象,此对象也被称为标准错误 standard error ; - 处理程序运行时的一般性消息(即日志)的输出,使用名为
clog 的ostream 类型对象。
系统会将程序运行的窗口与这些对象关联起来——读取 cin 时,数据从程序正在运行的窗口读入;向 cout, cerr, clog 写入数据时,将写到同一个窗口。
1.2.2 使用IO库的程序
如下是一个使用IO库的简单程序,提示用户输入两个数,然后输出它们的和。我们将具体分析这一程序:
#include <iostream>
int main()
{
std::cout << "Enter two numbers:" << std::endl;
int v1 = 0, v2 = 0;
std::cin >> v1 >> v2;
std::cout << "The sum of " << v1 << " and " << v2
<< " is " << v1 + v2 << std::endl;
return 0;
}
程序的第一行 #include <iostream> 告诉编译器,程序要使用 iostream 库。#include 是一个预处理 preprocess 指令,<> 中则指出了一个头文件 header ,每个使用标准库设施的程序都必须包含相关的头文件。且有以下注意事项:
#include 指令和头文件名字必须写在同一行中;#include 指令必须出现在所有函数之外;- 一个程序的所有
#include 指令,一般都放在源文件的开始位置。
1.2.3 向流写入数据
main 的函数体中,第一条语句执行了一个表达式 expression ——它由一个或多个运算对象和一个或多个运算符组成。C++中,一个表达式产生一个计算结果。这条语句中的表达式使用了输出运算符 << 在标准输出上打印消息 "Enter two numbers:" 。
std::cout << "Enter two numbers:" << std::endl;
<< 运算符接受两个运算对象:左侧必须是一个 ostream 对象,右侧运算对象是要打印的值对象。<< 将给定的值写到给定的 ostream 对象中,计算结果就是其左侧运算对象,即写入给定值的那个 ostream 对象。
这一条语句使用了两次 << 运算符。由于 << 返回左侧运算对象,因此第一个 << 的结果成为了第二个 << 的左侧运算对象,第二个 << 的结果还是那个左侧运算对象。这条链中每个 << 运算符的左侧运算对象和计算结果都是同一个对象,本例中是 std::cout (当然,对象的内部状态发生了改变),这样就可以将一系列输出请求连接起来,少写几条语句。
原表达式等价于:
(std::cout << "Enter two numbers:") << std::endl;
或者等价于以下形式。其中第一个 << 打印一条消息,消息是一个字符串字面值常量 string literal ,即用一对双引号包围的字符序列。双引号间的文本被打印到标准输出。第二个 << 打印 std::endl ,这是一个称为操纵符 manipulator 的特殊值,写入它的效果是结束当前行、并将与设备关联的缓冲区 buffer 中的内容刷到设备中。缓冲刷新操作保证,到目前为止程序产生的所有输出都真正写入输出流中、而非停留在内存中等待写入流。
std::cout << "Enter two numbers:");
std::cout << std::endl;
调试时经常要添加打印语句,这类语句应该保证一直刷新流;否则如果程序崩溃,输出可能还留在缓冲区中,导致错误推断程序崩溃的位置。
1.2.4 从流读取数据
提示输入数据后,要读入用户的输入。这里定义了两个名为 v1, v2 的变量 variable 来保存输入,将它们定义为内置的 int 类型以表示整数,还将它们初始化 initialize 为0。初始化一个变量,就是在变量创建的同时为它赋予一个值。
int v1 = 0, v2 = 0;
接着读入输入的数据:
std::cin >> v1 >> v2;
输入运算符 >> 和 << 类似,接受两个运算对象:左侧运算对象必须是一个 istream 对象,右侧运算对象用来存储读入的数据。>> 从给定的 istream 对象读入数据并存入给定对象中,计算结果就是其左侧运算对象。
这一条语句使用了两次 >> 运算符。由于 >> 返回左侧运算对象,因此第一个 >> 的结果成为了第二个 >> 的左侧运算对象,第二个 >> 的结果还是那个左侧运算对象。这条链中每个 >> 运算符的左侧运算对象和计算结果都是同一个对象,本例中是 std::cin (当然,对象的内部状态发生了改变),这样就可以将一系列输入请求连接起来,少写几条语句。
因此,这一表达式等价于:
(std::cin >> v1) >> v2;
或者等价于以下形式。它们的执行结果是一样的。
std::cin >> v1;
std::cin >> v2;
1.2.5 使用标准库中的名字、命名空间的作用
不可不提的是,程序使用了 std::cin, std::cout 而非 cin, cout 。前缀 std:: 指出名字 cin, cout 定义在名为 std 的命名空间 namespace 中。命名空间能避免不经意的名字定义冲突、使用库中相同名字导致的冲突。
标准库定义的所有名字都在命名空间 std 中,只是通过命名空间使用标准库有点不便——每次使用其中的一个名字时,必须用作用域运算符 :: 来显式指出,想使用来自命名空间 std 中的名字,即写出 std:: 。
3.1节有一个更简单的访问标准库中名字的方法。
1.2.6 完成程序
最后就是打印计算结果。有意思的是,<< 的右侧运算对象可以不是相同类型的值(>> 也类似),如字符串字面值常量,或者 int 值。原因在于,标准库定义了不同版本的输入和输出运算符,以处理不同类型的运算对象。
std::cout << "The sum of " << v1 << " and " << v2
<< " is " << v1 + v2 << std::endl;
1.2节练习
练习1.3:编写程序,在标准输出上打印 Hello, World 。
练习1.4:我们的程序使用加法运算符+来将两个数相加。编写程序使用乘法运算符*,来打印两个数的积。
练习1.5:我们将所有输出操作放在一条很长的语句中。重写程序,将每个运算对象的打印操作放在一条独立的语句中。
练习1.6:解释下面程序片段是否合法:
std::cout << "The sum of " << v1;
<< " and " << v2;
<< " is " << v1 + v2 << std::endl;
答:不合法。原因在于,前两个分号使得第三个及以后的 << 运算符失去了左侧运算对象,无法将右侧对象的值输出到 ostream 对象中。应该去掉多余的分号,修改为:
std::cout << "The sum of " << v1
<< " and " << v2
<< " is " << v1 + v2 << std::endl;
1.3 注释简介
这里简单介绍C++如何处理注释 comments ——注释用于概述算法、确定变量用途、解释代码段,帮助读者理解程序。编译器会忽略注释,注释对程序的行为或性能不会有任何影响。
错误的注释比完全没有注释更糟糕,因为它会误导读者!因此当你修改代码时,不要忘记同时更新注释!
1.3.1 C++中注释的种类
C++中有两种注释,单行注释(用于半行和单行附注)和界定符对注释(用于多行解释):
- 前者以
// 开始、以换行符结束,当前行双斜线右侧的所有内容都会被编译器忽略。这种注释可以包含除换行符外的任何文本,哪怕是额外的双斜线; - 后者使用继承自C的两个界定符
/* 和 */ ,以 /* 开始、以 */ 结束,中间的所有内容都被当作注释。界定符对注释可以放置于任何允许放置制表符、空格符和换行符的地方。这种注释可以包含除 */ 外的任意内容,包括换行符,因此可以跨越程序中的多行(并不是必须的)。 当界定符对注释跨越多行时,最好显示指出其内部的程序行都属于多行注释的一部分。具体做法是,注释内的每行都以一个星号开头。
一个同时包含两种注释的程序:
#include <iostream>
int main() {
std::cout << "Enter two numbers:" << std::endl;
int v1 = 0, v2 = 0;
std::cin >> v1 >> v2;
std::cout << "The sum of " << v1 << " and " << v2
<< " is " << v1 + v2 << std::endl;
return 0;
}
注意,界定符对注释不能嵌套!因为前面说过,界定符对注释可以包含除 */ 外的任意内容,也包括一个或多个 /* ,只要不包括 */ ——这个 */ 会提前结束界定符对,导致后面的内容不被看做注释。
不能嵌套
* “不能嵌套”几个字会被认为是源码,
* 像剩余程序一样处理
*/
int main() {
return -1;
}
在调试期间可能要注释掉一些代码,由于其中可能包含界定符对注释,就可能导致注释嵌套错误。最好的方法是使用单行注释方式,注释掉代码段的每一行:
1.3节练习
练习1.7:编译一个包含不正确的嵌套注释的程序,观察编译器返回的错误信息。
练习1.8:指出下列哪些输出语句是合法的(如果有的话):
std::cout << "/*";
std::cout << "*/";
std::cout << " */;
std::cout << " /* ";
预测编译这些语句会产生什么样的结果,实际编译这些语句来验证你的答案(编写一个小程序,每次将上述一条语句作为其主体),改正每个编译错误。
1.4 控制流
前面的语句都是顺序执行的:语句块的第一句首先执行,然后是第二条语句……然而我们可以写出更加复杂的执行路径。
1.4.1 while语句
while语句反复执行一段代码,直到给定条件为假为止。其形式如下,执行过程是交替地检测 condition 条件和执行关联的语句 statement ,直到 condition 为假时停止。所谓条件 condition 就是一个或真或假的表达式,只要 condition 为真,statement 就会被执行。执行完 statement ,会再次检测 condition 。只要 condition 仍为真,statemetn 再次被执行。如此交替检测 condition 和执行 statement ,直到 condition 为假为止。
while (condition)
statement
下面用while语句编写一段程序,求 1 到 10 这十个数之和。不难发现,while语句的条件中使用了小于等于运算符 <= 来比较 val 的当前值和 10 ,只要 val <= 10 ,条件就为真,就执行while循环体。如此循环,交替检测条件、执行循环体,直至 val > 10 为止。
本例中的循环体是由两条语句组成的语句块 block ——用花括号包围的零条或多条语句的序列。语句块也是语句的一种,在任何要求使用语句的地方都可以使用语句块。本例语句块的第一条语句使用了复合赋值运算符 += ,此运算符将右侧运算对象加到左侧运算对象上,将结果保存到左侧运算对象中,本质上与一个加法结合一个赋值 assignment 相同;下一条语句使用前缀递增运算符 ++ ,递增运算符将运算对象的值增加 1 ,++val 等价于 val = val + 1 。
#include <iostream>
int main()
{
int sum = 0, val = 1;
while (val <= 10) {
sum += val;
++val;
}
std::cout << "Sum of 1 to 10 inclusive is "
<< sum << std::endl;
return 0;
}
跳出循环后,继续执行之后的语句,本例中继续执行打印输出语句,然后执行 return 语句完成 main 程序。编译并执行这个程序,它会打印出:
Sum of 1 to 10 inclusive is 55
1.4.1节练习
练习1.9:编写程序,使用 while 循环将 50 到 100 的整数相加。
练习1.10:除了 ++ 运算符将运算对象的值增加 1 之外,还有一个递减运算符 -- 实现将值减少 1 。编写程序,使用递减运算符在循环中按递减顺序打印出 10 到 0 之间的整数。
练习1.11:编写程序,提示用户输入两个整数,打印出这两个整数指定范围内的所有整数。
1.4.2 for语句
上述示例中,在循环条件中检测变量、在循环体中递增变量的模式使用非常频繁,因此C++专门定义了第二种循环语句——for语句,来简化符合这种模式的语句。每个for语句都包含两个部分:循环头和循环体。循环头控制循环体的执行次数,由三个部分组成:一个初始化语句 init-statement (只在for循环入口处执行一次)、一个循环条件 condition (循环体每次执行前都要先检查循环条件)以及一个表达式 expression (表达式在for循环体之后执行,执行后重新检测循环条件)。循环体同while循环一样,是一个语句 statement 。
重写上例程序,int val = 1 是初始化语句,定义了一个 int 型对象 val 、并赋初值为 1 ,变量 val 仅在for循环内部存在,在循环结束之后不能使用;val <= 10 是循环条件,每次先检测循环条件,为真时再执行循环体;表达式是 ++val ,在for循环体之后执行,其后for语句重新检测循环条件,仍为真就再次执行for循环体。如此循环持续这一过程,直至循环条件为假。
#include <iostream>
int main()
{
int sum = 0;
for (int val = 1; val <= 10; ++val)
sum += val;
std::cout << "Sum of 1 to 10 inclusive is "
<< sum << std::endl;
return 0;
}
上述for循环的总体执行流程如下:
- 创建变量
val ,初始化为 1 ; - 检测
val 是否小于等于 10 。若检测成功则执行 for 循环体。若失败则退出循环,继续执行for循环体之后的第一条语句; - 将
val 的值增加 1 ; - 重复第二步的条件检测,只要条件为真就继续执行剩余步骤。
1.4.2节练习
练习1.12:下面的for循环完成了什么功能?sum 的终值是多少?
int sum = 0;
for (int i = -100; i <= 100; ++i)
sum += i;
答:for循环将 -100 到 100 之间的整数相加,sum 的终值为 0 。
练习1.13:使用for循环重做1.4.1节的所有练习。
练习1.14:对比for循环和while循环,两种形式的优缺点各是什么?
练习1.15:编写程序,包含第14页“再谈编译”中讨论的常见错误,熟悉编译器生成的错误信息。
1.4.3 读取数量不定的输入数据
如果我们预先不知道要对多少个数求和,就需要不断读取数据直至没有新的输入为止。
#include <iostream>
int main()
{
int sum = 0, value = 0;
while (std::cin >> value)
sum += value;
std::cout << "Sum is: " << sum << std::endl;
return 0;
}
显然,数据读取操作是在while的循环条件中完成的。对while循环条件进行求值,就是执行表达式 std:: cin >> value ,这一表达式从标准输入读取一个数,保存在 value 中——输入运算符 >> 的左侧运算对象必须是一个 istream 对象,右侧运算对象用来存储读入的数据。此处的 >> 从给定的 istream 对象 std::cin 读入数据并存入给定对象 value 中,计算结果就是其左侧运算对象 std::cin 。 因此,此循环条件实际上检测的是 std::cin 。
特别地,当我们使用一个 istream 对象作为条件时,效果是检测流的状态。如果流是有效的,即流未遇到错误,则检测成功;遇到文件结束符 end-of-file 或一个无效输入(如要求整数,读入的值却是一个字符串),istream 对象的状态会变为无效。处于无效状态的 istream 对象会使条件变为假。
因此,本例的while循环将一直执行,直到遇到文件结束符或无效输入。一旦条件失败,while循环将结束,并执行下一条语句,打印 sum 的值和一个 std::endl 。如果输入 3 4 5 6 +文件结束符,则程序会输出 Sum is: 18 。
我们可以从键盘键入文件结束符以指出文件结束,只是不同的操作系统有不同的约定。Windows系统中是输入 Ctrl+Z ,然后按下 Enter 键;Unix系统(包括Mac OS X系统)中,输入文件结束符是用 Ctrl+D 。
再探编译:
1.4.3节练习
练习1.16:编写程序,从 cin 读取一组数,输出其和。
1.4.4 if语句
类似其他语言,C++也提供了if语句来支持条件执行。比如说写一个程序,统计在不定长度的输入中每个值连续出现了多少次——程序以 val, currVal 两个变量的定义开始,currVal 记录正在统计出现次数的那个数,val 则保存从输入读取的每个数;最外层的if语句保证输入不为空,它读取一个数值存入 currVal 中,如果读取成功,则条件求值为真,继续执行条件之后的语句块;接着定义 cnt ,用于统计每个数值连续出现的次数;然后用一个while循环反复从标准输入读取整数;while循环中是第二条if语句,它使用相等运算符 == 检测 val 是否等于 currVal ,等于则执行紧跟条件之后的语句,将 cnt 增加 1 ,表示再次看到了 currVal ,否则执行 else 之后的语句块,输出语句打印我们刚刚统计完的值和值出现的次数,赋值语句将 cnt 重置为 1 、将 currVal 重置为刚刚读入的值 val :
#include <iostream>
int main()
{
int currVal = 0, val = 0;
if (std::cin >> currVal) {
int cnt = 1;
while (std::cin >> val) {
if (val == currVal)
++cnt;
else {
std::cout << currVal << " occurs "
<< cnt << " times" << std::endl;
currVal = val;
cnt = 1;
}
}
std::cout << currVal << " occurs "
<< cnt << " times" << std::endl;
}
return 0;
}
对应的输入和输出是:
Input: 42 42 42 42 42 55 55 62 100 100 100
Output:
42 occurs 5 times
55 occurs 2 times
62 occurs 1 times
100 occurs 3 times
1.4.4节练习
练习1.17:如果输入的所有值都是相等的,本节的程序会输出什么?如果没有重复值,输出又会是怎样的?
练习1.18:编译并运行本节的程序,给它输入全都相等的值。再次运行程序,输入没有重复的值。
练习1.19:修改你为1.4.1节练习1.10所编写的程序(打印一个范围内的数),使其能处理用户输入的第一个数比第二个数小的情况。
关键概念:C++程序的缩进和格式
很大程度上,C++程序是格式自由的,何处放置花括号、缩进、注释、换行符通常不会影响程序的语义。如表示 main 函数体开始的左花括号,可以放在 main 的同一行中,或者放在下一行的起始位置,或者放在我们喜欢的其他任何位置——唯一的要求是左花括号必须是 main 形参列表后的第一个非空、非注释的字符。
我们很大程度上可以按照自己的意愿自由地设定程序的格式,但是所做的选择会影响程序的可读性,所以必须谨慎行事——把整个 main 函数写在很长的单行内,虽然是合乎语法的,但会非常难读。
C/C++正确格式的辩论是无休无止的。以DevC++菜单栏的AStyle为例,可以设置格式化选项、并依此格式化当前文件,如下所示可以看到括号风格(很多种如 Allman, Java, K&R, Stroustrup, Whitesmith, Banner, GNU, ... )、缩进风格(Spaces, Tabs, Force Tab, Force Tab X )、Tab宽度、最大行数(搞错了吧,应该是列数)、缩进以下类型的代码、格式化命令(Artistic Style 3.1 是一个免费、快速、小型、自动化的格式化工具,用于C/C++, C++/CLI, Objective-C, C#, Java): 作者认为,不存在唯一正确的风格,但保持一致性是非常重要的……其他可能的程序格式总是存在的,当你要选择一种格式风格时,思考一下它会对程序的可读性和易理解性有什么影响,而一旦选择了一种风格,就要坚持使用。
1.5 类简介
为了解决书店程序,需要了解的唯一一个C++特性:如何定义一个数据结构 data structure ,以表示销售数据。事实上,C++中我们通过定义一个类 class 来定义自己的数据结构——一个类定义了一个新的类型和与其关联的一组操作,这一新类型的类型名就是类名。类机制是C++最重要的特性之一,也是C++最初的设计焦点——能定义在使用上像内置类型一样自然的类类型 class type 。
使用类需要了解三件事情:类名是什么?类是在哪里定义的?类支持什么操作?和使用标准库设施一样,使用自定义的类也要包含相关的头文件,来访问为自己的应用程序所定义的类。注意:头文件习惯上按照其中定义的类的名字来命名,且通常使用 .h 作为头文件的后缀(有些程序员习惯 .H, .hpp, .hxx 等)。标准库头文件通常不带后缀。不过,编译器一般不关心头文件名的形式,只是有的IDE对此有特定要求。
本节编写一个简单的类,用于书店程序,类名为 Sales_item ,在头文件 Sales_item.h 中定义。后续章节中学习了更多关于类型、表达式、语句和函数的知识后,才会真正实现这个类。
1.5.1 Sales_item 类
Sales_item 类的作用是表示一本书的总销售额、售出册数和平均售价。由于类名就是类型名,Sales_item 类定义了一个名为 Sales_item 的类型。与内在类型一样,我们可以定义类类型的变量,以下语句表示 item 是一个 Sales_item 类型的对象,或称为一个 Sales_item 对象。
Sales_item item;
为了使用一个类,我们不必关心它是如何实现的,只需知道类对象可以执行什么操作。当前,我们所知的可以在 Sales_item 对象上执行的全部操作,就是列出的这些操作:
- 调用一个名为
isbn 的函数从一个 Sales_item 对象中提取ISBN书号; - 用输入运算符
>> 和输出运算符 << 读、写 Sales_item 类型的对象; - 用赋值运算符
= 将一个 Sales_item 对象的值赋给另一个 Sales_item 对象; - 用加法运算符
+ 将两个 Sales_item 对象相加。注意,两个对象必须表示同一本书即相同的ISBN(对调用者的要求),加法结果是一个新的 Sales_item 对象,其ISBN与两个运算对象相同,其总销售额和售出册数则是两个运算对象的对应值之和。 - 使用复合运算符
+= 将一个 Sales_item 对象加到另一个 Sales_item 对象上。
要牢记的是,类 Sales_item 的作者定义了类类型对象上可以执行的所有动作,即 Sales_item 类定义了创建一个 Sales_item 对象时会发生什么事情,以及对 Sales_item 进行赋值、加法或输入输出运算时会发生什么事情。
读写 Sales_item
下面的程序从标准输入读入数据,存入一个 Sales_item 对象中,然后将 Sales_item 的内容写到标准输出。注意,该程序在包含来自标准库的头文件时,使用 <> 包围头文件名,对不属于标准库的头文件,则用双引号 "" 包围:
#include <iostream>
#include "Sales_item.h"
int main()
{
Sales_item book;
std::cin >> book;
std::cout << book << std::endl;
return 0;
}
该程序的输入和输出如下所示。输入告诉我们以每本24.99美元的价格售出了4册书,输出告诉我们总售出册数为4、总销售额为99.96美元、平均售价为24.99美元:
Input:
0-201-70353-X 4 24.99
Output:
0-201-70353-X 4 99.96 24.99
Sales_item 对象的加法
下面的程序从标准输入读入数据,存入两个 Sales_item 对象之中,输出表达式完成加法运算并打印结果。这一程序与1.2.2 使用IO库的程序非常相似,只是把运算对象从两个整数换成了两个 Sales_item 而已,读取和打印和的运算方式没有发生任何变化。当然,由于运算符重载,“和”的概念完全不同——我们对 int 对象用加法运算符 + ,计算的是传统意义上的算术加法和;对 Sales_item 用加法运算符 + ,得到的是两个 Sales_item 对象的成员对应相加的结果。
#include <iostream>
#include "Sales_item.h"
int main()
{
Sales_item item1, item2;
std::cin >> item1 >> item2;
std::cout << item1 + item2 << std::endl;
return 0;
}
该程序的输入和输出如下所示。
Input:
0-201-78345-X 3 20.00
0-201-78345-X 2 25.00
Output:
0-201-78345-X 5 110 22
使用文件重定向
测试程序时,反复从键盘敲入这些销售记录作为程序的输入,非常乏味费时。幸好多数操作系统支持文件重定向机制,允许我们将标准输入和标准输出与命名文件关联起来:
$ addItems < infile > outfile
假设 $ 是操作系统提示符,加法程序已经编译为 addItems.exe 的可执行文件(在Unix中是 addItems ),则上述命令从一个名为 infile 的文件读取销售记录,并将输出结果写入到一个名为 outfile 的文件中,两个文件都位于当前目录中。
除此以外,使用文件重定向机制,可以进一步优化对拍技巧。
1.5.1节练习
练习1.20:在网站http://www.informit.com/title/0321714113上,第一章的代码目录中包含了头文件 Sales_item.h 。将它拷贝到你自己的工作目录中,用它编写一个程序,读取一组书籍销售记录,将每条记录打印到标准输出中。
练习1.21:编写程序,读取两个ISBN相同的 Sales_item 对象,输出它们的和。
练习1.22:编写程序,读取多个具有相同ISBN的销售记录,输出所有记录的和。
1.5.2 初识成员函数(item.isbn() )
将两个 Sales_item 对象相加的程序,首先应检查两个对象是否具有相同的ISBN。如果相等则程序打印计算结果,并返回0表示成功;如果条件失败则打印错误消息,并返回-1表示错误标识。
#include <iostream>
#include "Sales_item.h"
int main()
{
Sales_item item1, item2;
std::cin >> item1 >> item2;
if (item1.isbn() == item2.isbn()) {
std::cout << item1 + item2 << std::endl;
return 0;
} else {
std::cerr << "Data must refer to same ISBN" << std::endl;
return -1;
}
}
需要注意的是if语句的检测条件,调用了名为 isbn() 的成员函数 member function ,成员函数是定义为类的一部分的函数,有时也称为方法 method 。我们常以一个类对象的名义来调用成员函数,如 item1.isbn() 中使用点运算符 . 表达需要“名为 item1 的对象的 isbn 成员”。
要注意的是,点运算符只能用于类类型的对象,其左侧运算对象必须是一个类类型的对象,右侧运算对象必须是该类型的一个成员名,运算结果为右侧运算对象指定的成员。
1.5.2节练习
练习1.23:编写程序,读取多条销售记录,并统计每个ISBN(每本书)有几条销售记录。
练习1.24:输入表示多个ISBN的多条销售记录来测试上一个程序,每个ISBN的记录应该聚在一起。
1.6 书店程序
现在万全具备,我们已经准备好完成书店程序了。先从一个文件中读取销售记录,生成每本书的销售报告,显示售出册数、总销售额和平均售价。此处假定每个ISBN书号的所有销售记录在文件中是聚在一起保存的。
程序将每个ISBN的所有数据合并起来,存入名为 total 的变量中,同时使用另一个名为 trans 的变量保存读取的每条销售记录。如果 total 和 trans 指向相同的ISBN,我们更新 total 的值;否则打印 total 的值,并重置为刚刚读取的数据 trans 。如果在第一条if语句就读取失败,则意味着没有任何销售记录,于是直接跳到最外层的else分支,打印一条警告消息,告诉用户没有输入:
#include <iostream>
#include "Sales_item.h"
int main()
{
Sales_item total;
if (std::cin >> total) {
Sales_item trans;
while (std::cin >> trans) {
if (total.isbn() == trans.isbn())
total += trans;
else {
std::cout << total << std::endl;
total = trans;
}
}
std::cout << total << std::endl;
} else {
std::cerr << "No data?!" << std::endl;
return -1;
}
return 0;
}
1.6节练习
练习1.25 借助网站上的 Sales_item.h 头文件,编译并运行本节给出的书店程序。
|