SAS Pattern Note · 临床编程 · 参考型文档

SAS Hash 对象的系统化用法:定义、键值查找、数据装载与输出

一次把整张表读进内存,再让主表的每一行各自去“钉”上去——这是临床 SAS 编程里最值钱的一条肌肉记忆。 本文给出 hash 对象的完整语法骨架(declare → definekey → definedata → definedone → find / output),四类真实项目形态的 可搬运代码(受试者筛选、横向合并、纵向检索、SDTM/ADaM 衍生),以及八条按官方文档与会议论文归集的易错点。

来源  SAS 官方文档 / PharmaSUG / PHUSE 公开文献整理;无真实研究数据 状态  静态分析 —— 本环境无 SAS 会话,代码未经执行
Scope and de-identification

这是一份语言用法参考,不是某个研究项目的交付物:研究编号与方案号一律写作 NNNN, 数据集与变量名使用 CDISC 标准名(USUBJID、PARAMCD、ADT、AVAL、 AESEQ、CMSEQ 等),公司环境、库名与路径均已剥离。凡涉及 SAS 语言行为的断言,均在正文中以 [doc](官方文档)或 [lit](文献作者陈述)标记出处;凡属工程判断者标 [实践]。 本文不包含任何受试者级数据,也没有可复核的衍生结果。

1需求与数据形态

先说要解决的问题,再说数据结构;这两件事清楚了,语法就只剩下翻译工作。

临床编程里的合并与取数需求,最终都能落进四种形态。先认出自己属于哪一种,方法选择就随之确定: 一对一用 find(),只判有无用 check(),一键多条才有 find_next() 的用武之地。

形态典型场景键的性质
横向关联
一对一 / 多对一
把 ADSL 的 AGE、SEX、ARMCD 贴到 ADLB 的每一行 USUBJID 唯一
纵向检索 同一受试者内部跨访视取值:基线旗标 ABLFL、末次观测 LSTFL USUBJID + 时间 / 访视
多对多展开 AE × CM 交叉:某项不良事件的持续区间是否被某类合并用药覆盖 一个键对应多条数据项
去重与重整 由长表压成一行一受试者;筛选失败人群列表;内存层面的 NODUPKEY 替代 去重键 + 需要留下来的那一条

1.1 术语

名称在本篇中的角色
definekey 键变量检索入口。键值相同的两条记录,在默认设置下只有第一条被收下。
definedata 数据变量命中后被写回 PDV 的那几个变量;从未调用它时,键自身充当数据项。
PDV程序数据向量。hash 对象正是通过改写 PDV 中的变量来“返回”结果,这一点是所有串值问题的根源。
rc 返回码0 表示成功,非零表示失败(与 SAS 布尔约定相反)。每个方法调用都必须接。
hiter 迭代器配合 ordered: 顺序遍历整张表;多命中场景下来自这一层。
问题形态

下面三条同时成立时,hash 才真正比别的方式省心:① 主表行数远大于被查表,且被查表能整体放进内存; ② 合并不需要排序,主表的行顺序必须原样保留(BDS 结构对此最为敏感); ③ 查到的中间结果需要在同一个 DATA 步内继续被加工,而不是另起一步。
只满足 ① 的时候,PROC SQL 往往同样够用;三条都不成立,hash 只是把事情绕远了。

2核心语法骨架

生命周期只有五步,顺序不能变;每一步背后都有一个默认值,而这些默认值大多不是你想要的。

IF _N_ = 1 — 只做一次 1 declare 参数标签在此 2 definekey 类型在此定型 3 definedata 命中后回写的项 4 definedone 这一刻才真正装载 主表逐行 — 每迭代一次 set 主表 find / check / find_next —— 回写 PDV output / 写出 datasets
Figure 1 | 生命周期。数据集的装载发生在 definedone(),而不在 declare [doc]; declare 与三个 define 调用必须位于 if _n_ = 1 块内 —— 对象实例及其所有条目跨 DATA 步迭代保留, 写在块外就是每行重建一次全表。

2.1 骨架:一次装载,逐行查找

