R 临床实战From SAS to R, In Depth
全书目录 / 第 5 章
05

进阶 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
逐行解读:
  1. 三个 tibble 挂在一个列表里:相当于 SAS 的三张分中心数据集,但不必维护 ads1/ads2/ads3 这类名字。
  2. \(df) 是 R 4.1+ 的匿名函数简写,等价于 function(df);map 把它逐一施加到列表每个元素,返回新列表。
  3. summarise(..., .by = site) 的 .by 是 dplyr 1.1+ 的临时分组,心智同 PROC MEANS 的 class。
  4. list_rbind() 把结果列表纵向拼成一张表(已弃用的 map_dfr 的后继者),相当于 set 合并。
  5. map_dbl(1:4, \(x) x * 2):已知返回类型就用 map_dbl/map_chr,直接拿到原子向量而不是列表。
SAS 误区:宏循环的三大坑——%do 索引泄漏进全局符号表、& 宏变量解析时机、忘写 %local 导致撞名——在 map 里统统不存在:匿名函数的参数天然局部,跑完不留任何状态。反过来,如果你在 R 里手写 for 循环、还得靠 <<- 累积结果,先停下来想想 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
逐行解读:
  1. n <- 0 住在 make_counter 本次调用创建的环境里,外界看不见也改不着。
  2. 返回的匿名函数"记得"这个 n——这就是闭包;c1、c2 各自携带一份独立环境。
  3. <<- 穿过函数自身环境去修改父环境(闭包环境)里的 n;若写 <- 只会新建一个每次归零的局部变量。
  4. 所以输出是 1 2 1 3:c1 数到三,c2 只数了一次,互不干扰。
  5. 最后一行证明:全局环境里根本没有 n——状态被封在闭包里,而不是挂在全局符号表上。
SAS 误区:SAS 开放代码里 %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
逐行解读:
  1. x 本来只是个普通 list;class(x) <- "adsl_report" 贴上类标签,一行完成"类定义"。
  2. 方法命名是硬约定:泛型.类名,即 print.adsl_report;在当前会话里定义完即生效,无需注册。
  3. print(x) 触发内部的分发机制(UseMethod):发现 class 是 adsl_report,就跳转执行 print.adsl_report——你永远不必显式调用方法名。
  4. invisible(x) 是 print 方法的惯例返回:不可见地返回原对象,保持可链式使用。
  5. methods(print) 能列出当前会话注册过的全部 print 方法;admiral、rtables 这些临床包全靠这套机制扩展。
别小看这十分钟:第 6 章开发包时,给团队的"分析结果对象"定一个类、配上 print/format 方法,QC 同事 print 一下就能看到结构化摘要——比把一坨裸 list 甩到日志里可审计得多。

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
逐行解读:
  1. for 循环里的 df$age[i] 每一圈都先重新抽一次列、再取第 i 个元素——双重反模式,花了 0.63 秒。
  2. sum(df$age) 把整列一次性交给编译代码,elapsed 在秒级精度下显示为 0(实际毫秒级)。
  3. colSums(m) 对 1000 列一次算完全部列和——"按组/按列"聚合几乎总有现成向量化函数(rowSums、colMeans、cumsum……)。
  4. 最后用 all.equal 确认三种算法数值一致:先正确、再快,性能改写后必须回归比对,这本身就是 QC 纪律。
  5. user/system/elapsed 三个数是 R 的秒表;system.time 是最低成本的剖析(profiling)手段。
想知道具体哪一行慢,用 profvis::profvis({ 你的代码 }) 包一层,会在浏览器里给出逐行耗时与内存的交互式火焰图——相当于 R 版的性能分析器。
SAS 误区:SAS DATA step 引擎为逐行处理而生,行级思维在 SAS 里是正道;R 里逐行 for + 循环内抽列却是头号性能杀手。心智顺序:向量化函数 > map/apply 族 > for 循环。但也别盲目改写——先用 system.time 测,确认瓶颈再动手。

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
逐行解读:
  1. derive_agegrp 遇缺失先 warning 再正常返回 NA——warning 不中断执行,等价于 SAS 的 WARNING 进日志。
  2. tryCatch:条件一发生,原调用立即退栈,handler 在调用者环境里执行,其返回值成为整个 tryCatch 的结果(r1)。
  3. withCallingHandlers:handler 在条件发生的现场执行,执行完函数体继续跑;invokeRestart("muffleWarning") 把这条警告按下不表,r2 最终拿到的是函数的正常返回值。
  4. logger 三件套:log_threshold 定级别门槛、log_formatter 定占位符风格、log_info / log_error 按级别输出——行首级别前缀就是后续筛日志的抓手。
  5. 输出的 INFO/ERROR 两行即"R 版 SAS LOG"的雏形;生产上用 appender_to_file 落盘,日志文件归档进验证证据链。
