讨论一下程序设计中的错误和异常

前言

故事要从1972年贝尔实验室发明C语言说起!噢,对不起,拿错隔壁计算机语言历史研究协会的演讲稿了。

事情的起因是昨晚跟一个朋友讨论关于NullPointerException传说的问题。说到这个NullPointerException的问题啊,我三天三夜也......(观众:你到底说不说啊)。

好,马上进入正题。

说明:
因为我们讨论的是错误与异常,为了更加准确而又不混乱的描述。
我们采用“问题”来概括错误和异常,因为这两个东西都表示“出了问题”。

问题的分类

首先来研(che)究(yi)下问题的分类。”问题“表示由于用户或者系统的某些不恰当的行为导致应用程序出现了非预期的结果或崩溃,甚至引发不可恢复的灾难。(一本正经)

那么我们研究的主题就是“由什么情况所引发的问题叫做错误,又由什么情况引发的问题叫做异常”。

错误
我个人喜欢把错误分为两种,一种是数据错误,另一种是逻辑错误。(其实还有一种,叫语法错误,但是这不是一个层面的东西,所以不讨论;这种错误多喝水多看书就好了。)

数据错误是指输入的数据不符合规定。例如,程序需要一个整数,你却输入一个大大的“SB”,这就叫数据错误;更进一步,程序需要0~5的整数,你却输入一个250,这也是数据错误;再进一步,程序需要一个“.png”的图片,你却上传一个“苍老师.avi”的视频,这更加是数据错误;又进一步......(观众:行了行了我们知道了,你还有完没完)。

最后再插一句,这里的输入不一定是人为直接输入,也有可能是其他的应用输出的文件,比如word,excel等输出的docx和xlsx文件也可以是输入。

逻辑错误是指程序员写代码的时候意外怀孕,噢不,我是说意外写错某些逻辑;没错,总的来说就是程序员的锅。例如,本来产品的逻辑应该是大于90分就评价为“A”的,然而程序员小手一抖把大于90分的评价为“B”了,学生看得一脸懵逼,这就叫做逻辑错误(当然,实际情况比这个要复杂得多)。也就是我们俗称的BUG。

异常
虽然前面说了要讨论错误和异常的边界,但是其实这两个东西的边界经常是很模糊的。下面来看看!

异常到底是什么呢?简单来说,异常是由不恰当的数据或逻辑没有正确处理以及环境不符合执行某个操作的条件而引发的。也就是说,异常是由契约被破坏所引发的。(等会专门讨论契约的问题)

举个例子,用户输入了错误的用户名和密码,而应用程序就会获得一个空的用户信息对象,这时如果不及时阻止并告知用户的输入是错误的,而是继续将空的对象传递到其他内部逻辑中,而内部逻辑用空对象执行某些操作的时候就会出现严重的问题。这个时候发生的这个严重的问题就叫异常,而一开始用户输入错误的信息仅仅是个错误。

由此可见,异常比错误更晚发生,它是错误被表现出来的形式之一。错误之所以会发生,是因为使用者对外部契约的破坏;而发生异常,是因为程序员之间对内部契约的破坏或者环境不符合运行条件,那么接下来就看看关于契约的问题。

契约

上面说了外部契约和内部契约。外部契约理解起来很简单,就是用户没有输入正确的数据以及没有按照规定来使用。而我们重点要研究的是程序员之间的君子协议--内部契约(以下简称契约)。

契约式思想最初出现于Eiffel语言当中,是一种执行者和调用者之间的义务的规定方法。当然它不仅仅限于Eiffel语言,契约式思想与语言无关。它与人类社会活动中的契约有相似的含义。例如某个函数需要传递规定范围(05)的值才能正常执行,那么使用者在调用这个函数之前必须先保证传递给这个函数的值是05,否则不应该执行这个函数;而这个函数内部也没有义务帮你检查值的范围是否正确,因为这个函数可能完全不懂怎么处理值超过的问题,或者它根本就不应该去处理。

这也涉及到了编程里面最伟大的词汇——单一职责(即每个函数都只做它该做的事情,不要乱搞)。而要遵循单一职责的两个重要概念是:高内聚、低解耦。可以大胆地说,所有的设计模式,设计思想,设计方法,设计******都是围绕着这两个东西进行的,目的就是为了单一职责。(观众:说得那么牛逼,你就是想装逼)。

