首先,假定你对函数式编程有所涉猎。用不了多久你就能明白纯函数的概念。随着深入了解,你会发现函数式程序员似乎对纯函数很着迷。他们说:“纯函数让你推敲代码”,“纯函数不太可能引发一场热核战争”,“纯函数提供了引用透明性”。诸如此类。他们说的并没有错,纯函数是个好东西。但是存在一个问题……
纯函数是没有副作用的函数。[1] 但如果你了解编程,你就会知道副作用是关键。如果无法读取 𝜋 值,为什么要在那么多地方计算它?为了把值打印出来,我们需要写入 console 语句,发送到 printer,或其他可以被读取到的地方。如果数据库不能输入任何数据,那么它又有什么用呢?我们需要从输入设备读取数据,通过网络请求信息。这其中任何一件事都不可能没有副作用。然而,函数式编程是建立在纯函数之上的。那么函数式程序员是如何完成任务的呢?
简单来说就是,做数学家做的事情:欺骗
说他们欺骗吧,技术上又遵守规则。但是他们发现了这些规则中的漏洞,并加以利用。有两种主要的方法:
- 依赖注入,或者我们也可以叫它问题搁置
- 使用 Effect 函子,我们可以把它想象为重度拖延[2]
依赖注入
依赖注入是我们处理副作用的第一种方法。在这种方法中,将代码中的不纯的部分放入函数参数中,然后我们就可以把它们看作是其他函数功能的一部分。为了解释我的意思,我们来看看一些代码:
1 | // logSomething :: String -> () |
logSomething()
函数有两个不纯的地方:它创建了一个 Date()
对象并且把它输出到控制台。因此,它不仅执行了 IO 操作, 而且每次运行的时候都会给出不同的结果。那么,如何使这个函数变纯?使用依赖注入,我们以函数参数的形式接受不纯的部分,因此 logSomething()
函数接收三个参数,而不是一个参数:
1 | // logSomething: Date -> Console -> String -> () |
然后调用它,我们必须自行明确地传入不纯的部分:
1 | const something = "Curiouser and curiouser!"; |
现在,你可能会想:“这样做有点傻逼。这样把问题变得更严重了,代码还是和之前一样不纯”。你是对的。这完全就是一个漏洞。
YouTube 视频链接:https://youtu.be/9ZSoJDUD_bU
这就像是在装傻:“噢!不!警官,我不知道在 cnsl
上调用 log()
会执行 IO 操作。这是别人传给我的。我不知道它从哪来的”,这看起来有点蹩脚。
这并不像表面上那么愚蠢,注意我们的 logSomething()
函数。如果你要处理一些不纯的事情, 你就不得不把它变得不纯。我们可以简单地传入不同的参数:
1 | const d = { toISOString: () => "1865-11-26T16:00:00.000Z" }; |
现在,我们的函数什么事情也没干,除了返回 something
参数。但是它是纯的。如果你用相同的参数调用它,它每次都会返回相同的结果。这才是重点。为了使它变得不纯,我们必须采取深思熟虑的行动。或者换句话说,函数依赖于右边的签名。函数无法访问到像 console
或者 Date
之类的全局变量。这样所有事情就很明确了。
同样需要注意的是,我们也可以将函数传递给原来不纯的函数。让我们看一下另一个例子。假设表单中有一个 username
字段。我们想要从表单中取到它的值:
1 | // getUserNameFromDOM :: () -> String |
在这个例子中,我们尝试去从 DOM 中查询信息。这是不纯的,因为 document
是一个随时可能改变的全局变量。把我们的函数转化为纯函数的方法之一就是把 全局 document
对象当作一个参数传入。但是我们也可以像这样传入一个 querySelector()
函数:
1 | // getUserNameFromDOM :: (String -> Element) -> String |
现在,你可能还是会认为:“这样还是一样傻啊!” 我们所做只是把不纯的代码从 getUsernameFromDOM()
移出来而已。它并没有消失,我们只是把它放在了另一个函数 qs()
中。除了使代码更长之外,它似乎没什么作用。我们两个函数取代了之前一个不纯的函数,但是其中一个仍然不纯。
别着急,假设我们想给 getUserNameFromDOM()
写测试。现在,比较一下不纯和纯的版本,哪个更容易编写测试?为了对不纯版本的函数进行测试,我们需要一个全局 document
对象,除此之外,还需要一个 ID 为 username
的元素。如果我想在浏览器之外测试它,那么我必须导入诸如 JSDOM 或无头浏览器之类的东西。这一切都是为了测试一个很小的函数。但是使用第二个版本的函数,我可以这样做:
1 | const qsStub = () => ({ value: "mhatter" }); |
现在,这并不意味着你不应该创建在真正的浏览器中运行的集成测试。(或者,至少是像 JSDOM 这样的模拟版本)。但是这个例子所展示的是getUserNameFromDOM()
现在是完全可预测的。如果我们传递给它 qsStub 它总是会返回 mhatter
。我们把不可预测转性移到了更小的函数 qs 中。
如果我们这样做,就可以把这种不可预测性推得越来越远。最终,我们将它们推到代码的边界。因此,我们最终得到了一个由不纯代码组成的薄壳,它包围着一个测试友好的、可预测的核心。当您开始构建更大的应用程序时,这种可预测性就会起到很大的作用。
依赖注入的缺点
可以以这种方式创建大型、复杂的应用程序。我知道是 因为我做过。
依赖注入使测试变得更容易,也会使每个函数的依赖关系变得明确。但它也有一些缺点。最主要的一点是,你最终会得到类似这样冗长的函数签名:
1 | function app(doc, con, ftch, store, config, ga, d, random) { |
这还不算太糟,除此之外你可能遇到参数钻井的问题。在一个底层的函数中,你可能需要这些参数中的一个。因此,您必须通过许多层的函数调用来连接参数。这让人恼火。例如,您可能需要通过 5 层中间函数传递日期。所有这些中间函数都不使用 date 对象。这不是世界末日,至少能够看到这些显式的依赖关系还是不错的。但它仍然让人恼火。这还有另一种方法……
懒函数
让我们看看函数式程序员利用的第二个漏洞。它像这样:“发生的副作用才是副作用”。我知道这听起来神秘的。让我们试着让它更明确一点。思考一下这段代码:
1 | // fZero :: () -> Number |
我知道这是个愚蠢的例子。如果我们想在代码中有一个 0,我们可以直接写出来。我知道你,文雅的读者,永远不会用 JavaScript 写控制核武器的代码。但它有助于说明这一点。这显然是不纯的代码。因为它输出日志到控制台,也可能开始热核战争。假设我们想要 0。假设我们想要计算导弹发射后的情况,我们可能需要启动倒计时之类的东西。在这种情况下,提前计划如何进行计算是完全合理的。我们会非常小心这些导弹什么时候起飞,我们不想搞混我们的计算结果,以免他们意外发射导弹。那么,如果我们将 fZero()
包装在另一个只返回它的函数中呢?有点像安全包装。
1 | // fZero :: () -> Number |
我可以运行 returnZeroFunc()
任意次,只要不调用返回值,我理论上就是安全的。我的代码不会发射任何核弹。
1 | const zeroFunc1 = returnZeroFunc(); |
现在,让我们更正式地定义纯函数。然后,我们可以更详细地检查我们的 returnZeroFunc()
函数。如果一个函数满足以下条件就可以称之为纯函数:
- 没有明显的副作用
- 引用透明。也就是说,给定相同的输入,它总是返回相同的输出。
让我们看看 returnZeroFunc()
。有副作用吗?嗯,之前我们确定过,调用 returnZeroFunc()
不会发射任何核导弹。除非执行调用返回函数的额外步骤,否则什么也不会发生。所以,这个函数没有副作用。
returnZeroFunc()
引用透明吗? 也就是说,给定相同的输入,它总是返回相同的输出? 好吧,按照它目前的编写方式,我们可以测试它:
1 | zeroFunc1 === zeroFunc2; // true |
但它还不能算纯。returnZeroFunc()
函数引用函数作用域外的一个变量。为了解决这个问题,我们可以以这种方式进行重写:
1 | // returnZeroFunc :: () -> (() -> Number) |
现在我们的函数是纯函数了。但是,JavaScript 阻碍了我们。我们无法再使用 ===
来验证引用透明性。这是因为 returnZeroFunc()
总是返回一个新的函数引用。但是你可以通过审查代码来检查引用透明。returnZeroFunc()
函数每次除了返回相同的函数其他什么也不做。
这是一个巧妙的小漏洞。但我们真的能把它用在真正的代码上吗?答案是肯定的。但在我们讨论如何在实践中实现它之前,先放到一边。先回到危险的 fZero()
函数:
1 | // fZero :: () -> Number |
让我们尝试使用 fZero()
返回的零,但这不会发动热核战争(笑)。我们将创建一个函数,它接受 fZero()
最终返回的 0,并在此基础上加一:
1 | // fIncrement :: (() -> Number) -> Number |
哎呦!我们意外地发动了热核战争。让我们再试一次。这一次,我们不会返回一个数字。相反,我们将返回一个最终返回一个数字的函数:
1 | // fIncrement :: (() -> Number) -> (() -> Number) |
唷!危机避免了。让我们继续。有了这两个函数,我们可以创建一系列的 ‘最终数字’(译者注:最终数字即返回数字的函数,后面多次出现):
1 | const fOne = fIncrement(zero); |
我们也可以创建一组 f*()
函数来处理最终值:
1 | // fMultiply :: (() -> Number) -> (() -> Number) -> (() -> Number) |
看到我们做了什么了吗?如果能用普通数字来做的,那么我们也可以用最终数字。数学称之为 同构。我们总是可以把一个普通的数放在一个函数中,将其变成一个最终数字。我们可以通过调用这个函数得到最终的数字。换句话说,我们建立一个数字和最终数字之间映射。这比听起来更令人兴奋。我保证,我们很快就会回到这个问题上。
这样进行函数包装是合法的策略。我们可以一直躲在函数后面,想躲多久就躲多久。只要我们不调用这些函数,它们理论上都是纯的。世界和平。在常规(非核)代码中,我们实际上最终希望得到那些副作用能够运行。将所有东西包装在一个函数中可以让我们精确地控制这些效果。我们决定这些副作用发生的确切时间。但是,输入那些括号很痛苦。创建每个函数的新版本很烦人。我们在语言中内置了一些非常好的函数,比如 Math.sqrt()
。如果有一种方法可以用延迟值来使用这些普通函数就好了。进入下一节 Effect 函子。
Effect 函子
就目的而言,Effect 函子只不过是一个被置入延迟函数的对象。我们想把 fZero
函数置入到一个 Effect 对象中。但是,在这样做之前,先把难度降低一个等级
1 | // zero :: () -> Number |
现在我们创建一个返回 Effect 对象的构造函数
1 | // Effect :: Function -> Effect |
到目前为止,还没有什么可看的。让我们做一些有用的事情。我们希望配合 Effetct 使用常规的 fZero()
函数。我们将编写一个接收常规函数并延后返回值的方法,它运行时不触发任何效果。我们称之为 map
。这是因为它在常规函数和 Effect 函数之间创建了一个映射。它可能看起来像这样:
1 | // Effect :: Function -> Effect |
现在,如果你观察仔细的话,你可能想知道 map()
的作用。它看起来像是组合。我们稍后会讲到。现在,让我们尝试一下:
1 | const zero = Effect(fZero); |
嗯。我们并没有看到发生了什么。让我们修改一下 Effect,这样我们就有了办法来“扣动扳机”。可以这样写:
1 | // Effect :: Function -> Effect |
并且只要我们愿意, 我们可以一直调用 map
函数:
1 | const double = x => x * 2; |
从这里开始变得有意思了。我们称这为函子,这意味着 Effect 有一个 map
函数,它 遵循一些规则。这些规则并不意味着你不能这样做。它们是你的行为准则。它们更像是优先级。因为 Effect 是函子大家庭的一份子,所以它可以做一些事情,其中一个叫做“合成规则”。它长这样:
如果我们有一个 Effect e
, 两个函数 f
和 g
那么 e.map(g).map(f)
等同于 e.map(x => f(g(x)))
。
换句话说,一行写两个 map
函数等同于组合这两个函数。也就是说 Effect 可以这样写(回顾一下上面的例子):
1 | const incDoubleCube = x => cube(double(increment(x))); |
当我们这样做的时候,我们可以确认会得到与三重 map
版本相同的结果。我们可以使用它重构代码,并确信代码不会崩溃。在某些情况下,我们甚至可以通过在不同方法之间进行交换来改进性能。
但这些例子已经足够了,让我们开始实战吧。
Effect 简写
我们的 Effect 构造函数接受一个函数作为它的参数。这很方便,因为大多数我们想要延迟的副作用也是函数。例如,Math.random()
和 console.log()
都是这种类型的东西。但有时我们想把一个普通的旧值压缩成一个 Effect。例如,假设我们在浏览器的 window
全局对象中附加了某种配置对象。我们想要得到一个 a 的值,但这不是一个纯粹的运算。我们可以写一个小的简写,使这个任务更容易:[3]
1 | // of :: a -> Effect a |
为了说明这可能会很方便,假设我们正在处理一个 web 应用。这个应用有一些标准特性,比如文章列表和用户简介。但是在 HTML 中,这些组件针对不同的客户进行展示。因为我们是聪明的工程师,所以我们决定将他们的位置存储在一个全局配置对象中,这样我们总能找到它们。例如:
1 | window.myAppConf = { |
现在使用 Effect.of()
,我们可以很快地把我们想要的值包装进一个 Effect 容器, 就像这样
1 | const win = Effect.of(window); |
内嵌 与 非内嵌 Effect
映射 Effect 可能对我们大有帮助。但是有时候我们会遇到映射的函数也返回一个 Effect 的情况。我们已经定义了一个 getElementLocator()
, 它返回一个包含字符串的 Effect。如果我们真的想要拿到 DOM 元素,我们需要调用另外一个非纯函数 document.querySelector()
。所以我们可能会通过返回一个 Effect 来纯化它:
1 | // $ :: String -> Effect DOMElement |
现在如果想把它两放一起,我们可以尝试使用 map()
:
1 | const userBio = userBioLocator.map($); |
想要真正运作起来还有点尴尬。如果我们想要访问那个 div,我们必须用一个函数来映射我们想要做的事情。例如,如果我们想要得到 innerHTML
,它看起来是这样的:
1 | const innerHTML = userBio.map(eff => eff.map(domEl => domEl.innerHTML)); |
让我们试着分解。我们会回到 userBio
,然后继续。这有点乏味,但我们想弄清楚这里发生了什么。我们使用的标记 Effect('user-bio')
有点误导人。如果我们把它写成代码,它看起来更像这样:
1 | Effect(() => ".userbio"); |
但这也不准确。我们真正做的是:
1 | Effect(() => window.myAppConf.selectors["user-bio"]); |
现在,当我们进行映射时,它就相当于将内部函数与另一个函数组合(正如我们在上面看到的)。所以当我们用 $
映射时,它看起来像这样:
1 | Effect(() => window.myAppConf.selectors["user-bio"]); |
把它展开得到:
1 | Effect( |
展开 Effect.of
给我们一个更清晰的概览:
1 | Effect(() => |
注意: 所有实际执行操作的代码都在最里面的函数中,这些都没有泄露到外部的 Effect。
Join
为什么要这样拼写呢?我们想要这些内嵌的 Effect 变成非内嵌的形式。转换过程中,要保证没有引入任何预料之外的副作用。对于 Effect 而言, 不内嵌的方式就是在外部函数调用 .runEffects()
。 但这可能会让人困惑。我们已经完成了整个练习,以检查我们不会运行任何 Effect。我们会创建另一个函数做同样的事情,并将其命名为 join
。我们使用 join
来解决 Effect 内嵌的问题,使用 runEffects()
真正运行所有 Effect。 即使运行的代码是相同的,但这会使我们的意图更加清晰。
1 | // Effect :: Function -> Effect |
然后,可以用它解开内嵌的用户简介元素:
1 | const userBioHTML = Effect.of(window) |
Chain
.map()
之后紧跟 .join()
这种模式经常出现。事实上,有一个简写函数是很方便的。这样,无论何时我们有一个返回 Effect 的函数,我们都可以使用这个简写函数。它可以把我们从一遍又一遍地写 map
然后紧跟 join
中解救出来。我们这样写:
1 | // Effect :: Function -> Effect |
我们调用新的函数 chain()
因为它允许我们把 Effect 链接到一起。(其实也是因为标准告诉我们可以这样调用它)。[4] 取到用户简介元素的 innerHTML
可能长这样:
1 | const userBioHTML = Effect.of(window) |
不幸的是, 对于这个实现其他函数式语言有着一些不同的名字。如果你读到它,你可能会有点疑惑。有时候它被称之为 flatMap
,这样起名是说得通的,因为我们先进行一个普通的映射,然后使用 .join()
扁平化结果。 不过在 Haskell 中,chain
被赋予了一个令人疑惑的名字 bind
。所以如果你在其他地方读到的话,记住 chain
,flatMap
和 bind
其实是同一概念的引用。
结合 Effect
这是最后一个使用 Effect 有点尴尬的场景,我们想要在一个函数中组合两个或者多个函子。例如,如何从 DOM 中拿到用户的名字?拿到名字后还要插入应用配置提供的模板里呢?因此,我们可能有一个模板函数(注意我们将创建一个科里化版本的函数)
1 | // tpl :: String -> Object -> String |
一切都很正常,但是现在来获取我们需要的数据:
1 | const win = Effect.of(window); |
我们已经有一个模板函数了。它接收一个字符串和一个对象并且返回一个字符串。但是我们的字符串和对象(name
和 pattern
)已经包装到 Effect 里了。我们所要做的就是提升我们 tpl()
函数到更高的地方使得它能很好地与 Effect 工作。
让我们看一下如果我们在 pattern Effect 上用 map()
调用 tpl()
会发生什么:
1 | pattern.map(tpl); |
对照一下类型可能会使得事情更加清晰一点。map 的类型签名可能长这样:
_map :: Effect a ~> (a -> b) -> Effect b_
模板函数是这样的签名:
_tpl :: String -> Object -> String_
因此,当我们在 pattern
上调用 map
, 我们在 Effect 内部得到了一个偏应用函数(记住我们科里化过 tpl
)。
_Effect (Object -> String)_
现在我们想从 pattern Effect 内部传递值,但我们还没有办法做到。我们将编写另一个 Effect 方法(称为 ap()
)来处理这个问题:
1 | // Effect :: Function -> Effect |
有了它,我们可以运行 .ap()
来应用我们的模板函数:
1 | const win = Effect.of(window); |
我们已经实现我们的目标。但有一点我要承认,我发现 ap()
有时会让人感到困惑。很难记住我必须先映射函数,然后再运行 ap()
。然后我可能会忘了参数的顺序。但是有一种方法可以解决这个问题。大多数时候,我想做的是把一个普通函数提升到应用程序的世界。也就是说,我已经有了简单的函数,我想让它们与具有 .ap()
方法的 Effect 一起工作。我们可以写一个函数来做这个:
1 | // liftA2 :: (a -> b -> c) -> (Applicative a -> Applicative b -> Applicative c) |
我们称它为 liftA2()
因为它会提升一个接受两个参数的函数. 我们可以写一个与之相似的 liftA3()
, 像这样:
1 | // liftA3 :: (a -> b -> c -> d) -> (Applicative a -> Applicative b -> Applicative c -> Applicative d) |
注意,liftA2
和 liftA3
从来没有提到 Effect。理论上,它们可以与任何具有兼容 ap()
方法的对象一起工作。
使用 liftA2()
我们可以像下面这样重写之前的例子:
1 | const win = Effect.of(window); |
那又怎样?
这时候你可能会想:“这似乎为了避免随处可见的奇怪的副作用而付出了很多努力”。这有什么关系?传入参数到 Effect 内部,封装 ap()
似乎是一项艰巨的工作。当不纯代码正常工作时,为什么还要烦恼呢?在实际场景中,你什么时候会需要这个?
函数式程序员听起来很像是中世纪的僧侣似的,他们禁绝了尘世中的种种乐趣并且期望这能使自己变得高洁。
—John Hughes [5]
让我们把这些反对意见分成两个问题:
- 函数纯度真的重要吗?
- 在真实场景中什么时候有用?
函数纯度重要性
函数纯度的确重要。当你单独观察一个小函数时,一点点的副作用并不重要。写const pattern = window.myAppConfig.templates['greeting'];
比写下面这样的代码更加快速简单
1 | const pattern = Effect.of(window).map(w => w.myAppConfig.templates("greeting")); |
如果代码里都是这样的小函数,那么继续这么写也可以,副作用不足以成问题。但这只是应用程序中的一行代码,其中可能包含数千甚至数百万行代码。当你试图弄清楚为什么你的应用程序莫名其妙地“看似毫无道理地”停止工作时,函数纯度就变得更加重要了。如果发生了一些意想不到的事,你试图把问题分解开来,找出原因。在这种情况下,可以排除的代码越多越好。如果您的函数是纯的,那么您可以确信,影响它们行为的唯一因素是传递给它的输入。这就大大缩小了要考虑的异常范围。换句话说,它能让你少思考。这在大型、复杂的应用程序中尤为重要。
实际场景中的 Effect 模式
好吧。如果你正在构建一个大型的、复杂的应用程序,类似 Facebook 或 Gmail。那么函数纯度可能很重要。但如果不是大型应用呢?让我们考虑一个越发普遍的场景。你有一些数据。不只是一点点数据,而是大量的数据 —— 数百万行,在 CSV 文本文件或大型数据库表中。你的任务是处理这些数据。也许你在训练一个人工神经网络来建立一个推理模型。也许你正试图找出加密货币的下一个大动向。无论如何, 问题是要完成这项工作需要大量的处理工作。
Joel Spolsky 令人信服地论证过 函数式编程可以帮助我们解决这个问题。我们可以编写并行运行的 map
和 reduce
的替代版本,而函数纯度使这成为可能。但这并不是故事的结尾。当然,您可以编写一些奇特的并行处理代码。但即便如此,您的开发机器仍然只有 4 个内核(如果幸运的话,可能是 8 个或 16 个)。那项工作仍然需要很长时间。除非,也就是说,你可以在一堆处理器上运行它,比如 GPU,或者整个处理服务器集群。
要使其工作,您需要描述您想要运行的计算。但是,您需要在不实际运行它们的情况下描述它们。听起来是不是很熟悉?理想情况下,您应该将描述传递给某种框架。该框架将小心地负责读取所有数据,并将其在处理节点之间分割。然后框架会把结果收集在一起,告诉你它的运行情况。这就是 TensorFlow 的工作流程。
TensorFlow™ 是一个高性能数值计算开源软件库。它灵活的架构支持从桌面到服务器集群,从移动设备到边缘设备的跨平台(CPU、GPU、TPU)计算部署。Google AI 组织内的 Google Brain 小组的研究员和工程师最初开发 TensorFlow 用于支持机器学习和深度学习领域,其灵活的数值计算内核也应用于其他科学领域。
—TensorFlow 首页[6]
当您使用 TensorFlow 时,你不会使用你所使用的编程语言中的常规数据类型。而是,你需要创建张量。如果我们想加两个数字,它看起来是这样的:
1 | node1 = tf.constant(3.0, tf.float32) |
上面的代码是用 Python 编写的,但是它看起来和 JavaScript 没有太大的区别,不是吗?和我们的 Effect 类似,add
直到我们调用它才会运行 (在这个例子中使用了 sess.run()
):
1 | print("node3: ", node3) |
在调用 se .run()
之前,我们不会得到 7.0。正如你看到的,它和延时函数很像。我们提前计划好了计算。然后,一旦准备好了,发动战争。
总结
本文涉及了很多内容,但是我们已经探索了两种方法来处理代码中的函数纯度:
- 依赖注入
- Effect 函子
依赖注入的工作原理是将代码的不纯部分移出函数。所以你必须把它们作为参数传递进来。相比之下,Effect 函子的工作原理则是将所有内容包装在一个函数后面。要运行这些 Effect,我们必须先运行包装器函数。
这两种方法都是欺骗。他们不会完全去除不纯,他们只是把它们推到代码的边缘。但这是件好事。它明确说明了代码的哪些部分是不纯的。在调试复杂代码库中的问题时,很有优势。
这不是一个完整的定义,但暂时可以使用。我们稍后会回到正式的定义。 ↩
在其他语言(如 Haskell)中,这称为 IO 函子或 IO 单子。 PureScript 使用 Effect 作为术语。我发现它更具有描述性。 ↩
注意,不同的语言对这个简写有不同的名称。例如,在 Haskell 中,它被称为
pure
。我不知道为什么。 ↩在这个例子中, 采用了 Fantasy Land specification for Chain 规范。 ↩
John Hughes, 1990, ‘Why Functional Programming Matters’, Research Topics in Functional Programming ed. D. Turner, Addison–Wesley, pp 17–42, https://www.cs.kent.ac.uk/people/staff/dat/miranda/whyfp90.pdf ↩
TensorFlow™: 面向所有人的开源机器学习框架, https://www.tensorflow.org/, 12 May 2018. ↩
:::tip
原文链接
以及感谢这个 PR 下几位帮忙校对以及掘金翻译计划
:::