Bash nameref 的改值与改指向:给 ref 赋新名字,为什么原变量反而变了

前天 3阅读

封装配置函数时,常会把变量名传进去,再通过 nameref 修改调用方的数据。最容易出错的一步,是把 ref=right 当成“以后改写 right”。当 ref 已经引用 left,这条普通赋值实际把字符串 right 写进 left;引用关系仍然指向原来的 left。

分别记录名字和名字对应的值

在这组实验中,left 与 right 各自保存一个字符串,ref 保存要引用的变量名,并带有 nameref 属性。读取或普通赋值经过 ref 时,Bash 会把操作转到目标变量上。因此要同时观察两项:引用现在指向谁,以及目标变量现在装着什么。

GNU Bash 手册把使用或改变 nameref 属性本身的操作列为例外。重新执行 declare -n ref=right 会修改 ref 的指向;随后再写 ref=updated,才会把 updated 放进 right。两种语句看起来都含等号,但作用对象不同,不能只凭右侧恰好是一个变量名判断。

Bash nameref 的改值与改指向:给 ref 赋新名字,为什么原变量反而变了

AI 生成概念配图:一个引用标记连接目标容器,写入内容与移动连接分别发生,不是实际运行截图。

把四种操作放在同一个实验里

保存为 nameref-demo.sh,并使用 bash nameref-demo.sh 运行。本文在 Bash 5.2.37 验证;不要用 sh 执行,也不要把脚本作为 source 导入自己的交互会话。实验只修改新启动进程中的变量,没有文件写入,退出后这些变量随进程一起消失。

#!/usr/bin/env bash
set -u
left=A
right=B
declare -n ref=left

ref=right
[[ "$left" == right && "$right" == B ]] || exit 1
printf 'ordinary assignment: left=%s right=%s\n' "$left" "$right"
declare -p ref

declare -n ref=right
ref=updated
[[ "$left" == right && "$right" == updated ]] || exit 1
printf 'retargeted assignment: left=%s right=%s\n' "$left" "$right"
declare -p ref

unset ref
[[ ! -v right && -R ref ]] || exit 1
printf 'unset ref: right absent, reference retained\n'
declare -p ref

ref=reborn
[[ "$right" == reborn ]] || exit 1
unset -n ref
[[ ! -R ref && ! -v ref && "$right" == reborn ]] || exit 1
printf 'unset -n ref: reference absent, right=%s\n' "$right"

首次普通赋值后,输出应为 left=right、right=B,紧接着的声明仍是 declare -n ref="left"。这里 right 是赋给 left 的文本,不是要求 Bash 重新解析一条指向指令。下一阶段声明改成 ref="right",随后输出 left=right、right=updated,证明目标已经切换。

删除目标与删除引用也要分开

执行 unset ref 时,当前目标 right 被移除,而 ref 这个名称引用仍然存在。代码用 -v 检查目标是否已赋值,用 -R 检查 ref 是否带有引用属性,并再次打印声明。此时若直接读取目标值,开启的 nounset 可能报错,所以实验先检查状态,不读取不存在的值。

下一条 ref=reborn 仍会沿原先关系写入,于是 right 又被创建。最后使用 unset -n ref 才移除引用本身,right 保留 reborn。清理函数里若只是想丢掉临时引用,却写成普通 unset,就可能顺手删除调用方正在使用的数据;这个区别需要单独验收。

函数封装时还要约束作用域

在函数内使用 declare 通常创建局部变量,nameref 的目标又受 Bash 的变量作用域规则影响。本文特意使用脚本顶层和两个固定名称,让改值、改指向与删除行为可以独立观察。迁移到函数后,应再检查局部变量是否与目标同名,避免意外遮蔽或让引用指回自己。

若变量名来自参数,应限定为程序允许的名称,并把普通数据与变量名分开传递。不要为了绕过一次引用错误就拼接 eval,也不要允许任意输入决定要修改哪个变量。这里使用的都是固定、可信的名称,代码没有验证数组下标、引用链或循环引用。

验收时不要只检查最终值。若两条路径恰好写入相同文本,错误的目标也可能被掩盖。像实验这样给两个目标设置不同初值,先核对一次普通赋值,再核对目标切换和两种删除,才能让每一步的实际影响都可见。

引用属性仍存在,也不保证目标已经赋值。两种状态应分别记录:名称关系可以留下来,目标内容却已经被删除。调用方若依赖这个目标,应在重新读取之前检查状态,并明确由哪一层负责重新创建。

排查时先用 declare -p 查看声明,再直接检查目标的值,比只打印 ref 的展开结果更容易看清差异。普通展开会跟随引用,屏幕上看到的常常只是目标内容。给工具函数安排明确的改值入口和改目标入口,再分别测试清理动作,能减少这类隐蔽的状态修改。

参考资料

文章版权声明:除非注明,否则均为云鹊BLOG原创文章,转载或复制请以超链接形式并注明出处。