data adlb_enr(drop = rc hit);
  /* (1) 借壳定型:把将被 hash 读入的变量属性先写死在本 DATA 步里 */
  if 0 then set adsl(keep = usubjid age sex armcd trtsdt);

  /* (2) 只实例化一次;DEFINEDONE 时才真正读取 adsl */
  if _n_ = 1 then do;
    declare hash h(dataset: 'adsl', hashexp: 8, duplicate: 'e');
    h.definekey('usubjid');
    h.definedata('age', 'sex', 'armcd', 'trtsdt');
    h.definedone();
    call missing(usubjid, age, sex, armcd, trtsdt);   /* 避开 uninitialized 提示 */
  end;

  /* (3) 主表逐行驱动 */
  set adlb_raw;
  call missing(age, sex, armcd, trtsdt);            /* 清掉上一行的残留 */
  rc  = h.find();
  hit = (rc = 0);                                   /* 0 表示成功,把它翻成布尔 */
  if not hit then do;
    call missing(age, sex, armcd, trtsdt);          /* 未命中也必须显式处置 */
    put 'ERROR: no ADSL row for ' usubjid=;
  end;
run;

三个编号各自对应一类失败。(1) 不做,键与数据变量的长度和类型就由被装载数据集隐式决定, 一旦来源表结构变化,USUBJID 的长度跟着漂。(2) 不做,每轮迭代重建整张表,最贵的那部分被反复支付。 (3) 的 call missing 是全文性价比最高的一行代码 —— 第 5 节会解释它挡住的是哪一类缺陷。

2.2 方法速查

成员作用与返回值场合
definekey('v1','v2')定义键。带 dataset: 装载时可用 all:'yes' 把所有变量定为键。装载前
definedata('v1','v2')定义命中后回写 PDV 的项。all:'yes' 表示整张表的变量都作为数据项。装载前
definedone()收束定义;若带 dataset:,数据集正是在此刻被装载 [doc]。装载前
find()查键;命中则把数据变量写回 PDV 并返回 0。find(key: v) 可直接指定键值。要取回数据
check()只判存在性,不更新数据变量 [doc]。凡是不想弄脏 PDV 的场合都该用它。只问在不在
find_next() / find_prev()多命中游标。须先 find() 再循环 [doc],取第二条、第三条……multidata
add() / replace()运行期写入 / 覆写当前 PDV 里那一条。add() 在键已存在时会失败并返回非零码 [doc]。运行期建表
ref()查得即调整、查不到即写入的合体;同时是 suminc: 计数的推进入口。累计 / 计数
remove() / clear() / delete()删单条 / 清空全部条目但保留对象 / 销毁对象本身。表维护
output(dataset:'out')把整张内存表写成数据集。键不会自动出现,除非它同时也是数据项,或压根没定义过数据项 [doc]。落盘
num_items
属性,非方法
条目数。装载之后、查找之前的一种廉价自检:h.num_items 是否等于预期行数。自检
first() / last() / next() / prev()按序遍历整表,须配合 ordered: 使用。有序遍历
sum() / sumdup()读出 suminc: 维护的键汇总值(一键一条 / 一键多条)。内存聚合

2.3 DECLARE 参数标签

六个标签全部写在 declare(或 _new_)的括号里。它们决定装载阶段的行为,是“一次装载”里唯一还能拧的旋钮。

标签取值与含义默认
dataset:要装载的数据集名(单双引号皆可;含宏变量时必须双引号)。支持数据集选项: dcl hash h(dataset: 'adsl(where=(fasfl="Y"))') —— keep=、rename=、where= 顺手一并办了 [doc]。 —
duplicate:重复键如何处置。'replace' | 'r' 保留最后一条;'error' | 'e' 向日志报错。 保留首条
multidata:'yes' | 'y' 允许一键多条;'no' | 'n' 一键一条。 'no'
hashexp:内部桶数的幂次:表大小 = 2n,最大值为 20。桶数不等于可存条目数(每桶可放任意多条),只影响查找效率 [doc]。 见文档
ordered:'ascending' | 'a' | 'yes' | 'y' 升序,'descending' | 'd' 降序,'no' | 'n' 不定义顺序。 影响 output() 的写出顺序与迭代器的遍历顺序。 'no'
suminc:指定一个 DATA 步变量作为累加增量。add() / replace() 时该键的汇总值初始化为此变量值; find() / check() / ref() 时把此变量值累加进去 [doc]。 —
值得单独记住的一处细节

suminc: 的计数是由访问驱动的:find()、check()、ref() 都会把它推进一步 [doc]。 这意味着一次“我只是看看在不在”的 check(),同样会让键的汇总值 +1。做暴露次数、合并用药计数这类派生时, 选错方法就等于选错算式。

