软件构造

AI WARNING

未进行对照审核。

软件构造概述

什么是软件构造?

定义与核心活动

软件构造是以编程为核心的活动,但其内涵远不止于编写代码本身。它是一个旨在生产出可工作的、有意义的软件的详细创建过程。

SWEBOK 定义

根据 SWEBOK (Software Engineering Body of Knowledge),软件构造是通过编码、验证、单元测试、集成测试和调试等工作的结合,生产可工作的、有意义的软件的详细创建过程

McConnell 在其著作中也强调,软件构造除了核心的编程任务外,还系统地整合了其他关键活动,包括:

  • 详细设计(例如,数据结构与算法设计)
  • 单元测试
  • 集成与集成测试
  • 调试
  • 代码评审

这些活动共同构成了软件构造的核心内容,确保了从设计蓝图到实际可用软件的有效转化。

软件构造与软件实现

传统观念中,「软件实现」常常被简单地等同于编程。然而,软件构造是一个更宽泛的概念。重要的是要区分活动阶段。将「构造」视为一种贯穿于软件开发过程中的活动组合,而不是一个孤立的、界限分明的阶段,有助于更深刻地理解软件开发的复杂性。

软件构造是设计的延续

即便在形式上的软件设计阶段结束后,软件构造阶段的编程工作本质上仍是整个设计工作的深化与延续。

  • 设计与实现的界限:Reeves 精辟地指出,设计是规划软件构建方案的过程,而实现则是依据该规划来建造真正产品的过程
  • 源程序的角色:源代码本身是软件构建方案的最终详细规划,它并非产品本身。真正的软件产品是那些在计算机上运行的、由二进制代码组成的可执行程序。
  • 编程作为设计活动:因此,编写源代码的生产过程——即编程——实质上是一种设计活动。后续由编译器完成的编译和链接操作,才是依据这份「设计规划」来实际构建软件产品的实现活动。

软件设计阶段通常规划了系统的总体结构和关键模块的细节(如模块、类、接口、方法等),但一般不会深入到每一行源代码的层面。软件构造阶段的设计工作,则是在更低的代码层级上展开,它将高层设计规划进一步细化和具体化,直至形成最终的源代码。

因此,编程绝非简单的机械式代码翻译,其核心在于精心设计代码逻辑、结构和交互,并持续验证这些设计的效果。忽视编程过程的复杂性和重要性,往往是导致软件质量低下的根源。

软件构造的核心活动

软件构造过程包含一系列紧密相关的核心活动,它们共同确保软件的质量和功能的正确实现。这些主要活动包括:详细设计、编程、测试、调试、代码评审、集成与构建,以及构造管理。

详细设计

在软件构造阶段,详细设计工作依然扮演着重要角色,即便在高层设计阶段已经完成了大部分设计蓝图。

  • 对于采用敏捷方法(如极限编程 XP)的项目,设计阶段可能仅进行初步或简单的设计,而将主要的详细设计工作通过重构的方式融入到软件构造阶段来完成。
  • 对于遵循传统方法的项目,即使在设计阶段已经制定了非常细致的设计方案,构造阶段仍可能需要根据实际情况进行调整,例如微调接口定义、确定方法参数的具体细节等。
  • 调整的不可避免性:编程语言本身就是软件设计的一个重要约束。在编程过程中,开发者可能会遇到与最初设想不一致的情况,或者发现新的技术约束和需求细节,这时就必须在构造阶段对详细设计方案进行相应的修改和完善。

软件构造阶段的详细设计所使用的方法与技术,与软件设计阶段是相似的,但其应用范围更聚焦于较小的代码单元和更具体的实现层面。

编程

编程是软件构造活动的核心环节,其根本目标是生产出高质量、可维护、可靠且高效的程序代码。

程序代码的质量要素

高质量的程序代码通常具备以下关键特征:

  • 易读性:代码逻辑应清晰、直观,力求「显而易见是正确的」。易读性是编程中至关重要的目标,它能显著提升开发效率(尤其是在调试阶段)、代码的可维护性以及复用潜力。
  • 易维护性:除了易于阅读理解外,代码还应易于修改、扩展和修复。
  • 可靠性:代码必须能够正确无误地执行其预定功能,并能妥善处理各种预期的错误情况和未预期的异常状况。
  • 性能:代码应展现出良好的时间性能(执行速度)和空间性能(内存占用),这通常依赖于对数据结构和算法的审慎选择与精心设计。
  • 安全性:应避免在代码中遗留安全漏洞,防止重要信息泄露(例如,因缓冲区溢出导致的内存数据区泄露)。

编程的主要技术考量

根据 SWEBOK 的总结,编程过程中需要重点关注以下技术方面:

  1. 构造可理解的源代码:包括采用一致且有意义的命名规范,以及合理的代码组织和空间布局。
  2. 合理使用语言特性:如类、枚举类型、变量、命名常量等实体。
  3. 清晰运用控制结构:准确表达程序的执行流程和逻辑分支。
  4. 周全处理错误条件:既要处理可预见的错误,也要妥善应对未预期的异常。
  5. 预防代码级安全隐患:例如,防范缓冲区超限、数组下标越界等常见的安全漏洞。
  6. 有效的资源管理:例如,在多线程环境下使用互斥机制(如线程锁、数据库锁)来安全访问共享资源。
  7. 良好的源代码组织:将代码逻辑地组织为语句、例程(方法/函数)、类、包(命名空间)或其他结构单元。
  8. 必要的代码文档:通过注释等方式解释代码的设计意图、复杂逻辑或使用约束。
  9. 适度的代码调整与优化:在保证正确性和可读性的前提下,对关键部分进行性能优化。

