八、异常、内存和性能

异常处理程序的主要职责是让程序员摆脱错误,让用户大吃一惊。只要你牢记这条基本原则,你就不会错得太离谱。—Verity Stob

尽管缺乏语言特性或运行时环境的吸引力,理解异常和内存管理将有助于您编写更好的 TypeScript 程序。对于使用过 C#、Java、PHP 或许多其他语言的程序员来说,JavaScript 和 TypeScript 中的异常可能看起来很熟悉,但是有一些细微但重要的区别。异常处理和内存管理这两个主题有着千丝万缕的联系,因为它们共享一个语言特性,这将在本章的后面描述。

记忆管理的主题经常被民间传说、谎言和盲目应用的最佳实践所支配。本章讨论了内存管理和垃圾收集的事实,并解释了如何进行测量来测试您的优化想法,而不是应用一个可能影响很小或没有影响的实践(甚至比原始代码执行得更差)。这将简单地引出性能的主题。

例外

异常用于指示程序或模块无法继续处理。就其本质而言,它们只应在真正特殊的情况下提出。因此得名!通常,异常用于指示程序的状态是无效的,或者继续处理是不安全的。

虽然每当例程将不合意的值作为参数传递时就开始发出异常可能很诱人,但处理您可以预料到的输入而不引发异常通常会更好。

当您的程序遇到异常时,它将显示在控制台中,除非用代码进行处理。控制台允许程序员编写消息,它会自动记录运行程序时发生的任何异常。

您可以在所有现代 web 浏览器中检查控制台的异常。快捷键因浏览器而异,因平台而异,但如果CTRL + SHIFT + I在您的 Windows 或 Linux 机器上无法工作,或者CMD + OPT + I在您的 Mac 上无法工作,您可以尝试 F12 键,或者在浏览器菜单中找到列在“开发人员工具,浏览器控制台”或类似名称下的工具。对于 Node,错误和警告输出将出现在用于运行 HTTP 服务器的控制台窗口中。

抛出异常

要在 TypeScript 程序中引发异常,可以使用throw关键字。尽管您可以对任何对象使用这个关键字,但是最好提供一个包含错误消息的字符串,或者包装错误消息的Error对象的实例。

清单 8-1 显示了一个典型的异常被抛出以防止一个不可接受的输入值。当用数字调用errorsOnThree函数时,它返回数字,除非用数字 3 调用,在这种情况下会引发异常。

function errorsOnThree(input: number) {
    if (input === 3) {
        throw new Error('Three is not allowed');
    }

    return input;
}

const result = errorsOnThree(3);

Listing 8-1.Using the throw keyword

本例中的一般Error类型可以替换为自定义异常。您可以使用实现清单 8-2 中所示的Error接口的类来创建一个定制异常。Error接口确保你的类有一个namemessage属性。

清单 8-2 中的toString方法不是Error接口所必需的,但是在很多情况下被用来获得错误的字符串表示。如果没有这个方法,来自ObjecttoString的默认实现将被调用,它将把[object Object]写到控制台。通过将toString方法添加到ApplicationError类中,您可以确保当异常被抛出并被记录时显示一条适当的消息。

class ApplicationError implements Error {

    public name = 'ApplicationError';

    constructor(public message: string) {
        if (typeof console !== 'undefined') {
            console.log(`Creating ${this.name} "${message}"`);
        }
    }

    toString() {
        return `${this.name}: {this.message}`;
    }
}

Listing 8-2.Custom error

您可以在throw语句中使用自定义异常来对已经发生的错误进行分类。一种常见的异常模式是创建一个通用的ApplicationError类,并从它继承来创建更多特定种类的错误。然后,任何处理异常的代码都能够根据抛出的错误类型采取不同的操作,这在后面的异常处理一节中有演示。

清单 8-3 显示了一个继承自ApplicationError类的特定的InputError类。errorsOnThree函数使用InputError异常类型来突出显示错误是对错误输入数据的响应。

class ApplicationError implements Error {

    public name = 'ApplicationError';

    constructor(public message: string) {
        if (typeof console !== 'undefined') {
            console.log(`Creating ${this.name} "${message}"`);
        }
    }

    toString() {
        return `${this.name}: {this.message}`;
    }
}

class InputError extends ApplicationError {
}