2.4 返回码约定

官方文档的统一约定是:返回码为零表示成功,非零表示失败;若不提供接收返回码的变量而调用失败, 日志会被写入相应的错误信息 [doc]。方向恰好与 SAS 布尔量相反,于是 rc = h.find(); hit = (rc = 0); 这一句“翻译”几乎值得无条件写:它同时完成了三件事 —— 接住返回码、把语义翻回来、给复核人员留下一行可读依据。

3临床项目应用示例

四段可直接改写的骨架。写法参考了上行文献里的技法,数据一律按 CDISC 通用结构虚构。

3.1 受试者筛选与分析集归属

场景:由 DM / DS 判定每位受试者是否进入 FAS,产出一行一受试者的旗标表。 这一步的本质是只问“在不在”,没有任何变量需要取回,因此用 check() 而非 find() —— 不动 PDV,也就没有串值的可能。

data pop_flag;
  if 0 then set elig(keep = usubjid);
  if _n_ = 1 then do;
    /* duplicate:'e' —— 让重复键变成日志里的 ERROR,而不是静默丢人 */
    declare hash h_fas(dataset: 'elig (where = (fasenrl = "Y"))',
                       duplicate: 'e');
    h_fas.definekey('usubjid');
    h_fas.definedone();
    call missing(usubjid);
  end;

  set dm(keep = usubjid siteid age sex);
  fasfl = ifc(h_fas.check() = 0, 'Y', 'N');   /* check 不回写任何数据变量 */
  keep usubjid siteid age sex fasfl;
run;

这里 duplicate: 'e' 是防御的核心。资格判定记录天然是多行的(每个入组/排除原因一行), 一旦把原始表直接喂进 hash,默认行为会保留首条、静默丢掉其余 —— 人群人数与实际情况对不上,而日志一个字都不提。 'e' 把假设推到台面上:要么先在 SQL / SORT 步内把资格表压成一行一受试者,要么明确改用 'r'。

3.2 横向合并:人口学变量并入每条观测记录

场景:ADLB 每位受试者 × 参数 × 访视多行,需要贴上 AGE、SEX、ARMCD、TRTSDT。 传统写法要先把 ADLB 排序、合并、再排回来;hash 让主表行序完全不动。

data adlb(drop = rc hit);
  if 0 then set adsl(keep = usubjid age sex armcd trtsdt);
  if _n_ = 1 then do;
    declare hash a(dataset: 'adsl', duplicate: 'e', hashexp: 8);
    a.definekey('usubjid');
    a.definedata('age', 'sex', 'armcd', 'trtsdt');
    a.definedone();
    call missing(usubjid, age, sex, armcd, trtsdt);
  end;

  set adlb_raw;
  /* 每次 find 之前先把自己清零:这一行的性价比高于全篇任何一处注释 */
  call missing(age, sex, armcd, trtsdt);
  rc  = a.find();
  hit = (rc = 0);
  if not hit then do;
    call missing(age, sex, armcd, trtsdt);
    put 'ERROR: ADSL lookup failed for ' usubjid=;
  end;
run;

3.3 纵向检索:受试者内跨访视取值

场景:为每位受试者在治疗期开始后的首次可用观测打 ABLFL = 'Y'。做法是让键只含受试者, 把该受试者的全部记录装进同一个键之下,再用 find_next() 走完这条链 —— 全程不排序。

data base_flag(drop = rc quit);
  if 0 then set adlb(keep = usubjid paramcd adt aval);
  if _n_ = 1 then do;
    /* 同一 USUBJID 下多条 —— 必须 multidata:'y' */
    declare hash v(dataset: 'adlb', multidata: 'y', ordered: 'a');
    v.definekey('usubjid');
    v.definedata('paramcd', 'adt', 'aval');
    v.definedone();
    call missing(usubjid, paramcd, adt, aval);
  end;

  set subjects(keep = usubjid trtsdt);      /* 一行一受试者的驱动表 */
  best_adt = .;   best_aval = .;

  /* 首次 find 取第一条,find_next 沿游标走完本受试者全部记录 */
  if v.find() = 0 then do;
    do until(quit);
      if paramcd = 'LABPARM' and not missing(aval)
         and trtsdt le adt then do;
        if missing(best_adt) or adt < best_adt then do;
          best_adt = adt;   best_aval = aval;
        end;
      end;
      quit = (v.find_next() ne 0);        /* 用游标自己的返回码退出 */
    end;
  end;

  ablfl = ifc(not missing(best_adt), 'Y', '');
  keep usubjid trtsdt best_adt best_aval ablfl;