测试

在软件构造阶段,程序员不仅要完成代码级别的设计与实现,还必须通过严格的测试来验证其设计的正确性和代码的健壮性。

  • 单元测试(Unit Testing):针对程序中最小的可测试单元(如单个函数、方法、类或模块)进行的测试。通常,开发者每完成一次对代码的修改,都应至少执行一次相关的单元测试。
  • 集成测试(Integration Testing):在单元测试的基础上进行,主要目的是测试多个独立开发的单元(模块、组件或服务)在协同工作时,它们之间的接口、交互和数据传递是否正确无误。

敏捷开发中的测试实践

在现代敏捷开发和持续集成(CI)的实践中,测试扮演着更为核心的角色:

  • 测试驱动开发(TDD):此方法论要求开发者在编写任何功能代码之前,首先为该功能编写单元测试用例。
  • 持续集成(CI):强调频繁地将代码集成到主干,并在每次集成(例如,修改类结构、模块接口时)后自动执行包括单元测试和集成测试在内的测试套件。

调试

当测试揭示了程序中存在错误(缺陷)后,接下来的关键步骤就是调试。调试是一个定位并修复这些代码缺陷的过程。这个过程非常依赖于开发者的经验和分析能力,对于经验尚浅的程序员而言,调试往往是程序设计和实现过程中最具挑战性的部分之一。

调试过程:重现、诊断与修复

一个典型的调试过程通常包含以下三个主要步骤:

  1. 重现问题

    • 这是调试的首要且最关键的一步。如果一个问题不能被稳定地重现,那么就无法明确界定其发生的条件和范围,后续的诊断和修复也就无从谈起。
    • 控制输入:尝试找到一组特定的数据输入或操作序列,能够稳定地触发该问题。这通常意味着缺陷与处理这些特定输入或执行这些操作的代码逻辑紧密相关。常用的辅助方法包括:问题回溯推理(从错误现象反推)、内存数据监控、记录详细的输入数据和程序状态日志等。
    • 控制环境:有些难以捉摸的问题可能并非由程序逻辑直接导致,而是由特定的外部环境因素(如编译器版本、操作系统特性、数据库系统行为、网络状况等)触发的。在这种情况下,仅通过控制输入数据可能无法重现问题。此时,需要尝试通过控制和改变外部环境(例如,在不同的机器上运行、更换操作系统版本、调整数据库配置等)来设法重现问题。如果在尝试了各种诊断手段后,仍坚信程序代码本身没有缺陷,那么就需要警惕问题是否源于软件运行环境
  2. 诊断缺陷

    • 避免盲目猜测:定位缺陷不应依赖于直觉或随意的猜测,而应采用系统性的方法。
    • 灵活运用编译器提示:编译器的错误信息和警告提示往往能够为定位缺陷提供有价值的线索,但有时也可能产生误导,需要审慎分析。
    • 持续缩小嫌疑代码范围:一种常用的有效策略是采用类似二分法的方式,逐步将可疑代码区域划分为两部分,通过测试或观察排除掉没有问题的那部分,从而将嫌疑代码的范围逐步缩小。
    • 重点检查最近修改过的代码:实践经验表明,代码的修改操作(无论是添加新功能还是修复旧缺陷)最容易引入新的错误。因此,如果问题是在最近一次修改后出现的,尤其是那些难以诊断的问题,应仔细检查刚刚修改过的代码部分。
    • 警惕已出现过的缺陷和常见缺陷模式:每个程序员在短期内可能会有其特定的编码习惯或思维盲点,导致其产生的程序缺陷类型具有一定的重复性。因此,要警惕那些自己曾经犯过的或业界常见的缺陷模式。
    • 善用调试工具:现代集成开发环境(IDE)通常都配备了强大的调试工具,如断点调试、单步执行、变量监视、调用栈分析等。此外,还有一些专门的程序分析工具,如程序切片工具,能够帮助开发者更容易地定位缺陷所在。
  3. 修复缺陷

    • 一次只修复一个缺陷:为了避免引入新的复杂性,并确保能够清晰地验证修复效果,通常建议每次只针对一个已确认的缺陷进行修复。
    • 修改前保留旧版本备份:在对代码进行任何修改之前,务必确保旧版本的代码已得到妥善备份。如果项目采用了版本控制系统(如 Git),这个工作通常会由系统自动完成。否则,就需要开发者手动进行备份。
    • 通过测试和评审验证修复的有效性:缺陷修复完成后,必须通过运行相关的测试用例来验证修复是否有效,并且没有引入新的问题。在某些情况下,可能还需要对修复方案进行代码评审。
    • 检查并修复类似的潜在缺陷:在定位并修复一个缺陷后,可以思考该缺陷的根本原因,并检查代码库中是否存在其他类似模式的潜在缺陷。这可以借助代码搜索、静态分析工具或程序切片等工具来辅助进行。

代码评审