function errorsOnThree(input: number) {
    if (input === 3) {
        throw new InputError('Three is not allowed');
    }

    return input;

}

Listing 8-3.Using inheritance to create special exception types

例子中的InputError只是简单的扩展了ApplicationError;它不需要实现任何属性或方法,因为它只是提供一类在程序中使用的异常。您可以创建异常类来扩展ApplicationError,或者进一步专门化ApplicationError的子类。

Note

你应该把本机类型视为神圣的,永远不要抛出这种类型的异常。通过创建自定义异常作为ApplicationError类的子类,您可以确保Error类型是为在真正的异常情况下在您的代码之外使用而保留的。

异常处理

当抛出异常时,除非异常得到处理,否则程序将被终止。要处理异常,可以使用 try-catch 块、try-finally 块,甚至 try-catch-finally 块。在任何一种情况下,可能导致引发异常的代码都被包装在 try 块中。

清单 8-4 显示了一个 try-catch 块,它处理前面部分中来自errorsOnThree函数的错误。由catch块接受的参数代表抛出的对象,例如,Error实例或自定义ApplicationError对象,这取决于您在throw语句中使用的是哪一个。

try {
    const result = errorsOnThree(3);
} catch (err) {
    console.log('Error caught, no action taken');
}
Listing 8-4.Unconditional catch block

err参数作用于catch块,使其等同于用let关键字声明的变量,而不是用var关键字,如第 4 章所述。

在支持 try-catch 块的语言中,允许捕获特定的异常类型是很常见的。这使得catch块只适用于特定类型的异常,对于其他类型的异常,就像没有 try-catch 块一样。建议使用这种技术,以确保您只处理您知道可以恢复的异常,留下真正意外的异常来终止程序,并防止状态的进一步恶化。

目前还没有符合标准的方法来有条件地捕捉异常,这意味着要么全部捕捉,要么一个都不捕捉。如果您只想处理特定类型的异常,您可以在catch语句中检查类型,并重新抛出任何与类型不匹配的错误。

清单 8-5 显示了一个异常处理例程,它处理ApplicationError自定义异常,但会抛出任何其他类型的异常。在 if 语句中,err变量的类型被缩小为ApplicationError类型。

try {
    const result = errorsOnThree(3);
} catch (err) {
    if (err instanceof ApplicationError) {
        console.log('Error caught, no action taken');
    }

    throw err;
}

Listing 8-5.Checking the type of error

Note

通过只处理自定义异常,可以确保只处理已知可以恢复的异常类型。如果您使用默认的catch块而没有instanceof检查,那么您就要对程序中可能出现的每种类型的异常负责。

这个例子将允许 catch 块处理一个ApplicationError,或者一个ApplicationError的子类,比如本章前面描述的InputError。为了说明在类层次结构的不同级别处理异常的效果,图 8-1 显示了一个更复杂的层次结构,它扩展了ApplicationErrorInputT5】类。

A323824_2_En_8_Fig1_HTML.jpg

图 8-1。

Error class hierar chy

当您选择处理InputError类异常时,您将处理如图 8-2 所示的四种异常:InputErrorBelowMinErrorAboveMaxErrorInvalidLengthError。所有其他异常都将在调用堆栈中向上传递,就像它们未被处理一样。

A323824_2_En_8_Fig2_HTML.jpg

图 8-2。

Handling InputError exceptions

如果您要处理App licationError类别的异常,那么您将处理如图 8-3 所示的层次结构中的所有七个自定义异常。

A323824_2_En_8_Fig3_HTML.jpg

图 8-3。

Handling ApplicationError exceptions

一般来说,程序越深入,处理的异常就应该越具体。如果您在低级代码附近工作,您将处理非常特殊类型的异常。当您在更接近用户界面的地方工作时,您会处理更一般的异常。

随着对性能的讨论,异常很快会再次出现,因为在程序中创建和处理异常会产生性能成本。尽管如此,如果您只是用它们来表示例程无法继续,您就不应该担心它们的运行时开销。

记忆

当你用高级语言如 TypeScript 或 JavaScript 编写程序时,你将从自动内存管理中获益。您创建的所有变量和对象都将被管理,因此您永远不会超出边界,也不会处理悬空指针或损坏的变量。事实上,您可能遇到的所有可管理的内存问题都已经为您解决了。但是,有些内存安全类别不能自动处理,例如内存不足错误,它指示系统资源已经耗尽,无法继续处理。

