01_Zsh-开发指南(第一篇-变量和语句).md 19 KB

导读

网上关于 zsh 的文章有很多,但其中超过 95% 的文章讲如何使用和配置,写如何用 zsh 编程的文章很少,能找到的多数也是只言片语,不成系统。国外有几本讲 zsh 的书,其中也有很多内容是配置、使用、编写补全脚本等等,对编程有用的篇幅占比并不多,而且比较零散不便于查询。至于官方文档?那是让即使有多年编程经验的开发者也会抓狂的神奇存在。可读性极差,而且基本没有例子,不熟悉文档结构和内容的话,很难找到自己想要的东西。但内容覆盖很全面,洋洋洒洒近 500 页,耐心去看总会找到的。还有一份官方“入门”文档,上次更新时间是 2002 年,也要 300 多页,至于可读性,比官网文档要稍微好一些吧,还是有一定的参考价值的。官网上还有一些链接,里边内容比较零散,也可以看看。

很多人在 zsh 中用 bash 语法写脚本,虽然也可以正常运行,但这样无法利用 zsh 的众多优秀特性,还是非常遗憾的。熟悉下 zsh 下独有的特性,对写脚本的帮助是很大的。

本系列文章无关 zsh 的安装、使用、配置(如果需要配置文件,可以参考我的 .zshrc,里边有比较详细的注释),更无 oh-my-zsh 相关内容,安装 zsh 后无需配置即可开始学习编写脚本。读者不需要有 bash 的基础(最好了解一些),但需要接触过任何一门编程语言,对编程的一些基础概念要有了解。

为什么用 zsh 写脚本

很多人对 zsh 的了解停留在界面漂亮、主题多、插件多、补全强等等,而对 zsh 的语言特性了解并不多。因为 zsh 基本兼容 bash,不少人使用 bash 语法写 zsh 脚本,或者偶尔使用一些 zsh 特有的小技巧,很难体会出 zsh 作为一门编程语言的强大之处。

另外有些人认为 bash 几乎在所有类 Unix 系统都有默认安装,而 zsh 往往要自己安装,为了通用性而用 bash 写脚本比较好。这个说法也有一定的道理,但并不是对所有开发者来说都有影响。如果是开源软件的开发者,为了避免洁癖用户因为不想安装他用不到的 zsh 而不使用自己的软件,而避免使用 zsh,是有一定道理的(但现在 zsh 的用户量也有一定的积累了)。除此之外,自己平时写脚本、公司内部使用等多数场景,都是不需要考虑这个因素的。

如果在公司使用,还涉及其他因素。

第一个是 zsh 的部署成本。但因为多数情况都需要部署其他软件,甚至自己的脚本可以和 zsh 打包部署(去掉用不到的文件后的 zsh 只有 1M 多),所以基本不成问题。而且如果使用系统默认的 bash 的话,还涉及版本不同导致的问题,比如不同系统的 bash 版本不一样,或者系统升级后,bash 的升级导致之前的脚本挂掉等等。所以即使使用 bash,最好也是统一部署或者自带一个特定的版本,而不是使用系统默认的,以减少不必要的麻烦。

第二个就是非常重要的学习成本。因为会写 bash 的人很多,但会写 zsh 的比较少,如果只有自己会写,那么和别人合作会出问题。但 zsh 的学习成本并没有那么大,尤其是对会 bash 开发者来说,要大致看懂 zsh 脚本基本只需要几十分钟的学习,而编写的话,循序渐进也是很自然的事情,而且想不起来的时候还可以用 bash 的语法写。所以学习成本没有那么可观。

第三个是使用 zsh 开发的好处。如果 zsh 和 bash 相比,没有明显的好处,为什么要学习和使用它呢?那么就要从 bash 痛点讲起了。我想经常写 bash 脚本的人,很少有人会举大拇指说 bash 真好用啊。相反,我曾经多次听某些开发者说我写过一个超过 2000(或者其他行数)行的 shell(bash)脚本。但几乎没有人会认为写一个超过 2000 行的 Python 脚本是一件多么特别的事情。蹩脚的语法(几乎所有从任何其他语言迁移过来的开发者,都要重新熟悉和习惯它的语法)、严重依赖外部命令(因为文件系统错误等问题,挂掉一个外部命令,脚本就休克了。命令版本不同会有用法上的微秒差别,调试测试困难。频繁起新进程性能低下)、功能孱弱蹩脚(很多需要频繁使用的功能不全面或者不好用,比如字符串处理和数组的用法)等等,让很多开发者非常头疼,其中有些人甚至主张禁止使用 shell 脚本,一律改用 Python 等等,但 Python 并非适用所有场景,而且也有另外的一些问题,这样做也是因噎废食。Zsh 并非将这些问题全部解决了,但和 bash 相比,有很大的改善。比如 zsh 支持多种风格的语法,开发者很容易找到亲切感;对外部命令的依赖比 bash 要轻很多,多数常用的功能不需要使用外部命令,性能更好,调试也更加方便;功能上和 bash 相比也有比较大的提升,处理不那么复杂的场景已经比较够用了。