run;

关键点有三:multidata 必须开,否则同一个 USUBJID 只有第一条进得了表; find_next() 必须接在成功的 find() 之后(官方文档把它定义为与 FIND 配合使用的游标方法); 退出条件写在循环体内,由游标自身的返回码决定,而不是指望某种隐含终止。

3.4 SDTM/ADaM 衍生:多对多展开与 output() 落盘

场景:判断每条不良事件的持续区间是否被某类合并用药覆盖 —— 一个 AE 可能对应多条 CM,一条 CM 也可能覆盖多个 AE。 这正是 PhUSE 2012 论文 CS05 用 hash 重写的原始案例之一(作者称其实现不依赖任何宏变量,单个 DATA 步完成)。 这类代码的实用价值之外,还集中体现了 output() 最容易踩到的那个陷阱。

data ae_cm_pairs;
  if 0 then set cm(keep = usubjid cmstdtc cmendtc cmdecod);
  if _n_ = 1 then do;
    /* 同一受试者多条 CM —— multidata:'y' */
    declare hash cm(dataset: 'cm', multidata: 'y');
    cm.definekey('usubjid');
    cm.definedata('cmstdtc', 'cmendtc', 'cmdecod');
    cm.definedone();
    call missing(usubjid, cmstdtc, cmendtc, cmdecod);
  end;

  set ae(keep = usubjid aeseq aestdtc aeendtc aedecod);
  call missing(cmstdtc, cmendtc, cmdecod);      /* 先清零,避免上一行的 CM 残留被写出 */

  rc = cm.find();
  do while(rc = 0);
    if cmstdtc le aestdtc and aeendtc le cmendtc then output;
    rc = cm.find_next();
  end;
  drop rc;
run;
Finding — output() 不会自动把键带上

官方文档的表述是明确的:hash 对象的键不会被自动写入输出数据集。想把键留在结果里,办法只有两条 —— 把键也写进 definedata(),或者干脆不定义任何数据项(此时键被写入输出数据集)。 上面的例子走的是第一条:cmdecod 属于数据项所以必须显式列出;漏掉任何一个参与结果呈现的变量, output() 出来的表看起来一切正常,只是少了那几列。这一条也是 WUSS 2022 那篇入门论文里 “不带 multidata 的 hash + output() 等价于 PROC SORT NODUPKEY”这则对照的前提。

4为什么是 hash,而不是 MERGE / SQL / FORMAT

把每种写法的边界一次性摊开;这里的结论由能力边界驱动,而不是由速度驱动。

写法能力边界代价 / 限制判定
DATA step MERGE 受限 要求两侧按 BY 变量排序;多对多会静默错位 两次排序的 I/O;BY 值组合缺失时容易产出意料之外的行对齐 够用但笨重
PROC SQL 连接 能做 多对多可以笛卡尔积形式表达,不需要排序 结果不保证行序;连接与逐行派生无法在同一个动程内交替进行;中间态难以调试 部分场景
自定义 FORMAT
CNTLIN /CNTLOUT
能做 静态字典映射的利器:编码 → 首选术语、ATC 分类 装载后不可变更;多返回值需要多个变量或多个格式;不适合内容需要随时改写的任务 静态字典首选
SET with POINT= / KEY= 受限 随机访问单条记录,无需整表装载 需要行号或已建索引;代价按访问次数累计,一万次查找就是一万次随机读 超大表慎用
Hash 对象 能做 无需排序、保留行序、支持多对多、运行期可维护表内容 整张被查表的内存占用;DATA 步一结束内容就消失,必须显式写出 本文推荐

真正让 hash 在这四类场景胜出的,并不是“更快”这一条,而是它可以边读边改那张内存表。 add()、replace()、ref()、suminc: 让累积计数、分组极值、动态去重 全部收缩进一个 DATA 步,中间结果既不必落盘,也不必在多个 PROC 之间维持状态。这一点 MERGE 与 SQL 都做不到; FORMAT 则完全没有考虑。

5非理想输入下的行为:易错点矩阵

八条症状、各自的机制,以及那一句最小改动就能挡住它的防御代码。

证据等级声明