代码评审是对软件代码进行系统性检查的过程,通常由开发者的同行(其他有经验的程序员或领域专家)来完成。通过组织代码评审会议或采用其他评审形式,可以有效地发现并修正开发过程中被忽略的代码错误和设计缺陷,从而提高软件的整体质量,并促进开发团队成员技能的提升。

评审的目的与方式

  • 主要目的
    • 发现并修正代码中的错误、缺陷和潜在问题。
    • 提高软件的质量、可维护性、可读性和性能。
    • 促进知识共享,提升团队成员的编程技能和对项目的理解。
    • 确保代码符合编码规范和设计标准。
  • 常见方式
    • 正式评审:也称为代码审查。这是一种结构化的评审过程,通常涉及多个同行专家组成评审小组,提前分发代码和相关文档,召开正式的评审会议,由专人引导,逐行或逐模块地对代码进行严格审查,并记录发现的问题。
    • 轻量级评审:这是一种更为灵活和非正式的评审方式。例如,开发者可以邀请一位或几位同事直接在其计算机屏幕上查看和讨论代码,或者通过电子邮件、即时通讯工具等方式将代码片段发送给其他开发者进行检查和反馈。常见的轻量级评审有走查和技术评审。
    • 结对编程:本身就是一种持续的、实时的代码评审形式。在结对编程中,一位程序员(驾驶员)编写代码,另一位程序员(观察员/领航员)则实时观察、思考并提供反馈,从而在代码产生的过程中就进行评审。

代码评审的实践经验

根据 IBM SmartBear Software 团队等业界经验总结,以下是一些有效的代码评审实践:

  • 即使项目时间和资源有限,不能对所有代码进行评审,也应至少评审一部分关键代码(例如 20% 至 33%),这有助于激励所有程序员编写更高质量的代码。
  • 控制单次评审的代码量,例如,一次评审的代码行数最好少于 200 至 400 行。
  • 设定合理的检查速率目标,例如,每小时检查的代码行数(LOC)低于 300 至 500 行,以保证评审的深度和效果。
  • 为评审分配足够的时间,进行正确且从容的评审,但注意避免单次评审时间过长(例如,每次评审不超过 60 至 90 分钟),以防评审者疲劳。
  • 确保代码的开发者在评审开始之前就已经为代码编写了清晰、必要的注释,这有助于评审者理解代码逻辑。
  • 使用检查列表,针对常见的错误类型、编码规范、设计原则等制定检查项,可以极大地提高代码评审的系统性和效率。
  • 跟踪并确认发现的缺陷确实得到了修复,形成闭环管理。
  • 培养良好的代码评审文化氛围,在团队中将搜索和指出缺陷视为一种积极的、建设性的活动,而不是对个人的批评。
  • 优先采用轻量级、易于实施且能得到工具支持的代码评审方法,以降低评审的门槛和成本,提高其在项目中的普及率。

集成与构建

在以分散的方式完成了程序的基本单元(如例程、类、模块)的开发和初步测试之后,软件构造过程还需要将这些分散的单元集成起来,并构建成为更大的构件、子系统,并最终形成完整的软件系统。

  • 集成方式
    • 大爆炸式集成:这是一种将所有独立开发的模块在开发周期的最后阶段一次性全部组合起来进行测试的方法。其主要缺点是风险高,一旦出现问题,很难快速定位问题的根源,因为大量的未知交互和潜在缺陷会同时暴露出来。
    • 增量式集成:与大爆炸式相反,增量式集成采用逐步添加和测试新模块的方式。每次只集成少量模块,并立即进行测试。这种方法能够更早地发现接口问题和模块间的兼容性错误,问题也更容易被隔离和解决。实践证明,增量式集成通常有着更好的效果和更低的风险
  • 构建:构建过程是指将开发者编写的可读的源代码(以及相关的资源文件、库依赖等)转换为标准的、能在目标计算机上运行的可执行文件(或部署包)的过程。这个过程可能包括编译、链接、代码打包、资源处理、版本标记等多个步骤。构建过程通常需要配置管理工具和自动化构建工具(如 Make, Ant, Maven, Gradle, Jenkins 等)的帮助,以确保构建的一致性、可重复性和效率。

构造管理

构造管理是确保软件构造活动有序、高效进行的关键,主要包括构造计划、度量和配置管理三个方面。

构造计划

构造计划需要根据整个项目的开发过程和里程碑进行安排。它主要负责:

  • 定义要开发的构件与开发次序:明确哪些软件构件(模块、功能)需要被开发,以及它们的开发优先级和先后顺序。
  • 选择合适的构造方法:例如,是采用测试驱动开发(TDD)、结对编程,还是其他更传统的方法。构造方法的选择是构造计划中的一个关键方面,它会直接影响到最终产出的源代码的质量
  • 明确构造任务并分配给程序员:将具体的开发任务分解,并合理地分配给开发团队中的程序员。

度量

在软件构造阶段,产品度量主要围绕产出的源代码展开,通过收集和分析这些数据,可以评估代码的质量、复杂度和维护性。常见的度量指标包括:

  • 每个类或方法的复杂度:例如,使用圈复杂度来衡量代码逻辑的复杂程度。高复杂度的代码通常更难理解、测试和维护。
  • 每个类或方法的代码行数:过长的方法或过大的类可能意味着其承担了过多的责任,违反了单一职责原则。
  • 每个类或方法的注释行数:注释的密度可以在一定程度上反映代码的可理解性。