好了,绕来绕去的文字游戏到此结束,吃瓜群众可能已经懵逼了,到底契约跟错误和异常有什么鸟关系啊?不要急,只要记住错误、异常、契约、内聚、解耦单一职责这几个概念就行了,下面进入实例讲解环节。代码才是才是程序员的第一语言,能用代码表达的事情,千万别废话。

这里再说几句,本文出现的代码均与具体语言无关,仅仅只是抽象描述。

关于NullPointerException

不得不说的一件事就是NullPointerException的问题,相信很多程序员喜欢在程序报NullPointerException的时候,直接粗暴地在调用之前加个if语句判断是否为null,比如下面这个代码:

void myFunction( object obj )
{
    obj.doSomething(); // object对象的某个成员函数
}

这个代码是那么美好,运行的时候优雅地执行完毕,不带走一片云彩;直到有一天,一切变得可怕起来,居然从这个函数里面抛出了个NullPointerException,程序员吓坏了,立刻改了一下代码:

void myFunction( object obj )
{
    if( obj ) // 就是这么粗暴
    {
        obj.doSomething();
    }
}

一切又回归了美好。但是,等等,程序员发现有时候调用这个函数变成无效了,原来如果obj为空的情况下,doSomething()根本不会执行,所以逻辑的一致性被破坏了。不过不怕,这难不倒聪明的程序员,小case啦,看我的:

bool myFunction( object obj )
{
    if( obj )
    {
        obj.doSomething();
        return true; // 我有返回值,你就知道我调用没有啦
    }
    return false;
}

看,多完美,只要告诉调用者我到底有没有执行doSomething()就行啦。然而问题又来了,有一天发现返回了true,但是还是没有得到我想要的结果,后来发现原来是doSomething()函数里面也报错了(因为其他的原因,大家自行脑补);然后程序员继续在doSomething()函数里面加if:

/* 我本来想用双冒号来表示的
 * 发现颜色不对,就用“.”来表示吧
 */
void object.doSomething()
{
    // m_member是object的某个成员变量
    if( m_member )
    {
        // 巧了,m_member成员变量也有个doSomething函数
        m_member.doSomething();
    }
}

然后继续一层一层地加if,最后情况变得可怕起来了,不该有返回值的函数有了返回值,应该报错的函数居然把错误隐藏了;调试的时候发现本来仅仅是最初的那个obj为null的小问题,最后发现不知道在doSomething()的第几层调用中发现某个值不对。这下麻烦了。

那么这种情况应该如何处理呢?Don't be afraid,下面我用字符串拷贝的例子来说说这种情况。

字符串拷贝

先来看看最初的字符串拷贝的代码:

void string.copy(string srcStr, int srcStart, string dstStr, int dstStart, int len)
{
    int srcIndex = srcStart;
    int dstIndex = dstStart;
    for( int index = 0; i < len; ++i )
    {
        dstStr[dstIndex++] = srcStr[srcIndex++];
    }
}

如果传进来的参数srcStr和dstStr其中一个参数为空的话,那么在执行的时候就会崩溃。看看简单粗暴的解决办法:

void string.copy(string srcStr, int srcStart, string dstStr, int dstStart, int len)
{
    if( srcStr == null || dstStr == null ) return;

    int srcIndex = srcStart;
    int dstIndex = dstStart;
    for( int index = 0; i < len; ++i )
    {
        dstStr[dstIndex++] = srcStr[srcIndex++];
    }
}

OK,现在即使它们都为空,也不会崩溃了,然而还有一个更加严重问题,因为可能需要拷贝的长度大于dstStr从dstStart开始后的剩余长度(这里当然假设字符串是不会自动调整长度的啦),又或者srcStr从srcStart开始后根本就没有len个字符。如果按照这个复制的话,那么就可能会有缓冲区溢出漏洞(说人话的意思就是会把dstStr最后一个字符后面的空间也覆盖掉);所以得继续改:

