Bash 腳本編程的一些高級用法

概述

偶然間發現 man bash 上其實詳細講解了 shell 編程的語法,包括一些很少用卻很實用的高級語法。就像發現了寶藏的孩子,興奮莫名。於是參考man bash,結合自己的理解,整理出了這篇文章。

本文並不包含man bash所有的內容,也不會詳細講解shell編程,只會分享一些平時很少用,實際很實用的高級語法,或者是一些平時沒太注意和總結的經驗,建議有一定shell基礎的同學進階時可以看一看。

當然,這只是 Bash 上適用的語法,不確定是否所有的Shell都能用,請慎用。

shell語法

管道

有一點shell編程基礎的應該都知道管道。這是一個或多個命令的序列,用字元|分隔。實際上,一個完整的管道格式是這樣的:

[time [-p]] [ ! ] command [ | command2 ... ]

time單獨執行某一條命令非常容易理解,統計這個命令運行的時間,但管道這種多個命令的組合,他統計的是某一個命令的時間還是管道所有命令的時間呢?如果保留字 time 作為管道前綴,管道中止後將給出執行管道耗費的用戶和系統時間

如果保留字 ! 作為管道前綴,管道的退出狀態將是最後一個命令的退出狀態的邏輯非值。 否則,管道的退出狀態就是最後一個命令的。 shell 在返回退出狀態值之前,等待管道中的所有命令返回。

複合命令

我們常見的case ... in ... esac語句,if ... elif ... else語句,while .... do ... done語句,for ... in ...; do ... done,甚至函數function name() {....}都屬於複合命令。

for 語句

for循環常見的完整格式是:

for name [ in word ] ;
do
	list ;
done

除此之外,其實還支援類似與C語言的for循環,

for (( expr1 ; expr2 ; expr3 )) ;
do
	list ;
done

返回值是序列 list 中被執行的最後一個命令的返回值;或者是 false,如果任何表達式非法的話。

case 語句

man bash上顯示,case語句的完整格式是case word in [ [(] pattern [ | pattern ] ... ) list ;; ] ... esac

展開後應該是這樣的:

case word in
	[(] pattern [ | pattern ])
		list
		;;
	...
esac

每一個case的分支,都是pattern,使用與路徑擴展相同的匹配規則來匹配,見下面的 路徑擴展 章節,且通過|支援多種匹配走同一分支。例如:

case ${val} in
	*linux* | *uboot* )
		...
		;;
	...
esac

如果找到一個匹配,相應的序列將被執行。找到一個匹配之後,不會再嘗試其後的匹配。

如果沒有模式可以匹配,返回值是 0。否則,返回序列中最後執行的命令的返回值。

select 語句

select語句可以說用得很少,但其實在需要交互選擇的場景下非常實用。它的完整格式是:

select name [ in word ]
do
	list 
done

它可以顯示出帶編號的菜單,用戶輸入不同的編號就可以選擇不同的菜單,並執行不同的功能。我們看一個例子:

#!/bin/bash
echo "What is your favourite OS?"
select name in "Linux" "Windows" "Mac OS" "UNIX" "Android"
do
    echo "You have selected $name"
done

運行結果是這樣的:

What is your favourite OS?
1) Linux
2) Windows
3) Mac OS
4) UNIX
5) Android
#? 4↙
You have selected UNIX
#? 1↙
You have selected Linux
#? 9↙
You have selected
#? 2↙
You have selected Windows
#?^D

#?用來提示用戶輸入菜單編號,這實際是環境變數PS3的值,可以通過改這變數來改用戶提示資訊。^D表示按下 Ctrl+D 組合鍵,它的作用是結束 select 循環。

如果用戶輸入的菜單編號不在範圍之內,例如上面我們輸入的 9,那麼就會給 name 賦一個空值;如果用戶輸入一個空值(什麼也不輸入,直接回車),會重新顯示一遍菜單。