IBM 的经验数据

IBM 的一项经验研究表明,平均每 10 行代码有 1 行注释时,代码的可读性和清晰度通常较好。当然,这并非一个强制性的标准或绝对的比例,注释的质量远比数量重要。但当代码行数与注释行数的差距过大时(例如,大量代码几乎没有注释),则值得仔细审视其可维护性。

配置管理

在团队协作进行软件开发的环境中,配置管理扮演着至关重要的角色。它涉及到对软件开发过程中产生的所有工作产品(包括源代码、文档、测试脚本、构建脚本等)的版本控制和变更管理。

  • 早期纳入管理:软件构造活动从一开始就需要将程序代码等制品纳入配置管理系统的管理之下,而不必等到代码功能相对固定或开发后期才进行。
  • 独立的开发配置库:为了不干扰整个项目的配置管理规则,并为开发过程中的频繁变更提供灵活性,实践中常常会为软件构造阶段建立一个专门的开发配置库(或称为开发分支、特性分支等),用于存储和管理正在开发中的程序代码。
  • 提交与更新规则:应建立明确的提交和更新规则。例如:
    • 每次完成一个代码单元(如一个功能、一个类、一个方法的修复)的开发和初步测试后,都应及时将其提交到开发配置库中。
    • 每次开始新的修改或开发任务前,都应以从开发配置库中获取到的最新版本的代码为基础。
  • 最终提交的制品:在软件构造阶段结束,准备将成果交付给后续阶段(如系统测试、部署)时,开发者需要向项目的中央制品存储库(或主干分支)提交一系列经过验证和评审的制品,主要包括:
    • 源代码基线:经过测试和评审的、构成某个稳定版本的源代码集合。
    • 程序运行所需的数据基线:如果程序运行依赖于特定的初始数据、配置文件等,这些数据也应作为配置项进行管理和提交。
    • 可执行程序:构建生成的可执行文件或部署包。

软件构造的实践方法

为了提升软件构造的效率和质量,业界总结了许多有效的实践方法。以下将重点介绍其中三种:重构、测试驱动开发和结对编程。

重构

重构

重构(Refactoring)是一种对软件系统进行修改的严谨、有纪律的方法,其核心在于在不改变代码外部行为(即软件功能)的前提下,系统地改进其内部结构。重构的主要目的是提升软件的详细设计质量,使其更易于理解、维护和扩展,从而能够更好地持续演化。

为何需要重构?

重构的需求源于软件开发和维护过程中的固有挑战:

  • 应对持续变化的需求:在软件开发的初始阶段,很难完全预见到未来数年内可能出现的所有修改需求。因此,最初的设计方案可能无法完美适应后续的变更。
  • 防止软件结构的腐化:随着软件生命周期中修改次数的不断增生,如果没有持续的关注和改进,软件的内部设计结构往往会逐渐变得复杂、混乱,质量随之下降,导致可维护性越来越差。

重构最初主要被应用于软件维护阶段,用以改善遗留系统的状况。但后来人们发现,在软件开发的构造阶段同样需要积极运用重构方法:

  • 对需求理解的逐步深入:在软件构造过程中,开发者对需求的理解往往会更加深入和具体,有时可能会发现最初设计中未曾考虑到的方面,或者需求本身发生了细微的变更。这时,就需要通过重构来调整和优化设计结构。
  • 应对编程中未预计的细节:在实际编程过程中,开发者经常会遇到各种未曾预料到的技术细节、约束条件或更优的实现思路。通过重构,可以将这些新的认知反馈到设计中,并对现有代码结构进行改进。

因此,相比于在软件设计阶段结束后就将设计结构完全固化,在软件构造阶段持续进行重构,是一种更加合理且适应变化的方法

重构的时机

当代码中出现所谓的「坏味道」时,通常就是需要进行重构的信号。以下是一些常见的重构时机:

  • 增加新功能时重构通常发生在新功能增加完成之后,目的是用来消除因添加新功能而引入到代码中的坏味道,或者为了使新旧代码更好地融合。重要的是,重构本身并不改变代码的外部行为,因此它不是用来实现新功能添加的方法,而是在新功能(初步)实现后用于改善其结构。
  • 发现并修复缺陷时:在诊断和修复缺陷的过程中,如果发现相关的代码段存在设计上的坏味道,或者修复缺陷的操作本身可能会引入新的坏味道(例如,通过打补丁的方式临时解决问题),那么在缺陷修复之后(或作为修复的一部分)就应该进行重构。
  • 进行代码评审时:如果在代码评审过程中发现了代码中存在的坏味道或结构性问题,那么评审之后也应该安排相应的重构活动。

代码的坏味道

