使用Either monad进行优雅的JavaScript错误处理

让我们谈谈如何处理错误。 在JavaScript中,我们具有用于处理异常的内置语言功能。 我们将有问题的代码包含在try...catch构造中。 这使您可以在try部分中指定正常的执行路径,然后在catch部分中处理所有异常。 不错的选择。 这使您可以专注于当前任务,而不必考虑每个可能的错误。 绝对比无休止的if阻塞代码更好。

如果没有try...catch则很难检查每个函数调用的结果是否有意外的值。 这是一个有用的设计。 但是她有某些问题。 这不是处理错误的唯一方法。 在本文中,我们将探讨使用Either monad作为try...catch的替代方法。

在继续之前,我要注意几点。 本文假定您已经了解函数组成和currying。 并警告。 如果您以前从未遇到过单子,它们似乎真的很奇怪。 使用此类工具需要改变思维方式。 首先,这可能很难。

如果您立即感到困惑,请不要担心。 每个人都有。 在文章的结尾,我列出了一些可能有用的链接。 不要放弃。 这些东西一旦渗入大脑就会被陶醉。

问题例子


在讨论异常问题之前,让我们谈谈为什么它们甚至存在以及为什么try...catch块出现。 为此,让我们看一个我试图至少部分实现的问题。 想象一下,我们正在编写一个显示通知列表的函数。 我们已经设法(以某种方式)从服务器返回数据。 但是由于某种原因,后端工程师决定以CSV格式(而不是JSON)发送它。 原始数据可能看起来像这样:

 时间戳,内容,已查看,href
 2018-10-27T05:33:34 + 00:00,@ madhatter邀请您喝茶,未读,https://example.com/invite/tea/3801
 2018-10-26T13:47:12 + 00:00,@ Queenofhearts在``门球锦标赛''讨论中提到了您,查看过,https://example.com/discussions/croquet/1168
 2018-10-25T03:50:08 + 00:00,@ cheshirecat给您咧嘴笑,未读,https://example.com/interactions/grin/88 

我们要以HTML格式显示。 它可能看起来像这样:

 <ul class="MessageList"> <li class="Message Message--viewed"> <a href="https://example.com/invite/tea/3801" class="Message-link">@madhatter invited you to tea</a> <time datetime="2018-10-27T05:33:34+00:00">27 October 2018</time> <li> <li class="Message Message--viewed"> <a href="https://example.com/discussions/croquet/1168" class="Message-link">@queenofhearts mentioned you in 'Croquet Tournament' discussion</a> <time datetime="2018-10-26T13:47:12+00:00">26 October 2018</time> </li> <li class="Message Message--viewed"> <a href="https://example.com/interactions/grin/88" class="Message-link">@cheshirecat sent you a grin</a> <time datetime="2018-10-25T03:50:08+00:00">25 October 2018</time> </li> </ul> 

为了简化任务,现在仅关注处理CSV数据的每一行。 让我们从一些简单的函数开始处理字符串。 第一个将文本字符串分为几个字段:

 function splitFields(row) { return row.split('","'); } 

该功能在这里得到了简化,因为它是教材。 我们处理错误处理,而不是CSV分析。 如果其中一条消息包含逗号,那么所有这些都将是错误的。 请不要使用此代码来分析真实的CSV数据。 如果您曾经必须分析CSV数据,请使用经过良好测试的CSV解析库

拆分数据后,我们要创建一个对象。 这样每个属性名称都与CSV标头匹配。 假设我们已经以某种方式分析了标题栏(稍后会进行详细介绍)。 我们已经到了可能出问题的地步。 我们在处理时出错。 如果字符串的长度与标题栏不匹配,则会引发错误。 ( _.zipObjectlodash函数 )。

 function zipRow(headerFields, fieldData) { if (headerFields.length !== fieldData.length) { throw new Error("Row has an unexpected number of fields"); } return _.zipObject(headerFields, fieldData); } 

