编写健壮的R代码:命名空间,掩码和使用`::`运算符

48

简介

对于那些不想读完我的“案例”的人,这是要点:

  1. 在编写代码时,如何最大程度地使您编写的代码尽可能健壮,以最小化新软件包破坏现有代码的机会?
  2. 在以下情况下,使用 命名空间机制 的最佳方式是什么:

    a)仅在某个 R 分析项目中使用贡献软件包时?

    b)在开发自己的软件包方面?

  3. 如何避免与正式类(在我的情况下主要是Reference Classes)冲突,因为甚至没有用于类的可比命名空间机制::


R语言的工作方式

这件事情已经在我脑海里萦绕了两年,但我感觉自己还没有得出一个令人满意的解决方案。而且我觉得这种情况正在变得更糟。

我们看到越来越多的CRANgithubR-Forge等平台上发布的包,这简直太棒了。

在这样一个分散的环境中,R语言代码库(我们称之为base Rcontributed R)难以做到与稳健性相关的理想状态:人们遵循不同的约定,有S3、S4、S4参考类等等。如果有一个“中央清算实例”强制执行约定,事情就不可能像现在这样“对齐”。但这没关系。

问题

考虑到上述情况,使用R编写健壮代码可能非常困难。并非所有所需内容都包含在基本R中。对于某些项目,您最终将加载相当多的贡献软件包。
在这方面,我认为最大的问题是R中命名空间概念的使用方式:R允许仅写入某个函数/方法的名称而不明确要求其命名空间(即foo vs. namespace::foo)。
因此,出于简单起见,每个人都这样做。但是,这种方式会导致名称冲突、代码错误以及需要重写/重构代码只是时间问题(或加载的不同软件包数量问题)。
最好的情况下,您将了解新添加的软件包掩盖/重载了哪些现有函数。最糟糕的情况下,您将毫无头绪,直到您的代码崩溃。
以下是一些例子:
尝试同时加载RMySQLRSQLite,它们不太兼容。 另外RMongo将覆盖一些RMySQL的函数。 forecast在ARIMA相关函数方面掩盖了很多东西。 R.utils甚至掩盖了base::parse例程。 (我无法回忆起具体是哪些函数导致问题,但如果有兴趣,我愿意再查一下。)

令人惊讶的是,这似乎并没有影响到很多程序员。我曾经在r-devel上多次提出过这个问题,但并没有引起多大关注。

::操作符的缺点

  1. 使用::操作符可能会在某些情况下显著降低效率,正如Dominick Samperi 指出的那样。
  2. 开发自己的软件包时,您甚至不能在整个代码中使用::操作符,因为您的代码还不是真正的软件包,因此还没有命名空间。所以我必须一开始就坚持使用foo方式,构建、测试,然后再改回namespace::foo。这不是很好。

避免这些问题的可能解决方案

  1. 重新分配每个包中的每个函数到遵循特定命名约定的变量,例如使用namespace..foo来避免与namespace::foo相关的低效率问题(我在这里链接中曾经概述过)。优点:它能够工作。缺点:它很笨拙,并且会使内存使用量加倍。
  2. 模拟在开发包时使用命名空间。据我所知,这实际上是不可能的,至少当时有人告诉我是这样的链接
  3. 强制使用namespace::foo必需的。在我看来,这将是最好的做法。当然,我们可能会失去一些简单性,但是R的世界已经不再像00年代初那样简单了(至少不再像那时那么简单)。

那么(正式的)类呢?

除了上述描述的方面,:: 的方式对于函数/方法非常有效。但是类定义呢?
以包timeDate及其类timeDate为例。假设另一个包也有一个名为timeDate的类。我不知道如何明确地声明我想要从这两个包中的哪一个获取timeDate类的新实例。
像这样的东西是行不通的:
new(timeDate::timeDate)
new("timeDate::timeDate")
new("timeDate", ns="timeDate")