「代码的坏味道」是 Martin Fowler 提出的一个形象说法,它指的是代码中某些可能预示着深层设计问题的模式或迹象。这些坏味道本身不一定是错误,但它们往往是设计结构低质量的表现,提示我们需要进行重构。常见的代码坏味道包括:

  • 过长的方法:一个方法包含了过多的代码行(例如,经验法则是超过一个屏幕的长度)。这通常意味着该方法承担了太多的责任,功能不够内聚,应该被分解为多个更小、更专注的方法。
  • 过大的类:一个类包含了过多的实例变量、方法,或者承担了过多的职责。这可能违反了单一职责原则,应该考虑将该类分解为多个更小、职责更明确的类。
  • 过多的方法参数:一个方法的参数列表过长。这可能意味着该方法的任务过于复杂,或者参数的数据类型抽象层次太低,不符合接口最小化的低耦合原则。可以考虑将其分解为多个参数较少的方法,或者将相关的参数封装成一个独立的对象或结构体进行传递。
  • 多处相似的复杂控制结构:例如,在代码的不同地方出现了结构相同或非常相似的 switch-case 语句或复杂的 if-else if 链。这往往意味着系统中缺乏足够的多态策略,可以考虑使用继承和多态机制(如策略模式、状态模式)来消除这种重复的复杂控制结构。
  • 重复的代码:在代码的不同位置出现了完全相同或高度相似的代码片段。这是最直接和最常见的坏味道之一,它意味着代码中存在隐式的耦合,修改时容易遗漏,增加了维护成本。应该将重复的代码提取为一个独立的方法或类。
  • 一个类过多使用其他类的属性或方法:一个类的方法频繁地访问另一个类的内部数据(属性)或调用其方法,而对自身类的成员访问较少。这通常意味着类的职责划分不当,或者协作设计存在问题。可能需要将相关的属性或方法在类之间进行转移,或者使用方法委托来代替直接的属性访问。
  • 过多的注释:虽然注释是必要的,但有时过多的、解释简单代码的注释,或者用注释来掩盖复杂难懂的代码逻辑,反而是一种坏味道。这可能意味着代码本身的逻辑结构不清晰或者可读性差,需要通过重构来改进代码本身的表达能力,而不是仅仅依赖注释。

重构的注意事项与示例

  • 重构是基于已有代码的设计改进:重构是在现有可工作代码的基础上进行的,目的是改进其内部设计,它不是一种从头开始开发新代码的方法。因此,在应用重构之前,通常需要先有初步实现的代码(例如,在极限编程中,会先采用简单设计快速实现功能,然后再进行重构)。
  • 重构要防止引入副作用:重构的核心原则是不改变代码的外部行为。因此,在重构过程中,不能修改软件的既有功能,也不应该在修改中引入新的错误。每次重构操作完成后,都应及时运行相关的测试用例进行验证(例如,测试驱动开发(TDD)与重构紧密结合,TDD 提供的测试套件为重构提供了安全网)。
  • 重构的重点是改进详细设计结构:重构主要针对的是代码级别的详细设计结构,如类、方法、变量的组织等。虽然理论上重构也可以应用于更高层次的体系结构设计,但体系结构层面的修改通常影响范围非常广泛,成本和风险也更高,除非非常必要,否则不应轻易进行大规模的体系结构重构。而仅仅通过代码级别的设计改进,对整个软件系统设计结构质量的提升作用是有限的,它可能不足以从根本上增强一个设计不良的软件系统的可演化性。

原始代码(坏味道:多个基本类型参数 id, name, point 共同代表一个「会员」的概念,但分散传递):

1
2
3
4
5
6
7
8
9
10
11
12
public class Member {
private long id;
private String name;
private long point;
// ... 其他属性和方法 ...

public void update() {
MapperService memberMapper = MemberMapper.getInstance();
// 直接将多个独立的属性作为参数传递给 update 方法
memberMapper.update(id, name, point);
}
}

重构后代码(引入参数对象 MemberPO):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 步骤 1: 创建一个新的类 MemberPO 来封装这些参数
public class MemberPO implements Serializable { // PO 通常指持久化对象或参数对象
private long id;
private String name;
private long point;

public MemberPO(long id, String name, long point) {
this.id = id;
this.name = name;
this.point = point;
}

// 可能还有 getters 方法
public long getId() { return id; }
public String getName() { return name; }
public long getPoint() { return point; }
}

// 步骤 2: 修改 Member 类及其 update 方法
public class Member {
private long id;
private String name;
private long point;
// ... 其他属性和方法 ...

public void update() {
MapperService memberMapper = MemberMapper.getInstance();
// 创建 MemberPO 对象
MemberPO po = new MemberPO(this.id, this.name, this.point);
// 将 MemberPO 对象作为参数传递
memberMapper.update(po);
}
}

这种重构手法(称为「引入参数对象」)提高了代码的内聚性和可读性,使得参数传递更加简洁和表意清晰。当参数列表很长,或者这些参数在多个地方都一起出现时,这种重构尤其有用。

测试驱动开发

测试驱动开发

测试驱动开发(Test-Driven Development, TDD),有时也被称为测试优先的开发方法,是一种软件开发实践,它要求程序员在编写一小段具体的功能代码之前,首先为其编写自动化测试代码。

TDD 最初作为极限编程(XP)的核心实践之一而广为人知,但由于其显著的优点,现在也经常被独立地应用于各种软件开发项目中。