本环境没有 SAS 会话,下列行为均未在本文中执行验证,而是逐条归因于官方文档或公开会议文献: [doc] 为官方文档既定行为,[lit] 为论文作者的陈述,[实践] 为工程判断而非语言规定。 在有 SAS 会话的验证环境中,建议先用三行数据实测确认每条行为在本机 SAS 版本上的实际表现,再据此固化写法。

5.1 场景矩阵

Case情形行为判定
P1 被装载的数据集里存在重复键 默认保留第一条,其后同键全部被忽略;'r' 保留最后一条;'e' 向日志报错 [doc] 静默丢数据
P2 某一次 find() 未命中 目标数据项不会被自动置为缺失,而是停留在最近一次成功调用的值上 [lit] 跨行串值
P3 键的类型或长度不一致(字符 vs 数值、长度不足) key: 后的值其类型必须与 definekey 定义的键变量一致 [doc];实务中表现为匹配不上,而不是报错 全表失配
P4 同一个 DATA 步内多次查找写入同一批变量 与 P2 同源:先前的命中把值落下,之后的未命中就拿它当答案 [lit] 串值
P5 multidata:'no' 时对同一个键第二次 add() ADD 失败并返回非零码 [doc] —— 不接 rc 就把失败咽了下去 被吞掉的失败
P6 declare 没包进 if _n_ = 1 对象与条目跨迭代保留,而每执行一次 declare 就重建一次实体集 [doc] [lit] 逻辑与内存双重浪费
P7 hashexp: 设得偏小 不影响正确性;桶数不等于容量,只影响查找效率,文档注明上限为 20 [doc] 仅性能
P8 DATA 步结束前没有 output() 整张内存表的内容随之消失 [lit] 一无所获

5.2 Findings

Finding 1 — 重复键石沉大海,人群人数对不上

症状:分析集人数比实际少几人,日志干净,无人报错。机制:被装载表里同一个 USUBJID 出现多行(同一访视多次采样、重复录入、资格原因表未压缩),默认行为是保留首条、忽略其余。 防御:装载时一律写 duplicate: 'e',把“我以为它唯一”这个假设交给 SAS 去证伪; 需要取最新一条时才改用 'r',并在规格里写明为什么最新那条才是答案。 依据:Component Objects: Reference,DECLARE 语句条目。

Finding 2 — 未命中的 find() 把上一位受试者的值留了下来

症状:少数几行的 ARMCD、TRTSDT 显示为前面某行的取值,QC 平台上表现为“个别人数据错”。 机制:find() 失败时,目标数据变量不会被自动置空,而是保持最近一次成功调用的值 —— 原文的说法是 “it is the value of your last successful call”。防御:两条路任选其一,第 3.2 节的代码里都写着: 查找前先 call missing(...),或在 rc ne 0 分支里显式赋值。绝不能指望 SAS 替你清零。 依据:Axelrod(2019,SGF Paper 3125-2019)第 6 步的原文陈述;本文未执行 SAS,此项未按本 note 实测。

Finding 3 — 只需要“在不在”却用了 find(),把 PDV 弄脏

症状:打旗标的派生里,顺带覆盖了主表本应保留的字段值。机制:FIND 与 CHECK 的分工是明确的 —— CHECK 只返回是否存在,不更新数据变量 [doc];用错名字就把一次查询变成了一次赋值。 防御:凡不需要取回变量的场合(人群旗标、派生内部存在性判断),一律 check()。 附带提醒:在带 suminc: 的表上,check() 同样会推进计数。

Finding 4 — 类型或长度不匹配,整张表静默失配

症状:一行都没合上,或者只有前几位受试者合上了。机制:键的类型由 definekey 调用那一刻 PDV 中该变量的类型决定,而 key: 后的键值类型必须与之匹配 [doc]。 临床数据里最常见的两例:USUBJID 在源数据里是数值 101001、在 SDTM 里是字符 'NNNN-101-001'; 以及没有写出足够的 length,导致长 ID 被截断后再去匹配一个未被截断的键。 防御:用 if 0 then set 在 DATA 步里把键的长度与类型统一固定下来;跨类型匹配时先用 put(..., , best.) / input(..., , best.) 显式转换,不要依赖隐式转换。[实践]

Finding 5 — 作用域与生命周期:该复用时不复用,该清空时不清空