注意,select 是無限循環(死循環),輸入空值,或者輸入的值無效,都不會結束循環,只有遇到 break 語句,或者按下 Ctrl+D 組合鍵才能結束循環。通常和 case in 一起使用,在用戶輸入不同的編號時可以做出不同的反應。例如

echo "What is your favourite OS?"
select name in "Linux" "Windows" "Mac OS" "UNIX" "Android"
do
    case $name in
        "Linux")
            echo "Linux是一個類UNIX作業系統,它開源免費,運行在各種伺服器設備和嵌入式設備。"
            break
            ;;
        "Windows")
            echo "Windows是微軟開發的個人電腦作業系統,它是閉源收費的。"
            break
            ;;
       ......
        *)
            echo "輸入錯誤,請重新輸入"
    esac
done

( list ) 語句

( list )會讓 list 序列將在一個子 shell 中執行。變數賦值和影響 shell 環境變數的內建命令在命令結束後不會再起作用。返回值是序列的返回值。

這個在需要臨時切換目錄或者改變環境變數的情況下非常使用。例如封裝編譯內核的命令,實現任何目錄下都可以直接編譯,我們總需要先cd到內核根目錄,再make編譯,最後再cd回原目錄。例如:

alias mkernel='cd ~/linux ; make -j4 ; cd -'

這樣會導致,在編譯過程如果Ctrl + C取消返回時,你所處在的目錄就變成了~/linux。這種情況下,使用( list )就能解決這問題,甚至都不需要cd -返回原目錄,直接退出即可。

alias mkernel='(cd ~/linux ; make -j4)'

也例如,有某個程式比較挫,只能在程式目錄執行,在其他目錄,甚至上一級目錄執行,都會找不到資源文件導致退出,我們可以這樣解決:

alias xmind='(cd ~/軟體/xmind/XMind_amd64 &>/dev/null && nohup ./XMind &>/dev/null) &'

(( expression)) 語句

表達式 expression 將被求值。如果表達式的值非零,返回值就是 0;否則返回值是 1。這種做法和 let “expression” 等價。

[[ expression ]] 語句

if 語句中,我們喜歡用 if [ expression ]; then ... fi單括弧的形式,但看大神們的腳本,他們更常用if [[ expression ]]; then ... fi雙括弧形式。

[ ... ]等效於test命令,而[[ ... ]]是另一種命令語法,相似功能卻更高級,它除了傳統的條件表達式(Eg. [ ${val} -eq 0 ])外,還支援表達式的轉義,就是說可以像在其他語言中一樣使用出現的比較符號,例如><=&&||等。

舉個例子,要判斷變數val有值且大於4,用單括弧需要這麼寫:

[ -n ${val} -a ${val} -gt 4 ]

用雙括弧可以這麼寫:

[[ -n ${val} && ${val} > 4 ]]

當使用==!=操作符時,操作符右邊的字元串被認為是一個模式,根據下面 模式匹配 章節中的規則進行匹配。如果匹配則返回值是 0,否則返回1。模式的任何部分可以被引用,強制使它作為一個字元串而被匹配。

引用

這裡主要講的是$'string'特殊格式,注意的是,必須是單引號。它被擴展為string,其中的反斜杠轉義字元被替換為 ANSI C 標準中規定的字元。反斜杠轉義序列,如果存在的話,將做如下轉換:

轉義 含義
\a alert (bell) 響鈴
\b backspace 回退
\e an escape character 字元 Esc
\f form feed 進紙
\n new line 新行符
\r carriage return 回車
\t horizontal tab 水平跳格
\v vertical tab 豎直跳格
\\ backslash 反斜杠
\' single quote 單引號
\nnn 一個八比特字元,它的值是八進位值 nnn (一到三個數字)
\xHH 一個八比特字元,它的值是十六進位值 HH (一到兩個十六進位數字)
\cx 一個 ctrl-x 字元

例如,我希望把有換行的一段話暫存到某個變數:

$ var="第一行"$'\n'"第二行"
$ echo "${var}"
第一行
第二行

參數

數組