TDD 简介与优势

  • 核心思想与流程:TDD 的核心在于「先测试,后编码」。开发者首先为即将实现的一小块功能编写一个(或多个)测试用例。由于功能代码此时尚未编写,这个测试理应失败。然后,开发者编写最少量的功能代码,恰好能让这个测试用例通过。之后,可能对代码进行重构以提高质量。这个「红-绿-重构」的循环不断重复。测试代码通常由自动化测试框架(如 JUnit, NUnit, pytest 等)加载并执行,但也可以由程序员手动执行。
  • 主要优势
    • 提高程序的正确性和可靠性:测试优先的开发方式确保了每一小段功能代码在编写完成后都会立即得到相应的测试覆盖,有助于及早发现和修复缺陷。
    • 提高软件的设计质量:在编写测试用例时,开发者被迫从代码使用者的角度来思考和定义接口行为及外部表现,这往往能促使他们设计出更清晰、更易用、更解耦的接口。同时,为了使代码易于测试,开发者通常会倾向于编写功能单一、高内聚、低耦合的小单元代码,从而整体上提升了设计质量。
    • 提高开发生产力:虽然编写测试代码需要额外的时间投入,但从长远来看,TDD 有助于提高生产力。它使得开发者对要实现的功能目标有更清晰的理解,能够更好地区分必要的工作和冗余的设计,避免过度工程化,从而减少不必要的时间耗费和后期大量的调试返工。

TDD 的开发流程

TDD 的开发过程通常遵循一个简短的、重复的循环,常被称为「红-绿-重构」(Red-Green-Refactor)循环:

graph TD
    A["1\. 编写一个失败的测试(Red)"] --> B{2\. 编译测试代码};
    B -- 通常会因功能代码缺失而编译失败或测试直接失败 --> C[3\. 编写最少量的功能代码(使其能编译并通过测试)];
    C --> D{4\. 运行所有测试};
    D -- 测试失败(红灯) --> E[重复步骤 3 和 4 直到测试通过];
    E --> C;
    D -- 所有测试通过(绿灯) --> F[5\. 重构代码(在测试保护下改进设计)];
    F --> A_loop(6\. 选择下一个小功能点,重复循环);

    style A fill:#f99,stroke:#333,stroke-width:2px
    style C fill:#9cf,stroke:#333,stroke-width:2px
    style D fill:#ccc,stroke:#333,stroke-width:2px
    style F fill:#9f9,stroke:#333,stroke-width:2px
    style A_loop fill:#f9f,stroke:#333,stroke-width:2px
  1. 编写一个失败的测试(Red):首先,针对你即将实现的下一个小功能点或行为,编写一个自动化的测试用例。因为你还没有编写任何实现该功能的代码,所以这个测试在运行时理应失败(或者由于缺少方法/类而无法编译,编译通过后也应运行失败)。这个「红色」状态确认了测试能够正确地检测到功能的缺失或不正确。
  2. 编写最少量的功能代码使测试通过(Green):接下来,编写恰好足够让这个失败的测试用例通过的功能代码。在这个阶段,目标不是编写完美或高效的代码,而是尽快让测试从「红色」变为「绿色」。可能只需要写几行简单的代码。
  3. 重构代码(Refactor):现在,测试已经通过,你有了一个功能正确的代码片段和一个保护网(测试用例)。在这个基础上,你可以安全地对刚刚编写的功能代码(以及相关的测试代码)进行重构,以消除重复、改进设计、提高可读性、提升性能等,而不用担心会破坏已有的功能。每次重构后,都应重新运行测试以确保一切仍然正常。

这个循环以非常小的步长不断重复,使得软件功能逐步增长,同时保持代码库的健康和高质量。

TDD 示例

以一个连锁商店管理系统中 Sales 对象的 getChange(找零)方法的开发为例,详细演示 TDD 的过程:

  1. 编写测试代码

    • 首先,明确 getChange 方法的规格,包括其前置条件(如 payment > 0payment >= salesTotal)和后置条件(如 return = payment - salesTotal)。
    • 基于这些规格,设计一系列测试用例,覆盖正常功能和各种边界条件、异常情况。
    • 然后,使用选定的测试框架(如 Java 的 JUnit)编写测试类(例如 SalesTester),将这些测试用例实现为具体的测试方法。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      // 示例:一个简化的 JUnit 测试方法(概念性)
      import static org.junit.Assert.assertEquals;
      import org.junit.Test;

      public class SalesTester {
      @Test
      public void testGetChange_PaymentLessThanTotal_ShouldReturnErrorCode() {
      Sales sale = new Sales();
      sale.setTotalAmount(90.0); // 假设有个方法设置销售总额
      double change = sale.getChange(50.0); // 顾客付款 50,少于总额 90
      // 假设业务规定,付款不足时返回特定错误码,例如 -2.0
      assertEquals(-2.0, change, 0.001);
      }
      // ... 其他测试方法 ...
      }
  2. 运行测试,看它失败

    • 此时,Sales 类中可能还没有 getChange 方法,或者只有一个空的实现。运行测试,它应该会失败(或者因编译错误而无法运行)。
  3. 编写最少量的代码使测试通过

    • Sales 类中添加 getChange 方法的声明。
    • 针对当前失败的测试用例,编写最简单的代码逻辑让它通过。例如,如果测试的是付款金额为负的情况,可以先这样写:
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      public class Sales {
      private double totalAmount;
      // ...
      public double getChange(double payment) {
      if (payment <= 0) {
      return -1.0; // 假设错误码为 -1.0
      }
      // 对于其他情况,暂时返回一个肯定会使其他测试失败的值
      return 0.0; // 或抛出未实现异常
      }
      }
    • 逐步添加逻辑,直到所有之前编写的测试用例都通过。例如,完整实现可能是:
      1
      2
      3
      4
      5
      public double getChange(double payment) {
      if (payment <= 0) return -1.0; // 对应测试用例 1, 2 (假设)
      if (payment < this.totalAmount) return -2.0; // 对应测试用例 3 (假设)
      return payment - this.totalAmount; // 对应测试用例 4, 5 (假设)
      }
  4. 重构代码

    • 当所有测试都通过后,审视 getChange 方法(以及相关的测试代码),看是否有可以改进的地方。例如,是否可以用常量代替魔法数字(如 -1.0, -2.0),代码是否清晰易懂,是否有重复逻辑等。在测试的保护下进行这些改进。

