防御式编程

作者: 刘硕jessie | 来源:发表于2017-11-21 10:35 被阅读322次

    防御式编程

    在防御式驾驶中要建立这样一种思维,那就是你永远也不能确定另一位司机将要做什么。这样才能够确保在其他人做出危险动作时你也不会受到伤害。你要承担起保护自己的责任,哪怕是其他司机犯的错误。同样,防御式编程的主要思想是:子程序应该不因传入错误数据而被破坏,哪怕是由其他子程序产生的错误数据。

    保护程序免遭非法输入数据的破坏

    通常有三种方法来处理可能带来非法数据的情况。

    检查所有来源于外部的数据的值 当从文件、用户、网络或其他外部接口中获取数据时,应检查所获得的数据值,以确保它在允许的范围内。

    检查子程序所有输入参数的值

    使用错误处理技术处理错误的输入数据 一旦检测到非法的参数,你可以选择使用错误处理技术中描述的十几种方案中的一种或几种

    断言

    断言是指在开发期间使用的、让程序在运行时进行自检的代码。断言为真,则表明程序运行正常,而断言为假,则意味着它已经在代码中发现了意料之外的错误。

    断言对于大型的复杂程序或可靠性要求极高的程序来说尤其有用。通过使用断言,程序员能更快速地排查出因修改代码或者别的原因,而弄进程序里的不匹配的接口假定和错误等。

    一个断言通常包含两个参数:一个描述假设为真时的情况的布尔表达式,和一个断言为假时需要显示的信息。eg: assert denominator !=0 : “denominator is unexpectedly equal to 0.”;

    断言主要是用于开发和维护阶段。在开发阶段,断言可以帮助查清相互矛盾的假定,预料之外的情况以及传给子程序的错误数据等。在生成产品代码时,可以不把断言编译进目标代码里去,以免降低系统的性能。

    使用断言的指导建议

    用错误处理代码来处理预期会发生的情况,用断言来处理绝不应该发生的情况 断言是用来检查永远不该发生的情况,而错误处理代码是用来检查不太可能经常发生的非正常情况,这些情况是能在写代码时就预料到的,且在产品代码中也要处理这些情况。错误处理通常用来检查有害的输入数据,而断言是用于检查代码中的bug。用错误处理代码来处理反常情况,程序就能够很从容的对错误做出反应。

    有种方式可以让你更好的理解断言,那就是把断言看作是可执行的注释——你不能依赖它来让代码正常工作,但与编程语言中的注释相比,它能更主动的对程序中的假定做出说明。

    用断言来注解并验证先验条件和后验条件 先验条件和后验条件是一种名为”契约式设计”的程序设计和开发方法的一部分。使用先验条件和后验条件时,每个子程序或类与程序的其余部分都形成了一分契约。

    先验条件是子程序或类的调用方代码在调用子程序或实例化对象之前要确保为真的属性。先验条件是调用方代码对其所调用的代码要承担的义务。

    后验条件是子程序或类在执行结束后要确保为真的属性。后验条件是子程序或类对调用方代码所承担的责任。

    断言是用来说明先验条件和后验条件的有利工具。也可以用注释来说明先验条件和后验条件,但断言却能动态地判断先验条件和后验条件是否为真。

    对于高健壮性的代码,应该先使用断言再处理错误 对于每种可能出错的条件,通常子程序要么使用断言,要么使用错误处理代码来进行处理,但是不会同时使用二者。一些专家主张只须使用一种处理方法即可。

    然而,现实世界中的程序和项目通常都很乱,仅仅依赖断言还是不够的。这时,同时使用断言和错误处理代码来处理同一个错误是一种更好的选择。以Microsoft Word 为例,在其代码中对应该始终为真的条件都加上了断言,但同时也用错误处理代码处理了这些错误,以应对断言失败的情况。因为,断言可以帮助在开发阶段排查出尽可能多的错误。然而,在软件交付之前发现并纠正一切错误是不现实的,所以应用断言的同时,对错误进行处理也至关重要。

    错误处理技术

    断言可以用于处理代码中不应发生的错误。对于那些预料中可能发生错误的情况,常用的解决方案如下:
    返回中立值 有时,处理错误数据的最佳做法就是继续执行操作并简单的返回一个没有危害的数值。比如说,数值计算可以返回 0,字符串操作可以返回空字符串,等等。

    换用下一个正确的数据 在处理数据流的时候,有时只需返回下一个正确的数据即可。例如,如果在读数据库记录并发现其中一条记录已经损坏时,你可以继续读下去直到又找到一条正确记录为止。如果你以每秒100次的速度读取体温计的数据,那么如果某一次得到的数据有误,你只需再等上1/100秒然后继续读取即可。

    返回与前次相同的数据 如果前面提到的体温计读取软件在某次读取中没有获得数据,那么它可以简单地返回前一次的读取结果。根据这一应用领域的情况,温度在1/100 秒的时间内不会发生太大改变。

    换用最接近的合法值 在有些情况下,你可以选择返回最接近的那个合法值。在从已经校准的仪器上取值时,这种方法往往是很合理的。比如说,温度计也许已经校准在0到100摄氏度之间。如果你检测到一次小于0的读取结果,那你可以把它替换为0,即最接近的那个合法值。如果发现结果大于100,那么你可以把它替换为100。

    把警告信息记录到日志文件中 在检测到错误数据的时候,你可以选择在日志文件中记录一条警告信息,然后继续执行。这种方法可以同其他的错误处理技术结合使用,比如说换用最接近的合法值、换用下一个正确的数据等。如果你用到了日志文件,要考虑是否能够安全的公开它,或是否需要对其进行加密或施加其他方式的保护。

    返回一个错误码 你可以决定只让系统的某些部分处理错误。其他部分则不在本地处理错误,而只是简单地报告说有错误发生,并信任调用链上游的某个子程序会处理该错误。通知系统其余部分已经发生错误可以采用下列方法之一:
    · 设置一个状态变量的值
    · 用状态值作为函数的返回值
    · 用语言内建的异常机制抛出一个异常

    在这种情况下,与特定的错误报告机制相比,更为重要的是要决定系统里的哪些部分应该直接处理错误,那些部分只是报告所发生的错误。如果安全性很重要,请确认调用方的子程序总会检查返回的错误码。

    调用错误处理子程序 另一种方法则是把错误处理都集中在一个全局的错误处理子程序或对象中。这种方法的优点在于能把错误处理的职责集中到一起,从而让调试工作更为简单。而代价则是整个程序都要知道这个集中点并与之紧密耦合。如果你想在其他系统中重用其中的某些代码,那就得把错误处理代码一并带过去。

    当错误发生时显示出错消息

    用最妥当的方式在局部处理错误 这种方法给予每个程序员很大的灵活度,但也带来显著的风险,即系统的整体性能将无法满足对其正确性或可靠性的需求。

    关闭程序 有些系统一旦检测到错误发生就会关闭。

    高层次设计对错误处理方式的影响

    既然有这么多选择,你就必须注意,应该在整个程序里采用一致的方式处理非法的参数。对错误进行处理的方式会直接关系到软件能否满足在正确性、健壮性和其他非功能性指标方面的要求。确定一种通用的处理错误参数的方法,是架构层次的设计决策,需要在那里的某个层次上解决。

    一旦确定了某种方法,就要确保始终如一地贯彻这一方法。如果你决定让高层的代码来处理错误,而低层的代码只需要简单的报告错误,那么就要确保高层的代码真的处理了错误!即使你认定某个函数绝对不会出错,也无论如何要去检查一下。防御式编程全部的重点就在于防御那些你未曾预料到的错误。

    这些指导建议对于系统函数和你自己编写的函数都是成立的。除非你已确定了一套不对系统调用进行错误检查的架构性指导建议,否则请在每个系统调用后检查错误码。一旦检测到错误,就记下错误代号和他的描述信息。

    异常

    异常是把代码中的错误或异常事件传递给调用方代码的一种特殊手段。如果在一个子程序中遇到了预料之外的情况,但不知道该如何处理的话,它就可以抛出一个异常。对出错的前因后果不甚了解的代码,可以把控制权转交给系统中其他能更好的解释错误并采取措施的部分。

    还可以用异常来清理一段代码中存在的杂乱逻辑。

    用异常通知程序的其他部分,发生了不可忽略的错误 异常机制的优越之处在于它能提供一种无法被忽略的错误通知机制。其他的错误处理机制有可能会导致错误在不知不觉中向外扩散,而异常则消除了这种可能性。

    只在真正例外的情况下才抛出异常 仅在真正例外的情况下才能使用异常——换句话说,就是仅在其他编码实践方法无法解决的情况下才使用异常。异常的应用情形跟断言相似——都是用来处理那些不仅罕见甚至永远不该发生的情况。

    异常需要你做出一个取舍:一方面他是一种强大的用来处理预料之外的情况的途径,另一方面程序的复杂度会因此增加。由于调用子程序的代码需要了解被调用代码中可能会抛出的异常,因此异常弱化了封装性。同时,代码的复杂度也有所增加。

    不能用异常来推卸责任 如果某种错误情况可以在局部处理,那就应该在局部处理掉它。不要把本来可以在局部处理掉的错误当成一个未被捕获的异常抛出去。

    避免在构造函数和析构函数中抛出异常,除非你在同一个地方把他们捕获

    在恰当的抽象层次抛出异常 子程序应在其接口中展现出一致的抽象,类也是如此。抛出的异常也是程序接口的一部分,和其他具体的数据类型一样。

    当你决定把一个异常传给调用方时,请确保异常的抽象层次与子程序接口的抽象层次是一致的。这个例子说明了应该避免什么样的做法:
    Java反例:抛出抽象层次不一致的异常的类

            class Employee {
    
                    ···
    
                    public TaxId GetTaxId() throws EOFException {
    
                    ···
    
                    }
    
                    ···
    
            }
    

    GetTaxId()把更低层的EOFException(文件结束,end of file)异常返回给了它的调用方。它本身并不拥有这一异常,但却通过把更低层的异常传给它的调用方,暴露了自身的一些实现细节。这就使得子程序的调用方代码不仅与Employee类的代码耦合,而是与比Employee类层次更低的抛出EOFException异常的代码耦合起来了。这样做既破坏了封装性,也减低了代码的智力上的可管理性。

    与之相反,GetTaxId()代码应抛回一个与其所在的类的接口相一致的异常,就像下面这样:
    Java 示例:一个在一致的抽象层次上抛出异常的类

            class Employee {
    
                    ···
    
                    Public TaxId getTaxId() throws EmployeeDataNotAvailable {
    
                    ···
    
                 }
    
                 ···
    
            }
    

    GetTaxId()里的异常处理代码可能只需要把一个io_disk_not_ready(磁盘IO未就绪)异常映射为EmployeeDataNotAvailable(雇员数据不可用)异常就好了,因为这样一来就能充分地保持接口的抽象性。

    在异常消息中加入关于导致异常发生的全部信息 每个异常都是发生在代码抛出异常时所遇到的特殊情况下。这一信息对于读取异常消息的人们来说是很有价值的,因此要确保该消息中含有为理解异常抛出原因所需的信息。如果异常是因为一个数组下标错误而抛出的,就应在异常消息中包含数组的上界、下界以及非法的下标值等信息。

    避免使用空的catch语句 有时你可能会试图敷衍一个不知该如何处理的异常,比如这个例子:
    Java示例:忽略异常的错误做法

            try {
    
                    ·····
    
                    ·····
    
            } catch ( AnException exception ) {
    
            }
    

    这种做法就意味着:要么是try里的代码不对,因为它无故抛出了一个异常;要么是catch里的代码不对,因为它没能处理一个有效的异常。确定一下错误产生的根源,然后修改try或catch二者其一的代码。
    偶尔你也可能会遇到某个较低层次上的异常,它确实无法表现为调用方抽象层次上的异常。如果确实如此,至少需要写清楚为什么采用空的catch语句是可行的。你也可以用注释或向日志文件中记录信息来对这一情况进行 “文档化”,就像下面这样:
    Java示例:忽略异常的正确做法

        try {
    
              ·····
    
              ·····
    
        }catch( AnException exception ) {
    
                LogError( “Unexpected exception” );
    
        }
    
        了解所用函数库可能抛出的异常    未能捕获由函数库抛出的异常将会导致程序崩溃,就如同未能捕获由自己代码抛出的异常一样。如果函数库没有说明它可能抛出哪些异常,可以通过编写一些原型代码来演练该函数库,找出可能发生的异常。
    

    考虑创建一个集中的异常报告机制 有种方法可以确保异常处理的一致性,即创建一个集中的异常报告机制。这个集中报告机制能够为一些与异常有关的信息提供一个集中的存储,如所发生的异常种类、每个异常该被如何处理以及如何格式化异常消息等。
    下面就是一个简单的异常处理器,它只是简单地打印出诊断信息:

            public class ReportException {
    
                    private static final String TAG = ReportException.class.getSimpleName();
    
                    public static void Report(Throwable throwable) {
    
                    Log.d(TAG, throwable.getMessage());
    
                    }
    
            }
    

    你可以像这样在代码中使用这个通用的异常处理器:

            try {
    
                    ····
    
            } catch (Exception e) {
    
                         ReportException.Report(e);
    
                    }
    

    这个版本的ReportException 代码非常简单。而在实际的应用程序中,你可以根据异常处理的需要开发或简或繁的代码。

    把项目中对异常的使用标准化 为了保持异常处理尽可能便于管理,你可以用以下几种途径把对异常的使用标准化。
    · 如果你在用一种像java一样的语言,其中允许抛出多种多样的对象、数据的话,那么就应该为到底可以抛出哪些种类的异常建立一个标准。
    · 考虑创建项目的特定异常类,它可用做项目中所有可能抛出的异常的基类。这样就能把记录日志、报告错误等操作集中起来并标准化。
    · 规定在何种场合允许代码使用try-catch 语句在局部对错误进行处理。
    · 规定在何种场合允许代码抛出不在局部进行处理的异常。
    · 确定是否要使用集中的异常报告机制。
    · 规定是否允许在构造函数和析构函数中使用异常

    考虑异常的替换方案 有些程序猿用异常来处理错误,只是因为他所用的编程语言提供了这种特殊的错误处理机制。你心里应该自始至终考虑各种各样的错误处理机制:在局部处理错误、使用错误码来传递错误、在日志文件中记录调试信息、关闭系统或其他的一些方式等。仅仅因为编程语言提供了异常处理机制而使用异常,是典型的“为用而用”;这也是典型的“在一种语言上编程”而非“深入一种语言去编程”的例子。

    隔离程序,使之包容由错误造成的伤害

    隔栏是一种容损策略

    以防御式编程为目的而进行隔离的一种方法,是把某些接口选定位”安全”区域的边界。对穿越安全区域边界的数据进行合法性校验,并当数据非法时做出敏锐的反应。

    也同样可以在类的层次采用这种方法。类的公用方法可以假设数据是不安全的,他们要负责检查数据并进行清理。一旦类的公用方法接受了数据,那么类的私用方法就可以假定数据都是安全的了。

    在输入数据时将其转换为恰当的类型 输入的数据通常都是字符串或数字的形式。这些数据有时要把被映射为 “是”或”否”这样的布尔类型,有时要被映射为像Color_Red、Color_Green和Color_Blue这样的枚举类型。在程序中长时间传递类型不明确的数据,会增加程序的复杂度和崩溃的可能性——比如说有人在需要输入颜色枚举值得地方输入了”是”。因此,应该在输入数据后立即将其转换到恰当的类型

    隔栏与断言的关系

    隔栏的使用使断言和错误有了清晰的区分。隔栏外部的程序应使用错误处理技术,在那里对数据做的任何假定都是不安全的。而隔栏内部的程序里就应使用断言技术,因为传进来的数据应该已在通过隔栏时被清理过了。如果隔栏内部的某个子程序检测到了错误的数据,那么这应该是程序里的错误而不是数据里的错误。

    隔栏的使用还展示了”在架构层次上规定应该如何处理错误”的价值。规定隔栏内外的代码是一个架构层次上的决策。

    辅助调试的代码

    防御式编程的另一个重要方面是使用调试助手(辅助调试的代码),调试助手非常强大,可以帮助快速地检测错误。

    尽早引入辅助调试的代码

    你越早引入辅助调试的代码,它能够提供的帮助也越大。通常,除非被某个错误反复地纠缠,否则你是不愿意花精力去编写一些调试辅助的代码的。然而,如果你一遇到问题马上就编写或使用前一个项目中用过的某个调试助手的话,它就会自始至终在整个项目中帮助你。

    采用进攻式编程

    应该以这么一种方式来处理异常情况:在开发阶段让它显现出来,而在产品代码运行时让它能够自我恢复。这种方式称为“进攻式编程”。

    假设你有一段case语句,期望用它处理5类事件。在开发期间,应该让针对默认情况的case分支显示警告信息说:“嗨!这儿还有一种没有处理的情况!改程序吧!” 然而,在最终的产品代码里,针对默认情况的处理则应更稳妥一些,比如说可以在错误日志文件中记录该信息。

    下面列出一些可以让你进行进攻式编程的方法。
    · 确保断言语句使程序终止运行。不要让程序猿养成坏习惯,一碰到已知问题就按回车键把它跳过。让问题引起的麻烦约大约好,这样才能被修复。
    · 确保每一个case语句中的defaut分支或else分支都能产生严重错误,或者至少让这些错误不会被忽视。
    · 让程序把他的错误日志文件用电子邮件发给你,这样你就能了解到已发布的软件中还发生了哪些错误——如果这对于你所开发的软件适用的话。

    计划移除调试辅助的代码

    你都有哪些常用的方法,说说看

    确定在产品代码中该保留多少防御式代码

    保留那些检查重要错误的代码 你需要确定程序的哪些部分可以承担未检测出错误而造成的后果,而哪些部分不能承担。

    去掉检查细微错误的代码 如果一个错误带来的影响确实微乎其微的话,可以把检查它的代码去掉。这里的”去掉”并不是永久地删除代码,而是利用版本控制、预编译器开关或其它技术手段来编译不包含这段特定代码的程序。如果程序所占的空间不是问题,你也可以把错误检查代码保留下来,但应该让它不动声色地把错误信息记录在日志文件里。

    去掉可以导致程序硬性崩溃的辅助调试代码 正如我所说的,当你的程序在开发阶段检测到了错误,你肯定想让它尽可能地引人注意,以便能修复它。实现这一目的的最好方法通常就是让程序在检测到错误后打印出一份调试信息,然后崩溃退出。这种方法甚至对于细微的错误也很有用。

    保留可以让程序稳妥的崩溃的代码 如果你的程序里有能够检测出潜在严重错误的调试代码,那么应该保留那些能让程序稳妥地崩溃的代码,以便辅助调试产品中的错误。

    为你的技术支持人员记录错误信息 可以考虑在产品代码中保留辅助调试用的代码,但要改变他们的工作方式,以便与最终产品软件相适应。如果你开发时在代码里大量地使用了断言来终止程序的执行,那么在发布产品时你可以考虑把断言子程序改为向日志文件中记录信息,而不是彻底去掉这些代码。

    确认留在代码中的错误信息是友好的 如果你在程序中留下了内部错误消息,请确认这些消息的用语对用户而言是友好的。

    对防御式编程采取防御的姿态

    过度的防御式编程会引起问题。如果你在每一个能想到地地方用每一种能想到的方法检查从参数传入的数据,那么你的程序会变得臃肿而缓慢。更糟糕的是,防御式编程引入的额外代码增加了软件的复杂度。防御式编程引入的代码也并非不会有缺陷,和其它代码一样,你同样能轻而易举地在防御式编程添加的代码中找到错误——尤其是当你随手编写这些代码时更是如此。因此,要考虑好什么地方你需要进行防御,然后因地制宜地调整你进行防御式编程的优先级。

    相关文章

      网友评论

      • leobert:不采取防御措施稳定性和健壮性会很差,对每个类,方法都采取防御性措施会使得代码极其臃肿。我工作中,对于可置信的部分采取的措施较少,对于不可控的部分,例如服务端接口,对通信数据的格式校验,业务校验;出错之后优雅的保持程序稳定运行,错误收集汇报;一点都不能少。 就拿推送业务来说,我收到相当多的因为运营本身的失误而发出的错误数据,并在处理透传的过程中将其抛弃。

      本文标题:防御式编程

      本文链接:https://www.haomeiwen.com/subject/ycmivxtx.html