过眼•物影天狼

博客通天下 淡墨书豪辞

Flower

Archive for the ‘基本原理’ Category

答 zhangdi 同学的问题

      Zhangdi 同学说,评论我的《Java 和 .Net 在异常处理机制上的区别》帖子说:我并不认为 C#(.NET)这样处理 Exception 是好的。我觉得这与 strong-type 的概念相冲突。几个问题:

  1. 在设计一个接口的时候,是不是需要同时告诉用户,这个接口有可能抛出什么类型的 Exception?

    答:这种说法自然是没错的,异常应该是接口设计的一部分,但是告知用户的方式可以有很多种。Java 的这种方式只是其中较为有效的方式之一,他通过编译器做出严格的限制,这其中兼有优劣。

  2. 如果需要,那么这个 Exception 类型是否应该成为一个接口声明的一部分?

    答:其实这还要从程序整体的设计角度来讨论。在 Java 中非 RuntimeException 其实已经不是一种普通的异常那么简单。因为最终在程序运行的时候,Java 不允许有非 RuntimeException 被抛出到虚拟机,这也就是说这种异常看上去只存在于设计时。他更像是程序运行期间的一种消息传递机制,只是首先这种消息“有别于(Except)”其他的返回类型,另外就是它会沿着调用栈强行向上追溯。显然这种异常更多的是在程序设计阶段起到操控程序非正常运行时行为的作用。因此他更像是一种接口,自然声明这样的接口也是理所应当的。

          但是 Java 还有 RuntimeException 的概念,而 .Net 或许就是没有区分他们而已,因为一旦程序遇到异常,无论是被程序本身恢复,还是被虚拟机捕捉到导致强行非法结束,都是可以接受的,毕竟对用户而言除了造成不方便,并不会有安全上的隐患。

  3. 一个已知的有可能发生的 Exception 被操作系统捕获是否可以说是用户在处理这个接口的声明的时候,出现了疏忽呢?

    答:这个自然,如果不是 Java 概念中的 RuntimeException,那么如果真有这样的异常被虚拟机捕捉到肯定就是 Bug,但至少是一个安全的 Bug。而同时,就像我在帖子里说到的,需要在开发难度和设计完整性上达到一定的平衡。.Net 这样自然忽视了后者,但同时显然使用就方便多了。

  4. strong-type 的目标是在编译器发现尽可能多的关于类型的错误,如果我们把接口的定义看作类型的一部分,那么是否可以说,这个有可能将 checked exception 暴露给操作系统的编译器在 strong-type 上有缺陷呢?

    答:没看懂!:-)

      还是那句话,我觉得我写这篇帖子已经足够公正了。.Net 在异常处理的设计上显然不如 Java 更趋近完美,但毕竟还是可以用的,还是好用的,剩下的瑕疵是可以通过程序员的经验,以及良好的开发习惯和管理来弥补的。

Java 和 .Net 在异常处理机制上的区别

      关于 Java 和 .Net 优劣的争论一直在继续,而在异常处理方面体现得最为激烈,因为他们之间的差异是如此明显。.Net 晚于 Java 出现,那么 Java 对 .Net 就理应起到很重要的借鉴作用,但是伟大的 Anders Hejlsberg 为什么没有继续 Java 的实现方式,而是另辟蹊径,这是一个非常值得研究的问题。因为我们要承认一个真理:正确的东西大家都是一样的正确,错误的却各有个的错误。可以肯定这不可能是 Anders 的疏忽,那么他的道理究竟何在,或者说他们之间究竟有什么区别?

      在你能耐下心来看完这篇帖子之前,我想要明确告诉你一个结论:Java 和 .Net 在异常处理的本质上是没有区别的。

一、Java 是如何处理异常的

      如果一个 Java 方法要抛出异常,那么需要在这个方法后面用 throws 关键字定义可以抛出的异常类型。倘若没有定义,就认为该方法不抛出任何异常。如果从方法的入口和出口的角度去考虑一下这个规范,我们知道参数可以认为是方法的入口(当然某些情况下也可以是出口),而返回值则是方法的出口,这是在程序正常执行的情况下,数据从入口入,出口出。要是程序非正常执行,产生异常又当如何? 被抛出的异常应该如何从方法中释放出来呢? Java 的这种语法规范就如同给异常开了一个后门,让异常可以堂而皇之“正确”地从方法里被抛出。

      这样的规范决定了 Java 语法必须强行对异常进行 try-catch。设想一下,对于以下的方法签名:

public void foo() throws BarException { ... }