这个过程会针对 getChange 方法的每一个行为点(每一个测试用例所代表的场景)重复进行,直到该方法的功能完全实现并通过所有测试。

结对编程

结对编程

结对编程是一种敏捷软件开发实践,其中两位程序员共同协作,在同一台计算机上一起完成设计、编码、测试等软件构造活动

结对编程的核心思想

在结对编程中,两位程序员扮演不同的但互补的角色:

  • 驾驶员:负责实际操作键盘和鼠标,专注于编写代码、运行测试等具体的实现任务。
  • 观察员/领航员:坐在驾驶员旁边,不直接操作键盘。其主要职责是实时地观察驾驶员输入的代码,进行即时的代码评审,帮助发现潜在的语法错误、拼写错误、逻辑缺陷等。更重要的是,观察员需要从更宏观的视角思考当前工作的战略方向,例如代码结构是否合理、是否符合设计模式、是否有更优的算法或实现方式、将来可能出现哪些问题等,并及时向驾驶员提出改进意见或引导讨论。
  • 频繁的角色互换:为了保持高效和专注,两位程序员会经常(例如每隔半小时或一小时,或者完成一个小任务后)互换角色。这有助于防止驾驶员因长时间操作而疲劳,也让观察员有机会将自己的思考和想法付诸实践。当观察员有了比驾驶员更好的主意或解决方案时,也应该主动要求交换角色。

软件构造的理念与趋势

这部分内容主要基于课件中的 PPT,总结了软件构造领域的一些重要发展、普遍规律以及需要警惕的错误观念。

软件构造的十大进展

