没有包的命名空间

22

在重新组织我的代码库时,我想清理一下我的代码共享机制。到目前为止,我使用 source 来处理大量小型、基本上是独立的功能模块。

然而,这种方法存在许多问题,其中包括:

  • 缺乏对循环性(意外的循环source链)的测试,
  • 需要复杂的语法来正确指定包含路径(chdir=TRUE 参数、硬编码路径),
  • 可能会产生名称冲突(在重新定义对象时)。

理想情况下,我希望得到类似于 Python 模块机制的东西。在这里,R 包机制过于繁琐:我不想生成嵌套路径层次结构、具有大量元数据和手动构建包的多个文件,只是为了获得一个小型、独立且可重用的代码模块。

目前,我正在使用一个代码片段来解决上述前两个问题。包含的语法如下:

import(functional)
import(io)
import(strings)

......模块定义为位于本地路径的简单源文件。import的定义很简单,但是我无法解决第三个问题:我想将该模块导入到单独的命名空间中,但从我所看到的,命名空间查找机制与包相当紧密。确实,我可以覆盖`::`getExportedValue,也许还有asNamespaceisNamespace,但这感觉非常不干净,有可能破坏其他包。


你能详细说明一下为什么将每个文件的内容添加到搜索路径上的单独环境中(如?sys.source示例中所示)是不够的吗? - Joshua Ulrich
@Joshua 实际上这正是我现在正在做的事情(我的例子被简化了)- 我认为明确限定命名空间的方式很好。当然,我可以用getassign来实现相同的功能,但是::的语法更加优美一些。 - Konrad Rudolph
我感到困惑,因为你的 import 函数并没有这样做。如果你将每个文件的内容放在搜索路径上的单独环境中,你可以使用 $ 运算符访问特定的环境(例如 strings$concatenate())。 - Joshua Ulrich
关于:将临时命名空间附加到搜索路径 - Joshua Ulrich
6
我认为需要更多的答案谴责那些想要避免创建包的开发者。;) - Josh O'Brien
6个回答

17

这里是一个完全自动化包创建、编译和重新加载的函数。正如其他人所指出的那样,实用函数package.skeleton()devtools::load_all()已经让你离目标非常接近了。这只是将它们的功能结合起来,使用package.skeleton()在临时目录中创建源目录,当load_all()处理完后会清理该目录。

您需要做的就是指向您想要读取函数的源文件,并给包命名:import()为您完成其余所有工作。

import <- function(srcFiles, pkgName) {
    require(devtools)
    dd <- tempdir()
    on.exit(unlink(file.path(dd, pkgName), recursive=TRUE))
    package.skeleton(name=pkgName, path = dd, code_files=srcFiles)
    load_all(file.path(dd, pkgName))
}

## Create a couple of example source files
cat("bar <- function() {print('Hello World')}", file="bar.R")
cat("baz <- function() {print('Goodbye, cruel world.')}", file="baz.R")

## Try it out
import(srcFiles=c("bar.R", "baz.R"), pkgName="foo")

## Check that it worked
head(search())
# [1] ".GlobalEnv"        "package:foo"       "package:devtools"
# [4] "package:stats"     "package:graphics"  "package:grDevices"
bar()
# [1] "Hello World"
foo::baz()
# [1] "Goodbye, cruel world."

create(path); file.copy(srcFiles, file.path(path, "R")) - 这并没有太大的改进,只是避免了创建你从未使用过的文件。但对于这种情况,你甚至不需要 create - hadley
4
@hadley,你所称的笨拙函数的好处在于它不会代表我签署GPL-3许可证。 - GSee
@JoshO'Brien 啊,不是的。我只是假设因为我在文档中没有找到任何相反的说明。我现在要尝试一下。/编辑:按预期工作。完美! - Konrad Rudolph
4
我已经在GitHub上创建了一个关于默认许可证的问题:https://github.com/hadley/devtools/issues/282。 - Brian Diggs
@JoshO'Brien @hadley 如果我使用 import(srcFiles=c("bar.R", "baz.R"), pkgName="foo") 创建了一个包,现在我想添加一个文件而不必再次添加所有文件,例如 bax.R... 如果我使用 import(srcFiles=c("bax.R"), pkgName="foo"),它会重新导入并擦除 bar.R 和 baz.R。是否有一种方法可以逐个或逐几个文件更新包呢? - Dnaiel
显示剩余4条评论

15

Konrad,认真地说,对于需求

获得一个小型、自包含、可重复使用的代码模块

答案是创建一个软件包。在SO和其他地方,这个教义已经被反复强调过了。实际上,您可以使用最少的模糊创建最小的软件包。