void string.copy(string srcStr, int srcStart, string dstStr, int dstStart, int len)
{
    if( srcStr == null || dstStr == null ) return;

    int srcIndex = srcStart;
    int dstIndex = dstStart;
    for( int index = 0; i < len; ++i )
    {
        if( srcIndex >= srcStr.len || dstIndex >= dstStr.len )
        {
            break;
        }
        dstStr[dstIndex++] = srcStr[srcIndex++];
    }
}

现在看上去好像很完美了,是吧?(我:来人呐,把那个说是的程序员拖出去罚抄代码)。

首先,这个程序有个很大的问题,就是不管拷贝结果是什么,调用者都不知道情况,比如srcStr或dstStr为空,那么就不会执行拷贝;如果dstStr的剩余长度不足以保存len个字符,那么就会拷贝少几个字符;如果srcStr的剩余长度不足,那么就会拷贝一些奇怪的字符到dstStr中;不管是这三种情况的哪种,得到的dstStr都是不正确的。并且我们也难以描述这个函数的名字(指的是copy),我们能叫它copy吗?不行,因为它的职责不再是单纯的copy了,还把错误给隐藏了。

A: 直接加个bool返回值就好啦。
B: 但是怎么描述三种情况呢?这里可是有拷贝成功、根本没拷贝、拷贝少(多)了的情况啊。
A: 那直接加个int返回值,0表示成功,1表示拷贝少了,2表示没拷贝。
B: 聪明!但是问题是为什么一开始参数不传对呢?
A: 咦......

好,说了一大堆,终于来到所谓的契约了。其实是这样的,string.copy()函数根本就不应该隐藏空指针和处理长度不够的问题;

这就好像家里没电饭锅,却硬要保姆去做饭一样,巧妇难为无米之炊。当然不是说不能做,而是保姆收到的是煮饭的需求,结果还要去买电饭锅,然后发现没钱买电饭锅,因为雇主根本没有给她钱买。然后你还想保姆能顺利完成任务?那么保姆怎么办呢?可以有两种方式:1、隐藏实际问题,去烧个火堆把米煮熟,当然可能会没煮熟就给你了。2、直接报告问题,说家里没有电饭锅。

这种的情况就和上面的程序一样,明明收到的只是执行copy任务,却还要我排除万难去解决一堆本来不是我擅长做的事情。
string.copy君: 我真的只是个copy函数而已

那么copy函数究竟应该做什么呢?应该这样,遇到不正确的前提条件,立刻报错,不要隐藏,不要做任何处理。前提条件都没有满足,还想我给你结果?没门,我直接把问题暴露出来,由上层去处理,该拨经费的拨经费,该配人员的配人员。那么程序就会变成这样:

void string.copy(string srcStr, int srcStart, string dstStr, int dstStart, int len)
{
    if( srcStr.len-srcStart < len || dstStr.len-dstStart < len )
    {
        throw LenghtErrorException;
    }

    int srcIndex = srcStart;
    int dstIndex = dstStart;
    for( int index = 0; i < len; ++i )
    {
        dstStr[dstIndex++] = srcStr[srcIndex++];
    }
}

当dstStr的剩余空间不够放len个字符或者srcStr的剩余空间不够len个字符时,就会抛出一个异常,当然这个异常最好自己定义,然后加上足够的信息,比如异常的具体原因。这里可能会问,为什么不判断空指针呢?因为一般的语言遇到空指针调用都会自动抛NullPointerException的。

现在这个函数符合了单一职责了,不会做其他傻事了。那么就可以好好来说说契约这个东西是怎么跟错误和异常产生关系的了。

在程序里面,函数间会有很多很复杂的调用关系,我们不可能在每个函数里面都去隐藏或处理各种额外的问题,但是错误和异常又是客观存在的,所以我们需要一个契约,使用者要调用函数,那么在调用前必须把前提条件都满足好,函数才开始执行操作。但是如果调用者的数据也是从其他地方传递过来的,怎么办呢?

这就涉及到契约里面被调用者应该履行的义务了,例如:

// 在字符串两边添加中括号,并返回新的字符串
string addBracket( string s )
{
    string newStr = new string(s.len + 2); // 需要两个额外的空间来放括号
    string.copy(s, 0, newStr, 1, s.len); // 第一个空间要放左括号

    newStr[0] = '[';
    newStr[newStr.len-1] = ']';
    return newStr;
}

