进阶 R 与工程化:GxP 级代码
SAS 老兵不怕写代码,怕的是写出"别人不敢审计"的代码。本章把 R 打磨成 GxP 级:函数式编程、作用域、S3、性能、错误与日志、单元测试、CI、验证框架、评审清单——九步走完,你的 R 代码就有资格进递交证据链。
5.1 函数式编程:purrr::map 家族
处理"一批数据集",你的 SAS 肌肉记忆是 %macro + %do;R 的答案是函数式编程:函数是一等公民,可以像变量一样装进列表、当参数传递。purrr::map 家族就是类型安全、管道友好的批量迭代器:map 永远返回列表;确定返回类型时用 map_lgl / map_chr / map_dbl;两个输入用 map2、多个用 pmap;结果列表用 list_rbind() 纵向拼表。
suppressPackageStartupMessages({ library(dplyr); library(purrr) })
# 三个中心的 ADSL 分片(列表 of data.frame)
adsl_by_site <- list(
site01 = tibble(site = "01", age = c(63, 71, 58), sex = c("M", "F", "F")),
site02 = tibble(site = "02", age = c(45, 52), sex = c("M", "M")),
site03 = tibble(site = "03", age = c(66, 48, 55, 61), sex = c("F", "M", "F", "M"))
)
# SAS 思路:%do 遍历中心 + proc means;R:一个 map 搞定
summ <- adsl_by_site |>
map(\(df) df |> summarise(n = n(), mean_age = round(mean(age), 1), .by = site))
list_rbind(summ)
# 匿名函数:R 4.1+ 的 \(x) 简写,等价于 function(x)
map_dbl(1:4, \(x) x * 2)
# 实跑捕获(R 4.5.0) # A tibble: 3 x 3 site n mean_age <chr> <int> <dbl> 1 01 3 64 2 02 2 48.5 3 03 4 57.5 [1] 2 4 6 8
- 三个 tibble 挂在一个列表里:相当于 SAS 的三张分中心数据集,但不必维护 ads1/ads2/ads3 这类名字。
\(df)是 R 4.1+ 的匿名函数简写,等价于function(df);map把它逐一施加到列表每个元素,返回新列表。summarise(..., .by = site)的.by是 dplyr 1.1+ 的临时分组,心智同PROC MEANS的 class。list_rbind()把结果列表纵向拼成一张表(已弃用的map_dfr的后继者),相当于 set 合并。map_dbl(1:4, \(x) x * 2):已知返回类型就用 map_dbl/map_chr,直接拿到原子向量而不是列表。
<<- 累积结果,先停下来想想 map 是不是更干净。5.2 环境与作用域:闭包
R 的每个函数在定义处携带一个环境:调用时去哪儿找变量名,由它在哪定义决定,而不是在哪被调用——这叫词法作用域。闭包 = 函数 + 它的出生环境,是 R 里"保存私有状态"的正规姿势。
make_counter <- function() {
n <- 0 # n 被封在函数环境里
function() { # 返回的匿名函数记住出生地
n <<- n + 1 # <<- 只改父环境(闭包环境)的 n
n
}
}
c1 <- make_counter()
c2 <- make_counter() # 第二个闭包,互不干扰
cat(c1(), c1(), c2(), c1(), "\n")
cat("全局环境里没有 n:", exists("n", envir = globalenv()), "\n")
# 实跑捕获(R 4.5.0) 1 2 1 3 全局环境里没有 n: FALSE
n <- 0住在 make_counter 本次调用创建的环境里,外界看不见也改不着。- 返回的匿名函数"记得"这个 n——这就是闭包;c1、c2 各自携带一份独立环境。
<<-穿过函数自身环境去修改父环境(闭包环境)里的 n;若写<-只会新建一个每次归零的局部变量。- 所以输出是 1 2 1 3:c1 数到三,c2 只数了一次,互不干扰。
- 最后一行证明:全局环境里根本没有 n——状态被封在闭包里,而不是挂在全局符号表上。
%let 默认全局,宏变量跨程序驻留,不 %symdel 就会被下一个程序撞名——很多 SAS 团队因此专门立了"宏变量命名前缀规范"。R 恰好相反:函数内变量天然局部,想污染全局反而要显式写 <<- 或 assign(..., envir = globalenv())。SAS 里要靠纪律防的"全局污染",在 R 里由语言默认帮你挡住。5.3 S3 十分钟:给对象贴上类
S3 是 R 最轻量的对象系统:给对象贴一个 class 属性,print、summary、format 这类泛型函数就会按类自动分发到你写的方法。不需要正式的类定义文件,十分钟上手。
x <- list(study = "STUDY01", n = 128, vars = c("AGE", "SEX", "RACE"))
class(x) <- "adsl_report" # 一行贴上 S3 类标签
# 方法名 = 泛型.类名,print() 会自动分发过来
print.adsl_report <- function(x, ...) {
cat("== ADSL Report ==\n")
cat("Study:", x$study, "\n")
cat("N :", x$n, "\n")
cat("Vars :", paste(x$vars, collapse = ", "), "\n")
invisible(x)
}
print(x) # 无需手工调用 print.adsl_report
cat("class(x) =", class(x), "\n")
# 实跑捕获(R 4.5.0) == ADSL Report == Study: STUDY01 N : 128 Vars : AGE, SEX, RACE class(x) = adsl_report
- x 本来只是个普通 list;
class(x) <- "adsl_report"贴上类标签,一行完成"类定义"。 - 方法命名是硬约定:泛型.类名,即
print.adsl_report;在当前会话里定义完即生效,无需注册。 print(x)触发内部的分发机制(UseMethod):发现 class 是 adsl_report,就跳转执行 print.adsl_report——你永远不必显式调用方法名。invisible(x)是 print 方法的惯例返回:不可见地返回原对象,保持可链式使用。methods(print)能列出当前会话注册过的全部 print 方法;admiral、rtables 这些临床包全靠这套机制扩展。
5.4 性能:向量化优先
SAS 用户写 R 最容易卡壳的地方:把 DATA step 的逐行思维直译成 for 循环。R 的循环每一步都经过解释器、还伴随"修改即复制";向量化函数底层是编译好的 C 循环。让 100 万行数据说话:
set.seed(42)
n <- 1e6
df <- data.frame(age = rnorm(n)) # 100 万行"受试者年龄"
m <- matrix(df$age, nrow = 1000) # 1000 x 1000 矩阵,同一批数据
cat("-- 反模式:逐行 for,每圈还重新抽列 --\n")
print(system.time({ s1 <- 0; for (i in seq_len(nrow(df))) s1 <- s1 + df$age[i] }))
cat("-- 向量化 sum() --\n")
print(system.time({ s2 <- sum(df$age) }))
cat("-- 列和 colSums() --\n")
print(system.time({ s3 <- colSums(m) }))
cat("三种算法结果一致:",
isTRUE(all.equal(s1, s2)),
isTRUE(all.equal(sum(s3), s2)), "\n")
# 实跑捕获(R 4.5.0)
-- 反模式:逐行 for,每圈还重新抽列 --
user system elapsed
0.61 0.00 0.63
-- 向量化 sum() --
user system elapsed
0 0 0
-- 列和 colSums() --
user system elapsed
0 0 0
三种算法结果一致: TRUE TRUE
- for 循环里的
df$age[i]每一圈都先重新抽一次列、再取第 i 个元素——双重反模式,花了 0.63 秒。 sum(df$age)把整列一次性交给编译代码,elapsed 在秒级精度下显示为 0(实际毫秒级)。colSums(m)对 1000 列一次算完全部列和——"按组/按列"聚合几乎总有现成向量化函数(rowSums、colMeans、cumsum……)。- 最后用
all.equal确认三种算法数值一致:先正确、再快,性能改写后必须回归比对,这本身就是 QC 纪律。 - user/system/elapsed 三个数是 R 的秒表;
system.time是最低成本的剖析(profiling)手段。
profvis::profvis({ 你的代码 }) 包一层,会在浏览器里给出逐行耗时与内存的交互式火焰图——相当于 R 版的性能分析器。5.5 错误与日志:tryCatch、withCallingHandlers、logger
SAS 有 ERROR/WARNING 的日志纪律;R 的对应物是条件系统:error(中断)、warning(警示)、message(提示),外加两种捕获姿势——tryCatch 与 withCallingHandlers——以及生产级日志包 logger。
derive_agegrp <- function(rfstdtc) {
if (is.na(rfstdtc)) {
warning("RFSTDTC missing, returning NA")
return(NA_real_)
}
as.numeric(substr(rfstdtc, 1, 4)) - 1960
}
# tryCatch:handler 在调用者环境执行,原调用已经退出
r1 <- tryCatch(derive_agegrp(NA),
warning = function(w) {
cat("tryCatch 捕获:", conditionMessage(w), "\n")
NA_real_
})
# withCallingHandlers:handler 在现场执行,可用 restart 让代码继续
r2 <- withCallingHandlers(
derive_agegrp(NA),
warning = function(w) {
cat("handler 现场看到:", conditionMessage(w), "\n")
invokeRestart("muffleWarning") # 按下警告,函数继续跑完
})
cat("r1 =", r1, "; r2 =", r2, "\n")
suppressPackageStartupMessages(library(logger))
log_formatter(formatter_sprintf) # 默认是 glue 风格 {},此处用 %s 演示
log_threshold(INFO)
log_info("derive_agegrp 开始,输入 n = %s", 100)
log_error("RFSTDTC 格式非法: %s", "2015-13-45")
# 实跑捕获(R 4.5.0) tryCatch 捕获: RFSTDTC missing, returning NA handler 现场看到: RFSTDTC missing, returning NA r1 = NA ; r2 = NA INFO [2026-09-16 23:51:50] derive_agegrp 开始,输入 n = 100 ERROR [2026-09-16 23:51:50] RFSTDTC 格式非法: 2015-13-45
- derive_agegrp 遇缺失先
warning再正常返回 NA——warning 不中断执行,等价于 SAS 的 WARNING 进日志。 - tryCatch:条件一发生,原调用立即退栈,handler 在调用者环境里执行,其返回值成为整个 tryCatch 的结果(r1)。
- withCallingHandlers:handler 在条件发生的现场执行,执行完函数体继续跑;
invokeRestart("muffleWarning")把这条警告按下不表,r2 最终拿到的是函数的正常返回值。 - logger 三件套:
log_threshold定级别门槛、log_formatter定占位符风格、log_info / log_error按级别输出——行首级别前缀就是后续筛日志的抓手。 - 输出的 INFO/ERROR 两行即"R 版 SAS LOG"的雏形;生产上用 appender_to_file 落盘,日志文件归档进验证证据链。
{} 占位符(如 log_info("n = {}", 100));本例用 log_formatter(formatter_sprintf) 切到 %s 风格,以保证纯命令行捕获环境下输出稳定。两者功能等价,团队约定一种即可。测验:tryCatch 与 withCallingHandlers 的关键区别是什么?
5.6 testthat:把 QC 写成断言
双编程很贵;testthat 把"QC 的另一半"机器化:每条派生规则写成可执行断言,改完代码一条命令全量回归。它也是第 6 章包开发与 5.7 CI 的地基。
library(testthat)
derive_age <- function(birth_year, ref_year) {
if (!is.numeric(birth_year)) stop("birth_year must be numeric")
ref_year - birth_year
}
with_reporter(ProgressReporter$new(show_praise = FALSE), {
test_that("derive_age 正确", {
expect_equal(derive_age(1960, 2024), 64)
expect_error(derive_age("1960", 2024))
})
})
# 实跑捕获(R 4.5.0) v | F W S OK | Context - | 1 | == Results ===================================================================== [ FAIL 0 | WARN 0 | SKIP 0 | PASS 2 ]
test_that("描述", {...}):一个测试用例,描述就是人读的需求名,失败时会出现在报告里。expect_equal(derive_age(1960, 2024), 64):正向断言——正确输入必须算出 64。expect_error(derive_age("1960", 2024)):负向断言——传字符串必须抛错。正负两面都测,才叫覆盖。with_reporter(ProgressReporter, ...)包一层进度报告器,跑完打出[ FAIL 0 | PASS 2 ]式计数——CI 里只要 FAIL 不为 0,整条流水线红灯。
tests/testthat/,用 testthat::test_dir("tests/testthat") 一条命令全量回归;打包后(第 6 章)由 R CMD check 自动执行,CI 再把它搬上云端定时跑。5.7 CI:GitHub Actions 上的持续回归
CI(持续集成)= 每次 git push,云端机器自动替你跑一遍 5.6 的回归测试,日志自动存档。对 GxP 团队,它的真实价值是:把验证从"一次性事件"变成"每次提交都有时间戳的持续证据"。GitHub Actions 是事实标准,一个 YAML 文件就够:
# .github/workflows/check.yaml —— 每次 push 自动回归
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
env:
GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
- uses: r-lib/actions/setup-r@v2
with:
use-public-rspm: true # 用 Posit 公共镜像装包,提速
- uses: r-lib/actions/setup-renv@v2 # 按 renv.lock 复原锁定环境
- uses: r-lib/actions/check-r-package@v2 # R CMD check + testthat 全量回归
on: push / pull_request:声明触发条件——推送到 main/develop 或提 PR 时自动开跑。runs-on: ubuntu-latest指定运行机器;GITHUB_PAT令牌让 Actions 有权安装依赖包。setup-r@v2装 R 本体,use-public-rspm打开包二进制镜像。setup-renv@v2按 renv.lock 装回完全相同的包版本——CI 证据链里的"环境"环节。check-r-package@v2= R CMD check + testthat 全量回归;任何 ERROR 或测试失败,整个 job 红灯,PR 不许合。
5.8 GxP 验证:valtools 工作流与 CSV 映射
第 0 章说过:在 R 世界,验证的最小单元是"包 + 测试 + 锁环境",而不是单个脚本。落到工具上,pharmaverse/PHUSE 社区开源的 valtools(pharmaverse.github.io/valtools)把 CSV 纪律搬进了 R 项目:用 vt_create_skeleton() 生成验证工程骨架(验证计划、需求、风险分析各就各位);需求、代码、测试同库管理,形成需求→代码→测试的追溯矩阵;最后 vt_create_report() 一键产出带追溯关系的验证报告。你在 SAS 时代的 VP/IQ/OQ/PQ 文档树,几乎可以逐层映射过去:
| 组件 | GAMP5 视角 | 验证活动(CSV) |
|---|---|---|
| R 本体 + CRAN/内部包 | 现成/可配置软件(Cat 1 / Cat 5) | 供应商评估 + riskmetric 风险打分;renv.lock 锁版本,作为 IQ 证据留档 |
| 派生函数(derive_*) | 自研代码(Cat 5) | 需求-代码-测试追溯(valtools);testthat 单元测试 = OQ |
| ADaM / TLF 输出 | 业务交付物 | 独立双编程 + diffdf 比对、结果审阅 = PQ |
| CI 流水线 | 自动化工具 | 每次 push 全量回归,日志归档为持续验证证据 |
| 分析环境 | 系统配置 | sessionInfo() + renv.lock 存档,进验证报告附录 |
5.9 代码评审清单:10 条硬杠
把下面的清单贴在 PR 模板里,评审时逐条打勾——这就是 R 版的"程序审阅 sign-off":
- 命名:函数/文件遵循团队规范(derive_* / tfl_* 前缀、snake_case),名字即用途。
- spec 链接:程序头或 roxygen 注释里给出映射规格(spec)链接与需求编号,可回溯。
- 无硬编码:研究号、路径、日期一律来自配置或参数;不出现 "C:/Users/..." 式绝对路径。
- NA 处理:join 键缺失、日期缺失、分母为零的行为被显式处理并在注释里说明。
- 键唯一性断言:对 USUBJID 等主键有
stopifnot(!anyDuplicated(...))或断言包检查。 - 日志:关键步骤有 logger/message 输出并可落盘,级别足以还原运行过程。
- 测试覆盖:每个派生函数至少一正(expect_equal)一负(expect_error)两条 testthat 断言。
- renv 锁:新增依赖后跑过
renv::snapshot(),renv.lock 随 PR 一起提交评审。 - sessionInfo 留存:正式运行的输出附 sessionInfo(),与产物、日志一并归档。
- 双人复核点:merge 逻辑、人群划分、分母定义等 QC 关键点标记"需第二人复核",留复核记录。
章末资源
- Advanced R (2e) — Hadley Wickham 的权威进阶教材;本章环境、闭包、S3、条件系统的完整展开 进阶
- testthat 官网 — 单元测试入门文档,先读 get-started 再翻期望函数大全 入门
- valtools — R 包 GxP 验证框架:需求-代码-测试追溯矩阵与验证报告生成 进阶
- PharmaSUG 2026 ET-140 R Package Validation — R 包验证全生命周期论文,5.8 节的底稿 参考
- logger CRAN 页 — 日志级别、formatter、appender 的完整参考 参考
本章练习
- (易)挑你项目里现有的一个派生函数,补两条 testthat 断言:一条 expect_equal 验证正确输入,一条 expect_error 验证非法输入必须抛错。
- (中)给一个完整脚本加上 logger 三级日志:开始/关键步骤用 log_info,可疑数据用 log_warn,致命失败用 log_error,并用 appender_to_file 落盘。
- (难)为练习 2 的脚本写一个 GitHub Actions workflow:push 触发,setup-r + setup-renv 复原环境,跑通 testthat 全量回归,直到 CI 全绿。
下一章:把散落各处的 derive_* 函数收编成真正的 R 包——roxygen 文档、tests 目录、R CMD check 与内部包发布流程,让 5.8 的验证框架有物可验。