Defensive Programming 防御式编程(Defensive Programming)

Defensive Programming

防御式编程(Defensive Programming)是提高软件质量技术的有益辅助手段

怎么理解呢?防御式编程思想的理解可以参考防御式驾驶:

在防御式驾驶中要建立这样一种思维,那就是你永远也不能确定另一位司机将要做什么。这样才能确保在其他人做出危险动作时你也不会受到伤害。你要承担起保护自己的责任,哪怕是其他司机犯的错误。

防御式编程的主要思想是:

子程序应该不因传入错误数据而被破坏,哪怕是由其他子程序产生的错误数据。

更一般地说,其核心想法是要承认程序都会有问题,都需要被修改,聪明的程序员应该根据这一点来编程序,这种思想是将可能出现的错误造成的影响控制在有限的范围内。

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

计算机领域有着一句GIGO(Garbage In Garbage Out)俗语,翻译过来就是垃圾进,垃圾出,意思就是有垃圾数据进来后,出来的也是垃圾数据

而就目前而言,对于已经成型的产品可能单单是这种原则并不适用,而是应该做到垃圾进,什么也不出垃圾进,出去的是错误提示垃圾进,经过筛选提取,出去的是有用信息或是不许垃圾进来。换句话说,GIGO于今天的标准看来已然是差劲程序的标志了。

防御式编程针对垃圾进这种情况,有以下三种方法处理:

1、检查所有来源于外部的数据

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

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

检查子程序输入参数的值,事实上和检查来源于外部的数据一样,只不过数据来源于其他子程序而非外部接口。

3、决定如何处理错误的输入数据

一旦检测到非法的参数,你该如何处理它呢?根据情况的不同,你可以选择适合你的错误处理技术断言来处理。

接下来我们将针对以上所说的情况讲解防御式编程中需要掌握的方式:

断言

断言是指在开发期间使用的、让程序在运行时进行自检的代码(通常为宏或一个子程序)。断言为真则程序正常运行,断言为假则意味着代码中发生了错误。

举个例子:一份客户信息程序要求包含记录数不超过10,我们加一个断言。当记录数小于10,断言会默默无语两眼泪,当超过10,断言就会大声的说程序中存在一个错误!

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