参数s是一个调用addBracket的使用者传递过来的,那么就有可能是null值。但是因为addBracket也是一个被调用者,所以同样可以定一个契约,让更上一层保证传递过来的值不为null。就类似于这个函数是一个中介,它要完成添加中括号这个任务需要string.copy()的帮助,那么它就必须符合string.copy()函数定的契约。而addBracket自己本身也是被调用的,所以更上层的使用者在调用addBracket的时候也必须满足addBracket的契约。那么addBracket里面就不需要去确定s是否为空了,那是上一层需要关心的问题。而如果上一层的数据也是其他的子程序传递的呢?那么接下来看看数据流动性的问题。

数据流动性

数据在程序中经过加工处理,生成新数据,然后再到另一个子程序加工处理,再生成新数据......以此类推,这叫数据的流动性。

现在就从数据在程序中的流动过程来看错误和异常的边界,我们假设有四个函数,分别是FuncA、FuncB、FuncC、FuncD;它们都对数据进行一些处理,并把数据继续传递到下一个函数:

string FuncA( string s )
{
    ...... // 做一些处理
    return FuncB( string s );
}

string FuncB( string s )
{
    ...... // 做一些处理
    return FuncC( string s );
}

string FuncC( string s )
{
    ...... // 做一些处理
    return FuncD( string s );
}

string FuncD( string s )
{
    ...... // 做一些处理
    return s;
}

那么经过这些函数的处理后,返回的string数据应该是我们想要的结果。那么我们来看看什么情况下问题应该被看作错误,而什么情况下会被看作异常。我们假设拿用户输入的数据来调用最上层的FuncA:

string res = FuncA(s); // 这个s是用户输入的数据

这时,用户输入的数据可能是任何字符串,也可能是null。那么这个时候,如果每个FuncX函数都对传入的数据进行验证,那么每个函数都会非常复杂,甚至会出现无法预料的结果。因为可能有些错误被它们中的一个隐藏了,所以当没有出现问题的时候,数据是正常的,一旦出现了问题,res还是会被生成,但这个res可能是错的,而这个res又有可能传递到系统的其他子程序中,直到最后的逻辑必须要求所有数据都是正确的时候才发生异常,这样出来的错误可能就已经不是最初引发错误的原因了。那么如果这样做了,系统在特定时候就会异常满天飞。

有可能出现下面的情况:因为网络原因导致的某个文件没有下载,然后读取的时候发现文件不见了,却去判断没有那个文件存在的时候就自己创建的一个空的字符串,继续传递给其他函数,那个函数没有解析出值,就判断了一下空指针继续执行自己的事情,然后到了要拿某个image地址去获取图片的时候发现图片不见了,然后拿到null的图片对象传到系统内部,抛出了一个NullPointerException,这时候,你要多久才能发现是因为网络连接问题没有处理好的原因?

回到那几个FuncX。当用户输入s之后,程序应该在调用FuncA之前就对数据做完备性校验,只要传递到FuncA之后,那么就应该相信,内部在正常情况下是不会出现问题的。这样就相当于程序的边界逻辑(就是程序的最外层接口)保证输入系统的数据是正确的,也就是传递给FuncA的数据是绝对符合FuncA的要求的。那么就仅仅需要保证FuncA不会生成违反FuncB契约的数据,FuncB不会生成违反FuncC契约的数据,FuncC不会生成违反FuncD契约的数据,而FuncD不会生成未预期的数据。那么最终出来的res就是正确的。否则一开始根本就不应该调用FuncA。

那么如果确定进入FuncA之前,数据是正确的,但是程序还是异常了呢?那绝对是FuncX中的一个或多个逻辑有问题。这时抛出的异常应该可以很容易看出BUG出在哪里。

内部逻辑处理外部数据

很多情况下,我们可能会提供一个文件路径给某个函数,然后这个函数在内部对文件进行读取,然后就有可能在不存在文件、读取的时候发生异常等情况。那么这种情况的错误和异常应该如何处理呢?
首先,确定这种设计是否合理,这个函数是属于核心的还是系统边界的。由边界模块来读取文件并传递进去的设计是否更加科学?
如果这个函数已经是读取文件的边界逻辑了,比如C语言里的open()函数,你很容易就可以保证传递的文件路径和读取方式是正确的,但是它的内部还是有可能会出现错误(即使你保证那个文件在open()之前是存在的,也有可能在open的时候瞬间被删除了),所以针对这种情况就要明确抛出异常,并且告知使用者。而这些事情最好是由边界逻辑来做。