有人可能会说,不如“一步到位”,使用 Powershell。Powershell 的确比 Python 更适合作为一种 shell 脚本语言,但使用它的话会有其他问题。

首先 Powershell 的学习成本是绝对要比 zsh 高的,如果想省点事,这并不是好的选择。

其次 Linux 下的 Powershell 目前还是 beta 版,以后会不会有很多人用也很难说,如果很少有人用,那么生态环境就成问题。比如遇到问题后找不到解决办法,配套的软件和库不完善等等。

再次 Powershell 解释器的启动速度非常感人,在我的机器上,Windows 下的 Powershell 空脚本要执行将近 200 毫秒,Linux 下的要更长一些(我只在 WSL 里安装试用过,时间翻了几倍),而 zsh 的话,在 Linux 下不超过 5 毫秒,在 WSL 下也不超过 20 毫秒。如果写一个简单的脚本,运行时都要卡一下,是非常影响体验的。

最后如果平时就使用 Powershell 作为交互 shell,那么虽然脚本的启动时间问题有所缓解,但用户体验会差很多,而且以后也很难提升上来,很容易得不偿失。

Zsh 脚本样例

可以通过一个例子直观感受下用 zsh 写的脚本。这是一个删除当前目录以及所有子目录下重复文件的脚本,通过 md5 判断文件是否相同(不严谨)。熟悉 bash 的读者可以尝试用 bash 完成相同的功能,然后对比一下代码(我之前写过一个 bash 版本的,不贴上来了),就能比较直观地感受到 bash 和 zsh 的区别了。

#!/bin/zsh

local files=("${(f)$(md5sum **/*(.D))}")
local files_to_delete=()
local -A md5s

for i ($files) {
    local md5=$i[1,32]

    if (($+md5s[$md5])) {
        files_to_delete+=($i[35,-1])
    } else {
        md5s[$md5]=1
    }
}

