BT

如何利用碎片时间提升技术认知与能力? 点击获取答案

理解Monad,一份monad的解惑指南

| 作者 Barry Burd 关注 0 他的粉丝 ,译者 易文英 关注 0 他的粉丝 发布于 2017年12月8日. 估计阅读时间: 34 分钟 | QCon北京2018全面起航:开启与Netflix、微软、ThoughtWorks等公司的技术创新之路!

亲爱的读者:我们最近添加了一些个人消息定制功能,您只需选择感兴趣的技术主题,即可获取重要资讯的邮件和网页通知

本文要点:

  • 避免显式地处理状态值是有必要的
  • 通过使用monad,你就可以移除代码中对状态值的明确处理。
  • 一个monads类型必须与特殊的函数(名为“bind”)相联系
  • 用了monad的bind函数后,状态值会从一个monad传递给下一个,而且始终在monad中(而非明确地在代码中被处理)
  • 许多问题都可以用monad来解决

随着函数式编程的再次兴起,“monad” 这种函数式结构再次让初学者感到恐惧。Monad借鉴了数学中的范畴学理论, 该理论在20世纪90年代被引入了编程语言,是Haskell和Scala这类纯函数式编程语言的一种基本构件。

以下是大部分初学者对于monads的了解:

  • monad对于输入输出很有用
  • monad对输入和输出以外的东西也会有用
  • monad很难理解,因为大部分与之相关的文章不是细节太多,就是细节太少

第三点就是促使我写这篇文章的原因,只有百分之一甚至是千分之一的文章对monads做过介绍。我希望在你阅读完这篇文章之后,会觉得其实monad并没有那么可怕。

系统的状态

在计算机程序中,“状态”这个词描述了全局变量、输入、输出以及对于特定函数而言非局部的东西(变量、输入、输出等)。以下是关于程序状态的几点:

  • 它的值在不同函数接连执行时是延续的。
  • 它可以用于多个函数。
  • 它可以是可变的。在这种情况下,它的值具有时间依赖性。

状态很难去管理,因为它不属于任何一个的函数。想象下以下场景:

  • 1号函数从状态获得了一个值,并在执行其命令的时候使用了该值。与此同时,2号函数修改了该值。结果会造成,1号函数在执行命令时,使用并不是最新修改过的值。
  • 更糟糕的是,1号函数会用旧的数据来执行计算,然后用其产生的新的不准确的值来替换已有的状态值。由此,1号函数的错误开始具有传染性,从函数的内部开始扩散到整个系统。

不管怎样,因为系统状态是时间的函数,所以我们需要对时间维度多加考虑。我们不能直接问,“值x是多少?”,而需要问,“在时间点t时,值x是多少?”。这就增加了一个维度的复杂性,让代码推理很难进行。所以底线是...

   程序内有状态表示:bad!
   程序没无状态表示:good!

表达式和操作

一个表达式是一段含有值的文本。例如,以下的代码:  

  1. x = 5
  2. y = x + 7
  3. x = y + 1

x第一次出现是在值为5的表达式里,最后一次出现是在值为13的表达式里。代码里也包含其他的表达式。例如上述例子中间一行,x + 7是一个值为12的表达式。

在大部分的计算机语言中,从键盘读取命令是一个表达式,并且该表达式具有一个值。见以下语句:  

  1. x = nextInput()

你会在Java、C++和一些其他的编程语言中见到这类型的语句。现在想象一下,当用户键入数字5,然后nextInput()是一个值为5的表达式。执行该语句会将nextInput()表达式的值(值5)赋给x。如果x是程序状态的一部分,那么这个语句就会改变状态值,但是正如我们上面讲过的,改变状态值可能会很危险。

在上述nextInput()的例子中,nextInput()的值有时间依赖性。执行nextInput()表达式一次,则值为5,再执行一次,则值为17。在弱类型的语言中,x值可能会从5变成"Hello, world"

为了降低时间依赖性,我们会停止用nextInput(),而用另一个函数来替换它,以下我会称这个函数为doInput。作为一个表达式,doInput的值不再是517、或者"Hello, world",而是一个操作,一个在运行时从键盘获得输入的操作。

一个操作可能发生也可能不发生。从键盘读取输入是一个操作。在屏幕上写下"Hello,world"是一个操作。打开"/Users/barry/myfile.txt"对应的文件是一个操作。建立与http://www.infoq.com的超链接是一个操作。一个操作是某种计算(computation)。通常情况下,程序源代码中不会详细地列出某个操作的细节。相反地,一个操作是一种运行时的现象。

