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.int
和 seq_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),应该算是补缺吧。值传递虽然有点低效,但更安全一些,不会冷不丁不小心就修改了一个变量。
这么写下去没完没了,不写了,还是写书更重要,就是这样(波波头的微博禅)。