那是否就不要判断null了呢?

说到这里,可能会觉得以后都不去判断空指针了。然而并不是;有两种情况下是需要空指针判断的,一种是在系统边界上,当接收到外部输入的时候,就需要对空指针进行判断,并拒绝不应该进入系统的数据,比如登录模块中的用户名密码判断,可能客户端没有传递用户名或者密码,那么就是空的,这个时候就应该判断,并且给出足够的信息去引导用户发送正确的数据。另一种情况是确实需要null来去标记某些变量的时候,例如:

单例模式中用于判断是否要创建对象

// 突然出现类不会觉得奇怪吧?
class MySingleton
{
    private static MySingleton _instance = null;

    public static MySingleton instance()
    {
        if( _instance == null )
        {
            _instance = new MySingleton();
        }
        return _instance;
    }
}

如果图片为空就显示默认图片

Image img = Image.createFromFile("img.png");
if( img == null )
{
    img = defaultImg;
}

在这个程序里面,img.png不存在顶多算个错误。但如果defaultImg也为空的话,那么这就算是异常了,因为默认的图片理应是始终存在的,如果它不存在,那么可能是被用户删了,或者用户做了一些操作导致它被删了,也或者是因为权限问题读取不了。不管什么情况,都不是应用程序自己能够恢复的问题。(当然有方法恢复,把默认图片的数据写到程序中,如果检测到默认图片丢失了,就重新生成,又或者把默认图片放在服务器上,如果发现本地图片丢失了,就到服务器获取。但是不管怎么样,到最终都会有超越你应用程序职责的情况出现,比如程序被篡改了,导致你无法生成默认图片;在服务器获取图片的时候网络不佳,导致无法拿到。所以不要陷入这种无限的循环当中,这是不值得的)。

其实还有更多的情况是需要空指针的,根据不同的需求和环境都会有不同的用法。例如在 “约定优于配置”的设计、配置覆盖设计中就可通过判断用户是否有提供某个值,如果有则使用用户的配置,如果为null,则使用默认配置。这是因为null也在设计当中。

对于null的判断与否,我自己总结了一个原则:如果null是因为错误而产生的,那么我们不应该去判断它,而是直接让它抛异常;如果null是被设计所包含的,那么应该判断它,因为设计包含的一定会有为null的情况如何创建它的方式;比如上面说的约定优于配置的设计

用户接口层和核心逻辑层

好了,终于到最后了。最后主要是讨论一下边界和核心的问题。用户接口层就属于一个服务级系统的业务边界。当然,边界的概念是动态的,例如linux 系统的边界可以bash、zsh,也可以是你自己写的应用程序。一个模块,一个类,一个函数也有它的边界;比如类的边界就是public函数。所以在对错误和异常的考虑中,应该清楚地知道设计的系统边界在哪里。而错误的检测和处理,就在边界进行,并且程序尽可能地不要生成错误的数据,让错误的数据止于系统边界,只要进入了系统的数据,都应该假设是正确的。例如,在网站设计中,我们应该保证插入数据库的数据是正确的,而不是在读取的时候去做各种无效的判断。

那么这样说是不是就是系统内部出了错就直接崩溃了呢?并不是,应该是尽可能的在边界逻辑中收集错误数据,并且想办法恢复,如果系统设计的好的话(其实就是解耦和内聚的好),是可以将异常隔离开的,也就有可能让应用程序不重启的情况下恢复到执行这个会发生异常的操作之前。而我个人更倾向于在发生异常的时候加入人为协助恢复,也就是在发生异常的时候尽量询问和引导用户来协助应用程序恢复。如果实在无法恢复的话,就让它原地爆炸吧,千万不要因为连带错误而篡改了用户数据。

好了, 一大堆废话讲完了。谢谢大家。(往台下看了一眼,发现人都跑光了)。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,560评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,104评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,297评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,869评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,275评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,563评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,833评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,543评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,245评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,512评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,011评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,359评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,006评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,062评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,825评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,590评论 2 273
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,501评论 2 268

推荐阅读更多精彩内容