logger 默认的 formatter 是 glue 风格 {} 占位符(如 log_info("n = {}", 100));本例用 log_formatter(formatter_sprintf) 切到 %s 风格,以保证纯命令行捕获环境下输出稳定。两者功能等价,团队约定一种即可。

测验:tryCatch 与 withCallingHandlers 的关键区别是什么?

选 B。tryCatch 的 handler 在调用者环境执行,条件发生时原调用已经退栈,handler 返回值即 tryCatch 的结果;withCallingHandlers 的 handler 在条件发生的现场执行,处理完还可用 invokeRestart("muffleWarning") 按下条件、让函数体继续跑完——本节的 r2 就是这样拿到正常返回值的。两者都能捕获 error/warning/message,区别在"退出后处理"还是"在现场处理"。

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 ]
逐行解读:
  1. test_that("描述", {...}):一个测试用例,描述就是人读的需求名,失败时会出现在报告里。
  2. expect_equal(derive_age(1960, 2024), 64):正向断言——正确输入必须算出 64。
  3. expect_error(derive_age("1960", 2024)):负向断言——传字符串必须抛错。正负两面都测,才叫覆盖。
  4. 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 全量回归
逐行解读:
  1. on: push / pull_request:声明触发条件——推送到 main/develop 或提 PR 时自动开跑。
  2. runs-on: ubuntu-latest 指定运行机器;GITHUB_PAT 令牌让 Actions 有权安装依赖包。
  3. setup-r@v2 装 R 本体,use-public-rspm 打开包二进制镜像。
  4. setup-renv@v2 按 renv.lock 装回完全相同的包版本——CI 证据链里的"环境"环节。
  5. check-r-package@v2 = R CMD check + testthat 全量回归;任何 ERROR 或测试失败,整个 job 红灯,PR 不许合。
CI 全绿是"持续回归"证据,不能替代验证计划与风险评估——它做的是把验证里 OQ 级的回归部分日常化。YAML 文件本身要进版本库、可评审、可追溯,它也是验证文档的一部分。

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 存档,进验证报告附录
PharmaSUG 2026 ET-140《R Package Validation》(Kevin Lee)把 R 包验证整理成一条持续生命周期:需求收集 → 治理 → 风险评估 → 验证执行 → 安装到系统 → 文档化留档;工具面用 testthat 承担测试、riskmetric / risk.assessr 承担包风险打分。读完它你会发现:这套框架没有发明新纪律,只是把你熟悉的 CSV 流程翻译成了 R 的工程形态。
SAS 误区:像验证 SAS 程序一样只验证"那一个脚本"。R 的分析结果依赖一整条包依赖链——同一个脚本在 dplyr 1.1.0 和 1.1.6 下行为可能有细微差别。验证对象必须是"包 + 版本 + 环境 + 测试"的整体,这正是 renv.lock、sessionInfo 和 valtools 追溯矩阵存在的意义。

5.9 代码评审清单:10 条硬杠

把下面的清单贴在 PR 模板里,评审时逐条打勾——这就是 R 版的"程序审阅 sign-off":

  1. 命名:函数/文件遵循团队规范(derive_* / tfl_* 前缀、snake_case),名字即用途。
  2. spec 链接:程序头或 roxygen 注释里给出映射规格(spec)链接与需求编号,可回溯。
  3. 无硬编码:研究号、路径、日期一律来自配置或参数;不出现 "C:/Users/..." 式绝对路径。
  4. NA 处理:join 键缺失、日期缺失、分母为零的行为被显式处理并在注释里说明。
  5. 键唯一性断言:对 USUBJID 等主键有 stopifnot(!anyDuplicated(...)) 或断言包检查。
  6. 日志:关键步骤有 logger/message 输出并可落盘,级别足以还原运行过程。
  7. 测试覆盖:每个派生函数至少一正(expect_equal)一负(expect_error)两条 testthat 断言。
  8. renv 锁:新增依赖后跑过 renv::snapshot(),renv.lock 随 PR 一起提交评审。
  9. sessionInfo 留存:正式运行的输出附 sessionInfo(),与产物、日志一并归档。
  10. 双人复核点: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 的完整参考 参考

本章练习

  1. (易)挑你项目里现有的一个派生函数,补两条 testthat 断言:一条 expect_equal 验证正确输入,一条 expect_error 验证非法输入必须抛错。
  2. (中)给一个完整脚本加上 logger 三级日志:开始/关键步骤用 log_info,可疑数据用 log_warn,致命失败用 log_error,并用 appender_to_file 落盘。
  3. (难)为练习 2 的脚本写一个 GitHub Actions workflow:push 触发,setup-r + setup-renv 复原环境,跑通 testthat 全量回归,直到 CI 全绿。

下一章:把散落各处的 derive_* 函数收编成真正的 R 包——roxygen 文档、tests 目录、R CMD check 与内部包发布流程,让 5.8 的验证框架有物可验。