在一些编程语言中,提到类型(type),你通常会想到整型、浮点类型、字符串、布尔值和其他诸如此类的东西,可能并不会将操作也作为一种类型。但是当我们做monadic I/O时,类似于doInput()这样表达式的值是一个操作。换句话说,调用(call)doInput()的产生的返回值是一个操作。

这样想的话,doInput()的值就不再具有时间依赖性。不论在程序里的哪个位置,doInput()这个表达式始终具有相同的值,也就是获得键盘输入的操作。

由此,我们在有无状态这个问题上,取得了进展。

操作并不是万能的

这里我们会有个小问题。当我们想使用一个表达式,但是这个表达式的值是一个操作时,我们要如何处理呢?如果一个操作从键盘输入获得了5,我们并不能直接加1到操作上。  

  1. x = doInput()
  2. print(x + 1)

在上述语言无关的代码中,变量x指的是一个操作。操作跟值1完全是不同的类型,所以表达式x+1只能解释为加1到重启计算机的操作上,因而表达式restart + 1毫无意义!

那么如果你并不想加1到用户输入上,以下的代码合理吗?  

  1. x = doInput()
  2. print(x)

当然不合理。语句x = doInput()将操作赋给了x,所以print(x)语句是在试图显示一个操作。那操作显示到电脑屏幕上是什么样的呢?

你或许会争论说在弱类型语言中,你可以模糊从键盘得到输入的操作和值之间的区别。正如2 == "2"在JavaScript被判断为True,在一些弱类型语言里5 == the_action_obtaining_5也可能会被判断为True。但是在这个表象之下,其实要经过某些处理才能从the_action_obtaining_5中拿到值5。而且,如果值5 和the_action_obtaining_5之间没了区别,你就会回到最初的问题,输入函数调用为具有时间依赖性的表达式。你当然不想这样。

建立一个链(chain)

现在问题就很清楚了,我们不能直接打印输出表达式doInput()的值。但是,如果我们能够巧妙的将一系列的操作连接成一个链,在doInput()操作之后紧跟另一个效用为打印输出的操作又会怎么样呢?亲爱的读者,这就是monad。

doInput()操作与用户键入了值(如数字5)有关,在我们处理doInput()时,我们最感兴趣的是用户键入的数字5,而不是doInput()操作本身。

让我们再走进些看看。 如果一个程序持续跟踪仓库中的库存,输入数字5可能代表货架上箱子的数量。值5在问题领域(problem domain)与货架上箱子的数量是相关的。如果你走到管理仓库的人说“你有五个箱子”,那么这个人就知道你的意思了。另一方面,doInput()表达式的值是一个操作,对管理库存的人员来说并没有意义。doInput()操作是我们处理库存过程中产生的artifact(译者注:维基百科解释artifact为软件开发过程中一种有形的副产品)。因此,在本文中,我将对与操作相联系的相关值(pertinent value)和操作本身之间进行区分。为了强调这里所指的操作缺乏针对性,我把操作本身称为artifact

这里做一个回顾。当你执行 doInput()时,用户输入了数字5,

  • 接收输入的操作就是我们所说的artifact
  • 数字5就是我们所说的相关值

这里的术语相关值和artifact并不适用于所有的monad,但是它可以帮助我解释monad究竟是什么。为了做出更加清楚的解释,我将本文大部分例子中均将相关值设定为整数。

笼统来说,我认为doInput()是一个容器,里面盛装着相关值如值5。 容器的隐喻是有用的,因为一旦一个值与一个monad相联系,我们喜欢把这个值附加到monad上,并会防止相关值跑到monad容器之外。但请记住,当你认真对待monads时,这些相关性和容器的类比就会失效。但是即便如此,这样的类比也有助于你对monad形成直观的感受。

挑战是:我们必须对与doInput()操作相联系的用户输入相关值的使用方法,进行形式化描述。(在这篇文章中,“形式化”这个词并不意味着“绝对严格”,而是指“用简明的语言,将复杂的概念略加简化的描述出来”)

一些类型是Monads,一些并不是

在传统的编程语言中,可能有整数类型、浮点类型、布尔类型、字符串类型以及许多不同类型的复合类型。你可以说一个类型是或者不是monad类型。那么什么样的类型是monad类型呢?