此外,在运行之后:

 setwd("/tmp")
 package.skeleton("konrad")

去除一个临时文件后,我剩下的是

 edd@max:/tmp$ tree konrad/
 konrad/
 ├── DESCRIPTION
 ├── man
 │   └── konrad-package.Rd
 └── NAMESPACE

 1 directory, 3 files
 edd@max:/tmp$ 

那真的那么繁琐吗?


5
但如果您只有一个两个功能的文件,那么您就不需要担心循环依赖,并且避免冲突也不应该太难。但说真的,将某些东西转换为软件包确实非常简单。没有人说您需要通过制作能够通过所有 CRAN 检查的软件包(这需要工作)来完成它。但是,如果您获得了基本的软件包结构,则可以使用 devtools 软件包中的 load_all 函数获取您所需的基本全部功能,而无需显式安装该软件包。 - Dason
3
确实,制作软件包可以解决你反复担心的大量开销问题,每个软件包只需进行一次设置,然后将所有相关的 R 文件放入该软件包中,使用 load_all 函数进行导入。将相关的 R 文件保存在单个软件包中的优点是值得付出这些开销的,因为在项目的某个阶段,你会有一些专门用于该项目的代码和可重用的代码,这些代码也可以在其他项目中使用。如何组织呢?很简单,使用两个软件包即可。通过这种方式,你还可以享受 devtools 提供的所有其他优势(例如 roxygen)。 - Spacedman
5
devtools非常有帮助,它将打包工作从X减少到X/5,但在R中X/5仍然很显著。在一些解释型语言中,如Python,X等于零!显示出R中包的复杂性过高的最终证据是,“source()在任何情况下都没有被使用。比较一下Python。有谁会使用execfile()来导入Python中的函数定义?没有人会这么做。你只需编写一个文件并import`它;就有了您的命名空间。门槛为零。你能想象编写一本介绍R的教科书而/n不提及“source()`吗? - crowding
2
请注意,当您开始将代码组织到文件夹中并需要创建“init.py”文件时,Python的价格会上涨至£0.01。但是,是的,R不是一种明智的解释性语言。 - Spacedman
5
敏捷开发可与包一起使用。只需将编辑好的函数“source”(或从编辑器中导入),再使用“assignInNamespace(...)”将副本推入工作区所在的包NAMESPACE中。完成当天的敏捷开发后,您可以使用最新的更新重建并安装该包。 - Gavin Simpson
显示剩余5条评论

13

一个包仅仅是存储文件的一种约定(`R/`中的R文件、`man/`中的文档、`src`中的编译代码、`data/`中的数据):如果你有超过少数文件,最好遵循已经建立的约定。换句话说,使用包比不使用包更容易,因为你不需要思考:你可以利用现有的约定,每个R用户都会理解其中的内容。

实际上,一个最小的包只需要一个 `DESCRIPTION` 文件,文件里面写明该包的功能、谁可以使用它(许可证)以及如果有问题要联系哪些人(维护者)。这可能是一些额外工作,但并不重要。一旦你写好了这个文件,你只需要根据需要填充其他目录即可,无需使用笨拙的 `package.skeleton()`。

尽管如此,处理包的内置工具很繁琐 - 你必须重新构建/重新安装包,重启R并重新加载包。这就是 `devtools::load_all()` 和 Rstudio的构建和重新加载起到作用的地方 - 它们使用相同的包规范,但提供了更新源自包的更加简单的方式。当然,你可以使用其他答案提供的代码片段,但为什么不使用已被数百(至少几十)位R开发者使用过的经过充分测试的代码呢?