暗含了两方面的意思:第一,该方法要抛出 BarException 类型的异常;第二,除了 BarException 外不能抛出其他的异常。而正是这第二点的缘故,我们要如何保证没有除 BarException 之外的任何异常被抛出呢? 很显然,就需要 try-catch 其他的异常。也就是说,一般情况下,方法不抛出哪些异常就要在方法内部 try-catch 这些异常。

      Java 这样的机制既有优点,也有缺点。先来说说优点:

  • 很显然,这种规范是由 Java 编译器决定的。倘若 Java 程序的入口点 main() 方法没有任何异常抛出,就是说要在 main() 方法内部,即整个程序内部捕捉所有的异常,否则将无法通过编译。这样编译器保证了程序对每个异常都有相应的计划和处理,不会有未处理的异常被泄露到虚拟机中,导致程序意外中断或退出,也就是增强了程序的健壮性。当然,Java 有 RuntimeException 的概念,这样的异常仍然可以随时被抛出到虚拟机中。
  • 强行 try-catch 要求把异常作为程序设计的一部分看待。就如同方法的参数和返回值一样,在编写一个方法时,要结合上下文做出通盘的考虑和打算。虽然异常是所谓的“意外情况”,但是这些“例外”理应是被我们全部了解并处理的。
  • 方便调试。异常理应在正确的位置被捕捉。当异常发生时,我们能更清楚的了解到其来源和相应处理程序的位置,而免去了在整个调用栈中摸索的麻烦。
  • 在不借助任何文档的情况下,从方法签名就可以知晓应该对哪些异常进行处理。

      Java 异常处理机制的这些优点也直接导致了他的致命弱点:将程序变得异常繁复。往往一个简单的程序,功能代码寥寥几行,而异常处理部分却占用了程序的绝大部分篇幅;同时导致缩进深度加深,既不利于书写,也不利于阅读。另外他的强行 try-catch 需要程序员有更高深的造诣,能够通盘考虑异常处理设计问题,这个在程序开始之初或者对于初学者是一个不小的门槛。这往往会阻碍其推广与发展,因为低水平初学者的信心往往因此而受到打击。然而对于高手来说,编译器是否能帮助他们找到未被处理的异常只是一个方便与否的问题,只要在编写方法时注意了异常处理,即便没有编译器的支持,情况也不会糟糕太多。反而倒是由于要遵循这样复杂的异常处理规范,以至于大多数人都可能为了图一时方便,对异常的基类型 Exception 或 Throwable 进行笼统地捕捉,这样做的危害就是那些你无法处理的异常被溺死在处理程序中,(按照异常处理原则,我们应该只捕捉那些可以被处理或恢复的异常,而把其他的异常继续抛出。至于这样做的优势,以及不这样做所带来的问题,不是一两句能够说清楚,这里就不展开讨论了。)导致程序的不稳定和不确定。既没有发挥 Java 语法在这方面的优势,反而增加了忧患。

二、.Net 是如何处理异常的

      一句话概括 .Net 的异常处理方式就是随心所欲。没有人要求你一定要抛出异常,也更没有人要求你一定要捕捉异常。未被捕捉的异常会被以 Unhandled Exception 的形式抛出到虚拟机中。在此我就要先解决一下文章开头提到的问题,为什么说 Java 和 .Net 这两种异常处理机制在本质上是相同的。可以从两个方面来考虑:

  1. 默认情况下。Java 在默认情况下 main() 方法是不抛出异常的,正如前面所说的,这要求所有的异常都必须在 main() 方法内部被捕捉;而 .Net 则没有这种约束,他的 Main() 以至于整个应用程序中的任何一个方法对异常都是完全开放的。这样来看,这两者刚好是对立互补的。
  2. 非默认情况下。Java 可以通过在 main() 方法后面加 throws 关键字使得整个应用程序对异常开放;而 .Net 则可以通过给应用程序域(Application Domain)的 UnhandledException 事件添加委托达到捕捉所有异常的目的,很显然这又是对立互补的。

因此,就好像一个是“正反”,一个是“反正”,加在一起“正反反正”都是一样的,对于达到控制异常的目录来说,是没有区别的。

      很多 Java 爱好者都鄙视 .Net 的这种行为,一方面他令程序变得不够健壮,因为默认情况下没有强制的办法要求所有的异常都被处理,或被正确处理;另外,他为调试增加了困难,不借助文档或代码你将无法了解到一个方法可能抛出什么异常,而当一个异常被抛出的时候,同时异常处理代码又写得不够完善,你将不得不仔细查看整个调用栈来确定异常出现的位置,而对于这一点 Java 默认是强制的。

      但是 Anders 的想法总是有道理的。

  1. .Net 代码写起来非常容易。这是对于初学者,或者那些只是想实现一些测试性小功能的人而言,你完全没有必要考虑太多异常处理的细节,你要的就是写代码,然后让他跑起来。这样的简单性无疑是你希望看到的,这样的简单性无疑更有利于 .Net 在市场上的推广。由于他在这方面并没有什么理论上的漏洞,也就仍然适合构建庞大的项目,只是感觉没有那么舒服罢了。
  2. 一定程度上增加了程序的安全性。难道不捕捉异常可以被成为是安全的吗?这个话也许要从另外一方面来想,前面说过,有些 Java 程序员(绝对不占少数)为了图省事,在强行捕捉异常的压迫下,选择捕捉异常的基类型,也就是捕捉所有的异常。这样当有你无法处理的异常出现时,他们就溺死在了你的代码中,而外面的程序全然不知,还在以一种不确定的状态运行着,这就可能是危险的开始。而如果是 .Net,那么 Unhandled Exception 会被虚拟机捕获,导致程序异常退出,虽然这从面子上对于用户不是一个好的交代,但是深层次地他避免了程序在危险的状态下继续运行。

      总之,萝卜白菜各有所爱。我的这篇帖子力求公正地讨论了这个问题,希望能对你有所帮助。