本节涵盖了您可能会遇到的问题类型以及避免这些问题需要了解的内容。

释放资源

在 TypeScript 中,您不太可能遇到非托管资源。大多数 API 遵循异步模式,接受操作完成时将调用的方法参数。现代 API 将通过一个承诺来公开这一点。因此,您永远不会在程序中保存对非托管资源的直接引用。例如,如果您想使用 proximity API,它检测物体何时靠近传感器,您可以使用清单 8-6 中的代码。

const sensorChange = function (reading) {
    const proximity = reading.near
        ? 'Near'
        : 'Far';

    alert(proximity);
}

window.addEventListener('userproximity', sensorChange, true);

Listing 8-6.Asynchronous pattern

异步模式意味着,尽管您可以从近程传感器获得信息,但您的程序从不负责资源或通信通道。如果您碰巧遇到这样的情况,您确实持有对必须管理的资源的引用,您应该使用 try-finally 块来确保资源被释放,即使发生错误也是如此。

清单 8-7 中的示例假设可以直接使用接近传感器来获取读数。

const sensorChange = function (reading) {
    var proximity = reading.near ?
        'Near' : 'Far';
    alert(proximity);
}

const readProximity = function () {
    const sensor = new ProximitySensor();
    try {
        sensor.open();

        const reading = sensor.read();

        sensorChange(reading);
    } finally {
        sensor.close();
    }
}

window.setInterval(readProximity, 500);

Listing 8-7.Imaginary unmanaged proximity sensor

finally块将确保传感器的close方法被调用,该方法执行清理并释放任何资源。即使调用read方法或sensorChange函数时出现错误,finally块也会执行。

清单 8-8 中显示了类似 promise 接口的等价示例。在处理承诺时,通常要么执行“then”块,要么在出现错误时执行“catch”块。在所有情况下都会调用 finally 块。

const sensorChange = function (reading) {
    var proximity = reading.near ?
        'Near' : 'Far';
    alert(proximity);
}

const readProximity = function () {
    const sensor = new ProximitySensor();

    sensor.open()
        .then(() => {
            return sensor.read();
        })
        .then((reading) => {
            sensorChange(reading);
        })
        .finally(() => {
            sensor.close();
        });
}

window.setInterval(readProximity, 500);

Listing 8-8.Umanaged proximity sensor with promise-like interface

在前两节中,我用“catch”介绍了异常处理,用“finally”介绍了资源管理在所有情况下,您都可以将两者结合起来执行异常和内存管理。

碎片帐集

当不再需要内存时,需要将其释放,以便分配给程序中的其他对象。用于确定是否可以释放内存的过程称为垃圾收集。根据运行时环境的不同,您会遇到几种垃圾收集方式。

旧的 web 浏览器可能使用引用计数垃圾收集器,当对一个对象的引用数达到零时释放内存。这在表 8-1 中进行了说明。这是一种非常快速的垃圾收集方式,因为引用计数一达到零就可以释放内存。但是,如果在两个或多个对象之间创建了循环引用,这些对象都不会被垃圾回收,因为它们的计数永远不会达到零。

现代 web 浏览器用标记和清除算法解决了这个问题,该算法检测所有从根可到达的对象,并对不能到达的对象进行垃圾收集。尽管这种垃圾收集方式可能需要更长的时间,但它不太可能导致内存泄漏。为了防止浏览器 UI 冻结,一些 JavaScript 引擎将垃圾收集偷偷放入空闲时间,这意味着它对浏览体验的影响较小。

表 8-1。

Reference counting garbage collection

| 目标 | 引用计数 | 内存取消分配 | | --- | --- | --- | | Object A | one | 不 | | Object B | one | 不 | | Object C | one | 不 | | Object D | one | 不 | | Object E | Zero | 是 |

8-1 中的相同对象如图 8-4 所示。使用引用计数算法,对象 A 和对象 B 都保留在内存中,因为它们相互引用。这些循环引用是旧浏览器中内存泄漏的来源,但这个问题通过标记-清除算法得到了解决。对象 A 和对象 B 之间的循环引用不足以使对象在垃圾收集中幸存下来,因为只剩下可从根访问的对象。

A323824_2_En_8_Fig4_HTML.jpg

图 8-4。

Mark and sweep