Bash 提供了一維數組變數。任何變數都可以作為一個數組;內建命令declare可以顯式地定義數組。數組的大小沒有上限,也沒有限制在連續對成員引用和 賦值時有什麼要求。數組以整數為下標,從 0 開始。

除了“`declare“定義數組外,更常用的是以下兩種方式定義數組變數:

$ array_var=(
	"mem1"
	3
	str
)
$ array_var[4]="mem4"

$ echo ${array_var[@]}
mem1 3 str mem4
$ echo ${array_var[1]}
3

數組的使用跟C語言很像,[] + 下標數字可以訪問特定某一個數組成員。花括弧是必須的,以避免和路徑擴展衝突。

如果下標是 @ 或是 *,它擴展為數組的所有成員。 這兩種下標只有在雙引號中才不同。在雙引號中,${name[*]},把所有成員當成一個詞,用特殊變數 IFS 的第一個字元分隔;${name[@]} 將數組的每個成員擴展為一個詞。 如果數組沒有成員,${name[@]} 擴展為空串。這種不同類似於特殊參數 *@ 的擴展。在作為函數參數傳遞的時候能很明顯感受到他們的差別。

#定義數組
$ array=(a b c)

# 定義函數
$ function func() {
> echo first para is $1
> echo second para is $2
> echo third para is $3
> }

# 雙引號+'*'
$ func "${array[*]}"
first para is a b c
second para is
third para is

# 雙引號+『@』
$ func "${array[@]}"
first para is a
second para is b
third para is c

內建命令 unset 用於銷毀數組。unset name[subscript] 將銷毀下標是 subscript 的元素。 unset name, 這裡name 是一個數組,或者 unset name[subscript], 這裡subscript*或者是@,將銷毀整個數組。

擴展

花括弧擴展

什麼是花括弧擴展,舉個例子就好理解了

mkdir /usr/local/src/bash/{old,new,dist}

等效於

mkdir /usr/local/src/bash/old /usr/local/src/bash/new /usr/local/src/bash/dist

除此之外,還支援模式匹配來批量選擇,例如:

chown root /usr/{ucb/{ex,edit},lib/{ex?.?*,how_ex}}

變數擴展

我們知道,${var}的形式可以獲取變數var的值,但其實還可以有更多花式玩法。其中表示用戶根目錄其實屬於 波浪線擴展,這比較常見,不展開介紹了。

下面的每種情況中,word 都要經過波浪線擴展,參數擴展,命令替換和 算術擴展。如果不進行子字元串擴展,bash 測試一個沒有定義或值為空的 參數;忽略冒號的結果是只測試未定義的參數。

大致描述下變數擴展的功能:

擴展 功能
${var} 獲取變數值
${!var} 取變數var的值做新的變數名,再次獲取新變數名的值
${!prefix* 獲取prefix開頭的變數名
${#parameter} 獲取變數長度
${parameter:-word} parameter為空時,使用wrod返回
${parameter:+word} parameter非空時,使用word返回
${parameter:=word} parameter為空時,使用word返回,同時把word賦值給parameter變數
${parameter:?word} parameter為空時,列印錯誤資訊word
${parameter:offset} 從offset位置截取字元串
${parameter:offset:length 從offset位置截取length長度的字元串
${parameter#word} 從頭開始刪除最短匹配word模式的內容後返回
${parameter##word} 從頭開始刪除最長匹配word模式的內容後返回
${parameter%word} 從尾開始刪除最短匹配word模式的內容後返回
${parameter%%word} 從尾開始刪除最長匹配word模式的內容後返回
${parameter/pattern/string} 最長匹配pattern的內容替換為string
${parameter//pattern/string} 所有匹配pattern的內容替換為string

${!var}

${!var}是間接擴展。bash 使用以 var 的其餘部分為名的變數的值作為變數的名稱; 接下來新的變數被擴展,它的值用在隨後的替換當中,而不是使用var自身的值。

有點拗口,舉個例子就懂了

$ var_name=val
$ val="Bash expansion"
$ echo ${!var_name}
Bash expansion

所以,${!var_name}等效於${val},就是取val_name的值作為變數名,再獲取新變數名的值。

!有一種例外情況,那就是${!prefix*},下面再介紹。

${!prefix*}

${!prefix*}實現擴展為名稱以 prefix 開始的變數名,以特殊變數 IFS 的第一個字元分隔。換句話說,這種用法就是用於獲取變數名的。例如:

# 創建3個以VAR開頭的變數
$ VAR_A=a
$ VAR_B=b
$ VAR_C=c

# 尋找以VAR開頭的變數名
$ echo ${!VAR*}
VAR_A VAR_B VAR_C

${#parameter}

${#parameter}用於獲取變數的長度。如果 parameter* 或者是 @, 替換的值是位置參數的個數。如果 parameter 是一個數組名,下標是 * 或者是 @, 替換的值是數組中元素的個數。

${parameter:-word}

${parameter:-word}表示使用默認值。如果 parameter 未定義或值為空,將替換為 word 的擴展。否則,將替換為 parameter 的值。

${parameter:=word}

${parameter:=word}賦默認值。如果 parameter 未定義或值為空, word 的擴展將賦予 parameterparameter 的值將被替換。位置參數和特殊參數不能用這種方式賦值。

${parameter:=word}${parameter:-word}有什麼差別?還是舉個例子:

# 刪除var變數
$ unset var
# 確認var變數為空
$ echo ${var}

# 當var為空時,把test賦值給var,同時返回test
$ echo ${var:=test}
test
# 可以看到,此時var已經被賦值
$ echo ${var}
test
# 再次刪除var變數,繼續實驗
$ unset var
# 當var為空時,返回test
$ echo ${var:-test}
test
# 對比驗證,此時var並沒有賦值
$ echo ${var}

所以,差別在於,當parameter為空時,${parameter:=word}會比${parameter:-word}多做一步,就是把word的值賦給parameter

${parameter:?word}

${parameter:?word}主要用於當parameter為空時,顯示錯誤資訊wordshell 如果不是交互的,則將退出。

${parameter:+word}

如果 parameter 未定義或非空,不會進行替換;否則將替換為 word 擴展後的值。這與${parameter:-word}完全相反。簡單來說,就是parameter非空時,才使用word

${parameter:offset}

${parameter:offset:length}

${parameter:offset:length}

${parameter:offset:length}可以實現字元串的截取,從offset開始,截取length個字元。如果 offset 求值結果小於 0, 值將當作從 parameter 的值的末尾算起的偏移量。如果parameter@,結果是 length 個位置參數,從 offset 開始。 如果 parameter 是一個數組名,以 @* 索引,結果是數組的 length 個成員,從 ${parameter[offset]} 開始。 子字元串的下標是從 0 開始的,除非使用位置參數時,下標從 1 開始。

${parameter#word}

參考 ${parameter##word}

${parameter##word}

word支援模式匹配,從parameter的開始位置尋找匹配,一個#的是尋找最短匹配,兩個#的是尋找最長匹配,把匹配的內容刪除後,把剩下的返回。例如:

$ str="we are testing, we are testing"
$ echo ${str#*are}
testing, we are testing
$ echo ${str##*are}
testing

這必須是從頭開始刪的,如果要刪除中間的某一些字元串,可以用${parameter/pattern/string}

如果 parameter是一個數組變數,下標是@或者是*,模式刪除將依次施用於數組中的每個成員,最後擴展為結果的列表。

${parameter%word}

參考${parameter%%word}

${parameter%%word}

這也是在parameter中刪除匹配的內容後返回。%#非常類似,前者是從頭開始匹配,後者是從尾部開始匹配。同樣的,一個%是尋找最短匹配,兩個%%是尋找最長匹配。例如:

$ str="we are testing, we are testing"
$ echo ${str%are*}
we are testing, we
$ echo ${str%%are*}
we

這必須是從末端開始刪的,如果要刪除中間的某一些字元串,可以用${parameter/pattern/string}

如果 parameter是一個數組變數,下標是@或者是*,模式刪除將依次施用於數組中的每個成員,最後擴展為結果的列表。

${parameter/pattern/string}

參考${parameter//pattern/string}

${parameter//pattern/string}

${parameter//pattern/string}${parameter/pattern/string},主要實現了字元串替換,當然,如果要替換的結果是空,就等效於刪除。一個/,表示只有第一個匹配的被替換,兩個/表示所有匹配的都替換。例如:

$ str="we are testing, we are testing"
# 替換首次匹配
$ echo ${str/we are/I am}
I am testing, we are testing
# 替換所有匹配
$ echo ${str//we are/I am}
I am testing, I am testing
# 刪除首次匹配
$ echo ${str/are/}
we testing, we are testing
# 刪除所有匹配
$ echo ${str//are/}
we testing, we testing

如果patten#開始,例如${str/#we are/},則必須從頭開始就匹配;以%表示,例如${str/%are testing/},必須從末端就要完全匹配。

如果 parameter是一個數組變數,下標是@或者是*,模式刪除將依次施用於數組中的每個成員,最後擴展為結果的列表。

路徑擴展

我們經常會這樣使用路徑擴展,ls ~/work*,這裡的*就是路徑匹配的一種,表示匹配包含空串的任何字元串。除了*之外,還有?[。路徑擴展其實運用了模式匹配,所以匹配規則不妨直接看模式匹配

模式匹配

任何模式中出現的字元,除了下面描述的特殊模式字元外,都匹配它本身。 模式中不能出現 NUL 字元。如果要匹配字面上的特殊模式字元,它必須被引用。

特殊模式字元有下述意義:

  • *: 匹配任何字元串包含空串。
  • ?: 匹配任何單個字元。
  • [...]: 匹配括弧內的任意一個字元,與正則匹配一致。

與正則的[...]一致,[!...]或者[^...]表示不匹配括弧內的字元;[a-zA-Z]表示從a到z以及從A到Z的所有字元;也支援[:alinum:]這類的特殊字元。

如果使用內建命令 shopt 啟用了 shell 選項 extglob, 將識別另外幾種模式匹配操作符。

  • ?(pattern-list):匹配所給模式零次或一次出現
  • *(pattern-list):匹配所給模式零次或多次出現
  • +(pattern-list):匹配所給模式一次或多次出現
  • @(pattern-list):準確匹配所給模式之一
  • !(pattern-list):任何除了匹配所給模式之一的字串

重定向

簡單的重定向不累述了,講一些高級用法。

Here Documents

here-document 的格式是:

<<[-]word
	here-document
delimiter

這種重定向使得 shell 從當前源文件讀取輸入,直到遇到僅包含 word 的一行 (並且沒有尾部空白,trailing blanks) 為止。直到這一點的所有行被用作 命令的標準輸入。

還是聽拗口,咱們看例子:

$ cat <<EOF
> fist line
> second line
> third line
> EOF
fist line
second line
third line

上述的做法,把兩個EOF之間的內容作為一個文件,傳遞給cat命令。甚至,我們還有更高級的用法,實現動態創建文件。

$ kernel=linux
$ cat > ./readme.txt <<EOF
> You are using kernel ${kernel}
> EOF
$ cat ./readme.txt
You are using kernel linux

Here Strings

here-document 的變種,形式是

<<<word

word 被擴展,提供給命令作為標準輸入,例如,我希望檢索變數的值,有以下兩種做法:

$ echo ${var} | grep "test"
$ grep "test" <<< ${var}

Opening File Descriptors for Reading and Writing

重定向操作符,[n]<>word,使得以 word 擴展結果為名的文件被打開,通過文件描述符 n 進行讀寫。如果沒有指定 n 那麼就使用文件描述符 0。如果文件不存在,它將被創建。

這操作暫時沒用過,待補充示例。

總結

本文結合man bash以及自己的一些經驗,總結了Shell編程的一些高級用法。還是那句話,建議有一定基礎的同學學習,畢竟在跑之前要先學會走路不是?