对于初学者来说,monad类型有一个或几个相关值与它相联系。以下是几个有相关值的类型:

  1. I/O操作类型:
    对于一个input-from-keyboard操作,相关值是用户输入的值,而操作本身就是artifact。

  2. list类型:
    想象一个含有值的列表(list):[3,17,24,0,1],则列表的相关值为3、17、24、0和1。artifact是这些值合在一起形成一个列表的事实。如果你不喜欢列表,那你也可以考虑数组、矢量或者其他你喜欢的编程语言的集合结构。

  3. Maybe类型:
    空值(null value)的使用在许多编程语言中都存在问题,但是函数式编程对此有一个解决方法。在我简化的场景中,Maybe值包含一个数字(如5)或一个Nothing指示符(indicator)。如果计算未能确定一个值,则Maybe可能包含Nothing而不是一个值。

    Java和Swift等语言都有Optional类型,类似于我们这里所说的Maybe类型。

    对于能产生Maybe值的计算,相关值可以是数字,也可以是Nothing指示符(indicator)。artifact是一个概念,一个计算结果不是一个简单数字的概念。

  4. Writer类型:
    Writer是一个函数,它的一部分的功能就是生成会被写入运行日志的信息。想象一下,就比如一个规整的平方函数附带有第二个值。

    square(6) = (36, "false")

    数字366*6的结果。而文字"false"则表明结果36不能被10整除。多次运用Writer函数之后,日志可能显示如下:  

    "false true false"

    在这个例子中,相关值是一个数字格式的结果如36,而artifact是字符串的值("true" 或者 "false"),将数字格式的结果与字符串值捆绑在了一起。

形式化地描述上述各类型的相关值是一个挑战。尤其是:

  1. 对于I/O操作类型而言:
    你有一个与一些用户输入相联系的操作。你不能直接让加1到操作,也无法在屏幕显示操作。操作并不是可以和1相加的类型。你必须定义如何使用操作相关值的方法。

  2. 对于list类型而言:
    你有含有数字的列表(list),像之前所举的列表例子中含有3、17、24、0和1。但是你不能对列表本身进行数字运算,因为列表类型不支持。你需要去定义如何使用列表中的每个数字(比如“+1”)。

  3. 对于Maybe类型而言:
    想象下这里有两个Maybe值,分别为maybe1maybe2maybe1与Nothing相联系,而maybe2与值5相联系。maybe1值是计算失败的产物,而maybe2值来自于某些结果为5的计算。

    那可以加1到maybe1值吗?不行,因为Nothing值与maybe1相联系,所以1 + maybe1毫无意义。

    那可以加1到maybe2值吗?出于学习monads的目的,我的回答仍然是否定的。虽然值5与maybe2相联系,但是maybe2值并不是一个数字,而是一个artifact结构,只是值5恰好与它相联系,所以1 + maybe2仍旧毫无意义。

  4. 对于Writer类型而言:
    从一些可以帮你找到的普通函数说起。 

    square(6) = 36
    plus4(36) = 40
    dividedBy5(40) = 8

    你可以将这些函数连接成链得到想要的结果: 

    dividedBy5(plus4(square(6))) = 8

    但是如果每个函数都是一个Writer,而且每个函数结果均另含有是否能被10整除的指示符,整个事情就会不一样。

累计的日志会显示如下: 

  1. square(6) = (36, "false") "false"
  2. plus4(36) = (40, "true") "false true"
  3. dividedBy5(40) = ( 8, "false") "false true false"

