换个姿势学C语言
0. 说明
《换个姿势学C语言》由何旭辉 著,清华大学出版社2022年出版。感谢何老师!
这是一本非常不错的书!
1. 开始之前
1.1 为什么很多人学不会编程
学习程序设计给很多人留下了痛苦的记忆,人们往往在兴致勃勃地学习了一段时间后遇到挫折,就由此认定自己“基础太差,不适合学编程”,从而选择了“从入门到放弃”。
程序设计是一种实践能力。很多人学习编程的方法和习惯是错误的。错误的观念会对初学者学习编程造成严重的障碍。要克服这个困难,就必须换一种方式来学习程序设计,真正掌握程序设计的特点和学习规律。这也是本书命名为《换个姿势学C语言》的原因。
1.2 基于应用的学习方法
学习的目的是为了应用,应用的目的是为了解决实际问题。
学习程序设计的目的是为了开发出可以实际应用的软件,而不是会做很多练习题。幼儿能在不识字、不懂语法的情况下快速掌握母语听说能力,就是因为他们的学习是基于应用目的的。
很多人陷入“我需要先读完很多书才能学以致用”的误区,这种学习方式有两个弊端:
- 学习后面的忘了前面的;
- 即使都学完了,也没有能力将零散的知识点连接起来加以运用。而应用时碰壁又会极大地打击积极性从而使自己无法坚持下去。
因此,“学以致用,学了立即用”是最有效而且能最鼓舞人心的学习方法。边干边学,边学边干才会锻炼实战能力。
1.2.1 基于应用的学习方法包括下列步骤
- 理解要解决的应用问题;
- 思考和设计解决问题的方案并找出自己目前不能解决的问题(知识盲区);
- 针对不能解决的问题开展学习(读书或查资料);
- 用代码实现应用目标;
- 分析、排除程序的故障;
- 思考和寻找更好的解决方案,并对代码进行优化和迭代。
《换个姿势学C语言》就是按照这种思路来组织案例和实践项目的。除了基础的语言知识外,你还会看到笔者是如何思考问题并设计解决方案的。笔者在书中实现了“外汇牌价看板”程序。
1.3 明确程序的设计目标
理解要解决的问题是解决问题的第1步。在普通人看来外汇牌价看板是一个很简单的程序,但作为它的设计者和实现者则需要了解更多相关的业务知识以及用户的需求,这项工作被称为【需求分析】,需求分析是否成功决定了软件开发的成败。
需求分析是一项需要理解业务需求并和用户反复沟通才能完成的工作。很多程序员认为只要掌握最先进的编程技术就可以了,不愿意与用户沟通,回避需求分析的环节或者敷衍了事;或者总是在第1次与用户交流时就在思考用什么语言、框架或数据库来完成系统,盲目地相信自己能做出用户满意的软件。但这种情形下做出的软件系统往往不能满足用户的需求,再加上有些不负责任的销售人员给客户承诺了一个根本不可能实现的工期,导致程序设计人员在多方压力下很快进入设计和开发阶段,最终“成功”地做出了一个客户不接受的软件系统。导致在交付项目以后反复加班修改, 甚至项目失败。
另一个方面,需求分析不准确的责任也不全在软件工程师身上。因为很多时候客户自己也不清楚想要什么,这听上去很荒谬但事实经常如此,很多客户只有在看到产品后才能更精确地知道自己要什么,因此总是在开发基本完成时提出一些意见,这在软件开发领域有一个词叫做【需求变更】,所有人(除了客户)都很痛恨它却又无法避免。【需求变更】是一种非常正常的现象,所以在制订项目计划时要将需求变更考虑进去;同时还要采用一些手段尽可能深入地与客户反复确认他们的需求。
从开发者的角度进行需求变更不一定是坏事,因为很多客户是愿意为需求变更付钱并增加额外的工期。但如果事先没有对客户需求进行详细的调研和反复确认,也没有任何文档来说明客户需求,当需求变更时将无法证明客户需求和以前不一样,就只能为自己犯下的错误买单---在工期和经费不增加的情况下加班加点满足客户的新需求。
1.3.1 了解业务知识
- 外国货币在大多数国家是不可以在本国市场上流通的。如果人们手里有外币想在本国花掉,需要去银行把它兑换成本国货币,这个过程称为【结汇】。反过来,如果人们出国需求携带外币,就需要去银行办理【购汇】,用手中的本国货币兑换外币。
- 结汇和购汇业务一般是在银行进行的,所以在银行的大厅里一般都会有一个显示着各种外汇兑换比率(汇率)的显示屏。我们要实现的就是在计算机上运行的外汇牌价看板程序。
- 中国境内的银行结汇和购汇时采用的汇率是各家银行参考每天上午9点15分中国外汇交易中心公布的【在岸人民币汇率】,结合本行的具体情况确定的。
- 中国外汇交易中心确定【在岸人民币汇率】的方法是,所有做市商报价,然后去掉最大最小值,对剩余报价求均值得到当日人民币兑美元的汇率中间价。由于美元是国际货币,结合当天上午9时国际外汇市场美元兑换其他外币的价格,就可以套算出其他国家的货币和人民币的汇率。所以每个工作日上午9时15分左右,中国外汇交易中心就会公布当天人民币兑各种外币的汇率中间价,也就是【在岸人民币汇率】。
- 银行会根据【在岸人民币汇率】在许可的范围内上下浮动,来公布当天的外汇牌价。银行的兑换价格被显示在银行大厅的牌子上,所以人们称它为【外汇牌价】。不同银行的外汇牌价可能不同。
以下是中行和招商银行人民币兑美元的牌价:
银行 | 交易币 | 交易币单位 | 现汇买入价 | 现钞买入价 | 现汇卖出价 | 现钞卖出价 |
---|---|---|---|---|---|---|
中行 | 美元 | 100 | 677.72 | 672.21 | 680.60 | 680.60 |
招行 | 美元 | 100 | 674.47 | 669.06 | 678.87 | 678.87 |
- 买入和卖出:外汇牌价上的买入和卖出,是从银行的角度来定义的。人们用100美元兑换人民币,在银行看来是【买入】外汇,而人们用人民币兑换美元在银行看来则是【卖出】外币。无论现汇还是现钞,卖出的价格一般高于买入的价格。(因为银行也要赚钱!!)
- 现钞和现汇:在银行看来,同样是100美元,钞和汇是不同的概念。【钞】是指现金,是实物,银行要为它付出保管和运输的成本 ;而【汇】则不同,它只需在银行的计算机交易系统内记录就可以了,银行因此付出的成本要低一些。例如,在2020年10月6日13时18分用100美元现钞在中国银行可以兑换672.21元人民币,此时如果你在海外的朋友或客户向你的外币账户汇款的100美元,银行会记作“美元现汇”,你把它兑换成人民币会得到677.72元,会存在差额。
1.3.2 通过需求会议确定软件功能要求
在学习了外币牌价的基础业务知识以后,开发人员就可以与客户沟通具体的产品需求了。沟通的内容包括:
- 了解、理解甚至修改(优化)客户原有的业务流程;
- 与客户一起讨论他们提出的需求合理性、可行性和可能的解决方案;
- 与客户一起讨论是否还有未考虑到的合理需求(需求挖据);
- 用文档、图表来描述客户需求并和客户确认。
这个过程往往很漫长,所有的客户都会认为他们需要的软件功能很简单。一方面是因为他们精通自己业务领域的知识所以自然觉得简单:另一方面是他们不了解软件开发需要关心更多的细节问题。他们会说:“这个很简单的,把我行外汇牌价显示出来就可以了。”但实际上软件工程师要考虑下面的问题:
- 要显示哪些外币的汇率?
- 汇率数据从哪里获取?要求多长时间更新一次?
- 用于显示汇率数据的计算机和网络怎么连接?
- 用什么类型的显示器来显示外汇数据?
- 如果是专用显示器,它支持什么样的显示方式和分辨率?接口是怎样的?
- 如果是通用显示器(比如计算机屏幕、液晶电视机等),显示器分辨率是多少?是否要求自动适应不同的分辨率?
- 显示器的尺寸会有多大?安装在什么地方?
- 对显示汇率的字体、字号和颜色有要求吗?
- 是否要用不同的颜色表示买入、卖出价格?
- 是否要在货币名称前面加上货币发行国家(地区)的国旗(行政区区旗)图片?
- 汇率变动的历史记录是否要保存?保存多久?
作为新手,提不出这些问题是正常的,这需要经验的积累。当软件设计人员向客户提出这些问题时,有些他们能够立刻回答你,而有些问题他们则要“问下领导”;也有一些不太负责任的客户代表会含糊地说出一个意见并试图让你“先做出来看看”。这些不确定的回答会带来成本增加、工期延长等后果,也可能会导致最终交付的软件与客户需要大相径庭。如果没有提前预料到这些风险并且采取有效的方法来控制,最后几平一定要通过加班来确保交货。
为了控制这种专业能力和责任心因素带来的不确定风险,召开需求分析会议并形成会议纪要是必要的。将需求讨论的结果用标准的会议纪要格式记录下来,在会后及时要求与会代表签字确认(有时也通过电子邮件确认),这种“凡事有据可查”的工作方式会让每一个队友都明确自己的职责,提高他们的责任心和需求分析的有效性,确保项目的顺利交付。
软件开发不是一个纯粹的技术工作,而是需要与人打交道的工作。软件工程师要关注和关心客户的感受,而不是只站在自己的角度,用自己的好恶去处理问题。需求调研也不是把客户的需求简单复述一遍就算是完成了,而是要主动学习客户的业务流程甚至商业模式。有经验的工程师能想到客户前面去,想到客户心里去,也可以提前感知将来可能出现的需求变更,减少项目需求变更的风险。
1.3.3 编写需求规格说明书
在经历了多次客户访谈和会议后,软件工程师可以基本明确客户对于软件系统的需求了,此时可以开始编写“需求规格说明书”。需求规格说明书有时候也被称作“需求文档”。编写需求规格说明书是为了使客户和软件开发者双方对该软件的范围、功能和性能要求有一个共同的理解和约定,它是整个开发工作的基础和契约。需求规格说明书应包含硬件、功能、性能、输入输出、接口需求、警示信息、保密安全、数据与数据库、文档和法规的要求等。
需求规格说明书的读者是客户或产品经理、项目经理、系统设计师、开发人员、测试人员、交互设计师、运营以及所有与项目相关的角色。在项目开发开始之前,必须让需求规格说明书通过评审和确认。几乎所有初级程序员都反感与客户交流和编写文档,他们认为“Talk is cheap,show me the code(谈话是廉价的,代码才有意义)”面针对大型软件项目或者功能比较复杂的系统,规范地编写各种文档是必不可少的工作。对于软件工程师个人发展来说,编写文档的能力也是至关重要的。
1.3.4 设计原型系统
软件工程师通过与客户的多次沟通,确定了要开发的“外汇牌价看板”的具体需求。然而,仅仅使用文字和语言来描述客户对软件的功能需求是不够的,因为同样的方案不同的人有不同的理解,并且文字也无法直观地描述软件的视觉和交互效果。
基于这样的原因,为了使客户、软件工程师能更直观地对软件功能、外观和交互效果进行确认,设计人员会采用原型系统的方式来制作一个软件的“模型”,在正式开始系统设计与编码之前就让客户和软件工程师看到软件最终运行的样子。它类似于各种工程建设中的效果图,在开工之前就让人们看到最终建成的效果。
原型图设计不是一蹴而就,一般是产品经理先在纸张上绘制初稿(线框图),然后再使用专门的原型设计工具实现视觉效果或向客户展示交互效果。常用的原型设计工具包含Photoshop、Axure等。
即使是用于确认软件界面设计的原型图,也要尽量采用真实的数据。胡乱编造的数据放在界面上固然能减少设计时的工作量,但只有最大程度接近软件最终运行时的设计稿才会引起客户深入讨论软件需求的兴趣并提出有价值的意见。
在软件的各个界面原型图设计和通过评审后,很多初学者认为可以直接开始写代码了,但很快会发现因为“没有思路”而进行不下去。这是因为对系统实现缺乏总体的思考,对要做的事有哪些,怎么做,以及做这些事步骤和完成标准都不够明确。
因此,即使是初学者也要先进行“概要设计”工作,它有助于开发者找到开发系统的思路并有条不紊(wěn)地开展工作。概要设计并不需要太多编程经验,因为【设计】和【实现】是两回事。举个例子,如果你要设计办公室装修方案,并不需要具备砌墙的技能。
1.4 找到程序设计的思路
在明确了要开发的软件功能后就要思考如何实现它。有经验的工程师此时会考虑诸如系统架构(包括软件和硬件)、功能模块、内部与外部接口、数据库、系统安全等问题,然后针对这些问题进行概要设计并编写概要设计说明书(概要设计就是概括地说明系统应该如何实现)。初学者因为经验匮乏很难完成全面的概要设计,但下面的工作是必不可少的,也不难做到:
- 划分功能模块;
- 确定程序运行的硬件环境;
- 确定使用C/S结构还是B/S结构;
- 选择程序设计语言。
1.4.1 划分功能模块
- 在概要设计阶段首先就是对需求分析进行归纳和总结,再把系统划分为若干个功能模块。
- 如果系统比较大,还应划分为几个子系统。随后还要确立模块之间、子系统之间的接口和通信方式与执行机制。
- 划分功能模块的思路是【自顶至下,逐步求精,分而治之】。 这种方式可以将复杂问题分解成一个个独立的子问题,对每个子问题再进一步分解,直到问题简单到可以很容易解决。
- 在此阶段不需要写代码, 而是使用自然语言来描述和分解问题。
以外汇牌价看板程序为例,先用一句话来总结它的功能:
实现显示实时外汇牌价的程序。
接下来对这句话进行细化和分解:
为了实现显示实时外汇牌价,先要获取最新的外汇牌价,然后才能将它们显示出来。
即使没有任何编程经验,也不难写出上面的语句,将其中包含动词的句子简化后就是一个功能模块的名称。例如【获取外汇牌价】和【显示外汇牌价】。可以用一个结构图表示系统的功能模块。
如下图描述了外汇牌价看板的二级功能。
根据前面的界面原型设计,还可以将【显示外汇牌价】这个功能分解成4个子功能,如下图所示:
上图的功能还可以进行细分,例如,【显示固定的界面内容】可以分为【显示程序标题(外汇牌价看板)】【显示程序副标题(Exchange Rate)】。
尽可能地细分每一个功能模块,这样就能搞清楚程序中需要完成的工作。即使这些功能你目前不知道该如何实现,但至少知道自己要解决哪些问题了。
- 同一个软件系统的功能可以选择不同的技术来实现,接下来要确定技术方案。
对于外汇看板项目而言,技术方案的主要内容包括:
- 确定程序运行的硬件环境;
- 确定程序结构;
- 选择程序设计语言。
1.4.2 确定程序运行的硬件环境
笔者选择使用普通的x86/x64架构的台式计算机或笔记本式计算机来开发和运行外汇牌价看板。
外汇牌价看板系统运行时需要将汇率数据显示在大厅里。目前人们经常见到的显示方式包括如下三种:
- 使用LED数码管的专用显示屏。
- LED大屏幕。
- 液晶显示屏(或大屏幕电视机)。液晶显示器是很常见的显示设备,笔者选择使用液晶显示器作为外汇牌价看板的显示设备。
1.4.3 选择程序架构
- 程序是分成多种类型的。我们要决定外汇牌价看板采用何种类型。
- 根据程序运行机制的不同,可以将应用程序分为如下3种:
- 单机程序。
- 客户机/服务器程序,对应C/S架构--Client/Server 客户机/服务器。
- 浏览器/服务器程序,对应B/S架构--Browser/Server 浏览器/服务器。
C/S与B/S架构应用程序对比:
程序架构 | 优点 | 缺点 |
---|---|---|
客户机/服务器(Client/Server) | 本地处理能力强;服务器负载较小 | 维护成本较高(需要安装和更新客户端软件) |
浏览器/服务器(Browser/Server) | 维护和升级方式简单;服务器负载较大 | 不能完全发挥客户端硬件的计算能力;各种浏览器所支持的标准在细节上不一致,带来浏览器兼容性问题 |
因为外汇牌价看板需要实时从一个统一的位置获取最新的外汇牌价数据,因此可以确定外汇牌价看板不是一个独立运行的单机程序。
确定外汇牌价看板不是单机程序后,再确定它应该采用C/S架构还是B/S架构,由于本书是一本讲述C语言的教程,而C语言并不适用于开发一般的B/S架构程序,因此本书选择C/S架构。
下图是外汇牌价看板的系统架构:
1.4.4 选择程序设计语言
确定了一个系统运行的硬件环境和程序架构之后,接下来将要选择编程语言,这也是初学者首先要面对的问题。
在很多时候,程序员们会争论各种语言的优劣,但实际上每一种语言都有其适用和不适用的场景。例如用C语言开发前面提到的B/S架构程序就不太适合(开发效率低),而在一些对性能要求比较高的场景,使用C语言是为数不多的选择之一。
如果外汇牌价看板是一个正式的商业项目,思维正常的软件工程师都不会选择使用C语言。因为相比其他语言而言,C语言的开发效率是较低的。从学习的角度,学习C语言也是最让初学者感到痛苦的。但本书依然选择使用C语言入门 ,主要考虑以下因素:
学习C语言会被迫学习计算机原理。相对于Java、Python这些更高级的语言,学习C语言要麻烦一些,它没有现成的、强大和丰富的功能库。很多功能都需要自己动手编码实现。在这个过程中,读者将被迫学习计算机运行的底层知识,同时训练自己的编程思维,这对未来的学习和工作无疑是有益的。如果只是学了一些高级的语言和流行的框架,不了解计算机和程序的本质,也没有经历过较底层编程思维的训练,解决复杂问题的能力也会受到限制。不要怕麻烦,越怕麻烦的人麻烦越大。
学会C语言,就学会了一切。可以这样说——在熟悉C/C++的程序员看来,所有的编程语言都是一样的。到目前为止,C语言的基础语法广泛应用于主流的编程语言中。综上所述,学习C语言对专业程序员而言非常有必要,更何况一些大型公司在招聘程序员时往往重点考察程序员的C语言水平,并以此判断程序员的基本功底。
2. 准备开发环境
在开发者选择了一种编程语言后,接下来就要在计算机上安装用以编写、调试程序的软件,这个过程称为"准备开发环境"。
不同的编程语言需要安装的开发环境不同,即使是同一种语言,也可以选择不同的开发环境。但不同的开发环境会导致在编程时产生细节的差异,这往往也是让新手感到困惑和麻烦的地方。
开发外汇牌价看板,使用Visual Studio 2022版。
2.1 软件开发工具的组成和用途
对于程序员来说,最基本的开发工具应该包括:
- 源代码编辑器。就是在一个文本编辑器里输入程序。如记事本、vim、notepad++等。
- 编译器。在源代码输入完成后,需要把它们转换成机器可以识别的代码,此时就需要编译器。编译器是把源程序转换为目标代码的工具。将源代码转换为目标代码的过程包括预处理、编译、汇编和链接等。有些编译器还会对程序进行优化以提高运行效率。
- 调试器。程序可以正常通过编译意味着这个程序没有明显的格式和语法错误,但并意味着这个程序在功能和逻辑上一定正确。当错误发生时,有些错误通过阅读代码就可以被轻易定位,复杂的程序则需要使用调试工具跟踪代码的执行情况才能被找到。在开发过程中最常见的做法是让程序在某个步骤【暂停】或逐步执行代码,通过观察每一条语句运行的过程和结果、变量和表达式的值来找出程序的问题。所以,程序员需要调试器来调试程序,调试程序是程序员最重要的技能之一。
- 版本管理系统。用版本管理系统来管理程序源代码。
2.2 安装集成开发环境
直接通过微软官方下载安装包实在是太慢了,可以参考 朱玛 大佬的帖子 【首次升级 64 位 IDE】Visual Studio 2022 完整版离线安装包下载 来下载离线安装包。
由于微软从 Visual Studio 2017 版本开始仅提供在线安装程序,不再提供传统的完整ISO镜像,因此本次分享的完整版 ISO 离线安装包为本人自制原版镜像,使用包含全部功能的企业版本制作可脱机安装。 下载链接 腾讯微云:http://share.weiyun.com/MDjuAMN2 或 百度网盘:http://pan.baidu.com/s/1gzJ6_sl-ePpOiwDKYh2KPw
文件校验 文件名:cn_visual_studio_enterprise_2022_version_17.0_x64.iso 文件大小:24.79GiB (26,626,865,152 字节) MD5:141DAF97D0BDA60C8E01F7C02F488940 SHA1:36302DCCB783B232CFAA1B82AF87F1D8DE205727 SHA256:561F22CAB0FAE563CA759A6B113822A794E2592FDE9E11C9707F7FBA61B87CDE CRC32:CDD6390A CRC64:E223917AE8FC0CF6
- 集成开发环境( Integrated Development Environment , IDE),可以简单地将其理解为是一个包含了开发人员需要的大多数功能的软件套装,有利于快速编写、编译和调试程序。
- 集成开发环境通常包括代码编辑器、编译器和调试器。
- 管理规范的公司不允许开发人员使用盗版软件,这是选择IDE的第1个要素。
- 软件公司里通常是由很多程序员在一起协同工作的,同一个团队使用不同的IDE可能会带来兼容性问题,因此软件公司一般会选用同一个版本的IDE并且对升级非常谨慎。
警告
注意,安装VS时,在【工作负载】界面选择【使用C++的桌面开发】。并在【单个组件】选项卡选择以下两个选项:
- 【适用于最新v143生成工具的C++ATL(x86和x64)】
- 【适用于最新v143生成工具的C++MFC(x86和x64)】
由于上面两个组件存在多个平台版本,所以此处一定要谨慎、认真地确认自己选择了正确的选项,否则会在后面遇到错误。
2.3 编写和运行第1个C语言程序
- 解决方案:大型应用系统往往需要拆分成若干个独立的项目(Project),每一个独立的项目又包含一个或多个源程序文件 ;将多个项目组织到一起就称为解决方案(Solution)。解决方案类似于一个容器。
例如本书有多个案例,每一个案例都是一个单独的项目。笔者将它们组织到一个名为Examples的解决方案中,以便管理它们。所以,即使只编写一个练习程序,也需要遵循以下步骤:
- 创建解决方案;
- 在解决方案中添加项目;
- 在C项目中添加源程序。
2.3.1 规划项目目录结构
- 使用层次分明的目录结构、有意义的程序会使未来的工作井井有条,而胡乱指定一个项目名和存储目录会使未来的工作变得混乱。这是初学者常见的问题。
在编写本书时,笔者使用D:\BC101
目录存放所有的案例程序和资源文件。资源文件包括图片文件和库文件等。该目录下包含以下4个子目录:
- Data目录,该目录用于存储程序运行产生的数据文件,未来从网络读入的外汇牌价数据将以文件形式存入其中。
- Examples目录,所有将要创建的解决方案、项目文件和源程序文件都将保存在此目录中。
- Libraries目录,未来要使用的第三方库的相关文件将存入此目录中。
- Resources目录,未来要使用的资源文件(如国旗、行政区旗的图片文件将存入此目录)。
我们也在自己电脑上创建这几个目录:
2.3.2 创建解决方案和项目
创建空白解决方案:
- 启动VS,选择【创建新项目】:
- 选择【空白解决方案】,并单击【下一步】:
- 为解决方案指定名称和位置,指定解决方案名称为“Examples”:
单击【创建】后,会创建解决方案:
此时可以看到目前解决方案Examples中的项目数量为0。
在解决方案中增加C++项目:
按照下面的步骤可以在解决方案中增加一个新的C++项目。
- Step 1: 在【解决方案'Examples'(0个项目)】节点上单击鼠标右键(下方称“右击”),在弹出的快捷菜单中选择【添加】-【新建项目】命令:
- Step 2: 在弹出的【添加新项目】窗口,Visual Studio提供了很多C++的项目模板和向导,但本案例还是从零开始创建一个项目,这里选择【空项目】,然后单击【下一步】按钮:
- Step 3:在弹出的【配置新项目】窗口输入项目名称。这里指定项目名称是【L02_01_HELLOWORLD】,表示第2课的第1个程序,程序名为HELLOWORLD。
注意,在【位置】输入框里面,末尾的L02是手工输入的。VS会自动在Examples目录下创建L02目录。未来我们会把所有第2课的范例程序都存入这个目录里。VS还会自动在这个目录下再创建一个子目录L02_01_HELLOWORLD用于存储本次创建的项目。
- Step 4:单击【创建】按钮完成新项目的创建。
此时会在D:\BC101\Examples\L02\L02_01_HELLOWORLD 目录下创建相关文件 ,L02_01_HELLOWORLD.vcxproj是C++项目的项目文件。
2.3.3 在空白项目中增加和运行程序
创建项目后,在VS的【解决方案资源管理器】窗口中可以看到新建的项目L02_01_HELLOWORLD。由于我们选择创建了“空项目”,因此项目中不包含任何源程序文件。接下来需要创建一个源程序文件并在其中输入代码。步骤如下:
- Step 1:在【解决方案资源管理器】窗口选择项目【L02_01_HELLOWORLD】,并右击【源文件】选项,在弹出的快捷菜单中选择【添加】-【新建项】:
出现【添加新项】对话框时,选择【C++文件(.cpp)】选项,并输入文件名【HelloWorld.cpp】,然后单击【添加】按钮:
- Step 2: 此时VS自动创建好HelloWorld.cpp源文件,并打开了该文件,我们只有在文件中输入源代码即可:
输入代码:
#include <stdio.h>
int sayHello()
{
printf("Hello,World\n");
return 0;
}
int main()
{
sayHello();
return 0;
}
这就是第1个C语言程序:
2.3.4 运行程序
单击工具栏的【本地Windows调试器】按钮,则可以运行程序程序:
可以看到程序正常输出了【Hello,World】。
Visual Studio 在运行程序前会创建D:\BC101\Examples\x64\Debug\L02_01_HELLOWORLD.exe
可执行文件,它就是编译器生成的可执行文件。
2.4 使用MSC编译器
2.4.1 为何使用cpp文件
在创建源文件时为何选择C++文件(.cpp)?
- 一方面是因为现在很难找到一个好用且只支持C语言的编译器,况且C++的编译器也可以编译符合C语言标准的程序。目前使用的Visual Studio 2022默认使用微软提供的MSC编译器,它既可以编译C语言的程序,也可以编译C++程序。当程序文件扩展名是
.c
时,编译器按照C语言的标准编译程序;当程序文件扩展名是.cpp
时,编译器按照C++的标准编译程序。这是因为C++可以兼容绝大部分C语言语法,因此保存为cpp
文件的C程序也能被编译。Visual Studio 2022 在创建源程序文件时的默认扩展名是.cpp
,为了避免每次都要修改扩展名,本例选择了【C++文件(.cpp)】。 - 另一方面,本例后期要用到的EasyX图形库仅支持以C++方式进行编译和链接,为确认前后一致性,选择了【C++文件(.cpp)】。
2.4.2 设置Visual Studio中的C++项目属性
Visual Studio使用的是微软提供的C++编译器,它可以编译以C语言格式编写的程序,但这个编译器的默认设置会使一些比较旧的教材上的程序不能被编译和运行,从而给初学者带来绪多麻烦,例如不允许使用scanf
函数。
如果需要输入和运行一些比较旧的教材上的程序,则需要在创建项目后进行一些特别设置。
- 关闭SDL检查。Visual Studio 2022默认开启了安全开发生命周期检查(Security Development Lifecycle, SDL),这个选项强迫程序员使用更安全的方式编程,即通过更严格的检测来确保程序的安全性。可以在项目属性页--【C/C++】选项--【常规】-【SDL检查】,选择【否(/sdl-)】:
- 关闭安全检查。在项目属性页--【C/C++】选项--【代码生成】-【安全检查】,选择【禁用安全检查 (/GS-)】:
注意:本例关闭这些安全检查是为了可以使用Visual Studio编写与大多数C语言教材兼容的代码并进行一些实验。如果使用Visual Studio 进行正式的软件开发,则建议遵循Visual Studio的默认设置。
3. 分析第1个程序
现在,Visual Studio 2022版已经安装完成,也编译和运行了第1个程序,但高质量的学习需要关注每一行代码,同时理解为何要这样写,还要了解C语言程序的组织结构,并且掌握常见的术语,这样才有助于提高后续的学习效率并少犯错。
3.1 程序由多个相互调用的功能(function)组成
- 每一种编程语言的设计者都规定了这种编程语言的基本代码结构。
- C语言是一种模块化开发语言,换句话说就是可以把程序的功能分解成不同的模块,模块之间可以相互调用,进而组成一个完整的程序。
- 将程序划分为不同的功能模块会带来诸多好处,可以使每一个功能模块都聚焦于一个功能的实现,从而减少了相互之间的干扰,使得程序的设计、分工、阅读代码和故障排查变得更容易。
- C语言中最基本的功能模块被称为函数。
- 在C语言里,规定每个程序必须包含一个名为
main
的函数,程序启动时自动执行其中的代码。因此main
函数常被当作【程序的入口】。
3.2 定义和调用函数的方法
3.2.1 函数从哪里来
在程序设计中函数的来源主要包括:
- 程序员自己创建的函数,供自己和他人调用。作为程序员,其主要的工作就是编写各种函数的代码,例如之前写的
main
函数和sayHello
函数。一个系统所用的全部函数不一定都是由一个人完成的,大多数情况下是在函数原型设计好后由不同的程序员分别完成,最后再“组装”到一起。 - 大多数语言都提供了标准函数库,可以用标准函数库中的函数实现常用的功能。编译器厂商大都提供了一套函数库,函数库里包含一些常用的功能函数,程序员们可以直接调用这些函数以提高编程效率,例如
printf
函数,这些函数被称为【库函数】。 - 一些厂商也公开自己的函数库,供开发者调用,这种函数被称为【第三方库】。例如C语言标准中没有提供图形函数,如果要进行2D、3D图形编程,就可能需要使用一些第三方图形库来进行图形编程,例如OpenGL、Direct3D等。
3.2.2 定义和调用函数
- 在实际编程时,程序员可以在程序中加入新函数以实现特定的功能,这被称为【定义函数】。
- 定义函数最简单的方法就是直接在源程序中输入函数代码。
- 在C程序中定义函数时,函数代码分为两部分:函数头(function header)和函数体(function body)。下图描述了函数的基本结构。
3.2.2.1 函数头
函数头是指函数代码的第1行,它一般包括三个元素:
- 函数名称。函数必须要一个在程序中唯一且便于识别的名称,在别处调用它时就要使用这个名称。一般来说,函数的名称就是它的功能描述。
- 函数的返回值类型。一段功能代码在执行完成后,需要向调用者报告函数的执行结果(是否执行成功?没有成功的原因是什么?...),函数报告的执行结果被称为【返回值】。在定义函数时,需要首先确定函数是否需要返回值和需要返回何种类型的值。这些是由函数的作者根据实际需要确定的。我们将
sayHello
函数的返回值类型设定为整型(整数)。 - 函数的参数。运行函数时可能还需要调用者提供一些信息才可以执行。调用者可以通过函数的参数向函数传递信息。函数的参数被写在函数名后的括号里。
sayHello
函数在函数头的函数名后只有一个括号,说明这个函数没有参数。
3.2.2.2 函数体
所谓函数体就是实现功能的代码。函数体以左大括号
{
开始,以右大括号}
结束。函数体中的代码负责实现具体的功能。在编写一个函数时,切记不要在一个函数中实现很复杂的功能。如果一个函数需要做很多复杂的工作,这个函数将变得难以编写和调试。如果有一个很复杂的程序需要编写,可以将它分解成更多的函数,并尽力使一个函数只完成一个任务。一般来说一个函数内部的代码超过20行,就要考虑将其分解成多个函数了。
3.2.2.3 return语句
在sayHello
和main
函数里都有一行return
语句,它的作用就是【返回值】。
- 一段功能代码执行后,无论成功或失败都需要向调用者报告函数执行的结果,这种报告被称为函数的【返回值】。
return
语句的作用就是返回函数执行后的值。 - 约定俗成,一个功能型函数如果返回0,就表示它正常执行完毕,否则就返回其他值,每个数值代表不同的失败原因,由函数的设计者规定。
sayHello
函数的最后一行固定地返回0,是因为printf
语句几乎绝对可以执行成功。 - 作为程序的入口,
main
函数是被操作系统调用的,它执行完毕后也要向操作系统执行执行结果。同样地,如果main
函数如果返回0则表示该程序正常结束;如果返回的值不是0,则是告诉操作系统该程序出现了错误。习惯上返回的数字越大,代表错误越严重。 - 还有一些函数的返回值不是简单的整型数值,如计算圆的面积的函数和获取实时汇率的函数的返回值会包含小数。
- 在有些程序中,
return
语句不是写的函数的末尾,也可能有不止一条return
语句,当程序执行到return
语句时就会终止函数的运行并返回。
3.2.2.4 调用函数
函数定义好以后就可以调用它。在调用函数时,只须在调用处写上函数的名字和参数值即可。
如我们第一个程序第10行代码sayHello();
就是调用sayHello
函数。
需要注意的是:
如果调用的函数没有参数,函数名后也要写上一对括号,这是大多数语言的规定。如果调用的函数有参数,在调用时必须按照该函数定义的次序把要传入的参数写在括号中。
如果调用的函数有返回值,在需要时可以将它的返回值赋值给其他变量;如果不需要它的返回值,也可以忽略返回值。
sayHello
函数是一个最简单的函数,没有参数也无须取得返回值。
3.2.3 调用标准库函数
printf
函数是一个标准库函数。
- 标准库函数不是一套全世界统一的代码,而是由不同的开发厂商根据C语言的规范在不同的软硬件平台下开发的、功能与调用方法基本一致的函数库。
Visual Studio 2022对应的部分函数库位于以下目录C:\Program Files (x86)\Windows Kits\10\Lib\10.0.19041.0\ucrt\x64
:
注意,上图中EasyXa.lib
和EasyXw.lib
是我安装EasyX第三方库安装的。
Visual Studio提供的标准函数库文件都是被编译过的,无法看到它的源代码。
要在自己的程序里使用标准库函数,必须在程序开始时引用对应的【头文件】。
3.2.3.1 头文件和#include指令
在前面的程序里,第1行代码是#include <stdio.h>
其中,include
是包含、包括的意思,#include
指令的作用是在编译程序前找到某个文件。
- 因此通常在程序的起始处(头部)加入对这类文件的引用语句,所以它们被称为【头文件】。头文件通常存储在
C:\Program Files (x86)\Windows Kits\10\Include\10.0.19041.0\ucrt
目录下。
- stdio是standard input/output的缩写,
stdio.h
是最常用的头文件。C89规定了15个头文件,每个头文件对应了不同领域的编程功能。例如头文件math.h
中主要是与数学计算相关的函数,头文件string.h
中主要包括与字符串处理相关的函数。C11标准规定了29个头文件,支持更多的库函数。每个库函数对应的头文件名在标准库参数中会有详细的解决,不用刻意记忆。
Visual Studio怎么知道到哪里去寻找标准库的头文件的?
在Visual Studio创建项目时,自动指定了搜索头文件的位置,可以项目属性的【VC++目录】中找到【包含目录】的配置,一般来说不需要修改它。
为什么有些程序里头文件的两端是引号,而有些头文件的两端是尖括号呢?
是的,我们可能经常看到这样的例子:
c#include <stdio.h> #include "myfile.h"
在C语言中,
#include
指令后面的文件名两端是引号还是尖括号,决定了C预处理程序到哪里去查找这个文件。如果文件名两端是尖括号,如#include <stdio.h>
,则预处理程序会在项目设置的【包含目录】下去寻找这个头文件,也就是我们上面说的头文件通常存储在C:\Program Files (x86)\Windows Kits\10\Include\10.0.19041.0\ucrt
目录。如果文件名两端是引号,则会首先在源程序所在的目录下查找这个文件。如果没有找到,就会再到包含目录下面去找。
3.2.3.2 printf函数的基本使用
参考之前的方法在解决方案中新增C++项目【L03_01_PRINTF】,注意项目保存位置是D:\BC101\Examples\L03
,并创建源文件main.cpp
,其内容如下:
#include <stdio.h>
int main()
{
int a = 255;
int b = 255;
printf("a is:%d\nb is:%x\n", a, b);
return 0;
}
由于我们在解决方案中加入了两个项目,直接运行的话,还是会运行之前项目的代码。要解决这个问题,可以按如下方法操作:
- 在【解决方案资源管理器】窗口中右击【L03_01_PRINTF】项目,然后在弹出的快捷菜单中选择:
- 【设为启动项目】,将当前项目设置为启动项目后,每次单击【本地Windows调试器】后,都会启动该项目。这种的话,相当于改变解决方案的默认启动项目。
- 【调试】--【启动新实例】,这种的话,不会改变默认启动项目,只是一次性启动。
我使用第一种方式。
运行程序:
3.3 源程序如何变成可执行文件
除了让Visual Studio自动完成编译,程序员也可以用手工操作的方式逐步编译源代码。接下来我们将使用命令行逐步处理源程序,将其编译成可执行文件。
在操作之前,请先请第2课的源程序文件HelloWorld.cpp
复制到计算机某个磁盘的某个目录下,例如D:\BC101\Examples\build
目录。
我用的系统是Windows 11,上面用的PowerShell命令行工具。
切换目录,并查看帮助信息:
执行以下三个命令:
cd D:\BC101\Examples\build
cl /? > cl.help.txt
link /? > link.help.txt
3.3.1 预处理
预处理不是将源代码生成二进制文件,而是将源程序文件进行初步处理。例如将头文件中的内容与HelloWorld.cpp
按顺序合并到一起以及处理各种编译条件等。
执行cl
命令时使用/EP
参数即可对源程序进行预处理,执行以下命令:
cl /EP HelloWorld.cpp > HelloWorld_p.cpp
参数说明:
- /EP 选项禁止编译。
PS D:\BC101\Examples\build> cl /EP HelloWorld.cpp > HelloWorld_p.cpp
用于 x86 的 Microsoft (R) C/C++ 优化编译器 19.30.30705 版
版权所有(C) Microsoft Corporation。保留所有权利。
HelloWorld.cpp
此时,会在当前目录生成HelloWorld_p.cpp
文件。该文件比源文件大很多。
对比两个文件内容:
可以看到,最后的几行代码保留了,前面增加了很多其他的内容。
3.3.2 编译
接下来,可以将预处理后的程序文件HelloWorld_p.cpp
编译成【目标文件】,这个过程是先依据程序文件HelloWorld_p.cpp
生成汇编语言代码,再将其生成机器码。执行以下命令:
cl /FAs /c HelloWorld_p.cpp
参数说明:
- /FA[scu] 配置程序集列表。s是可选的, 表示将源代码包括在此列表中。
- /c 只编译,不链接。
PS D:\BC101\Examples\build> cl /FAs /c HelloWorld_p.cpp
用于 x86 的 Microsoft (R) C/C++ 优化编译器 19.30.30705 版
版权所有(C) Microsoft Corporation。保留所有权利。
HelloWorld_p.cpp
PS D:\BC101\Examples\build> ls
目录: D:\BC101\Examples\build
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a---- 2024/3/13 23:27 17160 cl.help.txt
-a---- 2024/3/13 23:07 137 HelloWorld.cpp
-a---- 2024/3/13 23:47 3253 HelloWorld_p.asm
-a---- 2024/3/13 23:32 319996 HelloWorld_p.cpp
-a---- 2024/3/13 23:47 1624 HelloWorld_p.obj
-a---- 2024/3/13 23:28 6384 link.help.txt
PS D:\BC101\Examples\build>
可以看到,目录中多出了HelloWorld_p.asm
和HelloWorld_p.obj
文件。
HelloWorld_p.asm
是程序转换成汇编语言程序以后的代码,可以用编辑器打开查看。HelloWorld_p.obj
是根据汇编语言程序生成的二进制的代码(目标文件)。
3.3.3 链接
HelloWorld_p.obj
是根据由源代码生成的二进制的代码,但目前它还不能运行,原因是在程序中使用了标准库函数printf
,而printf
函数的二进制代码并没有包含在这个HelloWorld_p.obj
文件中。
将函数库的二进制代码与目标文件合并到一起,生成1个可执行文件,被称为【链接】,有的也称为【连接】,链接使用link
命令,在命令行继续执行以下命令:
link HelloWorld_p.obj
执行:
PS D:\BC101\Examples\build> link HelloWorld_p.obj
Microsoft (R) Incremental Linker Version 14.30.30705.0
Copyright (C) Microsoft Corporation. All rights reserved.
PS D:\BC101\Examples\build> ls
目录: D:\BC101\Examples\build
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a---- 2024/3/13 23:27 17160 cl.help.txt
-a---- 2024/3/13 23:07 137 HelloWorld.cpp
-a---- 2024/3/13 23:47 3253 HelloWorld_p.asm
-a---- 2024/3/13 23:32 319996 HelloWorld_p.cpp
-a---- 2024/3/13 23:55 101888 HelloWorld_p.exe
-a---- 2024/3/13 23:47 1624 HelloWorld_p.obj
-a---- 2024/3/13 23:28 6384 link.help.txt
PS D:\BC101\Examples\build>
此时,可以看到生成了HelloWorld_p.exe
文件。
在命令行执行:
PS D:\BC101\Examples\build> .\HelloWorld_p
Hello,World
可以看到正常输出结果。
对比之前用Visual Studio自动生成的可执行文件,以及我们自己编译生成的文件,可以看到两者大小不一样:
3.4 重复地sayHello
- 亲自动手实践最能帮助我们理解晦涩的定义和概念。
以下来重复地sayHello。
方案1,使用复制粘贴重复调用sayHello函数:
#include <stdio.h>
int sayHello()
{
printf("Hello,World\n");
return 0;
}
int main()
{
sayHello();
sayHello();
sayHello();
sayHello();
sayHello();
return 0;
}
可以看到,使用了复制粘贴的方式调用了5次sayHello
函数。
运行结果:
3.4.1 for循环
为了不这样,这时就可使用循环,这个时候就可以使用for
循环。
方案2,使用for循环来重复输出:
#include <stdio.h>
int sayHello()
{
printf("Hello,World\n");
return 0;
}
int main()
{
for (int i = 0; i < 5; i++) {
sayHello();
}
return 0;
}
此时可以得到相同的结果。
3.4.2 让用户决定重复次数
如果我想要通过用户输入一个数字来确认输出几次sayHello则可以这样:
// 以下代码禁用scanf不安全提示
#define _CRT_SECURE_NO_WARNINGS 1
// 以下代码用于关闭 "返回值被忽略 scanf"警告
#pragma warning(disable : 6031)
#include <stdio.h>
int sayHello()
{
printf("Hello,World\n");
return 0;
}
int sayHelloManyTimes(int time)
{
for (int i = 0; i < time; i++) {
sayHello();
}
return time;
}
int main()
{
printf("请输入一个整数:");
int time = 0;
scanf("%d", &time);
sayHelloManyTimes(time);
return 0;
}
运行后,如果我输入数字7,则会输出7次"Hello,World":
3.4.3 如何规范地给函数命名
- 函数的名称一般表示这个函数的用途,应该是【见文知意】的。
- 在实际开发中使用的函数名可以写得很长以明确表明它的含义,例如上一节的
sayHelloManyTimes
。 - 让程序清晰可读是重要的,不要担心函数的名称过长会影响程序的执行效率(实际上并没有什么影响)。
- 函数的命名还需要遵循一些特定的规则。
下表示例是一些非法的函数名。
序号 | 错误的函数名 | 违反的规则 |
---|---|---|
1 | 2timesSayHello | 不能以数字开始 |
2 | sayHello* | 不能包含星号* |
3 | sayHello+ | 不能包含运算符 |
4 | say.Hello.ManyTimes | 不可以使用点号. |
5 | say-Hello | 不能包含减号- |
6 | say'Hello | 不能包含引号 |
当函数名中有多个单词时,函数名全部使用大写字母或者小写字母会增加阅读难度,有很多不同的习惯用法来区别它们。如驼峰命名法是指函数名的第一个字母小写,其后每一个单词的首字母大写,如sayHelloManyTimes
。还有一种是下画线命名法,是所有字母都采用小写,但单词之间用下画线分开,如say_hello_many_times
。实际编程中使用哪一种命名法取决于开发团队的规定,在本书中使用驼峰命名法。
3.4.4 函数的声明和定义的区别
在3.4.2节点,如果不做别的变更,则sayHelloManyTimes
函数必须在sayHello
函数之后并在main
函数之前。
这是为什么呢?
- 因为编译器是编译程序时是从前到后逐行进行的。
我们测试一下,将main
函数向前移动,移动到sayHello
函数前面:
// 以下代码禁用scanf不安全提示
#define _CRT_SECURE_NO_WARNINGS 1
// 以下代码用于关闭 "返回值被忽略 scanf"警告
#pragma warning(disable : 6031)
#include <stdio.h>
int main()
{
printf("请输入一个整数:");
int time = 0;
scanf("%d", &time);
sayHelloManyTimes(time);
return 0;
}
int sayHello()
{
printf("Hello,World\n");
return 0;
}
int sayHelloManyTimes(int time)
{
for (int i = 0; i < time; i++) {
sayHello();
}
return time;
}
然后再次运行代码,提示异常:
提示异常,错误代码C3861
,错误说明“sayHelloManyTimes”: 找不到标识符
,即找不到标识符。
原因是编译到12行时编译器还不能识别sayHelloManyTimes
函数。
难道未来所有的程序都必须要按函数出现的次序写吗?不是的,答案是,先【声明】函数。
什么是声明?譬如在开会时,领导说“我先说一下,这个外汇牌价看板的模块交给新来的小白做。”即使其他与会者不认识、没见过小白,也知道小白是个新人,不会再问“小白是谁”这样的问题。
因此,在引用函数前,我们可以先声明函数。修改代码:
// 以下代码禁用scanf不安全提示
#define _CRT_SECURE_NO_WARNINGS 1
// 以下代码用于关闭 "返回值被忽略 scanf"警告
#pragma warning(disable : 6031)
#include <stdio.h>
// 函数声明
int sayHello();
int sayHelloManyTimes(int time);
int main()
{
printf("请输入一个整数:");
int time = 0;
scanf("%d", &time);
sayHelloManyTimes(time);
return 0;
}
int sayHello()
{
printf("Hello,World\n");
return 0;
}
int sayHelloManyTimes(int time)
{
for (int i = 0; i < time; i++) {
sayHello();
}
return time;
}
其中,第7-9行是新增的,第8-9行代码就是函数的声明。
可以看到,所谓函数的声明就是函数头(函数返回值类型、函数名和函数参数)加上分号,但不包含实现功能的函数代码。有了函数声明编译器就“认识”了这两个函数,程序就可以正常编译。
- 函数定义是指对函数功能的确定,包括指定函数名、参数个数、参数类型以及实现函数功能的代码。它是一个完整的、独立的函数单位。
- 函数声明是把函数的名字、参数个数、参数类型通知编译系统,以便在调用函数时系统按此进行对照检查(例如函数名是否正常,参数类型和个数是否一致)。
- 在书写形式上,函数声明包括函数返回值类型、函数名、用括号包含的函数参数列表,以及一个分号。
- 程序中做了函数声明,就可以不用严格按照调用次序来编写函数代码。
main
函数不需要声明。
3.4.5 注释
- 在前面的程序中,除了C语言代码还有以
//
开始的说明文字。这种以//
开始的文字都被称为【注释】。 - 注释的作用在于解释代码的作用,以增强程序的可读性。
- 如果需要的注释的内容有多行,可以写成以
/*
开始,以*/
结束的形式。 - 注释的内容不会参考编译,也不会出现在目标代码中。
下面的代码是对sayHelloManyTimes
函数增加的一些注释:
/*
函数: sayhellomanytimes
用途: 重复地在屏幕上输出hello,world
参数: int times, 用于说明输出的次数
返回值: int, 返回输出的次数
*/
int sayHelloManyTimes(int time)
{
for (int i = 0; i < time; i++) {
sayHello(); // 调用sayHello函数
}
return time;
}
Redis源码 https://github.com/redis/redis/blob/unstable/src/cluster.c 中就使用以/*
开始,以*/
结束的形式注释。
/*
* Copyright (c) 2009-Present, Redis Ltd.
* All rights reserved.
*
* Licensed under your choice of the Redis Source Available License 2.0
* (RSALv2) or the Server Side Public License v1 (SSPLv1).
*
* Portions of this file are available under BSD3 terms; see REDISCONTRIBUTIONS for more information.
*/
/*
* cluster.c contains the common parts of a clustering
* implementation, the parts that are shared between
* any implementation of Redis clustering.
*/
#include "server.h"
#include "cluster.h"
#include <ctype.h>
/* -----------------------------------------------------------------------------
* Key space handling
* -------------------------------------------------------------------------- */
/* If it can be inferred that the given glob-style pattern, as implemented in
* stringmatchlen() in util.c, only can match keys belonging to a single slot,
* that slot is returned. Otherwise -1 is returned. */
...以下省略
关于注释的事情,可以展开细说。此处先忽略。
可参考: