R的若干基因及争论

谢益辉 2012-09-18

R是一门古怪的语言,这一点没什么好否认的。它的古怪有好有坏,在不同人眼中也可能是好事或坏事。R是受S语言影响发展起来的,S语言诞生于贝尔实验室。后来AT&T被分拆的时候,这个实验室被拆为今天的贝尔实验室(已经没什么名气了)和AT&T实验室。如前文所说,这个夏天在AT&T实验室呆着,S语言的一位作者Rick是我的办公室邻居,另一位作者Allan暑假里退休了,第三位作者是John Chambers,他早已离开实验室去高校了。S诞生在(文本)数据堆里,而R诞生之后很快走向了合作开发,这一系列历史给它带来了一些看似古怪的特征。

赋值

赋值符号在绝大多数语言中都没什么好讨论的,因为就是一个等号而已。在R社区,这一点却被讨论来讨论去,主要原因是箭头赋值符号<-的存在。箭头来源于贝尔实验室早年的某台古董机器上有一个下划线的键,但打出来显示的是箭头。S祖先们认为箭头是一个很形象的赋值符号,于是下划线被采纳下来,甚至后来衍生出右箭头(->)这样更奇怪的赋值符号(表示把左边的值赋给右边的变量),还有双箭头(<<-)表示给上一层环境中的变量赋值。其实箭头也不是 S 祖先们发明的,而是继承了 APL

最初S代码中,下划线本身就是赋值符号,例如x_1表示把1赋值给x。我大三的时候还用S-Plus写了不少下划线赋值的S代码。后来R core们做了一个艰难的决定,允许等号作为赋值符号,废弃了下划线的赋值功能,但下划线的传统仍然无处不在,例如Emacs / ESS中默认情况下敲下划线会被替代为左箭头(为了得到真的下划线需要连续按两次下划线),尽管下划线本身已经不能直接显示为箭头,ESS仍然想昨日重现,通过软件方式强行替代之。

我不止一次地说过,我是坚定的等号党。因为等号有赋值功能,大多数语言都用等号(没见其它语言的程序员抱怨等号不形象),等号对我来说不存在歧义,让我的代码更安全。反对党(也就是箭头党)的理由通常是等号存在二义性:它既可以赋值,也可以传递函数参数,如:

x = 1:10
length(x = 1:10)

可是几乎所有语言都这样,也没有见那些语言的程序员对此有抱怨。规则很简单:如果等号出现在单独的环境中,它就是赋值;如果写在函数参数位置,它就是传参数。但R的古怪让这个简单规则也可以变得很难判断,例如:

length((x = 1:10))
length({x = 1:10})

因为(){}将表达式与外面的环境隔离开来,它们被解析的时候先单独运行,所以实际上x在自己的环境里被赋值了;然后(){}会返回整个表达式的结果,这个结果再传给length()函数。

箭头在任何地方的意思都是赋值(在当前环境下),它可以被写在任何地方,包括函数参数的位置,如:

length(x <- 1:10)

这句代码先对x赋值,然后把整个表达式的值传递给length()。我们可以把它写得更晕:

length(x = y <- 1:10)

此时等号表示传参数,而y无论如何都会被赋值。在函数参数位置上赋值通常有一举两得的效果,也就是把两件事情写在一行上,之所以能一举两得,主要是利用了箭头的副作用;如果是初学者,这种写法最好避免,首先追求代码的清晰性,避免产生副作用的代码。因为箭头无论何地都可以赋值,要是用错了也不会报错,这种错误往往难以意识到。例如我们创建一个向量,元素为1和2,元素名字分别为a和b,如果不小心写成这样:

c(a = 1, b <- 2)  # 本来应该是c(a = 1, b = 2)

你可能不会意识到第二个元素是没有名字的,并且这句话带来一个副作用,就是悄悄给b赋了值。

如果你像我一样有时候写代码不爱打空格,那么还有一个更可怕的潜在错误,要是不小心犯了的话可能很久都查不出来。我们写几个逻辑表达式:x大于5,x小于3,x小于-3。你可能想,这个太简单了,操起键盘就写:

x>5
x<3
x<-3

因为你的懒惰,小于号和负号悄磨叽走到了一起,不小心形成了具有强大法力的赋值箭头,你并没有完成x与-3的逻辑大小比较,而是给x赋值为3。当这几行代码在这里摆着的时候你可能觉得很容易看出来,可是当你玩了两个小时数据之后,想随手看一下df数据框中x变量小于-1的值有多少个,你可能会写出sum(df$x<-1)这样的语句,相应的结果是,你没得到df$x小于-1的总数,而是把df数据里x这一列给修改为1了。如果df是一个很大的数据,或你辛辛苦苦处理了半天才得来的数据,你就哭吧。我对这个诡异的案例印象深刻,是因为我亲眼看见过两例别人的错误,在那之后我明知有这样的危险,但自己还是傻不愣登毁了一次自己的数据。

对右箭头赋值,我的想象是这样:某天某祖先写了一长段代码,但没有事先写上把这段代码的结果赋值保留下来,悔得肠子都青了,只好敲回车任凭程序在那儿跑,跑完了得不到返回值,于是该祖先发明了一个右箭头,这样即使先写了一段代码也不用怕,因为可以最后加上-> x就把前面的返回值赋给x了。我不习惯阅读这种事后赋值的代码,就像读侦探小说似的,到最后才发现代码创建了一个变量。

这种“后悔”的想象还可以继续:R中有一个特殊的变量叫.Last.value,它总是保存最近一次运行的最后一个结果,即使你上一条代码没有赋值保存,你仍然可以通过.Last.value去获取。这也意味着,无论你跑什么程序,R都会随时盯着你的返回结果,把它赋值给.Last.value

命名风格

R core的主要命名风格是以点分隔词,例如t.test,这与早年时下划线有赋值含义有关,另外我猜想也是懒惰,因为点只需要按键一次,而其它命名法都需要按Shift键,如camelCase。不过这个也不绝对,R里面仍然有些下划线命名的函数如seq_along,或驼峰命名如summaryRprof。这里面有多人合作时的个人风格,更重要的可能是S3泛型函数的影响。S3函数的特点就是“主函数.类名”,如summary.lm,它根据传递进来的对象的类来匹配具体的子函数。因为在泛型函数中,点是有特殊意义的,所以我们要小心点(这里注意断句),为了安全起见,最好干脆避免点,免得跟泛型函数扯上关系,尤其是包的作者在写函数时。

在其它很多语言如JavaScript中,点通常表示取一个对象的子元素或者应用方法,如x.toString()。R的点不存在这个问题(要达到同样的效果,一般用$),它除了可能有S3的意思,没有其它特殊含义。考虑到其它语言以及S3两个原因,我最终投奔了下划线命名法(foo_bar),次要原因是我觉得下划线把两个单词分得更开,比fooBar易读。

考虑R中有成千上万的函数命名,某些对象命名可以理解,但仍然透露出某种不规范的痕迹。比如seq()是S3泛型函数,而同时又存在seq.intseq_along这两种风格的函数,并且前者并不是seq()应用在int类上的函数!

每一门语言都有一些历史糟粕,R作为一群统计学家维护的语言,从规范来说槽点很多,但事情的另一面是,他可以让什么最小惊讶原则见鬼去,老夫今天就是要写一个函数把混合效应模型中的随机效应算完打印出来。他的随意对应用统计者来说,可能恰好也是好事。没有这看似乱糟糟的各种贡献,R的发展也许会慢很多。无论如何,对如今已经趋于成熟的R,我们作为用户还是应该尽量朝规范的方向走。

语法

以for循环为例,很多语言都是教条式的for (i=0; i<10; i++)循环,而R是for (i in x),这个x可以是很多种对象,例如1:10,或10:1,或c('a', 'c', 'b'),或list(a = 1, b = 'fgh'),等等。这种让循环变量在一组对象中循环的做法,我猜想可能借鉴自bash脚本的语法,如

for i in `ls *.csv`
do
  echo $i
done

它为啥要参照bash脚本的循环语法而不是C语言的语法,可能跟贝尔实验室的数据处理传统有关。至今AT&T实验室仍然跑着大量的bash脚本,处理大量的文本数据(循环逐个处理每个文件),这一点我在那里感触太深了。同样诞生于贝尔实验室的Awk,其循环语法也借鉴了bash的语法(C语法也保留着),可以在一个数组中循环。

自动扩展和匹配

别的语言一般都不能计算猫加狗这样的表达式,但R可以算1:10 + 1:2,两个长度不一样的对象也可以做计算,原因是R总是把短对象自动扩展到长对象的长度再计算;这种扩展有时候很难想象,如matrix(1:10, 5) + 1:2(一个矩阵加一个向量)。向量一般来说看作列向量,也就是n x 1的矩阵,但你可以看见以下表达式都可以正常计算:

matrix(1:10, 5) %*% 1:2  # 5x2乘以2x1,没问题
1:2 %*% matrix(1:10, 1)  # 2x1乘以1x10,没问题
1:2 %*% matrix(1:10, 2)  # 你到底是2x1还是1x2?

这实在让人防不胜防。除非你事先小心实验,否则这种矩阵乘法出错了都不知道。但这问题其实也来源于作者的懒惰,只要把向量转化为严格的矩阵(不要让R去自动猜测调整),一切问题都解决了。

这些“自动”特征给数据分析其实带来了不少好处,例如在回归设计阵中加一列给截距项的1,你不必写一串1,只要X = cbind(X, 1)就可以了,R会自动把1扩展为X的行数;又如你想让散点图中的点按照数据顺序依次用红色、蓝色、红色、蓝色……那么plot(x, y, col = c('red', 'blue'))就够了,而不必把颜色向量写完整了。对数据分析者来说,那些计算机的严格规则最好是匿得越远越好。

有时候自动扩展悄无声息带来的问题会很难查找,例如在各种巧合之下,kuanguang坛霸问的这个问题下面掩盖了一个极大的阴谋,初学者可能看不出里面的门道,楼主的代码运行表面上看起来成功了,但实际上完全是错误的代码。本来这是个很好的例子,只是这家伙碎碎念实在太多了,一天到晚问题不断,我也来一次小心眼,装没看见好了。

引号

R的懒惰是别的语言打死都想不通的,比如把一个不存在的对象转化为字符,这么说有点抽象,我们可以考虑一下library()这个函数。

library(fun)

这样一句话是什么意思呢?fun不是一个R对象,它根本不存在,但为什么library(fun)就可以加载一个名叫fun的包?主要原因就是懒,因为懒得打引号:

library("fun")

正常来说,这个函数的第一个参数应该是R包的名字,也就是说应该是字符串。在函数内部,最终需要的也是一个字符串。R之所以能把这件事情搞得这么奇葩,也是与它强大的“基于语言的计算”(Computing on the Language)能力有关,参见手册“R Language Definition”第6节。所谓基于语言的计算,就是把代码拿来作计算,各种魔法parse()deparse()substitute()eval()match.call()等等,极大增强了R的语言功能,所以说它是一门统计计算语言实在太低估了它。

例如这里是一个简单的函数,把输入的合法的R符号转化为字符串:

f = function(x) deparse(substitute(x))
f(asdf)
f(hahaha)

这种懒惰在一些Linux工具中也可以看见身影,例如tar,我们可以按标准写上减号-以传参数,也可以省略减号让tar把第一个参数当作参数,后面的参数当文件名:

tar -x -z -f R-2.15.1.tar.gz
tar -xzf R-2.15.1.tar.gz
tar xzf R-2.15.1.tar.gz

这就是“多打一个字符会死星人”。

岔开话题回到library()这个函数,我印象中R core一直后悔这个函数的命名,想把它改成use()。因为library()的存在且高频使用,让很多用户称R包为library(例如I’m using the rpart library),这曾经让某R core(M.M.)极度不爽,因为library在R中的概念是“库”,而不是单个的包,一个库可能是多个包的集合,单个包叫package。啥时侯你意识到函数命名可能比写函数本身还难时,就表明你的码农功力又上一层楼了。

LaTeX

那个年代科学计算类都和LaTeX能扯上关系,这年头都奔HTML去了,谁还去打印大部头的手册啊。R的文档就是一种伪LaTeX文档,R自身也拼命模仿一些LaTeX程序,例如texi2dvi()函数。这种伪TeX文档带来的就是新的解析工作,参见parse_Rd()魔法,于是各种规矩铺天盖地而来……

值传递与引用传递

R一向没有引用传递,但这说法不太严格,我们可以把一个环境当作参数传递来去,环境里的对象可以在任何地方被改变。

z = new.env()
z$x = 1
f = function(env) {
  env$x = 2
}
f(z)
z$x  # 变成了2

近两年Chambers大人继历史上推出S3、S4之后,又推出了引用类(reference classes),应该算是补缺吧。值传递虽然有点低效,但更安全一些,不会冷不丁不小心就修改了一个变量。

这么写下去没完没了,不写了,还是写书更重要,就是这样(波波头的微博禅)。