OC中内置的断言:(iOS每个线程都可以指定断言处理器。想设置一个 NSAssertionHandler 的子类来处理失败的断言,在线程的 threadDictionary 对象中设置 NSAssertionHandlerKey 字段即可

对NSAssertionHandler有兴趣的童鞋请移步:传送门

#define NSAssert(condition, desc, ...)  \
    do {                \
    __PRAGMA_PUSH_NO_EXTRA_ARG_WARNINGS \
    if (__builtin_expect(!(condition), 0)) {        \
            NSString *__assert_file__ = [NSString stringWithUTF8String:__FILE__]; \
            __assert_file__ = __assert_file__ ? __assert_file__ : @"<Unknown File>"; \
        [[NSAssertionHandler currentHandler] handleFailureInMethod:_cmd \
        object:self file:__assert_file__ \
            lineNumber:__LINE__ description:(desc), ##__VA_ARGS__]; \
    }               \
        __PRAGMA_POP_NO_EXTRA_ARG_WARNINGS \
    } while(0)
#endif

下面将介绍一下断言使用时的建议:

1、建立自己的断言机制

很多时候,系统自带的断言无法满足我们的需求,比如iOS断言在release模式下会失效,那么我们可以自定义断言来适应我们的项目

下面是C++的断言宏示例:


#define ASSERT(condition, message) {    \
    if (!condition) {                   \
        Log("ERROR ",condition,message);\
        exit( EXIT_FAILURE );           \
    }                                   \
}                                       \

OC中示例:

#define WYHAssert(condition, desc)  \
if (DEBUG) {                        \
   NSAssert(condition, desc);       \
}else {                             \
   NSString *app_build = [[NSBundle mainBundle].infoDictionary objectForKey:@"CFBundleVersion"]; \
NSLog(@"Assert Error condition:%@ (desc: %@) \n Occur in <%s><第%d行> , AppBuildVersion:%@",condition,desc,__FILE__,__LINE__,app_build); \
   [LogModule postLog];                            \
} \

2、用错误处理代码处理预期发生的状况,用断言去处理那些不可发生的错误!

断言和错误处理代码的区别:

断言是用来检查永远不该发生的情况,而错误处理代码(error-handling code)是用来检查不太可能经常发生的情况,这些情况是能在写代码时被预料的,且在产品正式上线时也要处理这些情况,因而说错误处理通常用来检查有害的输入数据,而断言是用于检查代码中的bug !

有种方式可以让你更好理解断言:

把断言看做是可执行的注解,你不能依赖它来让代码正常工作,但与编程语言中的注解相比,它更能主动地对程序中的假定做出说明。

3、利用断言来注解前条件和后条件

前条件(先验条件)和后条件(后验条件)专有名词最初来自于契约式设计(Design by Contract)(DbC),使用契约式设计时,每个子程序或类与程序的其余部分都形成了一份契约。

很多语言都有对这种断言的支持。然而DbC认为这些契约对于软件的正确性至关重要,它们应当是设计过程的一部分。实际上,DbC提倡首先写断言。(百度百科)

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

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

而断言是用来说明前后条件的有利工具。

下面举个例子说明:

/// 警报站座标
private class EStationCoordinate: NSObject {

    var latitude: Float?
    
    var longitude: Float?
    
    var elevation: Float = 0.0
    
    init(_ latitude: Float,_ longitude: Float,_ elevation: Float) {
        super.init()
        
        self.latitude = latitude
        self.longitude = longitude
        self.elevation = elevation
    }
}


/// 取得报警站座标
///
/// - Parameters:
///   - latitude: <#latitude description#>
///   - longitude: <#longitude description#>
///   - elevation: <#elevation description#>
/// - Returns: <#return value description#>
private func createEmergencyCoordinate(_ latitude: Float,_ longitude: Float,_ elevation: Float) -> EStationCoordinate {
    
    // precondition
    assert(-90 <= latitude && latitude <= 90, "latitude must within range !");
    
    assert(0 <= longitude && longitude < 360, "longitude must within range !");
    
    assert(100 <= elevation && elevation < 500, "elevation must within range !");
    
    // handle .... searching in local
    
    // postcondition
    assert(isContain, "local not contain this coordinate !")
    
    var coordinate = EStationCoordinate(latitude,longitude,elevation)
    
    return coordinate
    
}

如果变量latitude、longitude和elevation都是来源于系统外部,那么就应该用错误处理代码来检查和处理非法的数值,而如果变量的值是源于可信的系统内部,并且这段程序是基于这些值不会超出合法范围的假定而设计,使用断言则是非常合适的。

4、避免将需要执行的子程序放到断言中

如果把需要执行的子程序代码写在断言的codition条件里,那么当你关闭断言功能时,编译器很可能就把这些代码排除在外了,下面举一个例子:

- (void)postFileToServer {
    
    // .... make file
    
    NSAssert([self compressFileToZip], @"File can't be compressed !");
    
    // ... post to server 
}

- (BOOL)compressFileToZip {
    
    //... compress file and create a zip path !
    if (zipPath.length > 0) {
        
        return YES;
    }
    return NO;
}

这样如果未编译断言,则condition语句的子程序也将不会执行,应修改为以下:

- (void)postFileToServer {
    
    // .... make file
    BOOL isCompressSuccess = [self compressFileToZip];
    
    NSAssert(isCompressSuccess, @"File can't be compressed !");
    
    // ... post to server
}

错误处理技术

前面我们提过了,断言是处理程序代码中那些不应发生的错误,那么又如何处理那些我们预料之内的可能发生的错误呢?

首先我们要明确对于程序而言,处理错误最恰当的方式是要根据程序软件的类别而定,进而言之就是对于程序的两个概念:健壮性与正确性

程序的健壮性:健壮性具体指的是系统在不正常的输入或不正常的外部环境下仍能表现出正常的程度。

健壮性的原则:

  • 不断尝试采取措施来包容错误的输入以此让程序正常运转(对自己的代码要保守,对用户的行为要开放)
  • 考虑各种各样的极端情况,没有impossible
  • 即使终止执行,也要准确/无歧义的向用户展示全面的错误信息
  • 错误信息有助于进行debug

例如:视频游戏中的绘图子程序接收到了一个错误的颜色输入,那么在设计的时候可以针对这种情况,采用它的默认背景色或前景色继续绘制,而不是让程序崩溃或退出。

程序的正确性:正确性意味着程序永不返回不准确的结果,即使这样做会不返回结果或是直接退出程序。

例如:在设计控制治疗癌症病人的放疗设备的软件时,当软件接收到错误的放射剂量,那么也许直接关闭程序就是最佳的选择,哪怕重启也比冒险施放错误的放射剂量要好的多。

总结,两者之间的区别在于:

  • 正确性:永不给用户错误的结果,哪怕是退出程序
  • 健壮性:尽可能的保持软件运行而不是总是退出

了解了程序的健壮性与正确性,我们就可以采用以下几种手段,或结合起来使用错误处理技术:

1、返回中立值:

有时,处理错误的最佳做法就是继续执行操作并简单的返回一个没有危害的值。

比如,一个基于输入颜色的绘图子程序接收到了一个错误的颜色输入,它可以忽略这个错误的颜色,而是采用默认的底色或前景色继续进行绘制,而不是直接崩溃。

2、换用下一个正确的数据

在处理轮询查询状态的子程序时,如果某次查询出的输出数据错误或有误,大可以忽略本次错误的数据,继续等待下一次轮询时读取正确的数据(例如,如果你以每秒100次的速度读取体温计的数据,如果某一次得到的数据有误,我们可以再等上1/100秒后继续读取正确的数据)

3、返回与前次相同的数据

还是举上一个例子,如果体温计在1/100秒读取到的是一个错误数据,那么大可以返回上一次正确的数据,因为温度在1/100秒内变化不会太大。

4、换用最接近的合法值

比如,当我们在编写一个滑块在规定区域内滑动的程序时,如果滑块超过规定区域,我们可以取最接近于超过区域的安全数值返回。

5、把警告信息记录到日志文件中

在检测到错误数据时,可以选择在日志文件中记录一条警告信息,然后继续执行。

6、返回一个错误状态码

可以决定只让系统的某些部分处理错误,其他部分则不在局部处理错误,而是简单的返回一个错误码。

比如在用户信息编辑页面有一个保存按钮,当某些信息填写错误时,这时只是记录一个错误码,当点击保存按钮时才去判断验证这个错误码是否存在,决定是否允许用户执行下一步操作

7、调用错误处理子程序或对象

把错误处理都集中在一个全局的错误处理子程序或对象中,这种方法优点在于能把错误处理的职责集中到一起,从而让调试变得更简单。而代价则是整个程序都要知道这个集中点,并与之紧密耦合。

什么意思呢?比如在一系列有上下文关系的请求中,针对所有的请求错误,我们只封装一个错误管理类来集中管理这些错误。

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

这种方法可以把错误处理的开销减到最小,然而你需要衡量此时的错误消息对于用户而言是否是友善的,相反对于攻击者而言,尽量不要让他们利用错误信息来发现如何攻击这个系统。

9、关闭程序

有一些更偏向于正确性的程序,当检测到错误发生时,也许关闭程序是最佳的选择。

如上面谈到的癌症病人的放疗设备的软件

异常

异常是把代码中的错误或异常事件传递给调用方代码的一种特殊手段。

异常处理,英文名为exceptional handling, 是代替日渐衰落的error code方法的新法,提供error code 所未能具体的优势。异常处理分离了接收和处理错误代码。这个功能理清了编程者的思绪,也帮助代码增强了可读性,方便了维护者的阅读和理解。 异常处理(又称为错误处理)功能提供了处理程序运行时出现的任何意外或异常情况的方法。异常处理使用 try、catch 和 finally 关键字来尝试可能未成功的操作,处理失败,以及在事后清理资源。(百度百科)

如果在一个子程序中遇到了预料之外的情况,但并不知道如何处理的话,你就可以选择抛出一个异常。

异常的基本结构是:子程序使用throw抛出一个异常对象,再被调用链上层其他子程序的try-catch语句捕获。(内建的异常机制都是沿着函数调用栈的函数调用逆向搜索,直到遇到异常处理代码为止)

我知道听到这,肯定有人懵逼了,我们来看下面的例子:

+ (void)tryFirstException {
    @try {
        // 1
        [self trySecondException];
        
    } @catch (NSException *exception) {
        //2
        NSLog(@"First reason:%@",exception.reason);
        
    } @finally {
        //3
    }
    //4
}

+ (void)trySecondException {
    @try {
        //5
        [self tryThirldException];
        
    } @catch (NSException *exception) {
        //6
        @throw exception; //如果将这段代码注释后又会怎样?
        NSLog(@"Second reason:%@",exception.reason);
    } @finally {
        //7
    }
    //8
}

+ (void)tryThirldException {
    //9
    @throw [NSException exceptionWithName:@"Exc" reason:@"no reason!" userInfo:nil];
}

有人知道程序应该怎么执行吗?

许多常见的程序设计语言,包括Actionscript,Ada,BlitzMax,C++,C#,D,ECMAScript,Eiffel,Java,ML,Object Pascal(如Delphi,Free Pascal等),Objective-C,Ocaml,PHP(version 5),PL/1,Prolog,Python,REALbasic,Ruby,Visual Prolog以及大多数.NET程序设计语言,内建的异常机制都是沿着函数调用栈的函数调用逆向搜索,直到遇到异常处理代码为止。一般在这个异常处理代码的搜索过程中逐级完成栈卷回(stack unwinding)。但Common Lisp是个例外,它不采取栈卷回,因此允许异常处理完后在抛出异常的代码处原地恢复执行。

下面将给予一些使用异常的建议:

1、用异常通知程序的其他部分,发生了不可忽略的错误

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

2、只在真正例外的情况下才抛出异常

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

3、不能用异常来推卸责任

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

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

如果尝试在构造函数或析构函数中抛出异常,则处理异常将变得非常复杂。
比如在C++中,只有对象在完全构造之后才能调用析构函数,也就是说如果再构造函数中抛出异常,就不会调用析构函数,从而造成潜在的资源泄漏。

5、在恰当的抽象层次抛出异常

当你决定把一个异常传给调用方时,请确保异常的抽象层次与子程序的接口抽象层次一致。
(比如在A类的某一子程序中,有一个getDefenseId的方法,在方法中的某些步骤中,我们抛出了一个文件读写错误的异常,这本应由层次更低的内部类F专职去做的事,却错误的在A类中抛出异常,破坏了封装性,也暴露了一些私有的实现细节,这显然不是我们想要的)

6、在异常消息中加入关于导致异常发生的全部信息

比如因为一个索引值错误而抛出的,就应该在异常消息中包含索引的上下界限值、非法的索引下标值等信息。

7、避免使用空的catch语句

不要视图敷衍一个不知该如何处理的异常

8、考虑创建一个集中的异常报告机制

封装一个异常报告的子程序(或基类)专门快速方便的报告程序中需要主动抛出的异常(异常处理器),将对于异常的使用更加标准化

9、考虑异常的替换机制

虽然一些编程语言对于异常的支持已有5~10年甚至更久的历史,但关于如何安全使用异常的经验仍然还是很少。

拿iOS举例,Apple.inc虽然同时提供了错误处理(NSError)和异常处理(Exception)两种机制,但是Apple不建议我们主动去使用Exception,更加提倡开发者使用NSError来处理程序运行中可恢复的错误。而异常被推荐用来处理不可恢复的错误。

Important: In many environments, use of exceptions is fairly commonplace. For example, you might throw an exception to signal that a routine could not execute normally—such as when a file is missing or data could not be parsed correctly. Exceptions are resource-intensive in Objective-C. You should not use exceptions for general flow-control, or simply to signify errors. Instead you should use the return value of a method or function to indicate that an error has occurred, and provide information about the problem in an error object. For more information, see Error Handling Programming Guide.

(developer.apple.com)

感兴趣的童鞋请移步苹果官网 传送门

多说一句,虽然apple不推荐我们经常主动使用Exception,但针对于crash的异常捕捉,iOS是可以通过NSSetUncaughtExceptionHandler来捕获大部分crash的,但值得注意的是无法捕获那些由于内存溢出野指针BAD_ACCESS导致的crash,比如Flurry中对crash处理就是这么运作的。

    - (void) uncaughtExceptionHandler(NSException *exception) 
    {
        [Flurry logError:@"Uncaught" message:@"Crash!" exception:exception];
    }
 
    - (void)applicationDidFinishLaunching:(UIApplication *)application
    {
        NSSetUncaughtExceptionHandler(&uncaughtExceptionHandler);
        [Flurry startSession:@"YOUR_API_KEY"];
        // ....
    }

隔栏

隔栏是防御式编程中的一种容损策略,举个例子,大家可以这样来理解:

船体外壳上装备隔离舱,如果船只与冰山相撞导致船体破裂,隔离舱就会被封闭起来,从而保护船体的其余部分不会受到影响。
隔栏过去叫防火墙,但现在防火墙这一术语常用来代指阻止恶意网络攻击)