在过去的几十年里,软件构造领域取得了显著的进步,以下是一些具有里程碑意义的进展:

  1. 设计提升了一个抽象层次:编程语言和方法论的发展,使得开发者能够操作和管理更大规模的代码聚合体,从最初的语句,到例程,再到类,乃至包或组件。面向对象思想的真正遗产之一,可能就是这种构建和管理更大规模软件结构的能力。
  2. 每日构建和冒烟测试:这种实践制度化了增量集成和早期测试,能够及时发现和解决集成问题,从而最大限度地减少了过去在项目后期因严重集成问题而导致的混乱和延期。
  3. 标准库的普及:虽然优秀的程序员一直以来都善于利用库来提高效率,但现代主流编程语言(如 Java, C++, C#, Python 等)普遍提供了功能强大且经过良好测试的内置标准库,极大地简化了常见任务的开发。
  4. Visual Basic (VB) 的影响:VB 在当时带来了多项创新:它是可视化编程的重要推动者;它是首个使得商业现货组件得到广泛应用的开发环境;它在语法设计上借鉴了 Ada 等语言的优点(例如清晰的 case 语句和控制结构);并且提供了一个高度集成的开发环境。
  5. 开源软件的兴起:开源软件运动为开发者提供了海量的、可学习和复用的代码资源,极大地帮助了软件开发;它降低了代码共享和协作的门槛;为开发者提供了直接从优秀(或糟糕)的真实代码中学习的机会;提升了开发者阅读和理解他人代码的能力;并催生了充满活力的全球性程序员社区。
  6. 互联网作为研究工具:互联网(特别是搜索引擎、专业论坛、FAQ 集合、技术博客、在线文档等)已经成为开发者获取技术信息、解决编程难题、学习新知识不可或缺的研究工具。
  7. 增量开发方法的广泛应用:增量和迭代的开发方法(如原型法、螺旋模型、敏捷方法等)的概念虽然在 90 年代甚至更早就已为人熟知,但在 2000 年代之后得到了更为广泛和成熟的实践应用,并被证明比传统的瀑布模型更适应需求变化和复杂项目。
  8. 测试优先/测试驱动开发的出现:如前所述,TDD 通过强调先写测试,缩短了缺陷从引入到被检测的时间,增强了开发者的个人纪律,并能很好地补充每日构建和冒烟测试等实践。
  9. 重构作为一种规范化的实践:重构为开发者在不破坏现有功能的前提下,系统地改进和演化代码内部结构提供了一种规范化的方法和一系列行之有效的技法。虽然它本身可能不是一种完整的、自上而下的设计策略,但作为一种增量式改进设计质量的实践,其价值巨大。
  10. 计算机硬件性能的飞速提升:持续的摩尔定律使得计算机的处理速度、内存容量、存储空间等硬件性能不断飞跃。这对软件构造产生了深远影响,例如:在某些情况下降低了对极致代码优化的迫切性(但并非总是如此);使得更高级、更消耗资源的编程语言和开发工具成为可能;也使得处理更大数据集和更复杂问题的软件应用得以实现。

现代软件构造的十大现实

理解以下这些关于现代软件构造的普遍现实,有助于我们更清醒地认识这一领域:

  1. 「构造」本身是一个值得深入探讨的主题:软件构造不仅仅是编码,它涵盖了从详细设计、编码、调试、单元测试、集成到构造管理等一系列复杂活动,是软件工程中一个核心且值得专门研究的领域。
  2. 个体程序员之间的能力差异非常显著:大量的研究和实践经验表明,在编码速度、调试效率、缺陷发现与修复能力、设计质量等方面,不同程序员之间的生产力和质量表现可能存在高达 10 倍甚至 20 多倍的巨大差异。
  3. 个人纪律至关重要:无论采用何种开发方法论,开发者个人的专业素养和纪律性(例如,在进行需求预测时的现实态度、在重构和原型设计中的严谨性、在优化时的审慎、在管理复杂性时的条理性等)都对项目成功和软件质量起着决定性的作用。
  4. 追求简单性通常比追求(不必要的)复杂性效果更好:在设计和编码时,应优先考虑代码的清晰性、可读性和可维护性(即「读时方便」),而不是仅仅为了追求一时的「写时方便」或展示技巧而引入不必要的复杂性。
  5. 缺陷修复成本随发现阶段的推迟而急剧增加的规律依然有效:一个在需求阶段引入的缺陷,如果能在需求阶段就被发现和修复,其成本可能非常低。然而,如果同一个缺陷直到系统测试阶段甚至软件发布后才被发现,那么修复它所需的成本(包括时间、人力、返工、对现有系统的影响、以及可能的商业损失等)可能会成百上千倍地增加。因此,尽早地、在缺陷引入的源头附近发现和修复缺陷,是控制软件开发成本和提高软件质量的关键原则之一。
  6. 设计的重要性不容忽视:在软件开发中,「完全不做预先设计」和「试图预先设计所有细节」这两种极端做法通常都是不可取的。成功的项目往往需要在两者之间找到一个合适的平衡点,进行适度的、迭代的、演进式的设计。
  7. 技术浪潮的更迭会影响构造实践:软件技术(如编程语言、框架、平台、开发工具等)总是在不断发展和演进,形成一波又一波的技术浪潮。每种技术在其生命周期的不同阶段(例如,早期探索期、成熟应用期、逐渐衰退期),其相关的软件构造实践和最佳做法也会随之发生变化。
  8. 增量和迭代的方法通常效果最佳:纯粹的、线性的瀑布模型由于其缺乏对变化和反馈的适应能力,在实践中已被证明很少能够成功应对复杂的软件项目。相比之下,各种形式的增量和迭代开发方法(如螺旋模型、敏捷开发等)能够更好地管理风险、适应需求变化、并持续交付价值,因此通常效果更佳。几乎所有项目都会在某个阶段或某个层面上经历迭代。
  9. 「工具箱」隐喻对于理解方法选择依然具有启发性:在软件工程领域,并不存在一种「放之四海而皆准」的最佳方法论或技术(无论是敏捷、XP、Scrum、CMMI 还是其他任何方法)。更恰当的看法是,我们拥有一个装满了各种工具、技术、方法和实践的「软件工程工具箱」。开发者和团队应该根据项目的具体特性、团队的能力、组织的文化等因素,从这个工具箱中明智地选择和组合最适合当前情境的「工具」。
  10. 软件开发中存在一些固有的、本质性的张力:在软件开发实践中,长期以来一直存在着一些看似矛盾但又必须努力去平衡的对立面,例如:严格的计划 vs. 灵活的即兴创作;详尽的规划 vs. 对未来的不确定性;创造性 vs. 结构与规范;纪律性 vs. 灵活性;量化度量 vs. 质性评估;关注过程 vs. 关注产品;追求最优 vs. 满足即可。这些基本张力是软件开发领域永恒的主题,平衡点可能会随着时间和情境的变化而摇摆,但张力本身是持续存在的。

应避免的构造理念

回顾过去,我们可以从一些曾被错误追捧或滥用的构造理念中吸取教训。有趣的是,有些糟糕的想法在不同年代会以新的面貌重新出现:

1990 年代的错误理念 2000 年代(及以后)的类似错误理念
编码然后修复(缺乏系统性设计和测试) 编码然后修复(问题依然存在)
「所有设计都预先完成」的僵化编程 「完全不做预先设计」的随意编程
为大量推测性的未来需求而过度设计 「计划稍后有时间再重构」(往往意味着永远不重构)
期望某种组件技术能解决所有构造问题 期望离岸外包能解决所有开发问题
对「自动编程」或代码生成器抱有不切实际的幻想 对「自动编程」或 AI 辅助编程抱有不切实际的幻想(问题依旧)
在不理解其适用性的情况下盲目套用瀑布模型 在不理解其核心实践和文化的情况下盲目套用极限编程(XP)或其他敏捷方法
将一切新兴事物都贴上「面向对象」的标签并滥用其概念 将一切新兴事物都贴上「敏捷」的标签并滥用其概念

警惕「银弹」思维和盲目跟风

无论是哪个年代,将某一种特定的技术、方法论或趋势视为能够解决所有软件开发问题的「银弹」,都是一种非常危险且不切实际的倾向。同样,盲目地追随最新的潮流,而不深入理解其核心思想、适用条件和潜在局限性,也往往会导致项目的失败和资源的浪费。在软件构造中,应始终保持批判性思维,理性评估,并根据实际情况做出明智的选择。