2.13 — 如何设计你的第一个程序

既然你已经了解了程序的一些基础知识,让我们更深入地探讨一下如何设计一个程序。

当你坐下来编写程序时,通常你会有一些想法,并想为此编写一个程序。新程序员常常难以将这些想法转化为实际代码。但事实证明,你已经具备了许多解决问题所需的技能,这些技能是从日常生活中获得的。

最重要(也是最难做到)的一点是,在开始编写代码之前先设计你的程序。在很多方面,编程就像建筑。如果你试图在没有建筑设计图的情况下建造一座房子,会发生什么?很有可能,除非你非常有天赋,否则你最终会得到一栋问题重重的房子:墙壁不直、屋顶漏水等等。同样地,如果你在没有一个好的行动计划之前就开始编程,你很可能会发现你的代码问题重重,你将不得不花费大量时间来修复那些本来可以通过提前思考完全避免的问题。

一点点前期规划就能为你节省时间和精力,从长远来看。

在本课程中,我们将阐述一种将想法转化为简单功能性程序的通用方法。

设计步骤 1:明确你的目标

为了编写一个成功的程序,你首先需要明确你的目标是什么。理想情况下,你应该能用一两句话来陈述。通常将其表达为面向用户的结果会很有用。例如:

  • 允许用户组织姓名和相关电话号码列表。
  • 生成随机地牢,产生有趣洞穴。
  • 为高股息股票生成推荐列表。
  • 模拟一个从塔上掉落的球击中地面所需的时间。

虽然这一步看似显而易见,但它也至关重要。最糟糕的事情莫过于编写一个实际上并非你(或你的老板)想要的程序!

设计步骤 2:定义需求

定义问题有助于你确定你想要什么结果,但它仍然是模糊的。下一步是考虑需求。

需求是一个花哨的词,既指你的解决方案需要遵守的限制(例如,预算、时间线、空间、内存等),也指程序为了满足用户需求必须具备的能力。请注意,你的需求也应该侧重于“是什么”,而不是“如何”。

例如

  • 电话号码应保存,以便以后可以调用。
  • 随机地牢应始终包含从入口到出口的路径。
  • 股票推荐应利用历史价格数据。
  • 用户应该能够输入塔的高度。
  • 我们需要在 7 天内提供一个可测试的版本。
  • 程序应在用户提交请求后 10 秒内产生结果。
  • 程序在用户会话中崩溃的概率应低于 0.1%。

一个问题可能会产生许多需求,只有当所有需求都满足时,解决方案才算“完成”。

设计步骤 3:定义你的工具、目标和备用计划

当你成为一名经验丰富的程序员时,通常会在此时进行许多其他步骤,包括:

  • 定义你的程序将在哪种目标架构和/或操作系统上运行。
  • 确定你将使用哪一套工具。
  • 确定你是独自编写程序还是作为团队的一员。
  • 定义你的测试/反馈/发布策略。
  • 确定你将如何备份你的代码。

然而,作为一名新程序员,这些问题的答案通常很简单:你正在为自己使用,独自,在自己的系统上,使用你下载的 IDE 编写程序,而且你的代码可能除了你之外没人使用。这让事情变得简单。

话虽如此,如果你要处理任何非平凡的复杂性,你应该有一个备份代码的计划。仅仅将你的源代码目录压缩或复制到同一存储设备上的另一个位置是不够的——如果你的存储设备损坏或损坏,你将失去一切。复制或压缩到可移动存储设备(例如闪存驱动器)会更好,尽管在发生盗窃、火灾或重大自然灾害时,你仍然有丢失一切的风险。

最好的备份策略是将代码副本保存到位于不同物理位置的机器上。有很多简单的方法可以做到这一点:将其压缩并通过电子邮件发送给自己,上传到云存储服务(例如 Dropbox),使用文件传输协议(例如 SFTP)将其上传到你控制的服务器,或者使用位于另一台机器或云中的版本控制系统(例如 github)。版本控制系统不仅能够恢复你的文件,还能将其回滚到以前的版本,这具有额外的优势。

设计步骤 4:将难题分解为简单问题

在现实生活中,我们经常需要执行非常复杂的任务。弄清楚如何完成这些任务可能非常具有挑战性。在这种情况下,我们通常会使用自顶向下的问题解决方法。也就是说,我们不是解决一个单一的复杂任务,而是将该任务分解为多个子任务,每个子任务单独解决起来都更容易。如果这些子任务仍然太难解决,可以进一步分解。通过不断地将复杂任务分解为更简单的任务,你最终可以达到一个点,即每个单独的任务都易于管理,甚至微不足道。

让我们来看一个例子。假设我们想打扫房子。我们的任务层级目前是这样的:

  • 打扫房子

打扫整个房子是一项相当大的任务,无法一次性完成,所以我们将其分解成子任务:

  • 打扫房子
    • 吸尘地毯
    • 清洁浴室
    • 清洁厨房