之后,在该对象中添加一个易于理解的日期,以便将其显示在我们的模板中。 结果变得有些冗长,因为JavaScript没有对日期格式的完美内置支持。 同样,我们面临潜在的问题。 如果遇到无效的日期,我们的函数将引发错误。

 function addDateStr(messageObj) { const errMsg = 'Unable to parse date stamp in message object'; const months = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' ]; const d = new Date(messageObj.datestamp); if (isNaN(d)) { throw new Error(errMsg); } const datestr = `${d.getDate()} ${months[d.getMonth()]} ${d.getFullYear()}`; return {datestr, ...messageObj}; } 

最后,获取对象并将其通过模板函数传递以获得HTML字符串。

 const rowToMessage = _.template(`<li class="Message Message--<%= viewed %>"> <a href="<%= href %>" class="Message-link"><%= content %></a> <time datetime="<%= datestamp %>"><%= datestr %></time> <li>`); 

如果遇到错误,也可以打印一个错误:

 const showError = _.template(`<li class="Error"><%= message %></li>`); 

一切就绪后,您可以组合一个函数来处理每一行。

 function processRow(headerFieldNames, row) { try { fields = splitFields(row); rowObj = zipRow(headerFieldNames, fields); rowObjWithDate = addDateStr(rowObj); return rowToMessage(rowObj); } catch(e) { return showError(e); } } 

这样功能就准备好了。 让我们仔细看看它如何处理异常。

例外:好部分


那么try...catch什么好处呢? 应当注意,在上面的示例中, try块中的任何步骤都可能导致错误。 在zipRow()addDateStr()我们故意抛出错误。 如果出现问题,只需捕获错误并在页面上显示任何消息即可。 没有这种机制,代码将变得非常丑陋。 这是它的外观。 假定函数不引发错误,但返回null

 function processRowWithoutExceptions(headerFieldNames, row) { fields = splitFields(row); rowObj = zipRow(headerFieldNames, fields); if (rowObj === null) { return showError(new Error('Encountered a row with an unexpected number of items')); } rowObjWithDate = addDateStr(rowObj); if (rowObjWithDate === null) { return showError(new Error('Unable to parse date in row object')); } return rowToMessage(rowObj); } 

如您所见,大量模板if 。 代码更加冗长。 而且很难遵循基本逻辑。 另外, null并不能告诉我们太多。 我们真的不知道为什么上一个函数调用失败。 我们不得不猜测。 我们创建一条错误消息并调用showError() 。 这样的代码更加肮脏和混乱。

再次查看异常处理版本。 它清楚地将程序的成功路径与异常处理代码分开。 try分支是一个好方法,而catch分支是一个错误。 所有异常处理都在一个地方发生。 并且各个功能可能会报告它们失败的原因。 总而言之,这似乎很不错。 我认为大多数人认为第一个示例非常合适。 为什么采用不同的方法?

处理异常的问题尝试...抓住


这种方法使您可以忽略这些烦人的错误。 不幸的是, try...catch做得很好。 您只是抛出一个异常然后继续前进。 我们待会儿再见。 实际上,每个人都打算始终放置此类障碍。 但是错误并不总是很明显。 而且该块太容易忘记了。 在意识到这一点之前,您的应用程序崩溃了。

另外,异常会污染代码。 我们将不在这里详细讨论功能纯度。 但是,让我们看一下功能纯度的一个小方面:参照透明性。 链接透明函数始终为特定输入返回相同的结果。 但是对于带有异常的函数,我们不能这么说。 他们可以随时抛出异常,而无需返回值。 这使逻辑复杂化。 但是,如果您找到双赢的选择—一种处理错误的干净方法,该怎么办?

我们提出了一个替代方案


纯函数总是返回一个值(即使该值丢失)。 因此,我们的错误处理代码应假定我们总是返回一个值。 因此,作为第一次尝试,如果失败,我们返回Error对象,该怎么办? 也就是说,无论我们在哪里出错,都将返回这样的对象。 它可能看起来像这样:

 function processRowReturningErrors(headerFieldNames, row) { fields = splitFields(row); rowObj = zipRow(headerFieldNames, fields); if (rowObj instanceof Error) { return showError(rowObj); } rowObjWithDate = addDateStr(rowObj); if (rowObjWithDate instanceof Error) { return showError(rowObjWithDate); } return rowToMessage(rowObj); } 

这不是毫无例外的特殊升级。 但是更好。 我们已将错误消息的责任转移回各个功能。 但是我们仍然拥有所有这些假设。 最好以某种方式封装模板。 换句话说,如果我们知道我们有一个错误,请不要担心其余的代码。

多态性


怎么做? 这是一个难题。 但这可以借助多态魔术来解决。 如果您以前从未遇到过多态,请不要担心。 本质上,它是“为不同类型的实体提供单一接口”(Straustrup,B。“BjörnStraustrup的C ++术语表”)。 在JavaScript中,这意味着我们创建具有相同命名方法和签名的对象。 但是行为不同。 一个典型的例子是应用程序日志记录。 我们可以根据我们所处的环境将杂志发送到不同的地方。 例如,如果我们创建两个记录器对象怎么办?

 const consoleLogger = { log: function log(msg) { console.log('This is the console logger, logging:', msg); } }; const ajaxLogger = { log: function log(msg) { return fetch('https://example.com/logger', {method: 'POST', body: msg}); } }; 

这两个对象都定义了一个期望单个字符串参数的日志函数。 但是他们的行为有所不同。 这样做的好处是,无论使用哪个对象,我们都可以编写调用.log()代码。 它可以是consoleLoggerajaxLogger 。 一切正常。 例如,下面的代码将对任何对象同样适用:

 function log(logger, message) { logger.log(message); } 

另一个示例是所有JS对象的.toString()方法。 我们可以为我们创建的任何类编写.toString()方法。 接下来,您可以创建两个以不同方式实现.toString()方法的类。 我们将它们命名为LeftRight (稍后我将解释名称)。

 class Left { constructor(val) { this._val = val; } toString() { const str = this._val.toString(); return `Left(${str})`; } } 

 class Right { constructor(val) { this._val = val; } toString() { const str = this._val.toString(); return `Right(${str})`; } } 

现在创建一个在这两个对象上调用.toString()的函数:

 function trace(val) { console.log(val.toString()); return val; } trace(new Left('Hello world')); // ⦘ Left(Hello world) trace(new Right('Hello world')); // ⦘ Right(Hello world); 

我知道不是出色的代码。 但是事实是,我们有两种使用同一接口的不同类型的行为。 这是多态性。 但是请注意一些有趣的事情。 我们使用了多少个if语句? 零 没有一个。 我们创建了两种不同类型的行为,而没有一条if语句。 也许这样的事情可以用来处理错误...

左右


回到我们的问题。 有必要为我们的代码确定成功和失败的路径。 在一个好的路径上,我们只是继续冷静地运行代码,直到发生错误或完成它。 如果发现自己走错了路,我们将不再尝试运行代码。 我们可以将这些路径命名为Happy和Sad,但是尝试遵循其他编程语言和库使用的命名约定。 因此,我们将错误的路径称为“左”,将成功的路径称为“右”。

让我们创建一个方法,如果我们走的很好,则可以运行该函数,但是如果走的不好,则可以忽略它:

 /** * Left represents the sad path. */ class Left { constructor(val) { this._val = val; } runFunctionOnlyOnHappyPath() { // Left is the sad path. Do nothing } toString() { const str = this._val.toString(); return `Left(${str})`; } } 

 /** * Right represents the happy path. */ class Right { constructor(val) { this._val = val; } runFunctionOnlyOnHappyPath(fn) { return fn(this._val); } toString() { const str = this._val.toString(); return `Right(${str})`; } } 

像这样:

 const leftHello = new Left('Hello world'); const rightHello = new Right('Hello world'); leftHello.runFunctionOnlyOnHappyPath(trace); // does nothing rightHello.runFunctionOnlyOnHappyPath(trace); // ⦘ Hello world // ← "Hello world" 

广播节目


我们正在尝试一些有用的方法,但还不是很有效。 我们的.runFunctionOnlyOnHappyPath()方法返回_val属性。 一切都很好,但是如果我们要运行多个功能,那么会很不方便。 怎么了 因为我们不再知道我们走的路是对还是错。 一旦我们将值取到Left和Right之外,信息就会消失。 因此,我们可以做的是返回带有新_val的Left或Right路径。 因为我们在这里,所以我们将简称。 我们要做的是将一个函数从简单值的世界转换为左和右世界。 因此,我们调用map()方法:

 /** * Left represents the sad path. */ class Left { constructor(val) { this._val = val; } map() { // Left is the sad path // so we do nothing return this; } toString() { const str = this._val.toString(); return `Left(${str})`; } } 

 /** * Right represents the happy path */ class Right { constructor(val) { this._val = val; } map(fn) { return new Right( fn(this._val) ); } toString() { const str = this._val.toString(); return `Right(${str})`; } } 

我们插入此方法,并在自由语法中使用Left或Right:

 const leftHello = new Left('Hello world'); const rightHello = new Right('Hello world'); const helloToGreetings = str => str.replace(/Hello/, 'Greetings,'); leftHello.map(helloToGreetings).map(trace); // Doesn't print any thing to the console // ← Left(Hello world) rightHello.map(helloToGreetings).map(trace); // ⦘ Greetings, world // ← Right(Greetings, world) 

我们创建了两条执行路径。 我们可以通过调用new Right()将数据放置在成功的路径上,或者通过调用new Left()将数据放置在失败的路径上。


每个类都代表一条路径:成功或不成功。 我从Scott Vlaschina窃取了这个铁路隐喻

如果map沿正确的路径工作,请继续处理并处理数据。 如果我们发现自己不成功,将不会发生任何事情。 只是继续传递价值。 例如,如果我们将Error放在这个不成功的路径上,我们将得到与try…catch非常相似的东西。


使用.map()沿路径移动

随着您的进步,编写“左”或“右”一直都很困难,因此我们将此组合简称为“任一个”(“两个”)。 左或右。

创建任一对象的快捷方式


因此,下一步是重写示例函数,以便它们返回Either。 向左表示错误,向右表示值。 但是在我们这样做之前,请先找点乐子。 让我们写一些捷径。 第一个是称为.of()的静态方法。 它只是返回一个新的Left或Right。 代码可能看起来像这样:

 Left.of = function of(x) { return new Left(x); }; Right.of = function of(x) { return new Right(x); }; 

老实说,即使Left.of()Right.of()Right.of() 。 因此,我倾向于使用更短的left()right()标签:

 function left(x) { return Left.of(x); } function right(x) { return Right.of(x); } 

使用这些快捷方式,我们开始重写应用程序功能:

 function zipRow(headerFields, fieldData) { const lengthMatch = (headerFields.length == fieldData.length); return (!lengthMatch) ? left(new Error("Row has an unexpected number of fields")) : right(_.zipObject(headerFields, fieldData)); } function addDateStr(messageObj) { const errMsg = 'Unable to parse date stamp in message object'; const months = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' ]; const d = new Date(messageObj.datestamp); if (isNaN(d)) { return left(new Error(errMsg)); } const datestr = `${d.getDate()} ${months[d.getMonth()]} ${d.getFullYear()}`; return right({datestr, ...messageObj}); } 

修改后的功能与旧功能没有太大不同。 我们简单地将返回值包装在Left或Right中,具体取决于是否存在错误。

之后,我们可以开始处理处理一行的主要功能。 首先,使用right()将字符串放在Either中,然后转换splitFields进行拆分:

 function processRow(headerFields, row) { const fieldsEither = right(row).map(splitFields); // … } 

这工作得很好,但是如果尝试使用zipRow()进行相同的操作, zipRow()发生麻烦:

  function processRow(headerFields, row) { const fieldsEither = right(row).map(splitFields); const rowObj = fieldsEither.map(zipRow /* wait. this isn't right */); // ... } 

事实是zipRow()需要两个参数。 但是,传递给.map()的函数._val属性获得一个值。 可以使用zipRow()zipRow()版本来纠正这种情况。 它可能看起来像这样:

 function zipRow(headerFields) { return function zipRowWithHeaderFields(fieldData) { const lengthMatch = (headerFields.length == fieldData.length); return (!lengthMatch) ? left(new Error("Row has an unexpected number of fields")) : right(_.zipObject(headerFields, fieldData)); }; } 

这个小的更改简化了zipRow的转换,因此可以与.map()一起很好地工作:

 function processRow(headerFields, row) { const fieldsEither = right(row).map(splitFields); const rowObj = fieldsEither.map(zipRow(headerFields)); // ... But now we have another problem ... } 

加盟


使用.map()运行splitFields()很好,因为.splitFields()不会返回Either。 但是,当您必须运行zipRow() ,会出现问题,因为它返回Either。 因此,使用.map()我们最终会在Either内部碰到Either。 如果我们走得更远,那么就会陷入困境,直到我们在.map()运行.map()为止。 那也不行。 我们需要某种方式来组合这些嵌套的Either。 因此,让我们编写一个新方法,我们将其称为.join()

 /** *Left represents the sad path. */ class Left { constructor(val) { this._val = val; } map() { // Left is the sad path // so we do nothing return this; } join() { // On the sad path, we don't // do anything with join return this; } toString() { const str = this._val.toString(); return `Left(${str})`; } } 

 /** * Right represents the happy path */ class Right { constructor(val) { this._val = val; } map(fn) { return new Right( fn(this._val) ); } join() { if ((this._val instanceof Left) || (this._val instanceof Right)) { return this._val; } return this; } toString() { const str = this._val.toString(); return `Right(${str})`; } } 

现在我们可以“解包”我们的资产:

 function processRow(headerFields, row) { const fieldsEither = right(row).map(splitFields); const rowObj = fieldsEither.map(zipRow(headerFields)).join(); const rowObjWithDate = rowObj.map(addDateStr).join(); // Slowly getting better... but what do we return? } 

链条


我们已经走了很长一段路。 但是您必须始终记住.join()调用,这很烦人。 但是,我们有一个通用的连续调用模式.map().join() ,因此让我们为其创建一个快速访问方法。 我们称它为chain() ,因为它将返回左或右的函数绑定在一起。

 /** *Left represents the sad path. */ class Left { constructor(val) { this._val = val; } map() { // Left is the sad path // so we do nothing return this; } join() { // On the sad path, we don't // do anything with join return this; } chain() { // Boring sad path, // do nothing. return this; } toString() { const str = this._val.toString(); return `Left(${str})`; } } 

 /** * Right represents the happy path */ class Right { constructor(val) { this._val = val; } map(fn) { return new Right( fn(this._val) ); } join() { if ((this._val instanceof Left) || (this._val instanceof Right)) { return this._val; } return this; } chain(fn) { return fn(this._val); } toString() { const str = this._val.toString(); return `Right(${str})`; } } 

回到铁路类比,如果遇到错误, .chain()将切换轨道。 但是,在图中更容易显示。


如果发生错误,.chain()方法可让您切换到左侧路径。 请注意,开关只能以一种方式工作。

代码变得更加简洁:

 function processRow(headerFields, row) { const fieldsEither = right(row).map(splitFields); const rowObj = fieldsEither.chain(zipRow(headerFields)); const rowObjWithDate = rowObj.chain(addDateStr); // Slowly getting better... but what do we return? } 

用价值做某事


processRow()函数的重构几乎完成。 但是,当我们返回值时会发生什么呢? 最后,我们要根据我们遇到的情况采取不同的操作:左或右。 因此,我们将编写一个将采取适当措施的函数:

 function either(leftFunc, rightFunc, e) { return (e instanceof Left) ? leftFunc(e._val) : rightFunc(e._val); } 

Left Right. , . :

 function processRow(headerFields, row) { const fieldsEither = right(row).map(splitFields); const rowObj = fieldsEither.chain(zipRow(headerFields)); const rowObjWithDate = rowObj.chain(addDateStr); return either(showError, rowToMessage, rowObjWithDate); } 

, :

 function processRow(headerFields, row) { const rowObjWithDate = right(row) .map(splitFields) .chain(zipRow(headerFields)) .chain(addDateStr); return either(showError, rowToMessage, rowObjWithDate); } 

. try...catch . if- . - , . , processRow() Left Right , right() . .map() .chain() .

ap lift


看起来不错,但还有最后一种情况需要考虑。按照我们的示例,让我们看看如何处理所有CSV数据,而不仅仅是单个行。我们将需要一个或三个辅助功能:

 function splitCSVToRows(csvData) { // There should always be a header row... so if there's no // newline character, something is wrong. return (csvData.indexOf('\n') < 0) ? left('No header row found in CSV data') : right(csvData.split('\n')); } function processRows(headerFields, dataRows) { // Note this is Array map, not Either map. return dataRows.map(row => processRow(headerFields, row)); } function showMessages(messages) { return `<ul class="Messages">${messages.join('\n')}</ul>`; } 

因此,我们有一个将CSV分成几行的助手。然后,使用Either返回该选项。现在,您可以使用.map()一些lodash函数从数据行中提取标题栏。但是我们发现自己处于一种有趣的情况下...

 function csvToMessages(csvData) { const csvRows = splitCSVToRows(csvData); const headerFields = csvRows.map(_.head).map(splitFields); const dataRows = csvRows.map(_.tail); // What's next? } 

我们已经准备好以显示标题字段和数据行processRows()而且headerFieldsdataRows包裹在Either中。我们需要某种方式来转换processRows()为可以与Either一起使用的函数。首先,我们进行currying processRows

 function processRows(headerFields) { return function processRowsWithHeaderFields(dataRows) { // Note this is Array map, not Either map. return dataRows.map(row => processRow(headerFields, row)); }; } 

. headerFields , Either, . , headerFields .map() processRows() ?

 function csvToMessages(csvData) { const csvRows = splitCSVToRows(csvData); const headerFields = csvRows.map(_.head).map(splitFields); const dataRows = csvRows.map(_.tail); // How will we pass headerFields and dataRows to // processRows() ? const funcInEither = headerFields.map(processRows); } 

使用.map(),此处将调用外部函数processRows(),而不是内部函数换句话说,processRows()返回一个函数。从那以后.map(),我们仍然可以让Either回来。因此,结果是Either内部的一个函数,称为funcInEither它需要一个字符串数组,并返回其他字符串数组。我们需要以某种方式采用此函数,并使用一个内部值调用它dataRows为此,向我们的Left和Right类添加另一个方法。我们将.ap()按照标准进行称呼

与往常一样,该方法在Left轨道上不执行任何操作:

  // In Left (the sad path) ap() { return this; } 

对于Right类,我们希望另一个Ether具有一个功能:

  // In Right (the happy path) ap(otherEither) { const functionToRun = otherEither._val; return this.map(functionToRun); } 

现在我们可以完成我们的主要功能:

  function csvToMessages(csvData) { const csvRows = splitCSVToRows(csvData); const headerFields = csvRows.map(_.head).map(splitFields); const dataRows = csvRows.map(_.tail); const funcInEither = headerFields.map(processRows); const messagesArr = dataRows.ap(funcInEither); return either(showError, showMessages, messagesArr); } 

.ap() ( Fantasy Land , ). , : « , . , Either». .ap() , . liftA2() , . , , «» (lift) «». ( , .ap() , .of() ). , liftA2 « lift, ».

, liftA2 :

 function liftA2(func) { return function runApplicativeFunc(a, b) { return b.ap(a.map(func)); }; } 

我们的顶级函数将按以下方式使用它:

 function csvToMessages(csvData) { const csvRows = splitCSVToRows(csvData); const headerFields = csvRows.map(_.head).map(splitFields); const dataRows = csvRows.map(_.tail); const processRowsA = liftA2(processRows); const messagesArr = processRowsA(headerFields, dataRows); return either(showError, showMessages, messagesArr); } 

CodePen上的代码

对不对 这就是全部吗?


您可能会问,有什么比简单的例外更好?在我看来这不是解决一个简单问题的方法太复杂了吗?首先考虑一下为什么我们喜欢例外。如果没有例外,您将不得不在各处编写许多if语句。我们将始终按照“如果后者起作用,请继续,否则处理错误”的原则编写代码。我们必须在整个代码中处理这些错误。这使得很难理解正在发生的事情。如果出现问题,可以通过异常退出程序。因此,您无需编写所有这些ifs。您可以专注于成功的执行路径。

但是有一个障碍。异常隐藏得太多了。引发异常时,会将错误处理问题转移到其他函数。忽略将弹出到最高级别的异常太容易了。 Either的好处是,它允许您跳出主程序流,就像有例外一样。它诚实地工作。您得到右或左。您不能假装“左”选项是不可能的。最后,您必须通过调用来提取值either()

我知道这听起来有些复杂。但是,请看一下我们编写的代码(不是类,而是使用它们的函数)。没有太多的异常处理代码。它几乎不存在,除了either()结尾处的通话csvToMessages()processRow()这就是重点。使用Either,您将拥有干净的错误处理,不会被意外遗忘。如果没有,则遍历代码并在各处添加填充。

这并不意味着您永远不要使用它try...catch有时候,这是正确的工具,而且很正常。但这不是唯一的工具。两者都会给您带来一些您没有的好处try...catch所以给这个单子一个机会。即使一开始很难,我想您也会喜欢。请,请不要使用本文中的实现。尝试其中一个著名的图书馆,例如CrocksSanctuaryFolktaleMonet他们更好地服务。在这里,为简单起见,我错过了一些东西。

其他资源


Source: https://habr.com/ru/post/zh-CN457098/


All Articles