Bash globbing 萬用字元與 extglob 用法
了解 Bash globbing 如何在命令執行前展開檔名,並掌握 nullglob、dotglob、globstar 與 extglob 否定模式,避免萬用字元比對錯誤。
Bash 在執行命令前進行的 globbing
Bash globbing 是指 shell 在命令啟動前,將模式(例如 *.log)轉換為已排序的實際檔名清單。rm *.log 絕不會將模式交給 rm。Bash 會讀取目錄,保留符合模式的名稱、排序這些名稱,然後以每個名稱作為個別引數執行 rm。rm 程式無法得知其中曾經使用過模式。
幾乎所有令人意外的 globbing 行為,都源自這項事實。比對由 shell 執行,對象是當下存在的檔案,而命令只會收到處理完成的清單。這項功能的正式名稱是 pathname expansion。日常用語通常稱為 globbing,兩者指的是同一件事。
建立一個小型樹狀結構以供練習
以下每個範例都會針對相同的檔案執行。請在暫存目錄中建立這些檔案,避免影響重要資料,最後只需執行一個命令即可清理。
lab=$(mktemp -d)
cd "$lab"
mkdir -p logs archive/2025 archive/2026
touch app.log app.log.1 app.log.2 app.log.10 error.log debug.LOG
touch notes.txt notes.md README 'weekly report.log'
touch .env .apprc
touch logs/nginx.log logs/nginx.log.1
touch archive/2025/dec.log archive/2026/jan.logmktemp -d 會建立新的目錄並傳回其路徑,$(...) 則會將該路徑存入變數 lab。如果你不熟悉這種語法,命令替換如何擷取命令輸出會完整說明其運作方式。
查看建立的內容,並確認 shell 版本,因為最後的一項說明取決於版本:
ls -a
bash --version這裡的所有內容都適用於 bash 4.0 及後續版本。文末附近的 globskipdots 說明適用於 bash 5.2 及後續版本;截至 August 2026,這是目前 Ubuntu 和 Debian 發行版本所提供的版本。
全文使用的預覽命令是 printf '%s\n' PATTERN。它會逐行輸出每個符合項目,且不會變更磁碟上的內容,因此即使不確定模式是否正確,也能安全執行。
三種萬用字元:星號、問號與方括號
*可比對任意長度的字元序列,也可以是空序列。?可比對恰好 1 個字元。[...]可比對恰好 1 個字元,且該字元必須來自方括號內的集合。
printf '%s\n' *.log
printf '%s\n' app.log.?
printf '%s\n' app.log.[0-9]
printf '%s\n' notes.*
printf '%s\n' *將第 2 行與你建立的檔案清單進行比對。? 代表 1 個字元,不能多也不能少,因此 2 位數字尾碼不符合該模式。將模式放寬為 app.log.[0-9]*,再執行一次以查看差異。
這 3 種萬用字元都適用 2 項規則。萬用字元永遠不會比對 /,因此模式會限制在單一目錄層級內。開頭的 . 必須以字面值輸入,因此以 * 開頭的模式會略過所有隱藏名稱。這也是 rm * 不會處理 dotfiles 的原因。
Glob 結果會依目前 locale 的排序規則排列,也就是逐字元進行文字排序。因此,10 尾碼會排在 2 尾碼之前,因為比對時不會將任一尾碼視為數字。執行 printf '%s\n' app.log.*,再執行 ls -v app.log.*,並比較兩者:ls -v 會依數值排序嵌入的數字,而 sort -V 在 pipeline 中執行相同工作。
字元類別與範圍
在方括號內,您可以撰寫清單、範圍或否定條件。
[ch]會比對一個字元,可以是c或h。[a-f]會比對 a 到 f 範圍內的一個字元。[!0-9]會比對一個非數字字元。[^0-9]的意義相同。
printf '%s\n' [dn]*
printf '%s\n' *.[A-Z]*
printf '%s\n' [A-Z]*範圍是造成不同機器結果不一致的部分。範圍會依目前 locale 的字典排序解析,而不是單純依 ASCII 順序,因此在某些系統上,[a-z] 也會比對大寫字母。Bash 提供 globasciiranges 選項,可強制使用純 ASCII 順序;近期版本預設會啟用此選項,因此請使用 shopt globasciiranges 檢查目前設定,不要直接假設。若指令碼必須在所有環境中以相同方式比對,可以在靠近檔案頂端的位置,單獨一行設定 LC_ALL=C,將所有範圍恢復為純位元組順序。
這項設定必須是獨立的命令。若將它寫成前綴,LC_ALL=C ls [a-z]* 只會為 ls 設定 locale,其他內容不受影響;而 shell 早已在自己的 locale 中展開模式,之後 ls 才開始執行。這再次說明了第一節提到的規則。
大括號展開不是萬用字元比對
大括號看起來像萬用字元,但行為完全不同。大括號展開的執行時間早於萬用字元比對,而且完全不會檢查磁碟。
echo file{1,2,3}.txt
echo {01..10}
echo {a..e}
echo {0..20..5}這些檔案一個都不存在,但展開仍會執行。這就是兩者的根本差異:萬用字元會向檔案系統查詢,大括號只會產生文字。因此,建立項目時應使用大括號,而不是尋找項目。
mkdir -p site/{css,js,img}
cp app.log{,.bak}
touch report-{2025,2026}-{01,02}.csvcp app.log{,.bak}會展開成兩個字詞:app.log和app.log.bak,因為逗號前的空項目會重現原始文字。{01..10}會保留前導零,因為只要任一端點有前導零,bash 就會將所有產生的數字補成相同寬度。
展開順序也能解釋一個常見的失敗情況。Bash 會先展開大括號,再展開變數,最後才執行路徑名稱展開。
n=5
echo {1..$n}$n變成數字時,大括號展開早已完成,因此不會建立任何範圍。請使用seq 1 "$n",或使用寫成for ((i=1; i<=n; i++))的 C 語言風格迴圈。
沒有任何比對結果時會發生什麼事
這是第一種失敗情況,而且每個人都會遇到一次。預設情況下,bash 會完全保留沒有比對結果的 pattern。該字串會以你輸入的原樣傳給 command,因此 command 會收到 *、.、t、m 和 p 這些字元,並嘗試將它們當成檔名處理。
printf '%s\n' *.tmp搭配 printf 時,這沒有問題。但對會建立項目的 command 而言,情況就不同了。在沒有任何 .tmp 檔案的目錄中執行 touch *.tmp,會建立名稱就是 *.tmp 的檔案。之後刪除該檔案時必須加上引號,避免 shell 再次展開它:rm -- '*.tmp'。
有兩個 shell option 會改變這項規則。請分別使用一個沒有比對結果的 pattern 測試,然後取消設定。
shopt -s nullglob
printf '%s\n' *.tmp
shopt -u nullglob
shopt -s failglob
printf '%s\n' *.tmp
shopt -u failglobnullglob 會讓沒有比對結果的 pattern 完全展開為空字串,因此 command 的引數會比你輸入的少。這正是迴圈中所需的行為。關閉 nullglob 時,for f in *.tmp 會以 f 保存的原始 pattern 執行一次迴圈主體。幾乎所有這類 script 都會因此發生錯誤。開啟 nullglob 後,迴圈主體會執行 0 次。
nullglob 本身也有風險,因為即使 command 的所有引數都被移除,command 仍然會執行。ls *.nope 會變成單獨的 ls,列出整個目錄。grep -l needle *.nope 會變成 grep -l needle,沒有檔案可供讀取,因此會等待 standard input,看起來像是凍結。請在需要此行為的迴圈周圍開啟 nullglob,完成後再關閉。
failglob 採用另一種方式:沒有比對結果的 pattern 會被視為錯誤,bash 會回報錯誤,而且完全不執行 command。這是互動式 shell 的良好設定,因為它能避免輸入錯誤的 pattern 後,將其以原始名稱傳給 rm。
隱藏檔案:dotglob
shopt -s dotglob
printf '%s\n' *
shopt -u dotglob執行該區塊,並將列出結果與前文的一般 printf '%s\n' * 比較。dotglob 會移除開頭點號規則,因此 * 也會比對隱藏名稱。在 dotglob 下,項目 . 與 .. 一律維持排除。
未啟用 dotglob 時,.* 是存取隱藏檔案的常用方式,但過去存在風險。在 5.2 之前的 bash 中,.* 也會比對 . 與 ..,因此像 chmod -R 755 .* 這類遞迴命令會直接進入父目錄。Bash 5.2 新增了 globskipdots 選項,預設為啟用,可讓 . 與 .. 不出現在任何展開結果中。依賴此行為前,請先執行 shopt globskipdots,因為較舊的主機不一定提供該選項。
globstar:向下比對整個目錄樹
shopt -s globstar
printf '%s\n' **/*.log
printf '%s\n' **/
shopt -u globstar設定 globstar 後,構成完整路徑元件的 ** 可以比對任意深度的檔案與目錄,包括向下 0 層。因此,**/*.log 會涵蓋目前目錄、logs/ 以及 archive/ 下的所有內容。結尾加上斜線的 **/ 只會比對目錄,適合快速查看目錄樹的結構。
未設定 globstar 時,** 會像一般的 * 一樣,只在單一目錄中比對。關閉該選項後執行相同模式並比較結果:從其他地方複製遞迴模式後,若結果遠少於預期,通常就是這個原因。每個 bash 預設都會關閉 globstar。
有一項限制需要注意:** 不會追蹤指向目錄的 symbolic link。若目錄樹是透過 symbolic link 串接而成,則需要改用 find -L。
nocaseglob,用於處理大小寫不一致的名稱
shopt -s nocaseglob
printf '%s\n' *.log
shopt -u nocaseglobnocaseglob 會讓檔名比對忽略大小寫,因此大寫的 .LOG 副檔名會符合小寫的 .log 模式。將此清單與第 1 節的清單比較。請立即關閉此選項,因為保持啟用會變更該 shell 中後續每個命令的比對行為,等到 1 小時後才進行除錯時會很難釐清原因。
nocaseglob 只適用於檔名展開。在 case 陳述式或雙中括號測試中進行模式比對時,相關選項是 nocasematch;這兩個選項必須分別設定。
extglob:可表示「排除這些項目」的模式
延伸模式預設為關閉。請單獨使用一行將它開啟:
shopt -s extglob共有五種形式,每種形式都接受以 | 分隔的模式清單:
?(list)比對出現 0 次或 1 次。*(list)比對出現 0 次以上。+(list)比對出現 1 次以上。@(list)比對替代項目中恰好 1 個。!(list)比對不符合任何替代項目的所有內容。
延伸模式必須由已經啟用該選項的 shell 讀取,因此下列批次內容會放在小型指令碼中,並以 bash -O extglob 啟動。-O 旗標會在新 shell 讀取檔案中的第一行前,先設定 shopt 選項。這可確保檔案中的所有模式都能正確解析。本節最後一段會說明順序為何重要。
cat > patterns.sh <<'EOF'
printf '%s\n' @(app|error).log
printf '%s\n' app.log.+([0-9])
printf '%s\n' *.@(md|txt)
printf '%s\n' !(*.log)
printf '%s\n' !(*.log|*.md|README)
EOF
bash -O extglob patterns.sh+([0-9]) 代表 1 個以上的數字,因此可涵蓋任意長度的編號後綴。這修正了前文的 ? 限制,建議連續執行這兩個模式,以便比較結果。
否定形式可用來表示「除了這些以外的所有項目」,也是啟用 extglob 的主要原因。這個形式有兩點常令人意外。
它同樣會比對目錄與檔案,因此 ls !(*.log) 會將結果中的目錄內容列出,而不是列出目錄名稱。請使用 ls -d,或使用 printf 預覽,以查看名稱本身。
它也和其他 glob 一樣遵守點號規則,因此 !(*.log) 會涵蓋所有不以 .log 結尾的可見名稱。若要涵蓋隱藏名稱,也必須設定 dotglob。
!(...) 中的 ! 是 glob 否定。它與 歷程擴展與驚嘆號字元 無關;後者是獨立功能,會在不同階段執行。
還有一個陷阱,而且發生在解析階段。Bash 會先解析完整指令,再執行其中任何部分。因此,在同一行啟用 extglob 並使用延伸模式會失敗:解析該模式時,選項仍處於關閉狀態。請將 shopt -s extglob 放在獨立一行,置於模式之前,並放在指令碼接近開頭的位置。函式本體會在函式定義時解析,因此必須在定義函式前啟用選項,而不是在呼叫函式前啟用。使用 bash -O extglob 啟動 shell,如同前述批次內容的做法,即可在開始解析前確定正確順序。
Shell 會展開樣式,命令不會
這是第二種失敗模式,也說明了一整類錯誤的成因。
grep -l needle *.log
find . -name '*.log'
find . -name *.log第一行讓 bash 執行比對,並將檔名清單傳給 grep。第二行將樣式加上引號,因此 find 會收到 *.log 這 5 個字元,並在起始路徑以下的每一層自行執行比對。第三行就是錯誤所在:bash 會先在目前目錄中展開樣式,因此 find 收到的是一個特定名稱。在只有單一相符項目的目錄中,它會悄悄搜尋錯誤的目標。若有 2 個以上相符項目,find 會回報用法錯誤,因為多出的名稱會出現在原本預期放置運算式的位置。
規則很簡單。如果樣式是給命令使用的,請加上引號。如果樣式是給 shell 使用的,請不要加引號。同樣的區分也適用於 tar 中的 --exclude 樣式、rsync 中的 --exclude 樣式,以及 grep 中的 --include 篩選條件。
Glob 也不是正規表示式,雖然兩者會使用相同的字元。在 glob 中,* 代表任意長度的字元序列。在正規表示式中,* 代表前一個項目重複 0 次以上,因此 grep '*.log' 要求的內容,與表面看起來的意義完全不同。
變數也遵循相同的處理順序。變數展開之後才會執行 glob,因此未加引號且包含樣式的變數,會以目前目錄為範圍進行展開。
pat='*.log'
printf '%s\n' $pat
printf '%s\n' "$pat"未加引號時,bash 會對變數的結果執行 glob。加上引號時,樣式會維持字面值。這還有一項應善加利用的結果:glob 產生的字詞不會再次依空格切分,因此 for f in *.log 會將包含空格的檔名視為一個名稱,而 for f in $(ls *.log) 會將該名稱拆成 2 個字詞。請直接迴圈處理 glob。
在執行破壞性模式前先預覽
不要讓 rm 成為你首次測試新模式時執行的第一個命令。先以不會造成影響的方式執行該模式,閱讀檔案清單後,只修改行首的命令。
target='!(*.log)'
printf '%s\n' $target
ls -ld -- $target
rm -- $target將模式寫入 target 可避免三行內容彼此偏離;$target 刻意不加引號,因為這樣 bash 才能展開它。printf '%s\n' 會每行輸出一個名稱,不會修改任何內容。ls -ld 會顯示每個項目本身,而不是列出目錄內容;模式欄會標示哪些項目是目錄,這正是執行遞迴刪除前必須確認的資訊。讀取 drwxr-xr-x 權限位元說明了該欄位。
使用向上鍵叫回上一行,只編輯命令,讓模式保持完全相同。憑記憶重新輸入模式,最容易在這個環節出錯。
將 -- 放在模式前面。它會標示選項的結束位置,因此以 - 開頭的檔名會被視為名稱,而不是旗標。
如果你想在腳本的某一段停用 globbing,set -f 會停用它,而 set +f 會重新啟用它。
這個習慣很重要,因為 rm 沒有復原功能。復原以 rm -rf 刪除的檔案既耗時,通常也無法完整復原,因此先預覽的成本遠低於不這麼做的代價。
完成後清理練習目錄。先輸出路徑並閱讀確認,因為讓 rm -rf 指向錯誤的變數,正是本節要避免的意外。
cd ~
echo "$lab"
rm -rf -- "$lab"FAQ
為什麼 find . -name *.log 找到錯誤的檔案?
因為 bash 會先展開 *.log,之後 find 才會開始執行。Shell 會在目前目錄中比對樣式,並將產生的檔名傳給 find;接著 find 會在每一層深度搜尋該名稱。如果有 2 個以上的檔案符合,find 會在應為運算式的位置收到額外引數,並回報使用方式錯誤。將樣式加上引號,例如 find . -name '*.log',讓 find 自行依照本身的規則進行比對與遞迴。
樣式顯示為文字本身代表什麼?
這表示沒有任何項目符合。預設情況下,bash 會保留未符合的樣式不變,並將原始字元傳給命令;命令接著會將它們視為檔名。設定 shopt -s failglob,可在樣式未符合時產生錯誤並停止命令;或設定 shopt -s nullglob,讓樣式展開為空值。使用 nullglob 時,請確認命令在完全沒有引數的情況下仍然合理,因為 ls *.nope 會變成單獨的 ls,並列出整個目錄。
如何在 bash 中比對除了某個樣式以外的所有檔案?
在單獨一行啟用 shopt -s extglob 的延伸樣式,然後使用否定形式。!(*.log) 會比對不以 .log 結尾的名稱,而 !(*.log|*.md) 會排除兩者。除非同時設定 dotglob,否則隱藏檔案不會出現在結果中;目錄也會被納入,因此在將樣式傳給 rm 前,請先使用 printf '%s\n' !(*.log) 預覽。
為什麼 * 會略過隱藏檔案?
開頭的點號必須以字面值比對,因此 * 永遠不會比對以點號開頭的名稱。使用 .* 存取隱藏名稱,或設定 shopt -s dotglob,將隱藏名稱納入每個一般樣式。. 與 .. 這兩個項目在 dotglob 下永遠會被排除;在 bash 5.2 及更新版本中,透過預設啟用的 globskipdots 選項,它們也會從 .* 中排除。
** 要正常運作,是否需要 globstar?
需要。沒有 shopt -s globstar 時,bash 會將 ** 視為一般的 *;它只會比對單一目錄內的項目,然後停止。啟用 globstar 後,若 ** 構成完整的路徑元件,就能在任意深度進行比對,因此 **/*.log 會涵蓋目前目錄及其下的所有子目錄,而單獨使用 **/ 時只會比對目錄。請注意,** 不會追蹤指向目錄的 symbolic link;若目錄樹由 symbolic link 組成,請使用 find -L。