这更容易管理了,因为我们现在有了可以单独关注的子任务。但是,我们还可以进一步分解其中一些任务:

  • 打扫房子
    • 吸尘地毯
    • 清洁浴室
      • 刷马桶(脏死了!)
      • 洗水槽
    • 清洁厨房
      • 清理台面
      • 清洁台面
      • 刷水槽
      • 倒垃圾

现在我们有了一个任务层级,其中没有一个任务特别难。通过完成这些相对容易管理的子项,我们可以完成打扫房子这个更困难的总体任务。

创建任务层级的另一种方法是采用**自底向上**的方式。在这种方法中,我们将从一组简单的任务开始,并通过分组来构建层级。

举个例子,许多人平日里都要上班或上学,所以我们假设我们要解决“去上班”的问题。如果你被问到早上从床上起来去上班都做了哪些事,你可能会列出以下清单:

  • 挑选衣服
  • 穿衣服
  • 吃早饭
  • 上班路上
  • 刷牙
  • 起床
  • 准备早餐
  • 骑自行车
  • 洗澡

使用自底向上的方法,我们可以通过寻找将相似项目分组的方式,将这些项目组织成一个层级结构:

  • 从床上到工作
    • 卧室用品
      • 关闭闹钟
      • 起床
      • 挑选衣服
    • 浴室用品
      • 洗澡
      • 穿衣服
      • 刷牙
    • 早餐用品
      • 煮咖啡或茶
      • 吃麦片
    • 交通工具
      • 骑自行车
      • 上班路上

事实证明,这些任务层级在编程中非常有用,因为一旦你有了任务层级,你就基本定义了整个程序的结构。顶层任务(本例中是“打扫房子”或“去上班”)成为 main()(因为它是你要解决的主要问题)。子项成为程序中的函数。

如果某个项目(函数)实现起来太困难,只需将该项目分解为多个子项目/子函数。最终,你应该会达到程序中每个函数都易于实现的程度。

设计步骤 5:弄清楚事件序列

现在你的程序有了结构,是时候确定如何将所有任务连接起来了。第一步是确定将要执行的事件序列。例如,早上起床时,你按什么顺序完成上述任务?它可能看起来像这样:

  • 卧室用品
  • 浴室用品
  • 早餐用品
  • 交通工具

如果我们要编写一个计算器,我们可能会按这个顺序执行操作:

  • 从用户获取第一个数字
  • 从用户获取数学运算
  • 从用户获取第二个数字
  • 计算结果
  • 打印结果

此时,我们已准备好进行实现。

实现步骤 1:规划你的主函数

现在我们准备开始实现。上面的序列可以用来规划你的主程序。暂时不用担心输入和输出。

int main()
{
//    doBedroomThings();
//    doBathroomThings();
//    doBreakfastThings();
//    doTransportationThings();

    return 0;
}

或者在计算器的情况下:

int main()
{
    // Get first number from user
//    getUserInput();

    // Get mathematical operation from user
//    getMathematicalOperation();

    // Get second number from user
//    getUserInput();

    // Calculate result
//    calculateResult();

    // Print result
//    printResult();

    return 0;
}

请注意,如果您将使用这种“大纲”方法来构建程序,您的函数将无法编译,因为定义尚不存在。在准备好实现函数定义之前,注释掉函数调用是一种解决此问题的方法(我们在此处将展示这种方法)。或者,您可以“存根”函数(创建带有空函数体的占位函数),以便您的程序能够编译。

实现步骤 2:实现每个函数

在此步骤中,对于每个函数,您将完成三件事:

  1. 定义函数原型(输入和输出)
  2. 编写函数
  3. 测试函数

如果你的函数足够细化,那么每个函数都应该相当简单明了。如果某个函数仍然显得过于复杂,也许它需要分解成更容易实现的子函数(或者你可能做错了顺序,需要重新审视你的事件序列)。

让我们从计算器示例中取出第一个函数:

#include <iostream>

// Full implementation of the getUserInput function
int getUserInput()
{
    std::cout << "Enter an integer: ";
    int input{};
    std::cin >> input;

    return input;
}

int main()
{
    // Get first number from user
    int value{ getUserInput() }; // Note we've included code here to test the return value!
    std::cout << value << '\n'; // debug code to ensure getUserInput() is working, we'll remove this later

    // Get mathematical operation from user
//    getMathematicalOperation();

    // Get second number from user
//    getUserInput();

    // Calculate result
//    calculateResult();

    // Print result
//    printResult();

    return 0;
}

首先,我们确定 `getUserInput` 函数不接受任何参数,并将一个整数值返回给调用者。这反映在函数原型中,其返回类型为 `int` 且没有参数。接下来,我们编写了函数体,这是一个简单的 4 条语句。最后,我们在 `main` 函数中实现了一些临时代码,以测试 `getUserInput` 函数(包括其返回值)是否正常工作。

我们可以使用不同的输入值多次运行此程序,并确保程序在此阶段按预期运行。如果我们发现有问题的地方,我们就知道问题出在我们刚刚编写的代码中。

