Shell 中的冒號 (:) 毫無作用。但還是用它吧

Shell 中的冒號 (:) 毫無作用。但還是用它吧

Introduction

Shell 中的冒號 (:) 是一個內建的空指令 (null-command),它會評估其參數並捨棄結果。它本身不執行任何操作,但當它放在參數展開 (parameter expansion) 之前時,可以讓展開動作發生,而不會讓 shell 嘗試將產生的字串作為指令來執行。

Validating required arguments

使用 : 搭配 ${VAR:?diagnostic} 展開,提供了一種單行的方式來確保變數已設定且不為空。

: "${1:?missing argument, aborting.}"
echo "Hello $1!"

如果 $1 未設定或為空,shell 會將診斷訊息輸出到 stderr 並以非零狀態退出;否則,展開會產生 $1 的值並繼續執行腳本。同樣的技術也適用於具名變數,例如 : "${GREET_NAME:?missing argument, aborting.}",這會在錯誤訊息中包含變數名稱。

Setting defaults with :=

${VAR:=default} 展開會在 VAR 未設定或為空時賦予預設值。在前面加上 : 會在執行賦值動作的同時捨棄該賦值結果。

: "${DATA_DIR:=/var/data}"
: "${RETRIES:=3}"

執行這些行之後,如果 DATA_DIR 和 RETRIES 尚未設定,它們將持有預設值。

Truncating and testing files

: 後面使用重定向 (redirection) 會將檔案截斷為零長度,因為空指令不會產生任何輸出。

: > error.log # truncate error.log
: > error.log > access.log # truncate both files

同樣地,一個僅包含重定向和 : 的子 shell (subshell) 可以用來測試可讀性或可寫性。

( : < dataset.json ) && echo YES # dataset.json readable?
( : >> result.json ) && echo YES # result.json writable?

之所以需要冒號,僅僅是因為子 shell 需要一個指令;重定向本身才是執行工作的核心。

Using colon in trap and conditionals

trap 內建指令需要一個指令;: 在不執行任何操作的情況下滿足了這一需求。

trap : INT
sleep 60 # sleep remains interruptible

if-else 結構中,如果 then 分支必須包含一個指令,: 可以作為佔位符。

if some-command; then : # command required else echo "command failed" fi

Frequently asked questions (from the article)

為什麼我需要這個空指令?沒有冒號展開不也會發生嗎?

參數展開無論如何都會發生,但如果沒有空指令或類似結構,shell 會將產生的字串視為要執行的指令。例如:

% ${HELLO:=123} zsh: command not found: 123

在前面加上 : 會在執行展開的同時捨棄結果:

% : ${HELLO:=123} % echo $HELLO 123

既然可以用 VAR=${VAR:-default-value},為什麼還要用空指令?

兩者達到的效果相同,但冒號形式只需提及一次變數,降低了打錯字的機率:

: "${DATA_DIR:=/var/data}" # DATA_DIR 只出現一次 DATA_DIR="${DATA_DRI:-/var/data}" # 可能出現打錯字 (DATA_DRI)

Community reactions (selected HN comments)

  • @gpvos 回想起了 Larry Wall 的語言重新設計法則 (Laws of Language Redesign):「每個人都想要冒號。Larry 得到了冒號。」

  • @amiga386 注意到了 if some-command; then : else echo "command failed" fi 這個慣用法,並指出等效的 if! some-command; then echo "command failed" fi 也是符合 POSIX 標準的。

  • @rgrau 建議直接在指令參數中內聯 :? 測試,例如 echo "${1:? first param required}",並描述了一種在迴圈體中使用冒號的 while 迴圈模式。

  • @fphilipe 將冒號用作 Git 的 EDITOR,以便在不打開編輯器的情況下執行自動 squash 的互動式 rebase。

  • @archargelod 認為用 : "${1:?...}" 取代可讀性高的 if 語句會損害可讀性,並提出了替代方案 [ -z "$1" ] && { echo "missing argument, aborting." 1>&2; exit 1 }

  • @kevincox 發現 :? 形式對於檢查必要的環境變數很有用,但認為許多其他範例使用專用指令會更清晰。

  • @kazinator 批評子 shell 測試 ( : < file ) && echo YES 是不必要的,指出單靠重定向就能測試可讀性/可寫性,且使用 : >> file 建立零長度檔案可能並非好事。

  • @olexsmir 分享了一個個人使用案例:在 dotfiles bootstrap 腳本中使用 : "${DOTFILES_PATH:=$HOME/.dotfiles}" 為配置環境變數設定預設值。

  • @luciana1u 評論說冒號毫無作用,這使得它成為「LLM 無法過度設計的唯一 bash 指令」。

  • @m2f2 描述了在 getopts 風格的迴圈中使用冒號:while : ; do case "$1" in... esac done

  • @jeffrallen 感謝作者,但表示他不會使用這種慣用法,因為他更喜歡能向受眾解釋清楚的程式碼。

  • @lucideer 覺得這些範例只是將可讀性高的多行程式碼變成了單行程式碼,他認為這在腳本中沒有地位。

  • @normie3000 比起 if! x; then something; fi,更喜歡 if x then :; else something; fi

  • @PunchyHamster 說他會拒絕引入這種單行程式碼的 pull request,並主張用 Python 或 Perl 重寫較長的腳本。

  • @garethrowlands 將這種技巧的需求歸因於 shell 語法中古老的字串替換特性。

  • @ocd 使用冒號來建立「無操作」別名,例如 : alias f3probe,以便快速搜尋遺忘的工具。

  • @dominiwe 需要冒號來讓一個 shell 腳本同時成為有效的 YAML 檔案。

  • @del 奇想冒號是否是早期 Unix shell 中少數經過特殊處理的程式碼路徑之一。

  • @greatgib 說他第一次在 Claude 生成的程式碼中看到這種結構,後來又在本文中看到。

  • @search_facility 從 bash 切換到純 Python 腳本以獲得跨平台穩定性,並放棄了「bash zoo」。

Sources