症状:两个方向都会中招。① declare 写在 if _n_ = 1 之外 —— 对象被反复实例化,装载代价每行重复支付一次,运行期 add() 出来的条目也随之错乱。 ② 反过来:想按 BY 组分组处理(每位受试者各自的临时子表),却漏了 clear(), 于是后一位受试者的表里混进了前一位的条目。防御:声明一律放进 if _n_ = 1; 需要按组重建时,在 first.usubjid 处显式 clear(),并在 last.usubjid 之前把结果取出。 [实践]

连续三行的横向合并 —— 第 2 行没能在被查表里命中 主表第 1 行 USUBJID=01 find() → rc = 0 ARMCD=A (正确) 主表第 2 行 USUBJID=02 find() → rc ≠ 0 ARMCD=A (串值) 主表第 3 行 USUBJID=03 find() → rc = 0 ARMCD=B (正确) 缺失处不会被清零, 而是沿用上一次 成功调用的取值 挡住它的两条路:find() 前先 call missing(...),或在未命中分支里显式赋值。二者取其一即可。
Figure 2 | 情形 P2 的串值机制。危险之处在于这不是“某一行报错了”,而是“某几行错了、且看起来完全合理”—— 这类缺陷从不在日志里留痕,只能靠独立第二重编程比出来。

6复用检查清单

把任何一段代码复制进另一个程序之前,逐条核对。

7参考来源

正文中的 [doc] 与 [lit] 指向下列条目。

来源用于支撑本文的哪些断言
SAS Institute,SAS 9.4 / SAS Viya Component Objects: Reference(DECLARE 语句、DEFINEDONE、DEFINEDATA、OUTPUT 方法等条目) 六项 declare 参数标签的语义与默认值;重复键“保留首条”的默认行为与 'r' / 'e' 的取舍; 数据集在 definedone() 时被装载;hashexp: 表大小 = 2n、上限 20、桶数不等于条目数; output() 不会自动写入键;未显式赋值时的 uninitialized 提示与 call missing 处置;suminc: 计数由访问驱动。
https://support.sas.com/documentation/cdl/en/lecompobjref/69740/PDF/default/lecompobjref.pdf
SAS Help Center,FIND Method(Base SAS 参考) FIND 与 CHECK 的差异(“数据变量不被更新”);key: 值的类型须与 definekey 定义的键变量一致; 存在多个数据项时应配合 FIND_NEXT / FIND_PREV 使用。
https://support.sas.com/documentation/cdl/en/lrdict/61724/HTML/default/a002588683.htm
Axelrod, E.(2019),HASH Beyond Lookups – Take Another Look,SAS Global Forum 2019,Paper 3125-2019,Abt Associates Inc. Finding 2 的核心依据 —— 原文指出 find() 不成功时目标数据项不会被自动置为缺失,而是保持最近一次成功调用的值, 并给出 call missing() 与未命中分支赋值两条防御写法;另支援两条生命周期陈述:“hash 表中的条目不会在每次 DATA 步迭代时被重新初始化” 与“DATA 步结束时表内容即消失”。
Secosky, J. & Bloom, J.(2007),Getting Started with the DATA Step Hash Object,SAS Global Forum 2007,Paper 271-2007 DATA 步 hash 对象的基础用法与入门惯例。
Burlew, M. M.(2012),SAS Hash Object Programming Made Easy,SAS Institute Inc. §2.3 参数标签表的交叉核对来源(dataset:、duplicate:、hashexp:、multidata:、ordered:、suminc:)。
Garbutt, D. J.(2012),Re-Programming a Many-To-Many Merge with Hash Objects,PhUSE 2012(Budapest),Paper CS05 §3.4 多对多合并示例的行业原型(CM 回并进 AE 的一对多场景),以及作者所述“不依赖任何宏变量、单个 DATA 步完成”的实现主张。
Lafler, K. P.(2011),An Introduction to SAS® Hash Programming Techniques,PharmaSUG 2011,Paper TT15 PharmaSUG 论坛层面 hash 编程技术的综述性入口。另见同届 King, J. H.(2011), Using a HASH Table to Reference Variables in an Array by Name,PharmaSUG 2011,Paper TT04。
Western Users of SAS Software(2022),Getting Started with DATA Step Hash Objects,Paper WUSS-2022-108 §3.4 中“不带 multidata 的 hash 加 output() 其结果等价于 PROC SORT NODUPKEY”这则对照。