Bash 指令碼程式設計的一些高階用法

廣漠飄羽 發表於 2020-06-30

概述

偶然間發現 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程式設計的一些高階用法。還是那句話,建議有一定基礎的同學學習,畢竟在跑之前要先學會走路不是?