R 包开发:把推导变成资产
散落在脚本里的 derive_* 函数只是"你写得出来";打成包,它才成为"团队用得上、审计查得清"的资产。本章用一个真跑通、测试全绿的迷你包 clinderive,带你走完从 create_package 到安装分发的全链。
6.1 包 = 复用与验证单元
在 SAS 世界,你的推导资产通常长这样:autocall 宏库或公司标准宏,配一套受控的安装环境。到了 R 世界,对应物不是"一堆 .R 文件",而是包(package)——代码、文档、测试、版本号打包成一个整体:
- 复用:一次安装、处处
library();版本号与变更记录(NEWS)让"这张表用的是哪版函数"有据可查。 - 文档:roxygen 注释自动生成
?derive_age帮助页,取代口口相传的"程序头注释规范"。 - 验证:testthat 测试随每次改动全量回归;
R CMD check/ CI 的全绿记录就是验证证据链的一环。
source() 挨个加载就行。source() 没有命名空间(函数互相覆盖你毫无察觉)、没有依赖声明、没有测试、没有版本。审计问"这张 ADSL 表用的哪个版本的 derive_age",一堆脚本答不上来,包能。6.2 工作流全链:从 create_package 到 install
开发一个包的日常循环只有五步:建骨架 → 写函数 → 生成文档 → 加载调试 → 跑测试。usethis 负责把每一步的样板文件都替你写好。先建包:
# 建包骨架:DESCRIPTION / NAMESPACE / R/ 一次到位
usethis::create_package("clinderive")
# 实跑捕获(R 4.5.0)
v Creating 'C:/.../tmp_ch6/clinderive/'.
v Setting active project to "C:/.../tmp_ch6/clinderive".
v Creating 'R/'.
v Writing 'DESCRIPTION'.
Package: clinderive
Title: What the Package Does (One Line, Title Case)
Version: 0.0.0.9000
Authors@R (parsed):
* First Last <first.last@example.com> [aut, cre]
Description: What the package does (one paragraph).
License: `use_mit_license()`, `use_gpl3_license()` or friends to pick a
license
Config/roxygen2/version: 8.1.0
Encoding: UTF-8
Roxygen: list(markdown = TRUE)
v Writing 'NAMESPACE'.
v Setting active project to "<no active project>".
接着把 DESCRIPTION 从模板改成正式内容。DESCRIPTION 是包的"身份证 + 依赖清单",每个字段都值得你亲手写一遍:
Package: clinderive
Title: Clinical Derivation Helpers for ADaM-Style Variables
Version: 0.1.0
Authors@R:
person("Beijing", "Wang", , "bwang@example.com", role = c("aut", "cre"))
Description: Provides small, well-tested derivation functions for clinical
programming, such as integer age from birth and reference dates.
License: MIT + file LICENSE
Encoding: UTF-8
Roxygen: list(markdown = TRUE)
RoxygenNote: 8.1.0
Imports:
lubridate
Suggests:
testthat (>= 3.0.0)
Config/testthat/edition: 3
Package / Title / Version:包名全小写;标题一行、实词首字母大写;版本号遵循"major.minor.patch",开发中的包常以0.0.0.9000起步(9000 段表示开发版)。升级包 = 升版本号,这是资产化的第一纪律。Authors@R:用person()声明作者与维护者,cre(maintainer)必须有——出了质量问题审计找谁,就写在这里。Imports: lubridate:函数体内真正调用的依赖写 Imports,安装 clinderive 时会强制装上(详见 6.6)。Suggests: testthat / Config/testthat/edition: 3:只有测试和 vignette 用到的包写 Suggests;edition 3 启用 waldo 比对,断言更严格。Roxygen: list(markdown = TRUE):允许在 roxygen 注释里写 Markdown(如[base::Date]自动生成帮助页链接)。
usethis::use_package("lubridate") 写入 Imports,usethis::use_testthat() 搭好 tests/ 骨架并写入 Suggests,usethis::use_mit_license() 生成 LICENSE 文件。usethis 的 use_* 家族就是"包开发的标准操作规程"。然后写第一个推导函数 R/derive_age.R——注意函数上方的 #' 注释(roxygen),它是文档的源头(6.3 详解):
#' Derive integer age from birth date and reference date
#'
#' Computes the completed (integer) age at the reference date, using
#' [lubridate::interval()] so that leap days and the "birthday not yet
#' reached" boundary are handled correctly. Vectorised over `brth` and
#' `ref`; `NA` in either input yields `NA` in the output.
#'
#' @param brth A [base::Date] vector of birth dates.
#' @param ref A [base::Date] vector of reference dates (e.g. TRTSDT or cutoff).
#'
#' @return An integer vector of completed ages, the same length as the
#' longer of `brth` and `ref`.
#' @export
#'
#' @examples
#' derive_age(as.Date("2000-02-29"), as.Date("2025-02-28")) # 24
#' derive_age(as.Date("2000-06-16"), as.Date("2025-06-15")) # 24, birthday not yet reached
#' derive_age(as.Date(NA), as.Date("2025-01-01")) # NA
derive_age <- function(brth, ref) {
if (!inherits(brth, "Date") || !inherits(ref, "Date")) {
stop("`brth` and `ref` must both be Date vectors.", call. = FALSE)
}
iv <- lubridate::interval(brth, ref)
age <- floor(lubridate::time_length(iv, "year"))
as.integer(age)
}
写完函数,跑 roxygen 生成文档与 NAMESPACE:
# 扫描 R/ 下的 #' 注释 -> man/*.Rd + NAMESPACE
roxygen2::roxygenise("clinderive")
# 实跑捕获(R 4.5.0) i Loading clinderive Writing 'NAMESPACE' Writing 'derive_age.Rd'
调试阶段用 pkgload::load_all() 直接加载源码——不用每次改一行就重装一遍:
pkgload::load_all("clinderive", quiet = TRUE)
cat("derive_age(as.Date('2000-02-29'), as.Date('2025-02-28')) =",
derive_age(as.Date("2000-02-29"), as.Date("2025-02-28")), "\n")
# 跑全部测试(等价于 devtools::test())
testthat::test_local("clinderive")
# 实跑捕获(R 4.5.0)
derive_age(as.Date('2000-02-29'), as.Date('2025-02-28')) = 24
v | F W S OK | Context
v | 7 | derive_age
== Results =====================================================================
[ FAIL 0 | WARN 0 | SKIP 0 | PASS 7 ]
功能稳定后再正式安装——安装是"发布"动作,把源码编译成库里的正式包:
# 从源码目录安装到指定库(本地演示装进 mini-lib)
dir.create("mini-lib", showWarnings = FALSE)
install.packages("clinderive", repos = NULL, type = "source", lib = "mini-lib")
# 装好后像任何 CRAN 包一样加载使用
library(clinderive, lib.loc = "mini-lib")
cat("installed version:", as.character(packageVersion("clinderive", lib.loc = "mini-lib")), "\n")
cat("derive_age(2000-06-16 -> 2025-06-15) =", derive_age(as.Date("2000-06-16"), as.Date("2025-06-15")), "\n")
# 实跑捕获(R 4.5.0) * installing *source* package 'clinderive' ... ** this is package 'clinderive' version '0.1.0' ** using staged installation ** R ** byte-compile and prepare package for lazy loading ** help *** installing help indices ** building package indices ** testing if installed package can be loaded from temporary location ** testing if installed package can be loaded from final location ** testing if installed package keeps a record of temporary installation path * DONE (clinderive) installed version: 0.1.0 derive_age(2000-06-16 -> 2025-06-15) = 24
create_package():只生成 DESCRIPTION、NAMESPACE 和 R/ 三件套,最小可开发状态。roxygenise():把@export翻译成 NAMESPACE 里的export(derive_age),把整段#'注释翻译成 man/derive_age.Rd 帮助页。两个文件都是生成物,永远不要手改。load_all():模拟"安装 + 加载"——源码即时生效,连未导出的内部函数都能调试。test_local():在包目录跑 tests/testthat/ 全部测试;FAIL 0 才算改完。install.packages(repos = NULL, type = "source"):从本地源码目录安装的固定写法;日常开发中它被 devtools/pkgload 工作流取代,只在发版时用。
devtools::document() / load_all() / test() 只是转发器,底层分别是 roxygen2、pkgload、testthat。本机没装 devtools 也完全不影响——直接用这三件套即可,报错信息还更干净。测验:pkgload::load_all()(即 devtools::load_all())与 library() 的区别是什么?
6.3 roxygen 标签速查表
roxygen 注释就是写在函数头顶的 #' 行。六个核心标签覆盖日常 95% 的需求:
| 标签 | 作用 | 生成到哪里 |
|---|---|---|
| 首行描述(无标签) | 函数标题,一句话说明"做什么";空行后的段落是详细描述 | Rd 的 title / description |
@param | 逐个解释参数:类型、单位、NA 行为——临床函数的参数说明就是 mini-spec | Rd 的 arguments |
@return | 返回值类型与长度;R CMD check 会检查缺失 | Rd 的 value |
@export | 把函数写入 NAMESPACE,用户才能 clinderive::derive_age();不写 = 内部函数 | NAMESPACE 的 export() |
@examples | 可直接运行的示例;R CMD check 会真的执行它们,写错就红 | Rd 的 examples |
@importFrom | 声明"从某包只导入某函数",如 @importFrom lubridate interval;NAMESPACE 里生成 importFrom()。也可以不用它、在函数体里写全 lubridate::interval()(clinderive 采用后者,依赖关系更显式) | NAMESPACE 的 importFrom() |
对照 6.2 里 derive_age.R 的真实文件头看一遍:首行是标题,[lubridate::interval()] 与 [base::Date] 因 Roxygen: list(markdown = TRUE) 自动变成帮助页超链接,@param / @return / @export / @examples 各司其职。roxygenise() 之后,?derive_age 就能看到与源码同步的帮助页。
@param,等于宏改了参数却没更新 spec——R CMD check 与代码审查都会抓出来。6.4 测试布局:tests/testthat/
testthat 的目录约定是固定的两件套:
tests/testthat.R:入口 runner,只有三行(library(testthat); library(clinderive); test_check("clinderive")),R CMD check时自动执行;tests/testthat/test-*.R:真正的测试文件,命名以test-开头,惯例是一个源函数对应一个测试文件——R/derive_age.R ↔ test-derive_age.R。
clinderive 的测试文件全文如下(本章实跑所用,7 条断言全绿):
test_that("derive_age returns completed integer age", {
expect_equal(derive_age(as.Date("2000-01-01"), as.Date("2025-06-15")), 25L)
# 生日未到:应返回 24 而不是 25
expect_equal(derive_age(as.Date("2000-06-16"), as.Date("2025-06-15")), 24L)
# 闰日出生
expect_equal(derive_age(as.Date("2000-02-29"), as.Date("2024-02-29")), 24L)
})
test_that("derive_age propagates NA", {
expect_true(is.na(derive_age(as.Date(NA), as.Date("2025-01-01"))))
expect_true(is.na(derive_age(as.Date("2000-01-01"), as.Date(NA))))
})
test_that("derive_age is vectorised", {
brth <- as.Date(c("2000-01-01", "1990-12-31"))
ref <- as.Date(c("2025-06-15", "2025-06-15"))
expect_equal(derive_age(brth, ref), c(25L, 34L))
})
test_that("derive_age rejects non-Date input", {
expect_error(derive_age("2000-01-01", as.Date("2025-01-01")),
"must both be Date")
})
运行方式:
# 开发中:跑包内全部测试(会自动 load_all)
testthat::test_local("clinderive")
# 只想跑单个文件:test_file 不会自动加载包,需先 load_all
pkgload::load_all("clinderive", quiet = TRUE)
testthat::test_file("clinderive/tests/testthat/test-derive_age.R")
# 实跑捕获(R 4.5.0) v | F W S OK | Context v | 7 | derive_age == Results ===================================================================== [ FAIL 0 | WARN 0 | SKIP 0 | PASS 7 ]
test_that("描述", { ... }):一个测试块 = 一条需求。描述用人话写,失败时直接出现在报告里——它就是测试用例标题。- 边界断言:
25L而不是25——edition 3 用 waldo 比对,整数型与双精度型不匹配也算失败。"生日未到返回 24"和"闰日 2000-02-29"正是 SAS 程序员用intck('year', ...)时最容易踩的两个边界。 expect_true(is.na(...)):NA 传播是临床推导函数的硬需求(BRTHDT 缺失 → AGE 必须缺失而非报错或 0)。expect_error(..., "must both be Date"):不只测"对的时候对",还要测"错的时候错得体面"——错误信息也是接口的一部分。
6.5 vignette:给包配一篇"使用说明书"
帮助页(?derive_age)回答"这个函数怎么用",vignette 回答"这个包解决什么问题、典型工作流长什么样"——相当于包的 SOP 附培训材料。usethis::use_vignette("clinderive") 会在 vignettes/ 下生成骨架并把 knitr、rmarkdown 写进 Suggests。clinderive 的 vignette 骨架如下:
---
title: "clinderive:临床推导函数入门"
output: rmarkdown::html_vignette
vignette: >
%\VignetteIndexEntry{clinderive:临床推导函数入门}
%\VignetteEngine{knitr::rmarkdown}
%\VignetteEncoding{UTF-8}
---
```{r setup}
library(clinderive)
```
整数年龄推导以"生日是否已到"为边界,NA 自动传播:
```{r demo}
derive_age(as.Date("2000-02-29"), as.Date("2025-02-28")) # 闰日出生,生日未到 -> 24
derive_age(as.Date(c("2000-01-01", "1990-12-31")),
as.Date(c("2025-06-15", "2025-06-15"))) # 向量化 -> 25, 34
```
6.6 依赖与分发:Imports、Suggests 与内部仓库
依赖声明写在 DESCRIPTION 里,两个字段的选择直接影响安装行为:
| 字段 | 放什么 | 安装/加载行为 | clinderive 里的例子 |
|---|---|---|---|
Imports | 函数体内真正调用的包(:: 或 @importFrom) | 安装时强制安装;缺失则装不上 | lubridate——derive_age 没它不能活 |
Suggests | 仅测试、vignette、示例或条件分支用到的包 | 不强制安装;check 时缺失只会 SKIP | testthat、knitr、rmarkdown |
Depends | 几乎不用;仅剩声明 R 版本下限一种正当用途 | 加载包时连带 attach,污染搜索路径 | 如 R (>= 4.3) |
原则:能写 Suggests 就不写 Imports。每多一个 Imports,安装链就多一环失败可能,验证范围也大一圈——这与 SAS 里"少挂几个 autocall 库"是同一纪律。
renv 与包管理是互补,不是二选一:包回答"函数资产如何版本化、复用";renv(第 0 章 0.3)回答"某次分析在哪套环境版本里跑"。pharma 的常见组合拳是——clinderive 0.1.0 发布到内部仓库,各试验项目的 renv.lock 同时锁住 clinderive 及其全部依赖的版本;函数升级靠发新版本号,绝不原地偷改。
包建好了,怎么发给同事?三条主流路线:
- Posit Package Manager:企业级仓库/CRAN 镜像,支持 Windows/Linux 二进制分发、按日期快照(
.../cran/2025-06-01这样的 URL 让"当时的环境"可回放),是内部包平台的常见底座。 - drat:轻量自建仓库——把包 tarball 用
drat::insertPackage()丢进一个仓库目录(内网服务器或 GitHub Pages),用户options(repos = ...)后即可install.packages()。适合小团队起步。 - r-universe:GitHub push 触发云端自动构建,每个组织一个 universe。pharmaverse 生态(admiral 等 CDISC 推导包)就发布在 pharmaverse.r-universe.dev,安装只需把 repos 指过去。
6.7 实战迷你包 clinderive:完整档案
把本章全部内容收拢成一份完整档案。以下文件树与四个文件均为本章实跑验证过的真实内容(R 4.5.0,roxygen2 8.1.0,testthat 3.2.3,lubridate 1.9.4):
clinderive/
├── DESCRIPTION # 身份证 + 依赖清单
├── NAMESPACE # 导出/导入清单(roxygen 生成,勿手改)
├── R/
│ └── derive_age.R # 函数源码 + roxygen 注释
├── man/
│ └── derive_age.Rd # 帮助页(roxygen 生成)
└── tests/
├── testthat.R # check 入口 runner
└── testthat/
└── test-derive_age.R # 7 条断言
文件 1/4:DESCRIPTION
Package: clinderive
Title: Clinical Derivation Helpers for ADaM-Style Variables
Version: 0.1.0
Authors@R:
person("Beijing", "Wang", , "bwang@example.com", role = c("aut", "cre"))
Description: Provides small, well-tested derivation functions for clinical
programming, such as integer age from birth and reference dates.
License: MIT + file LICENSE
Encoding: UTF-8
Roxygen: list(markdown = TRUE)
RoxygenNote: 8.1.0
Imports:
lubridate
Suggests:
testthat (>= 3.0.0)
Config/testthat/edition: 3
文件 2/4:NAMESPACE(roxygenise() 生成)
# Generated by roxygen2: do not edit by hand
export(derive_age)
文件 3/4:R/derive_age.R
#' Derive integer age from birth date and reference date
#'
#' Computes the completed (integer) age at the reference date, using
#' [lubridate::interval()] so that leap days and the "birthday not yet
#' reached" boundary are handled correctly. Vectorised over `brth` and
#' `ref`; `NA` in either input yields `NA` in the output.
#'
#' @param brth A [base::Date] vector of birth dates.
#' @param ref A [base::Date] vector of reference dates (e.g. TRTSDT or cutoff).
#'
#' @return An integer vector of completed ages, the same length as the
#' longer of `brth` and `ref`.
#' @export
#'
#' @examples
#' derive_age(as.Date("2000-02-29"), as.Date("2025-02-28")) # 24
#' derive_age(as.Date("2000-06-16"), as.Date("2025-06-15")) # 24, birthday not yet reached
#' derive_age(as.Date(NA), as.Date("2025-01-01")) # NA
derive_age <- function(brth, ref) {
if (!inherits(brth, "Date") || !inherits(ref, "Date")) {
stop("`brth` and `ref` must both be Date vectors.", call. = FALSE)
}
iv <- lubridate::interval(brth, ref)
age <- floor(lubridate::time_length(iv, "year"))
as.integer(age)
}
文件 4/4:tests/testthat/test-derive_age.R
test_that("derive_age returns completed integer age", {
expect_equal(derive_age(as.Date("2000-01-01"), as.Date("2025-06-15")), 25L)
# 生日未到:应返回 24 而不是 25
expect_equal(derive_age(as.Date("2000-06-16"), as.Date("2025-06-15")), 24L)
# 闰日出生
expect_equal(derive_age(as.Date("2000-02-29"), as.Date("2024-02-29")), 24L)
})
test_that("derive_age propagates NA", {
expect_true(is.na(derive_age(as.Date(NA), as.Date("2025-01-01"))))
expect_true(is.na(derive_age(as.Date("2000-01-01"), as.Date(NA))))
})
test_that("derive_age is vectorised", {
brth <- as.Date(c("2000-01-01", "1990-12-31"))
ref <- as.Date(c("2025-06-15", "2025-06-15"))
expect_equal(derive_age(brth, ref), c(25L, 34L))
})
test_that("derive_age rejects non-Date input", {
expect_error(derive_age("2000-01-01", as.Date("2025-01-01")),
"must both be Date")
})
- 入参校验:
inherits(brth, "Date")挡住字符型日期——SAS 里日期是数值,R 里 Date 是带 class 的数值,类型错误静默传下去比报错可怕得多。 lubridate::interval(brth, ref):构造时间区间,闰日、月末边界全部由 lubridate 处理;任一输入为 NA,区间即 NA,结果自然 NA——不需要写一行if (is.na(...))。floor(time_length(iv, "year")):按 365.25 天折算年数后向下取整,得到"满岁";生日未到时不会进位。as.integer(age):统一返回整数型,与测试里的25L严格一致——返回类型也是接口契约。
usethis::use_mit_license() 与 usethis::use_news_md(),否则 R CMD check 会给出 NOTE/WARNING。内部包还常加 use_readme_rmd() 与 CI 徽章(见练习 3)。章末资源
- R Packages (Hadley Wickham & Jenny Bryan) — 包开发系统主线教材,免费在线;本章工作流即其精简版 入门主线
- usethis 官方文档 — use_* 自动化命令速查,建包/加测试/加 vignette 的标准动作 入门
- roxygen2 官方文档 — 全部标签语法与 NAMESPACE 生成规则的参考手册 参考
- pharmaverse r-universe — admiral 等临床推导包的发布平台,看真实 pharma 包长什么样 参考
本章练习
- (易)给 clinderive 加第二个函数
derive_sex(sexcd):把 SDTM 的"M"/"F"映射为"Male"/"Female",其他值(含 NA)返回 NA;配 roxygen 注释与 tests/testthat/test-derive_sex.R,test_local()全绿后重新roxygenise()。 - (中)按 6.5 骨架写出 vignettes/clinderive.Rmd:讲清整数年龄的边界规则,至少嵌入两个真实可跑的 chunk(含闰日与向量化示例),用
rmarkdown::render()渲染通过。 - (难)用
usethis::use_github_action("check-standard")给包加 GitHub Actions 的 R CMD check 工作流,push 后让徽章全绿;思考:这条 CI 记录在你的验证报告(如 valtools 的 release 证据,见第 5 章)里应该放在哪一节。
下一章:Shiny 与交互——把推导逻辑与 TFL 变成临床团队能自己点、自己查的交互式应用。