大多数现代垃圾收集器通过几代来提升对象,最频繁和最有效的收集是对短命对象进行的。随着对象生存时间的延长,它们通常被检查的频率会降低,收集的速度也会变慢。完整的垃圾收集还可以包括压缩步骤,以优化内存使用。

使用标记-清除垃圾收集算法意味着您很少需要担心 TypeScript 程序中的垃圾收集或内存泄漏。

表演

毫无疑问,追求效率会导致滥用。程序员浪费大量的时间去思考或担心他们程序中非关键部分的速度,当考虑到调试和维护时,这些提高效率的尝试实际上有很大的负面影响。我们应该忘记小的效率,比如说 97%的时候:过早的优化是万恶之源。然而,我们不应该错过这关键的 3%的机会。—唐纳德·克努特

这不是 Donald Knuth(1974 年《计算调查》中的结构化编程与 go to 语句)第一次被引用关于性能和优化,当然也不会是最后一次。他的话,至少在这方面,经受住了时间的考验(尽管它们来自一篇为后藤言论辩护的论文——随着时间的推移,这种情绪多少有些下降)。

如果性能问题出现在可测量的性能问题之前,您应该避免优化。有许多文章声称使用局部变量将比全局变量快,您应该避免闭包,因为它们很慢,或者对象属性比变量慢。虽然这些通常是正确的,但是把它们当作设计规则会导致糟糕的程序设计。

优化的黄金法则是,您应该衡量两种或更多种潜在设计之间的差异,并确定性能提升是否值得您为获得它们而必须做出的任何设计权衡。

Note

对于您的 TypeScript 程序,测量执行时间需要在多个平台上运行测试。否则,您可能会在一个浏览器中变得更快,但在另一个浏览器中变得更慢。

清单 8-9 中的代码将用于演示一个简单的性能测试。将测试轻量级CommunicationLines类。该类包含一个方法,该方法接受一个teamSize,并使用著名的 n(n–1)/2 算法计算团队成员之间的通信行数。名为testCommunicationLines的函数实例化了该类,并成功测试了团队规模为 4 人和 10 人的两个案例,这两个案例分别有 6 条和 45 条通信线路。

class CommunicationLines {
    calculate(teamSize: number) {
        return (teamSize * (teamSize - 1)) / 2
    }
}

function testCommunicationLines() {
    const communicationLines = new CommunicationLines();

    let result = communicationLines.calculate(4);

    if (result !== 6) {
        throw new Error('Test failed for team size of 4.');
    }

    result = communicationLines.calculate(10);

    if (result !== 45) {
        throw new Error('Test failed for team size of 10.');
    }
}

testCommunicationLines();

Listing 8-9.Calculating lines of communication

清单 8-10 中的Performance类在一个方法中包装了一个回调函数,该方法使用第 4 章中讨论的高保真定时器使用performance.now方法来为操作计时。为了得到一个公平的度量,默认情况下,Performance类运行代码 10,000 次,尽管这个数字可以在调用run方法时被覆盖。

Performance类的输出包括执行代码 10,000 次的总时间以及每次迭代的平均时间。

export class Performance {
    constructor(private func: Function, private iterations: number) {

    }

    private runTest() {
        if (!performance) {
            throw new Error('The performance.now() standard is not supported in this runtime.');
        }

        const errors: number[] = [];

        const testStart = performance.now();

        for (let i = 0; i < this.iterations; i++) {
            try {
                this.func();
            } catch (err) {
                // Limit the number of errors logged
                if (errors.length < 10) {
                    errors.push(i);
                }
            }
        }

        const testTime = performance.now() - testStart;

        return {
            errors: errors,
            totalRunTime: testTime,
            iterationAverageTime: (testTime / this.iterations)
        };
    }

    static run(func: Function, iterations = 10000) {
        const tester = new Performance(func, iterations);
        return tester.runTest();
    }
}

Listing 8-10.Performance.ts runner

要使用Performance类来度量程序,必须导入代码,并通过将函数传递给Performance类的run方法来替换对testCommunicationLines函数的调用,如清单 8-11 所示。

import { Performance } from './Listing-8-010';

class CommunicationLines {
    calculate(teamSize: number) {
        return (teamSize * (teamSize - 1)) / 2
    }
}