你不能将plus4函数应用于square函数返回的一对(pair)结果。

    plus4(square(6))plus4((36, "false")并没有意义

取而代之地,你需要将plus4函数应用于square函数执行得到的相关值。以此类推,将dividedBy5函数应用于plus4函数执行得到的相关值。

通过简单的规则,就可以一劳永逸地对每个函数调用应用于前一个函数调用的相关值的方式进行形式化地描述。由此引出了bind函数。

一个类型必须含有bind函数,才能被称为monad

在大多数的编程语言中,不同的类型有它们自己的函数。比如,整数类型有自己的+、-、*和/函数。字符串类型有它的连接函数(Concatenation function)。布尔类型有其or、and和not函数。

为了成为monad,一个类型必须有一个函数使用了monad的相关值或者值,并且函数有特定的形式。下面就来讲下这些特定的函数形式。

第一种候选的函数形式(一个错误的想法)是用来从monad中分离相关值的函数(我将会把它称为badIdea)。例如,badIdea函数会从操作里面获得用户输入。如果你调用了badIdea函数,并且用户键入了数字5,那么badIdea(doInput())就是值5。通过调用badIdea函数,你就可以输出调用badIdea()的结果值,甚至可以加1到结果值。 

  1. x = badIdea(doInput())
  2. print(x)
  3. y = x + 1

现在回到我们之前开始提到nextInput()的时间依赖性的问题上。表达式badIdea(doInput())具有原始函数nextInput()所有的不良特性。将badIdea(doInput())函数表达式执行一次的值是5。再执行一次,值就变成了17。在弱类型的语言中,badIdea(doInput())的值或许会从5变为"Hello, world"

通过函数badIdea, 你就可以从doInput()操作中抓取相关值,并用该值去执行任何操作,但这并不是一个好的方法。相反地,让我们从doInput()操作抓取相关值,然后再创建另一个操作来使用这个值。这很重要的:你需要在一个doInput()操作后面紧跟着使用另一个操作。当你形式化地描述这个想法的时候,这个操作类型就变成了一个monad类型。所以让我们开始形式化地描述这个想法:

我们从两个东西入手:操作和函数。例如:

  • doInput()操作,获得键入数字的操作。
  • doPrint函数,拿一个数字,并生成一个在屏幕上写入该数字的操作
    doPrint(5) = 在屏幕上写入5的操作
    doPrint(19) = 在屏幕上写入19的操作

图1对该场景进行了说明。图中每个齿轮均代表某种操作。

我可以把doPrint描述为一个from-number-to-action函数。当你做函数式编程的时候,很自然的会出现这种函数。

让我们在这里暂停一下,考虑下两个表达式,不带括号的opSystem和带括号的opSystem()opSystem表达式的值是个很特殊的函数,这个函数会去发现正在运行的操作系统,但是opSystem()表达式的值是一个名字,像是LinuxWindows 10。简单来说,

  • opSystem表示一个函数,而
  • opSystem()表示调用opSystem函数的返回值

记住这些后,请注意带括号的“操作doInput()”和不带括号的“函数doPrint”之间的区别。doInputdoPrint这两种函数都会返回值,而且返回的值都为操作。当我说“操作doInput()”时,我指的是调用doInput()函数得到的值。当我说“函数doPrint”时,我指的是doPrint函数本身,而不是函数的返回值。这也是为什么我在图1中用不同的方式去表示doInput()doPrint。为了说明doInput(),我画了一个齿轮,用来让你联想到一个操作。为了说明doPrint,我画了一个箭头,用来让你联想到一个函数。当你想到monad时,这些示意图能够帮你更直观地想到与之相关的问题。

有了doInput()操作和doPrint函数之后,我们还需要另一部分来组成一个monad,需要一种将doInput()doPrint粘合在一起的方法。更准确地说,我们需要一个方程式(formula),来获取任何操作A和from-number-to-action函数f,并且将它们结合起来创建一个新的操作。我们给这个方程式起名叫bind。见图2。

在上段中,我把bind称为一个方程式(formula),但是bind其实是另一个函数。

bind(A,f) = 某种操作

bind函数是一个高阶函数(higher-order function),因为它将函数作为其实参(argument)之一。如果你还不习惯考虑函数的函数,那么bind函数足以让你晕头转向。

对于输入和输出而言,bind函数必须是一个通用的规则,获取任何I/O操作A和任何from-number-to-action函数f作为它的形参(parameter)。bind函数必须返回一个新的I/O操作。例如:

  • doInput() = 拿到键入数字的操作
  • doPrint(x) = 将值x显示到屏幕的操作
    • bind(doInput(),doPrint)= 将doInput()的相关值显示到屏幕的操作

见图3。

让我们稍微改动下例子:

  • doPrintPlusl(x) = 将x+1的值显示到屏幕的操作

    bind(doInput(),doPrintPlusl) = 将doInput()的相关值+1显示到的操作

见图4。

一般来说,对于输入/输出操作A和from-number-to-action函数f而言:

    bind(A,f) = 将f应用于A的相关值的一个操作

见图5。

如果你发现还是对bind的解释感到困惑,不用担心,你并不是唯一一个。高阶函数本来就不好理解。

在Haskell编程语言中,因为bind函数扮演了很重要的角色,所以bind操作(operator),>>=被直接内置到了这个语言内。事实上,很多处理monads的功能都是直接内置于Haskell内。

舍弃时间依赖性

让我们重温一下在图3中所示的doInput(),doPrint情景。

bind(doInput(),doPrint) = 将doInput()的相关值显示到屏幕的操作
不论出现在代码的何处,表达式bind(doInput(),doPrint)中没有任何一部分具有时间依赖性,

  • doInput()总是同一个操作——拿到键入的数字
  • doPrint也总是from-number-to-action函数
  • 完整的表达式bind(doInput(),doPrint)也总是相同的操作——将数字显示到屏幕的操作

用户键入的数字因时而异,但是表达式bind(doInput(),doPrint)中没有任何一部分表示那个值。我们并没有消除所有的负面影响,但是清除了我们代码中任何对系统状态的明确地提及。一旦用户在键盘上键入了一个数字,这个数字会像烫手山芋一样从一个操作传到下一个操作,而代码中的任何一个变量都不表示用户键入的那个数字。

Monads不总是关于数字和输入/输出操作

在我们提过的例子中:

  • doInput的相关值是一个数字,而doPrint的实参类型也是一个数字
  • doInput()的类型是一个操作,而doPrint的返回类型也是一个操作

bind函数告诉我们如何将doInput()doPrint相结合来得到一个新的操作。

一些monad跟数字和输入/输出操作都没有关系。所以让我们用更笼统的说法来概括一下,我们对于doInputdoPrintbind函数的发现:

  • doPrint的实参类型和doInput()相关值的类型相同
  • doPrint的返回类型和doinput()的类型相同

bind函数告诉我们如何将doInput()doPrint相结合来得到一个全新的值。新值的类型与最初的doInput()值的类型相同。

更多的Monad类型

任何有bind函数(和另一个我将在文末描述的函数)的类型就是一个monad。有了我在之前几段中提到的针对输入/输出的bind函数,输入/输出操作类型就变成了monad。我们先前提到的list、Maybe和Writer类型也是monad类型。以下是bind函数与列表类型的关系:

从两件事说起,列表L和函数f。跟之前一样,函数f是某种特殊的类型。

  • f需要一个类型与列表中元素相同的值,作为它的实参
  • 作为返回结果,f会生成一个列表

如果列表含有数字,那么f就是一个from-number-to-list函数。这里有一个方程式(formula,一个bind的定义)可以获取任何的列表和from-number-to-list函数,并将它们相结合来形成一个新的列表:

bind(L,f)= 一个新的列表,即将f应用于L中每个元素后,扁平化一个返回值列表的列表(list of lists)得到的新列表

让我们来看一个例子:

  • f(x)= 列表[squareRoot(x),-squareRoot(x)]

    正如要求的一样,f是一个from-number-to-list函数。

    L = [4,25,81]

    根据我们对用于列表bind函数的定义:

    bind(L,f)= 扁平化[[2,-2], [5,-5], [9,-9]] = [2,-2,5,-5,9,-9]

bind函数给出了所有将函数f应用到列表L任意元素上所有可能得到的结果。见图6。

  • 这里是一个不涉及数据的例子。假设x是一本书,令f(x) = 书的作者列表。
    例如:
    f(C_Programming_Lang) = [Kernighan,Ritchie]

    在这个例子中,f是一个from-book-to-list函数。根据我们对用于列表的bind函数的定义:

    bind([C_Programming_Lang, Design_Patterns], f)=

        扁平化的[[Kernighan,Ritchie],[Gamma,Helm,Johnson Vlissides]] =

        [Kernighan,Ritchie,Gamma,Helm,Johnson,Vlissides]

我们已经成功地从书的列表中拿到了作者列表。见图7。

注意图2-图7这些图像的相似处:

  • 在图的左侧,有一个monad值(如操作,列表等)和一个函数。函数获取相关值来生成一个monad值。
  • 在图的中间,有用箭头表示的bind函数的应用。
  • 在图的右侧,有一个全新的monad值

对于Maybe类型的bind函数又是怎样的呢?假设m是一个Maybe值(包含一个数字或Nothing),并令f是一个获得数字并生成Maybe值的函数。bind(m,f)的值取决于Maybe值的内容:

bind(m,f) =
f(m's pertinent value), 如果m不含有Nothing
      或者
      Nothing,如果m含有Nothing

让我们来举一些例子:
         假设maybe1含有Nothing
         maybe2含有0
         maybe3含有0
         f(x) = 一个含有100/x的Maybe值
然后,
         bind(maybe,f) = Nothing
         bind(maybe2,f) = 含有20的Maybe值
         bind(maybe3,f) = Nothing

同样的,bind函数的实参是一个monad(在该情况下,是一个Maybe值)和一个函数。函数的实参是一个monad的相关值,而函数会返回另一个monad。

当然,我们可以为Writer monad创建一个bind函数。

bind((aNumber,aString), f) =

(f(aNumber)数字部分,aString+f(aNumber)的字符串部分

一些例子能够帮我们记住这个要点的例子。回想下之前的squareplus4dividedBy5函数——这些函数的返回值均包含能被10整除的指示符。

bind((36, "false"), plus4) = (40, "false true") 见图8。

bind((40, "false true"), dividedBy5) = (8, "false true false")

当你应用bind的时候,结果的字符串部分会将早期计算得到的字符串都包括进去。期间,字符串部分会运行所有计算的日志。

Monad需要一个额外的函数

bind函数可能没有很好理解,但是另一个作为一个monad必须有的函数却很好懂。我把它称为toMonad函数。

  • toMonad函数将一个相关值作为它的实参
  • toMonad函数返会还一个monad

粗略地说,toMonad返回的是可能包含给定相关值的最简单的monad。(这里有一个更精确的定义,涉及到toMonadbind的交互,但我在本文中不会讲这个。好奇的话,可以访问https://en.wikibooks.org/wiki/Haskell/Understanding_monads。)

  1. 对于I/O操作monad,toMonad(aValue) = 一个相关值是aValue但是却没什么作用的操作。

  2. 对于列表monad,toMonad(aValue) = 包含aValue并将其作为唯一入口的列表

  1. 对于Maybe monad,toMonad(number) = 含有该number的Maybe值。

    请记住,含有5的Maybe值与原来普通的5并不完全相同。

  2. 对于Writer monad,toMonad(number)会返回一个含有空字符串的monad。

    toMonad(number) = (number, "")

写在最后

monad是一个带有bind函数和toMonad函数的类型。bind函数在没有明确揭示monad相关值的情况下,机械地从一个monad执行到下一个monad。

从功能上来看,monad无处不在。有了I/O操作monad,代码中的表达式都不再代表用户的输入,所以系统状态在你程序内的任何地方均不会明确地表示出来。进而,你代码中的表达式就不具有时间依赖性。你就不会去问“此时,这个程序中的x表示什么?”,而会问“在这个程序中,x表示什么?”

记住底线:

     程序内有状态表示:bad!
     程序内无状态表示:good!

我们生活的世界是有状态的,对此我们无能为力。Monad并不能将从系统中消除状态,但是它可以帮我们消除在编程代码中对系统状态的显式表示。这可能会很有用。

原文作者简介:

本文作者Dr.Barry Burd撰写过很多文章和书籍,包括大受欢迎的《Java For Dummies》和《Android Application Development All-in-One For Dummies》这两本均出版于Wiley Publishing的书。除此以外,他还是O'Reilly's Introduction to Functional Programming课程的讲师。自1980年以来,Dr.Burd就一直担任着新泽西州麦迪逊德鲁大学数学和计算机科学系的教授,也曾在美国、欧洲、澳大利亚和亚洲的诸多会议上发表过演讲。

原文链接:Understanding Monads. A Guide for the Perplexed

感谢罗远航对本文的审校。

评价本文

专业度
风格

您好,朋友!

您需要 注册一个InfoQ账号 或者 才能进行评论。在您完成注册后还需要进行一些设置。

获得来自InfoQ的更多体验。

告诉我们您的想法

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

maybe2含有0 不对 by tang jie

"maybe2含有0" 不对,原文也已经改动了

maybe2含有0不对 by tang jie

“maybe2含有0”不对,原文也已经改动

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

2 讨论

登陆InfoQ,与你最关心的话题互动。


找回密码....

Follow

关注你最喜爱的话题和作者

快速浏览网站内你所感兴趣话题的精选内容。

Like

内容自由定制

选择想要阅读的主题和喜爱的作者定制自己的新闻源。

Notifications

获取更新

设置通知机制以获取内容更新对您而言是否重要

BT