1
这种方法的另一个优点是可以通过使用ROxygen轻松包含文档和自动生成Namespace,包括imports等。当然,还可以使用testthat进行单元测试等。 - rmflight
完全支持这一点。出于这个原因,我使用R软件包结构来组织我的所有项目。我要指出的是,如果需要,R软件包提供了一个很好的方法来组织与项目相关的数据、文档甚至出版物。(例如:https://github.com/cboettig/) devtools使这个工作流程变得非常简单。 - cboettig
我偶然又看到了这个答案,我喜欢你自嘲地估计“devtools”用户数量在“几十”到“几百”的方式。说实话,十年是很长的时间。请永远不要更新这个估计,它非常古雅。 - undefined

8

我对提问者的问题的评论并不完全正确,但我认为重新编写 import 函数可以解决问题。foo.Rbar.R 是当前工作目录中包含单个函数 (baz) 的文件,该函数打印出下面所示的输出。

import <- function (module) {
  module <- as.character(substitute(module))
  # Search path handling omitted for simplicity.
  filename <- paste(module, 'R', sep = '.')
  # create imports environment if it doesn't exist
  if ("imports" %in% search())
    imports <- as.environment(match("imports",search()))
  # otherwise get the imports environment
  else
    imports <- attach(NULL, name="imports")
  if (module %in% ls("imports"))
    return()
  # create a new environment (imports as parent)
  env <- new.env(parent=imports)
  # source file into env
  sys.source(filename, env)
  # ...and assign env to imports as "module name"
  assign(module, env, imports)
}
setwd(".")
import(foo)
import(bar)
foo$baz()
# [1] "Hello World"
bar$baz()
# [1] "Buh Bye"

请注意,单独使用baz()是找不到的,但是似乎OP仍然想要使用::来表达显式意图。

6
我完全同意@Dirk的回答。制作最小包时涉及的小额开销似乎值得遵循“标准方式”。
然而,我想到的一件事是sourcelocal参数,它可以让你将代码源引入一个environment中,就像使用命名空间一样,例如:
assign(module, new.env(parent=baseenv()), envir=topenv())
source(filename, local=get(module, topenv()), chdir = TRUE)

为了用简单的语法访问导入的环境,请给这些导入环境分配一个新类(比如“import”),并将::指定为通用设置,当pkg不存在时,默认使用getExportedValue
import <- function (module) {
    module <- as.character(substitute(module))
    # Search path handling omitted for simplicity.
    filename <- paste(module, 'R', sep = '.')

    e <- new.env(parent=baseenv())
    class(e) <- 'import'
    assign(module, e, envir=topenv())
    source(filename, local=get(module, topenv()), chdir = TRUE)
}

'::.import' <- function(env, obj) get(as.character(substitute(obj)), env)
'::' <- function(pkg, name) {
    pkg <- as.character(substitute(pkg))
    name <- as.character(substitute(name))
    if (exists(pkg)) UseMethod('::')
    else getExportedValue(pkg, name)
}

更新

以下是一个更安全的选项,可以避免在加载的包中包含与使用::访问的包具有相同名称的对象时出现错误。

'::' <- function(pkg, name) {
    pkg.chr <- as.character(substitute(pkg))
    name.chr <- as.character(substitute(name))
    if (exists(pkg.chr)) {
        if (class(pkg) == 'import')
            return(get(name.chr, pkg))
    }
    getExportedValue(pkg.chr, name.chr)
}

假设你加载了 data.table,随后尝试使用 :: 访问其中一个对象,则可以得到正确的结果。

我认为我们对“小开销”的定义存在很大分歧 - 正如我在另一条评论中所说,对于小模块而言,开销要高出一个数量级,并且惩罚了小型的、数量众多的包,而青睐于大型的、单块式的包。这支持了不同的开发方法论。话虽如此,我会尝试使用devtools来看看是否可以减少小型包的麻烦。最后,关于你的回答:你的方法将所有内容都放在环境中,而不是命名空间中。 - Konrad Rudolph
@KonradRudolph:来自R Internals手册,1.2.2节:命名空间,“命名空间是与包相关联的环境...”。也就是说,没有包就不能有命名空间。 - Joshua Ulrich
@Joshua 好的,但这是在回避问题。如上面的评论所提到的,我想要的是一种用漂亮的语法明确限定命名空间/环境的方法。确实,R将其与包耦合在一起,但(就我所知)仅仅是按照惯例。你可以编写自己的 :: 版本来规避这个问题。 - Konrad Rudolph
@MatthewPlourde 好像这就是一个答案... - Konrad Rudolph
@GavinSimpson虽然这不是一个大问题,但我认为这种吹毛求疵的行为并不值得被踩。 - Matthew Plourde
显示剩余6条评论

6
我已经实现了一个全面的解决方案,并将其发布为一个名为“box”的程序包。
在内部,“box”模块使用类似于程序包的方法;即,它将代码加载到专用的命名空间环境中,然后将选定的符号导出到一个模块环境中,并返回给用户,可选择性地附加。与程序包的主要区别在于,模块更轻量级且更易编写(每个R文件都是自己的模块),并且可以嵌套。
该程序包的使用详见其网站

1
我喜欢这个!作为一个有着很强的面向对象编程背景(主要是C#)的人,现在作为一个R语言新手试图创建一个相当大型的企业级Shiny应用程序 - 我根本无法理解我应该如何将所有文件保存在一个单独的R文件夹中 - 更不用说如何避免它们之间的命名冲突了!我感觉自己做错了,但所有这些类型的问题都被否定了。 - Ctrl-Zed

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