function testCommunicationLines() {
    const communicationLines = new CommunicationLines();

    let result = communicationLines.calculate(4);

    if (result !== 6) {
        throw new Error('Test failed for team size of 4.');
    }

    result = communicationLines.calculate(10);

    if (result !== 45) {
        throw new Error('Test failed for team size of 10.');
    }
}

const result = Performance.run(testCommunicationLines);

console.log(result.totalRunTime + ' ms');

Listing 8-11.Running the performance test

此代码的结果是控制台记录了 2.73 毫秒的总运行时间。这意味着 10,000 次迭代(对通信线路算法的 20,000 次调用)的整个运行时间不到 3 ms。在大多数情况下,这样的结果很好地表明您在错误的地方寻找优化机会。

通过调整清单 8-12 中所示的代码,有可能得到非常不同的结果。对代码所做的唯一更改是在 7 条通信线路中检查对团队规模为 4 的communicationLines.calculate的调用结果。该测试将失败,并将引发异常。

import { Performance } from './Listing-8-010';

class CommunicationLines {
    calculate(teamSize: number) {
        return (teamSize * (teamSize - 1)) / 2
    }
}

function testCommunicationLines() {
    const communicationLines = new CommunicationLines();

    let result = communicationLines.calculate(4);

    if (result !== 7) {
        throw new Error('Test failed for team size of 4.');
    }

    result = communicationLines.calculate(10);

    if (result !== 45) {
        throw new Error('Test failed for team size of 10.');
    }
}

const result = Performance.run(testCommunicationLines);

console.log(result.totalRunTime + ' ms');

Listing 8-12.Running the performance test with exceptions

运行带有失败测试的代码以及异常的创建和处理导致总运行时间为 214.45 ms,比第一次测试慢 78 倍。可以使用这些数据来指导您的设计决策。您可能想要多次重复测试,或者尝试不同的迭代大小,以确保获得一致的结果。

下面是使用清单 8-10 中的Performance类收集的一些数字,以证明本节开始时关于优化的声明。使用一个简单但有限的测试,每次迭代的基线时间为 0.74 毫秒,结果如下(其中数字越大表示执行时间越慢):

  • 全局变量:0.80 毫秒(每次迭代慢 0.06 毫秒)
  • 闭包:1.13 毫秒(每次迭代慢 0.39 毫秒)
  • 属性:1.48 毫秒(每次迭代慢 0.74 毫秒)

超过 10,000 次执行后,您可以看到执行时间上的微小差异,但重要的是要记住,由于对象复杂性、嵌套深度、创建的对象数量以及许多其他因素的差异,您的程序将返回不同的结果。在进行任何优化之前,请确保您已经进行了测量,这样您就可以比较任何更改前后的性能,以确定它们是否产生了积极的影响。

摘要

本章涵盖了三个重要的主题,它们可能是任何用 TypeScript 编写的大型应用的基础。在大多数情况下,这三个领域很可能是跨领域的关注点,在您编写大量需要更改的代码之前,可能更容易考虑这些问题。

在程序中使用异常来处理真正的异常状态可以防止程序数据的进一步损坏。您应该创建自定义异常来帮助管理不同种类的错误,并测试您的catch块中的类型,以便只处理您知道可以恢复的错误。

现代运行时都使用可靠的标记-清除算法来处理内存,这种算法不会像早期的引用计数垃圾收集器那样遭受循环引用内存泄漏。人们普遍认为程序员在编码时不需要考虑垃圾收集,但是如果您可以测量性能问题并发现垃圾收集是问题所在,您可能会决定通过创建更少的对象来帮助垃圾收集器进行管理。

无论何时进行优化,您都应该首先测量程序的性能,以证明在进行更改时您关于优化的假设是否正确。您应该在多个环境中测量您的变化,以确保您在所有环境中都提高了速度。

要点

  • 您可以对任何对象使用throw关键字,但是最好使用自定义错误的子类。
  • 您可以用 try-catch-finally 块处理异常,其中您必须指定一个catchfinally块,或者两个都指定。
  • 处理承诺时,可以使用 catch 块、finally 块,或者两者都用。
  • 您不能可靠地只捕捉自定义异常,但是您可以在catch块中测试异常类型。
  • 您遇到的大多数 API 将遵循异步或类似 promise 的模式,但是如果您发现必须管理资源,请使用 try-finally 块进行清理。
  • 说到性能,您需要前后测量来备份您以优化的名义更改的任何代码。