测试驱动开发和软件开发流程模型

      我终于决定不再继续看极限编程大师 Kent Beck 所写的那本 Test Driven Development: By Example 了。因为我实在无法忍受我与作者在软件开发流程模型上的分歧。但这并不妨碍我认同并实践作者所谓“测试先行”的理念。我是一个趋近完美主义者,因此我更喜欢像设计模式那样偏重设计的方法论,我不能认可一个靠不断修改而成长起来的软件。

      按照 Kent Beck 极端的测试驱动开发模型,一个软件应该是这样写出来的:

  1. 写测试代码。
  2. 编译测试代码,编译无法通过。(因为没有实现代码。)
  3. 写一份最简单的实现代码,让编译通过。
  4. 迭代地修改代码以减少实现代码的重复度,降低实现代码和测试代码之间的耦合度。

就是这样,一个软件被一点一点修改而成。他强调了在这个过程中有两个主意事项:第一,在添加任何功能之前都要先写测试,除非代码是用于调试的,这也就是所谓测试先行的概念;第二,当编译无法通过时尽量不要再写新的测试。

      我之所以不认同这个模型,就是因为其中间产生了大量的迭代过程,而这些过程很可能只在头脑中出现,或者根本就不曾出现。这些过程一方面增加了开发的消耗,也在某种程度上引入了更多的不确定性。但是其中测试先行的做法,能最大程度保证单元测试的完整性与正确性。

      因此,我理想中的开发流程模型应该是这样的:

  1. 用户需求调研,导出成软件需求
  2. 根据软件需求进行设计
  3. 根据设计编写测试用例
  4. 根据测试用例编写测试代码
  5. 构建软件框架使测试代码能够编译通过
  6. 写 Mock 来保证测试用例代码准确无误
  7. 按部就班地写程序,用测试用例来保证所写的代码准确无误

这其中可以按照极限编程的思想以一个可独立发布可运行的模块作为单位,这样可以有效地控制模块的大小,还可以避免出现过度设计的问题。

如何重写 Equals 方法

      不要以为这是件容易的事情,先来看一个正确的 Equals 方法应该具备的条件:

  1. obj.Equals(obj) 应该永远返回真
  2. obj1.Equals(obj2) 和 obj2.Equals(obj1) 应该返回相同的结果
  3. 如果 obj1.Equals(obj2) 而 obj2.Equals(obj3) 那么 obj1.Equals(obj3) 应该也为真

别以为我的智商就小学水平,回去看看你的代码,这些都能做到吗?写一个具备如此条件的 Equals 方法不是件容易的事情!从实现角度说,一般来说分为两种情况:

一、给直接继承自 Object 基类的类型重写 Equals 方法

public class MyType {
    private int x;
    private string s;

    public override bool Equals(object obj) {
        // 如果 obj 是空的话,就直接返回假
        // 因为当前对象不会是空,否则在还没有调用 Equals 方法之前就会先抛出空指针异常
        if (obj == null) return false;

        // 如果 obj 与当前对象不属于同一个类型,也直接返回假
        // 显然两个不同类型的对象不可能相等
        if (this.getType() != obj.getType()) return false;

        // 这个强制类型转换是不会抛出异常的
        // 因为你已经知道 obj 属于该类型
        MyType o = (MyType)obj; 

        // 最后逐个比较他们的成员变量是否相等就可以了
        if (this.x != o.x || this.s != o.s) return false;

        return true;
    }
}

二、给间接继承自 Object 基类的类型重写 Equals 方法

// MyType2 继承自 MyType,而不是直接继承自 Object
public class MyType2 : MyType {
    private DateTime d;
    