如下图:

5毛钱特效翻译过来就是:

左侧外部接口数据假定是肮脏的不可信的,中间这些类(子程序)构成隔栏,负责清理和验证数据并返回可信的数据,最右侧的类(子程序)全部在假定数据干净(安全)的基础上工作,这样可以让大部分的代码无须再担负检查错误数据的职责!

这种策略可以拟一个比较生动的例子,可以看做是手术室里使用的一种策略。
任何东西在允许进入手术室之前都要经过消毒处理,因此手术室里的任何东西都可以认为是安全的。这其中最核心的设计决策是规定什么可以进入手术室,什么不可以,还有把手术室的门设在哪里。

在编程中也是如此,约定哪些子程序可认为是在安全区域里的,哪些又是在安全区域外的,哪些负责清理数据(完成这一工作最简单的方法是在得到外部数据时,立即进行清理,不过数据往往需要经过一层以上的清理,因此多层清理有时也是必须的)

隔栏的应用:

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

辅助调试代码

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

应用在开发期间应牺牲一些速度和对资源的使用,来换取一些可以让开发更顺畅的内置工具。

1、应尽早的引入辅助调试代码

越早进入辅助调试代码,它能够提供的帮助也越大。如果你经常遇到某些问题,尝试自己编写或引入一些辅助调试代码,它就会自始至终在项目中帮助你。

2、采用进攻式编程

什么又是进攻式编程,其实这也是防御式编程中的一种习惯,其思想在于:

尽量让异常的情况在开发期间暴露出来,而在产品上线时自我恢复。

比如你有一段switch case语句用来处理事件,在开发期间应尽量考虑所有你能预料得到的情况并作出处理,另外在default case语句中,如果是开发阶段可以采用进攻式编程处理,而在产品正式上线期间,针对default case应更稳妥一些,比如记录错误日志。

下面列举一下进攻式编程的方法:

  • 确保断言语句使程序终止运行
  • 完全填充分配到的所有内存
  • 完全填充己分配到的所有文件或流
  • 确保每一个case 语句中的default分支或else 分支都能产生严重错误(如终止程序)
  • 在删除一个对象之前把它填满垃圾数据
  • 让程序将错误日志主动用电子邮件或推送发送给开发者(安防目前采用)

3、计划移除调试辅助的代码

如果是商用软件,调试用的代码有时会使软件的体积变大且速度变慢,从而给程序造成巨大的性能影响。要事先做好准备计划,避免调试用的代码和程序原代码纠缠不清,下面列举一些可以选择的移除方法:

  • 使用类似ant和make这样的版本控制工具
    (可以从同一套源码编译出不同版本的程序。在开发模式下,你可以让make工具把所有的调试代码都包含进来一起编译。而在产品上线期间,把那些调试代码排除在外。)

  • 使用内置的预处理器(C++ #字符为预处理器指令,包含#if、#warning、#include、#define等
    (如果你所用的编程环境里有预处理器,你可以用预处理器来包含或排除调试的代码)

- (void)handleSomething {
    
#ifdef DEBUG
    
    [self debugF];//通常为一些debug用的耗时操作
#else
    
    [self releaseF];
#endif
    
}
  • 为应用增加调试模式的入口

如果你的应用需要同时支持两种模式(发布和调试),那么我们可以自定义进入调试模式的入口,而不是针对编译层次的DEBUG,我们的调试代码的嵌入也依赖于这个调试模式是否开启,下面将演示安防app内定义的调试模式。

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

说了这么多,那么是不是防御式代码越多越好呢?

其实也不是,过度的防御式编程也会引起问题,如果你在每一个能想到的地方都用每一种能想到的方法来检查参数、处理错误,那么你的程序会变得臃肿而缓慢,更糟的是,过度的防御式代码换来的是软件的复杂度。

这说明,防御式编程引入的代码也并非不会有缺陷,和其他代码一样,你同样能轻而易举的在防御式编程添加的代码中找到错误,尤其是当你随手编写这些代码时更是如此。

因此,要考虑好什么地方需要进行防御,然后因地制宜地调整你进行防御式编程的优先级。

总结

  • 程序代码中对错误处理的方式远比GIGO复杂的多。

  • 防御式编程技术可以让错误更容易发现问题、更容易修改,并减少错误对产品代码的破坏。

  • 遵从防御式编程的思想去开发,会让你在开发阶段就提前处理了许多问题,提高代码质量,降低测试周期,要做到主动去发现错误并做出处理(千万不要存侥幸心理,明明可以多考虑几种情况,偏偏却要忽略它们的可能性),而不是等到bug隐式的出现所带来的未曾预料的灾难。

  • 错误处理技术更适用于暴露的接口程序或类,而断言则更强调不可允许的错误,多适用于不暴露在外的私有方法(或内部类)。

  • 处理错误最恰当的方式是要根据程序软件的类别而定,更倾向于正确性还是健壮性

  • 异常提供了一种与代码正常流程角度不同的错误处理手段,但同时也应该在异常和其他错误处理手段之间进行权衡比较,比如iOS中就很少采用异常处理机制。

  • 合理的运用辅助调试代码,会让你不管是在开发还是发布阶段都能更快速定位问题,并从容地解决问题。(预处理器就是个很好的选择)

最后,我对于防御式编程的理解是,我认为程序的好坏与其健壮性(和正确性)有很大的联系,所有的程序开发人员都要对它有足够的重视,主动去迎战错误,从一点一滴开始做起,不要忽视任何的细节,不能盲目依赖测试去发现bug,而是以测试驱动编程,不断地思考可能发生的问题以进行预防,做一个聪明的程序员。这才是防御式编程所告诉我们的事 !

推荐

最后列举一下文中出现的引用来源及一些推荐看的文章或书籍:

  • 《代码大全第2版》第八章防御式编程

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章