随着越来越多的人转向面向对象编程风格来开发他们的R包,导致了大量的类定义,这可能会成为一个巨大的问题。如果有一种明确地解决类定义命名空间的方法,我将非常感激您的指引!

结论

尽管这篇文章有点冗长,但我希望能够指出核心问题/疑问,并在此处引起更多关注。

我认为devtoolsmvbutils确实有一些值得推广的方法,但我相信还有更多需要探讨的内容。


4
这是一个很好的概述,但也许你可以更明确地陈述问题是什么? - Andrie
1
相关问题:https://dev59.com/l2855IYBdhLWcg3w5InZ - Andrie
1
另一个隐藏的问题出现方式:https://dev59.com/4mgu5IYBdhLWcg3w9ryX - Andrie
我可以问一个非常新手的问题吗?为什么像“f <- dplyr::filter”这样的解决方案不能同时获取所需功能并防止掩盖(在这种情况下,是来自stat软件包的filter)? - d8aninja
这不是一个新手问题,它是可行的。我不太喜欢它作为一种通用方法,因为它涉及到函数对象的手动选择。但我相信有很好的使用案例(例如,在具有“紧密”循环的情况下,::运算符会引入太多开销,同时仍然希望绝对确保掩码不会妨碍你。 - Rappster
显示剩余15条评论
2个回答

35

非常好的问题。

验证

编写健壮、稳定和适用于生产环境的R代码很难。您说:“令人惊讶的是,这似乎并没有困扰很多程序员。” 这是因为大多数R程序员并没有编写生产代码。他们执行一次性的学术/研究任务。我会严重质疑任何声称R易于投入生产的编码者的技能水平。除了您已经链接到的搜索/查找机制文章外,我还写了一篇关于警告危险的文章。这些建议将有助于减少生产代码中的复杂性。

编写健壮/生产R代码的提示

  1. 避免使用使用Depends的包,更倾向于使用使用Imports的包。 仅将依赖项放入Imports的包完全可以安全使用。如果你确实需要使用一个采用Depends的包,那么在调用install.packages()后立即发送电子邮件给作者。

以下是我告诉作者的内容:"嗨,作者,我是XYZ包的粉丝。我想提出一个请求。您能否在下次更新中将ABC和DEF从Depends移动到Imports?在此之前,我无法将您的包添加到我的软件包的Imports中。随着R 2.14对每个包实施NAMESPACE,R Core的普遍信息是,包应该尝试成为“好公民”。如果我必须加载一个Depends包,那么它会增加很大的负担:每次我对新包进行依赖时,我都必须检查冲突。使用Imports,包是没有副作用的。我知道你可能会因此破坏其他人的软件包。但我认为这样做是正确的,可以证明对Imports的承诺,并且从长远来看,它将帮助人们生成更健壮的R代码。"

  1. 使用importFrom。不要将整个包添加到Imports中,而只需添加所需的特定函数。我通过Roxygen2函数文档和roxygenize()来实现这一点,它会自动生成NAMESPACE文件。这样,您可以导入两个可能存在冲突的包,但这些冲突并不在您需要使用的函数中。这很繁琐吗?只有在养成习惯之前才是。好处是:您可以快速识别出所有第三方依赖项。这有助于...

  2. 不要盲目升级包。逐行阅读变更日志,并考虑更新如何影响您自己的包的稳定性。大多数情况下,更新不会触及您实际使用的函数。

  3. 避免使用S4类。我这里有些含糊其辞。我认为S4过于复杂,在处理R的功能面时已经需要耗费足够多的脑力了。你真的需要这些面向对象的特性吗?管理状态=管理复杂性-将其留给Python或Java =)

  4. 编写单元测试。使用testthat包。

  5. 每当您R CMD构建/测试您的包时,请解析输出并查找NOTE、INFO、WARNING。同时,也要用自己的眼睛仔细检查。在构建步骤中有一部分会记录冲突,但不会附加WARN等。

  6. 在调用第三方包后立即添加断言和不变量。换句话说,不要完全信任别人给你的东西。稍微检查一下结果,如果结果出乎意料就stop()。您不必疯狂地检查 - 选择一两个意味着有效/高置信度结果的断言即可。