    public override bool Equals(object obj) {
        // 如果基类认为这两个对象不相等
        // 直接返回假
        if (!base.Equals(obj)) return false;

        // 两种实现方法的区别仅在于此,下面都是一样的
        // 直接继承自 Object 的类型之所以没有上面这一段
        // 是因为 Object 判断两个对象是否相等的逻辑非常简单
        // 通常就是判断他们的引用是否相等。因为当前对象和 obj 很有可能是不同的引用
        // 所以这种情况下,你的 Equals 方法绝大多数情况下都会返回假
        // 如果 obj 是空的话,就直接返回假
        // 因为当前对象不会是空,否则在还没有调用 Equals 方法之前就会先抛出空指针异常
        if (obj == null) return false;

        // 如果 obj 与当前对象不属于同一个类型,也直接返回假
        // 显然两个不同类型的对象不可能相等
        if (this.getType() != obj.getType()) return false;

        // 这个强制类型转换是不会抛出异常的
        // 因为你已经知道 obj 属于该类型
        MyType o = (MyClass)obj;

        // 最后逐个比较他们的成员变量是否相等就可以
        if (this.d != o.d) return false;

        return true;
    }
}

参考了 Jeffry Richter 的 Applied Microsoft.NET Framework Programming 一书。

一个好的错误提示信息应该是什么样子的

礼貌:让用户感觉像是一个白痴是非常不好的。

可读性:粗劣的语法和易曲解的句子结构是不好的。

正确性:错误信息必须正确描述问题所在。

精确性:“什么东西错了”是个正确的错误信息,但不非常精确。

诊断而不是指示:描述问题而不是解决方法。

可翻译性:可以被容易地翻译成其他语言。

什么是堆栈?

      堆栈其实不只是我们平常意义上所谓的具有后进先出特性的数据结构。严格来讲并不存在堆栈这样一种结构,只是在日常工作中我们将前述的这种数据结构称为堆栈罢了,但其实确切的说应该叫做栈 (Stack) 。而堆 (Heap) 其实是另一种允许随意访问的数据存储空间。

首先从汇编的角度来理解堆和栈
      我们都知道在汇编语言中有著名的三个段:代码段,数据段和堆栈段。同为存储数据为什么有两个不同区域呢?其实我们仔细想一下就会明白,我们所谓的堆栈段,或者说栈段,正是那种支持后进先出特性的内存区域。汇编语言里面的 POP 和 PUSH 两个指令就是来操作堆栈段的。而对于数据段我们可以在其中开辟自己命名的内存空间,然后使用指针来访问,这正是堆。

标准 C++
      再将语言提升一个层次,在标准 C++ 中,是否也有这样的区别呢?答案是肯定的。先来看这样的一个类。

class MyClass {
    public:
        MyClass(int _a, int _b) {
            a = _a;
            b = _b;
        }

        ~MyClass() {};

    private:
        int a;
        int b;
};

然后我们声明 MyClass 类的实例,问题就出来了。
void main() {
    MyClass myObj(1, 2); //此时该对象位于栈上
    MyClass* pMyObj = new MyClass(1, 2); //此时该对象位于堆上,并通过指针与我们交流。
    ...
}

      上面的示例似乎在表明这样一个原则,对象所存在的位置与程序员声明的方式有关。是的!不仅如此,栈对象和堆对象的行为也是不一样的。我们都知道从汇编角度来看,当一个子程序退出时,我们需要使用 RET n 来退栈,即将在子程序中使用过的内存空间释放。因此栈对象会随着方法执行的结束而自动释放,不会产生泄漏。而堆对象却是不可以的,因此我们才须要在方法退出之前,手动释放内存空间,即 delete pMyObj; 这也是我上面为什么给出省略号的原因。

在 .net Framework 中
      在 .net 中问题又有所不同,由于内存被 CLR 托管,我们不能再随意地将对象放在你希望的位置上了。这部分工作完全由 CLR 来接管。CLR 的实现是所有的值对象都被放在栈上,当方法退出时自动销毁;而所有的引用对象都被放在托管堆上 (由 CLR 的内存回收服务控制的内存区域被称为托管内存或托管堆,而前面提到的标准 C++ 中的堆相应的被称为本地堆),通过托管的指针,在 C# 中是对象引用,在 C++/CLI 中是追踪句柄,来访问。它的释放不依赖于方法的退出,也不依赖于程序员,而是依赖于内存回收机制。

      这里就引出了另外一个问题,为什么装箱 (Boxing) 和拆箱 (Unboxing) 操作会有性能损失。因为对于所有值类型都为栈上,而将其转变为引用类型 object 会发生两个动作,一是将值对象从栈拷贝到托管堆上,然后再给其加上一些原数据 (Metadata)使之可以被托管堆控制,这比无论是直接访问值对象还是引用对象而言都要额外消耗不少的时间,因此也就产生了性能问题。拆箱的原理刚好相反。