(($#files_to_delete)) && rm -v $files_to_delete

为什么要使用 shell 脚本语言

对于没有接触过 shell 脚本的开发者或者用户来说,有一个更重要的问题,我为什么要学习和使用 shell 脚本呢?

那么要从 shell 脚本的使用场景说起。Shell 是一种和计算机系统交互的文本界面(CLI),简单说就是输入命令后返回结果(也有比较复杂的操作)。CLI 在某些场景要比图形界面(GUI)方便和高效很多,是不可取代的(即使有一天语音识别取代了文本输入,CLI 也会换汤不换药地继续存在)。那么使用 CLI 就必须约定好指令格式,而 shell 脚本就是一种用于 CLI 交互的指令格式。

因为这个比较特别的场景,shell 脚本有一些与其他编程语言不同的特点。一个很重要的特点,shell 脚本要比较简洁,容易输入。如果发送一条简单指令就要打几十个字符,那恐怕谁也无法接受。而为了达到可以接受的简洁程度,shell 脚本的语法,往往比其他编程语言的更加怪异。

有人可能会说,这搞混了两个事情。在 CLI 输入命令和写脚本文件然后执行命令是两回事,不需要使用同一种语言,而只是在 CLI 交互中,通常是没有必要写复杂逻辑的,也就是说 shell 脚本基本没有必要学习。

是两回事不假,但二者并不是不相关的。比如有人这么想后,决定在 shell 里只使用最简单的命令,不学习较为复杂的语法,如果需要写脚本,就用 Python 之类的语言写。那么有什么问题吗?

Python 是为通用的场景设计的,虽然也能处理 shell 脚本所做的事情,但往往要写出多几倍甚至几十倍(如果对 Python 也不甚了解的话)的代码出来。而很多时候,shell 脚本做的是一次性工作,运行完就直接删除,或者直接在一行敲完回车,这样的场景用 Python 写成本要高出很多。而且并不是一个 Python 初学者就能用 Python 实现 shell 脚本的功能的,甚至熟练的 Python 开发者也很可能一时想不好怎么实现某个用 shell 脚本能很容易实现的功能。Shell 脚本的很多工作是和字符串和目录文件打交道,特点是要实现的功能复杂多样,没有固定模式,无论用什么语言写,都不容易。Python 自带的字符串和目录文件等类库功能非常基础,基本只能实现功能很单一的操作,稍微复杂点的功能都需要自己写。如果去找某些功能复杂的第三方库,那就会涉及一堆问题,比如同样有学习和部署成本,可能因为用户少所以有 bug 未被发现,可能已经没有人维护了,Python 的语法决定库怎么写都不能让语法太简洁等等。

而初步熟悉一门 shell 脚本只需要几十分钟,用多了自然就熟悉了,成本收益的权衡不言而喻。

格式约定

文中行首的 % 代表 zsh 的命令提示符(类似 bash 的 $,这个是可以自由定义的,具体是什么不重要),行首的 > 代表此行是换行后的输入内容,以 # 开头的为注释(非 root 用户的命令提示符,本系列文章不需要 root 用户),其余的是命令的输出内容。另外某些地方会贴成段的 zsh 代码,那样就省略开头的 %,比较容易分辨。

一个样例:

# 前两行是输入内容,第三行是输出内容
% echo "Hello \
> World"
Hello World

本系列文章使用的 zsh 版本是 5.4.1(写这篇文章时的最新版本),代码在老版本中可能运行不了或者结果有出入,尽量使用最新版本。

下面直接进入正题。

变量

接触一门新的编程语言,运行完 Hello World 后,首先要了解的基本就是如何定义和使用变量了。有了变量后可以比较变量内容,进而可以接触条件、循环、分支等语句,继而了解函数的用法,更高级的数据结构的使用,更多库函数,等等。这样就大概了解了一门面向过程的语言的基本用法,剩下的可以等到用的时候再查手册。

所以这一篇讲最基本的变量和语句。

zsh 有 5 种变量:整数、浮点数(bash 不支持)、字符串、数组、哈希表(或者叫关联数组或者字典,本系列文章统一使用哈希表),另外还有一些其他语言少有的东西,比如 alias(但主要是交互时使用,编程时基本用不到)。此篇只涉及整数、浮点数、字符串,并且不涉及数值计算和字符串处理等内容。

变量定义

Zsh 的变量多数情况不需要提前声明或者指定类型,可以直接赋值和使用(但哈希表是一个例外)。

# 等号两端不能有空格
% num1=123
% num2=123.456
% str1=abcde
# 如果字符串中包含空格等特殊字符,需要加引号
% str2='abc def'
# 也可以用双引号,但和单引号有区别,比如双引号里可以使用变量,而单引号不可以
% str3="abc def $num1"
# 在字符串中可以使用转移字符,单双引号通用
% str4="abc\tdef\ng"

# 输出变量,也可以使用 print
% echo $str1
abcde

# 简单的数值计算
% num3=$(($num1 + $num2))
# (( 中的变量名可以不用 $
% num3=$((num1 + num2))

# 简单的字符串操作
% str=abcdef
# 2 和 4 都是字符在数组的位置,从 1 开始数,逗号两边不能有空格
% echo $str[2,4]
bcd
# -1 是最后一个字符
% echo $str[4,-1]
def

变量比较

# 比较数值
% num=123
# (( )) 用于数值比较等操作,如果为真返回 0,否则返回 1
# && 后边的语句在前边的语句为真时才执行
# 双等号可以替换成单引号,可以根据自己的习惯选用,其他多数地方也是如此
# 本系列文章统一使用双等号,因为使用双等号的常用编程语言更多些
% ((num == 123)) && echo good
good
# (( 里边可以使用与(&&)或(||)非(!)操作符,同 c 系列语言
% ((num == 1 || num == 2)) && echo good

# 比较字符串
% str=abc
# 比较字符串要用 [[,内侧要有空格,字符串最好用引号包含,避免产生语法错误
# 不需要 x"$str" == x"abc" 之类的用法
% [[ "$str" == "abc" ]] && echo good
good
# 可以和空字符串 "" 比较,未定义的字符串和空字符串比较结果为真
# [[ 里也可以用 && || !,但不能随意加小括号,[[ 的用法比 (( 要严格很多
% [[ "$str" == "" || "str" == "123" ]] && echo good

语句

稍微了解下简单变量的使用后,快速进入语句部分。

zsh 支持多种风格的语法,包括经典的 posix shell (bash 的语法和它类似,但有一些扩展,可以归为一类)的,以及 csh 风格的等等。但 posix shell 的语法并不好用,我们没必要一定使用这个。我只选用一种我认为最方便简洁的语法,没有 fi、then、do、done、esac、in 等的关键字(虽然其中某些关键字其他编程语言也有,但基本用法都各异,而且容易混淆),也不需要多余的分号。如果不确定语法是否符合预期,可以定义一个函数然后使用 which 查看,内容会被转化成原始(posix shell 风格)的样子。熟悉 bash 并且喜欢使用 bash 语法的读者可以跳过这部分内容,语法的不同并不影响后续内容的阅读,继续使用 bash 风格语法写 zsh 也是没有问题的。

条件语句

# 格式
if [[ ]] {
} elif {
} else {
}

大括号也可以另起一行,本系列文章统一使用这种风格,缩进为 4 个空格。elif 也可以写作 else if。

[[ ]] 用于比较字符串、判断文件等,功能比较复杂多样,这里先使用最基础的用法。注意尽量不要用 [[ ]] 比较数值,因为不留神的话,数值会被转化成字符串来比较,没有任何错误提示,但结果可能不符合预期,导致不必要的麻烦。

# 样例
if [[ "$str" == "name" || "$str" == "value" ]] {
    echo "$str"
}

(( )) 用于比较数值,里边可以调用各种数值相关的函数,格式类似 c 语言,变量前的 $ 可省略。

# 格式
if (( )) {
}
# 样例
if ((num > 3 && num + 3 < 10)) {
    echo $num
}

{ } 用于在当前 shell 运行命令并且判断运行结果。

# 格式
if { } {
}
# 样例
if {grep sd1 /etc/fstab} {
    echo good
}

() 用于在子 shell 运行命令并且判断运行结果,用法和 {} 类似,不再举例。

# 格式
if ( ) {
}

这几种括号可以一起使用,这样可以同时判断字符串、数值、文件、命令结果等等。最好不要混合使用 && ||,会导致可读性变差和容易出错。

# 格式
if [[ ]] && (( )) && { } {
}

循环语句

# 格式
while [[ ]] {
    break/continue
}

和 if 一样,[[ ]] 可以替换成其他几种括号,功能也是一样的,不再依次举例。break 用于结束循环,continue 用于直接进入下一次循环。所有的循环语句中都可以使用 break 和 continue,下边不再赘述。

# 样例 死循环
 while (( 1 )) {
    echo good
}

until 和 while 相反,不满足条件时运行,一旦满足则停止,其他的用法和 while 相同,不再举例。

# 格式
until [[ ]] {
}

for 循环主要用于枚举,这里的括号是 for 的特有用法,不是在子 shell 执行。括号内是字符串(可放多个,空格隔开)、数组(可放多个)或者哈希表(可放多个,哈希表是枚举值而不是键)。i 是用于枚举内容的变量名,变量名随意。

# 格式
for i ( ) {
}
# 样例
for i (aa bb cc) {
    echo $i
}

# 枚举当前目录的 txt 文件
for i (*.txt) { 
    echo $i
}

# 枚举数组
array=(aa bb cc)
for i ($array) {
    echo $i
}

经典的 c 风格 for 循环。

# 格式
for (( ; ; )) {
}
# 样例
for ((i=0; i < 10; i++)) {
    echo $i
}

这个样例只是举例,实际上多数情况不需要使用这种 for 循环,可以这样。

# 样例,{1..10} 可以生成一个 1 到 10 的数组
for i ({1..10}) {
    echo $i
}

repeat 语句用于循环固定次数,n 是一个整数或者内容为整数的变量。

# 格式
repeat n {
}
# 样例
repeat 5 {
    echo good
}

分支语句

分支逻辑用 if 也可以实现,但 case 更适合这种场景,并且功能更强大。

# 格式 + 样例
case i {
    (a)
    echo 1
    ;;

    (b)
    echo 2
    # 继续执行下一个
    ;&

    (c)
    echo 3
    # 继续向下匹配
    ;|

    (c)
    echo 33
    ;;

    (d)
    echo 4
    ;;

    (*)
    echo other
    ;;
}

;; 代表结束 case 语句,;& 代表继续执行紧接着的下一个匹配的语句(不再进行匹配),;| 代表继续往下匹配看是否有满足条件的分支。

用户输入选择语句

select 语句是用于根据用户的选择决定分支的语句,语法和 for 语句差不多,如果不 break,会循环让用户选择。

# 格式
select i ( ) {
}
# 样例
select i (aa bb cc) {
    echo $i
}

输出是这样的。

1) aa  2) bb  3) cc
?#

按上边的数字加回车来选择。

异常处理语句

# 格式
{
    语句 1
} always {
    语句 2
}

如果语句 1 执行出错,则执行语句 2。

简化的条件语句

if 语句的简化版,在只有一个分支的情况下更简洁,功能和 if 语句类似,不赘述。

格式:
[[ ]] || {
}

[[ ]] && {
}

最好不要连续混合使用 && ||,比如。

aa && bb || cc && dd

容易导致逻辑错误或者误解,可以用 { } 把语句包含起来。

aa && { bb || { cc && dd } }

比较复杂的判断还是用 if 可读写更好,&& || 通常只适用于简单的场景。

总结

本篇简单介绍了变量和语句的使用方法。变量部分只涉及了最基础常用的部分,后续文章会详细介绍。语句部分已经覆盖了所有需要使用的语句,实际上这些语句都不只有这一种语法,但本系列文章统一使用这个语法。但涉及到的几种括号的用法比较复杂,之后的文章也会详细介绍。