一旦我们确信程序运行正常,我们就可以删除临时测试代码,然后继续实现下一个函数(函数 `getMathematicalOperation`)。我们不会在本课程中完成该程序,因为我们首先需要涵盖一些其他主题。

记住:不要一次性实现整个程序。分步进行,在继续之前测试每一步。

相关内容

我们在课程 9.1 — 代码测试入门 中详细介绍了测试。

实现步骤 3:最终测试

一旦您的程序“完成”,最后一步是测试整个程序并确保它按预期工作。如果它不工作,请修复它。

编写程序时的忠告

**从简单程序开始。** 新程序员通常对他们的程序想要做的一切都有一个宏伟的愿景。“我想写一个带有图形、声音、随机怪物和地牢,并有一个可以访问以出售在地牢中找到的物品的城镇的角色扮演游戏”。如果你一开始就尝试编写过于复杂的程序,你将感到不知所措,并因缺乏进展而气馁。相反,将你的第一个目标设为尽可能简单,一个你肯定能实现的目标。例如,“我希望能够在屏幕上显示一个二维场。”

**随着时间推移添加功能。** 一旦你的简单程序运行良好,你就可以开始为其添加功能。例如,一旦你能显示你的场,就添加一个可以四处走动的角色。一旦你能四处走动,就添加可以阻碍你前进的墙壁。一旦有了墙壁,就用它们建造一个简单的城镇。一旦有了城镇,就添加商人。通过逐步添加每个功能,你的程序将逐渐变得更复杂,而不会让你在此过程中感到不知所措。

**一次只关注一个领域。** 不要试图一次性编写所有代码,也不要将注意力分散到多个任务上。一次只专注于一个任务。有一个可工作的任务和五个尚未开始的任务,这比有六个部分可工作的任务要好得多。如果你的注意力分散,你更有可能犯错误和忘记重要细节。

**边写边测试代码的每一部分。** 新程序员常常会一次性编写整个程序。然后当他们第一次编译时,编译器会报告数百个错误。这不仅令人望而生畏,如果你的代码不工作,可能很难找出原因。相反,编写一段代码,然后立即编译和测试它。如果它不工作,你就会确切地知道问题出在哪里,并且很容易修复。一旦你确定代码工作正常,就转到下一段并重复。这可能需要更长的时间来完成代码编写,但当你完成时,整个程序应该都能工作,你就不必花费双倍的时间来弄清楚它为什么不工作。

**不要急于完善早期代码。** 一个功能(或程序)的初稿很少是完美的。此外,程序往往会随着时间的推移而演变,因为您会添加功能并找到更好的组织方式。如果您过早地投入到代码的打磨中(添加大量文档、完全符合最佳实践、进行优化),当代码需要更改时,您就有可能失去所有这些投入。相反,让您的功能 minimally 工作,然后继续前进。随着您对解决方案的信心增加,逐渐添加更多的打磨层。不要追求完美——非平凡的程序永远不会完美,总有更多可以改进的地方。达到“足够好”即可,然后继续前进。

**优先考虑可维护性,而非性能。** 有一句名言(出自唐纳德·克努特):“过早优化是万恶之源”。新程序员往往花费过多时间思考如何对代码进行微优化(例如,试图找出两段语句中哪一个更快)。这很少重要。大多数性能提升来自于良好的程序结构、针对手头问题使用正确的工具和能力,以及遵循最佳实践。额外的时间应该用于提高代码的可维护性。找出冗余并消除它。将长函数拆分为更短的函数。用更好的代码替换笨拙或难以使用的代码。最终结果将是代码更容易在以后改进和优化(在你确定确实需要优化的地方之后),并且bug更少。我们在课程 3.10 — 在问题发生之前发现问题 中提供了一些额外的建议。

一个能运作的复杂系统,必然是从一个能运作的简单系统演变而来。

——约翰·盖尔,《系统学:系统如何真正运作以及它们如何失败》第 71 页

总结

许多新程序员会省略设计过程(因为它看起来工作量很大,或者不如编写代码有趣)。然而,对于任何非平凡的项目,遵循这些步骤从长远来看将为你节省大量时间。一点点的前期规划可以节省大量的后期调试时间。

关键见解

花一点时间提前思考如何构建你的程序,将带来更好的代码,并减少查找和修复错误所需的时间。

我想说这可以说是编程中最重要的事情,而我们中的一些人,比如我一开始,都把它视为理所当然。

——读者 Emeka Daniel,评论 learncpp.com

当你对这些概念和技巧越来越熟悉时,它们会自然而然地出现。最终你会达到这样的程度:即使没有太多预先计划,也能编写出完整的函数(和简短的程序)。

guest
您的电子邮箱地址将不会被显示
发现错误?请在上方留言!
与勘误相关的评论在处理后将被删除,以帮助减少混乱。感谢您帮助使网站对每个人都更好!
来自 https://gravatar.com/ 的头像与您提供的电子邮箱地址相关联。
有回复时通知我:  
507 评论
最新
最早 最多投票
内联反馈
查看所有评论