我认为还有更多,但这已经成为肌肉记忆了=) 如果我想起来了,我会增补的。


7
不错的帖子,但我不认为“避免使用S4类”这个忠告应该列在那个列表上。一些出色的R包,包括 lme4Matrixsp 以及相关的空间包,都很好地使用了S4。当您想要通用函数并调度多个参数类时,它非常有用。(输入 library(Matrix); showMethods("solve")library(sp); showMethods("over") 即可了解我的意思。)此外,我的印象是,S4编码往往会产生/强制执行更紧密和更具鲁棒性的代码。我会说,“除非您知道自己需要使用它,否则不要使用S4。” - Josh O'Brien
好的,我对于S4建议有些过度了。我不知道S4支持多重派发。不错的评论,乔希!+1 - Suraj
1
非常好的回答,非常感谢!除了关于S4的陈述之外,我也完全同意您所说的一切。嗯,不完全是:纯S4有点“无聊”,但是S4参考类却非常出色!它具有真实且易于传递引用、多重签名参数分派等优点。我认为,对于拥有更复杂(面向生产)的项目/软件,使用参考类来实现OOP风格可能更为合适。 - Rappster
除非存在内存问题,否则我会避免使用传递引用。我尽可能使R保持功能性,以便避免状态带来的影响。我还发布了关于断言/不变量的另一个提示。 - Suraj
@SFun28:如果你愿意,我非常想讨论并听听你的意见。对于我的背景(网络爬虫/大数据分析),它们给了我更多的灵活性。 - Rappster
显示剩余6条评论

19

我的看法:

总结: 灵活性是有代价的,我愿意为此付出代价。

1) 我简单地不使用会引起这种问题的软件包。如果我确实需要该软件包中的某个功能用于自己的软件包中,我会在我的NAMESPACE文件中使用importFrom()。在任何情况下,如果我遇到软件包的问题,我会联系软件包作者。问题在他们这边,而不是 R 的问题。

2) 我从不在自己的代码中使用::。通过仅导出用户需要的函数,我可以将自己的函数保留在NAMESPACE中,而不会引起冲突。未导出的函数也不会隐藏同名的其他函数,因此这是双赢。

你可以在这里找到一个关于环境、命名空间和类似概念如何工作的好指南: http://blog.obeautifulcode.com/R/How-R-Searches-And-Finds-Stuff/

这绝对是每个编写软件包及类似项目的人必读之物。在阅读完此文之后,您将意识到在软件包代码中使用::是不必要的。


2
广告1):我还没有研究过importFrom(),谢谢你的建议。 广告2):我不仅在我的包中使用::来调用自己的函数,也会用于贡献包中的函数。如果我不这样做,我怎么能确保,比如一年后,一切都能正常工作呢?对我来说,最自然的做法仍然是在调用函数时显式指定命名空间。其他编程语言不也是这样做的吗? - Rappster
通过使用Import()和ImportFrom(),以及在DESCRIPTION文件中正确规定Depends和Imports,您可以更加确信事情会继续正常工作。关于其他编程语言:在Java中,您只需导入一个库,然后每次使用函数时无需指定它来自哪里。关于Depends和Imports之间的区别:Depends对版本进行检查,而Imports则不进行。但请注意,DESCRIPTION文件中的Imports和NAMESPACE文件中的import()是两个不同的东西。 - Joris Meys
是的,我已经了解并且现在更清楚了;-)仍然不太明白为什么依赖关系不总是通过 ImportsDESCRIPTION 文件中指定(而不是 Depends)。谢谢。 - Rappster
好的,明白了。你指向的帖子对我来说是今年最有见地的帖子之一,再次感谢! - Rappster

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接