数值运算

    从本文开始,打算结合平时积累和进一步实践,通过一些范例来介绍Shell编程。因为范例往往能够给人以学有所用的感觉,而且给人以动手实践的机会,从而激发人的学习热情。

    考虑到易读性,这些范例将非常简单,但是实用,希望它们能够成为我们解决日常问题的参照物或者是“茶余饭后”的小点心,当然这些“点心”肯定还有值得探讨、优化的地方。

    更复杂有趣的例子请参考 Advanced Bash-Scripting Guide (一本深入学习 Shell 脚本艺术的书籍)。

    该系列概要:

    • 目的:享受用 Shell 解决问题的乐趣;和朋友们一起交流和探讨。
    • 计划:先零散地写些东西,之后再不断补充,最后整理成册。
    • 读者:熟悉 Linux 基本知识,如文件系统结构、常用命令行工具、Shell 编程基础等。
    • 建议:看范例时,可参考和《Shell十三问》
    • 环境:如没特别说明,该系列使用的 Shell 将特指 Bash,版本在 3.1.17 以上。
    • 说明:该系列不是依据 Shell 语法组织,而是面向某些潜在的操作对象和操作本身,它们反应了现实应用。当然,在这个过程中肯定会涉及到 Shell 的语法。

    这一篇打算讨论一下 Shell 编程中的基本数值运算,这类运算包括:

    • 数值(包括整数和浮点数)间的加、减、乘、除、求幂、求模等
    • 产生指定范围的随机数
    • 产生指定范围的数列

    Shell 本身可以做整数运算,复杂一些的运算要通过外部命令实现,比如 ,bcawk 等。另外,可通过 RANDOM 环境变量产生一个从 0 到 32767 的随机数,一些外部工具,比如 awk 可以通过 rand() 函数产生随机数。而 seq 命令可以用来产生一个数列。下面对它们分别进行介绍。

    整数运算

    说明: expr 之后的 $i+,1 之间有空格分开。如果进行乘法运算,需要对运算符进行转义,否则 Shell 会把乘号解释为通配符,导致语法错误; awk 后面的 $1$2 分别指 $i 和 1,即从左往右的第 1 个和第 2 个数。

    用 Shell 的内置命令查看各个命令的类型如下:

    1. $ type type
    2. type is a shell builtin
    3. $ type let
    4. let is a shell builtin
    5. $ type expr
    6. expr is hashed (/usr/bin/expr)
    7. $ type bc
    8. bc is hashed (/usr/bin/bc)
    9. $ type awk
    10. awk is /usr/bin/awk

    从上述演示可看出: let 是 Shell 内置命令,其他几个是外部命令,都在 /usr/bin 目录下。而 exprbc 因为刚用过,已经加载在内存的 hash 表中。这将有利于我们理解在上一章介绍的脚本多种执行方法背后的原理。

    说明:如果要查看不同命令的帮助,对于 lettype 等 Shell 内置命令,可以通过 Shell 的一个内置命令 help 来查看相关帮助,而一些外部命令可以通过 Shell 的一个外部命令 man 来查看帮助,用法诸如 help letman expr 等。

    范例:从 1 加到某个数

    1. #!/bin/bash
    2. # calc.sh
    3. i=0;
    4. while [ $i -lt 10000 ]
    5. do
    6. ((i++))
    7. done
    8. echo $i

    说明:这里通过 while [ 条件表达式 ]; do .... done 循环来实现。-lt 是小于号 <,具体见 test 命令的用法:man test

    如何执行该脚本?

    办法一:直接把脚本文件当成子 Shell (Bash)的一个参数传入

    1. $ bash calc.sh
    2. $ type bash
    3. bash is hashed (/bin/bash)

    办法二:是通过 bash 的内置命令 .source 执行

    1. $ . ./calc.sh

    1. $ source ./calc.sh
    2. $ type .
    3. . is a shell builtin
    4. $ type source
    5. source is a shell builtin

    办法三:是修改文件为可执行,直接在当前 Shell 下执行

    1. $ chmod ./calc.sh
    2. $ ./calc.sh

    下面,逐一演示用其他方法计算变量加一,即把 ((i++)) 行替换成下面的某一个:

    1. let i++;
    2. i=$(expr $i + 1)
    3. i=$(echo $i+1|bc)
    4. i=$(echo "$i 1" | awk '{printf $1+$2;}')

    比较计算时间如下:

    1. $ time calc.sh
    2. 10000
    3. real 0m1.319s
    4. user 0m1.056s
    5. sys 0m0.036s
    6. $ time calc_let.sh
    7. 10000
    8. real 0m1.426s
    9. user 0m1.176s
    10. sys 0m0.032s
    11. $ time calc_expr.sh
    12. 1000
    13. real 0m27.425s
    14. user 0m5.060s
    15. sys 0m14.177s
    16. $ time calc_bc.sh
    17. 1000
    18. user 0m9.353s
    19. sys 0m24.618s
    20. $ time ./calc_awk.sh
    21. 100
    22. real 0m11.672s
    23. user 0m2.604s
    24. sys 0m2.660s

    说明: time 命令可以用来统计命令执行时间,这部分时间包括总的运行时间,用户空间执行时间,内核空间执行时间,它通过 ptrace 系统调用实现。

    通过上述比较可以发现 (()) 的运算效率最高。而 let 作为 Shell 内置命令,效率也很高,但是 exprbcawk 的计算效率就比较低。所以,在 Shell 本身能够完成相关工作的情况下,建议优先使用 Shell 本身提供的功能。但是 Shell 本身无法完成的功能,比如浮点运算,所以就需要外部命令的帮助。另外,考虑到 Shell 脚本的可移植性,在性能不是很关键的情况下,不要使用某些 Shell 特有的语法。

    letexprbc 都可以用来求模,运算符都是 %,而 letbc 可以用来求幂,运算符不一样,前者是 **,后者是 ^ 。例如:

    范例:求模

    1. $ expr 5 % 2
    2. 1
    3. $ let i=5%2
    4. $ echo $i
    5. 1
    6. $ echo 5 % 2 | bc
    7. 1
    8. $ ((i=5%2))
    9. $ echo $i
    10. 1

    范例:求幂

    1. $ let i=5**2
    2. $ echo $i
    3. 25
    4. $ ((i=5**2))
    5. 25
    6. $ echo "5^2" | bc
    7. 25

    进制转换也是比较常用的操作,可以用 Bash 的内置支持也可以用 bc 来完成,例如把 8 进制的 11 转换为 10 进制,则可以:

    1. $ echo "obase=10;ibase=8;11" | bc -l
    2. 9
    3. $ echo $((8#11))
    4. 9

    范例:ascii 字符编码

    如果要把某些字符串以特定的进制表示,可以用 od 命令,例如默认的分隔符 IFS 包括空格、 TAB 以及换行,可以用 man ascii 佐证。

    letexpr 都无法进行浮点运算,但是 bcawk 可以。

    范例:求 1 除以 13,保留 3 位有效数字

    1. $ echo "scale=3; 1/13" | bc
    2. .076
    3. $ echo "1 13" | awk '{printf("%0.3f\n",$1/$2)}'
    4. 0.077

    说明: bc 在进行浮点运算时需指定精度,否则默认为 0,即进行浮点运算时,默认结果只保留整数。而 awk 在控制小数位数时非常灵活,仅仅通过 printf 的格式控制就可以实现。

    补充:在用 bc 进行运算时,如果不用 scale 指定精度,而在 bc 后加上 -l 选项,也可以进行浮点运算,只不过这时的默认精度是 20 位。例如:

    1. $ echo 1/13100 | bc -l
    2. .00007633587786259541

    范例:余弦值转角度

    bc -l 计算,可以获得高精度:

    1. $ export cos=0.996293; echo "scale=100; a(sqrt(1-$cos^2)/$cos)*180/(a(1)*4)" | bc -l
    2. 4.934954755411383632719834036931840605159706398655243875372764917732
    3. 5495504159766011527078286004072131

    当然也可以用 awk 来计算:

    1. $ echo 0.996293 | awk '{ printf("%s\n", atan2(sqrt(1-$1^2),$1)*180/3.1415926535);}'
    2. 4.93495

    在这里随机产生了一组测试数据,文件名为 income.txt

    1. 1 3 4490
    2. 2 5 3896
    3. 3 4 3112
    4. 4 4 4716
    5. 5 4 4578
    6. 6 6 5399
    7. 7 3 5089
    8. 8 6 3029
    9. 9 4 6195
    10. 10 5 5145

    说明:上面的三列数据分别是家庭编号、家庭人数、家庭月总收入。

    分析:为了求月均收入最高家庭,需要对后面两列数进行除法运算,即求出每个家庭的月均收入,然后按照月均收入排序,找出收入最高家庭。

    实现:

    1. #!/bin/bash
    2. # gettopfamily.sh
    3. [ $# -lt 1 ] && echo "please input the income file" && exit -1
    4. [ ! -f $1 ] && echo "$1 is not a file" && exit -1
    5. income=$1
    6. awk '{
    7. printf("%d %0.2f\n", $1, $3/$2);
    8. }' $income | sort -k 2 -n -r

    说明:

    • [ $# -lt 1 ]:要求至少输入一个参数,$# 是 Shell 中传入参数的个数
    • [ ! -f $1 ]:要求输入参数是一个文件,-f 的用法见 test 命令,man test
    • income=$1:把输入参数赋给 income 变量,再作为 awk 的参数,即需处理的文件
    • awk:用文件第三列除以第二列,求出月均收入,考虑到精确性,保留了两位精度
    • sort -k 2 -n -r:这里对结果的 awk 结果的第二列 -k 2,即月均收入进行排序,按照数字排序 -n,并按照递减的顺序排序 -r

    演示:

    1. $ ./gettopfamily.sh income.txt
    2. 7 1696.33
    3. 9 1548.75
    4. 1 1496.67
    5. 4 1179.00
    6. 5 1144.50
    7. 10 1029.00
    8. 6 899.83
    9. 2 779.20
    10. 3 778.00
    11. 8 504.83

    补充:之前的 income.txt 数据是随机产生的。在做一些实验时,往往需要随机产生一些数据,在下一小节,我们将详细介绍它。这里是产生 income.txt 数据的脚本:

    1. #!/bin/bash
    2. # genrandomdata.sh
    3. for i in $(seq 1 10)
    4. do
    5. echo $i $(($RANDOM/8192+3)) $((RANDOM/10+3000))
    6. done

    说明:上述脚本中还用到seq命令产生从1到10的一列数,这个命令的详细用法在该篇最后一节也会进一步介绍。

    随机数

    环境变量 RANDOM 产生从 0 到 32767 的随机数,而 awkrand() 函数可以产生 0 到 1 之间的随机数。

    范例:获取一个随机数

    1. $ echo $RANDOM
    2. 81
    3. $ echo "" | awk '{srand(); printf("%f", rand());}'
    4. 0.237788

    说明: srand() 在无参数时,采用当前时间作为 rand() 随机数产生器的一个 seed

    范例:随机产生一个从 0 到 255 之间的数字

    可以通过 RANDOM 变量的缩放和 awkrand() 的放大来实现。

    1. $ expr $RANDOM / 128
    2. $ echo "" | awk '{srand(); printf("%d\n", rand()*255);}'

    思考:如果要随机产生某个 IP 段的 IP 地址,该如何做呢?看例子:友善地获取一个可用的 IP 地址。

    1. #!/bin/bash
    2. # getip.sh -- get an usable ipaddress automatically
    3. # author: falcon &lt;zhangjinw@gmail.com>
    4. # update: Tue Oct 30 23:46:17 CST 2007
    5. # set your own network, default gateway, and the time out of "ping" command
    6. net="192.168.1"
    7. default_gateway="192.168.1.1"
    8. over_time=2
    9. # check the current ipaddress
    10. ping -c 1 $default_gateway -W $over_time
    11. while :; do
    12. # clear the current configuration
    13. ifconfig eth0 down
    14. ifconfig eth0 \
    15. $net.$(($RANDOM /130 +2)) \
    16. up
    17. # configure the default gateway
    18. route add default gw $default_gateway
    19. # check the new configuration
    20. ping -c 1 $default_gateway -W $over_time
    21. # if work, finish
    22. [ $? -eq 0 ] && break
    23. done

    说明:如果你的默认网关地址不是 192.168.1.1,请自行配置 default_gateway(可以用 route -n 命令查看),因为用 ifconfig 配置地址时不能配置为网关地址,否则你的IP地址将和网关一样,导致整个网络不能正常工作。

    范例:获取一系列数

    一个比较典型的使用 seq 的例子,构造一些特定格式的链接,然后用 wget 下载这些内容:

    1. $ for i in `seq -f"http://thns.tsinghua.edu.cn/thnsebooks/ebook73/%02g.pdf" 1 21`;do wget -c $i; done

    或者

    1. $ for i in `seq -w 1 21`;do wget -c "http://thns.tsinghua.edu.cn/thnsebooks/ebook73/$i"; done

    补充:在 Bash 版本 3 以上,在 for 循环的 in 后面,可以直接通过 {1..5} 更简洁地产生自 1 到 5 的数字(注意,1 和 5 之间只有两个点),例如:

    1. $ for i in {1..5}; do echo -n "$i "; done
    2. 1 2 3 4 5

    我们先给单词一个定义:由字母组成的单个或者多个字符系列。

    首先,统计每个单词出现的次数:

    1. $ wget -c http://tinylab.org
    2. $ cat index.html | sed -e "s/[^a-zA-Z]/\n/g" | grep -v ^$ | sort | uniq -c

    接着,统计出现频率最高的前10个单词:

    1. $ wget -c http://tinylab.org
    2. $ cat index.html | sed -e "s/[^a-zA-Z]/\n/g" | grep -v ^$ | sort | uniq -c | sort -n -k 1 -r | head -10
    3. 524 a
    4. 238 tag
    5. 205 href
    6. 201 class
    7. 193 http
    8. 189 org
    9. 175 tinylab
    10. 174 www
    11. 146 div
    12. 128 title

    说明:

    • cat index.html: 输出 index.html 文件里的内容
    • sed -e "s/[^a-zA-Z]/\n/g": 把非字母字符替换成空格,只保留字母字符
    • grep -v ^$: 去掉空行
    • sort: 排序
    • uniq -c:统计相同行的个数,即每个单词的个数
    • sort -n -k 1 -r:按照第一列 -k 1 的数字 -n 逆序 -r 排序
    • head -10:取出前十行

    范例:统计指定单词出现次数

    可以考虑采取两种办法:

    • 只统计那些需要统计的单词
    • 用上面的算法把所有单词的个数都统计出来,然后再返回那些需要统计的单词给用户

    不过,这两种办法都可以通过下面的结构来实现。先看办法一:

    1. #!/bin/bash
    2. # statistic_words.sh
    3. if [ $# -lt 1 ]; then
    4. echo "Usage: basename $0 FILE WORDS ...."
    5. exit -1
    6. fi
    7. FILE=$1
    8. ((WORDS_NUM=$#-1))
    9. for n in $(seq $WORDS_NUM)
    10. do
    11. shift
    12. cat $FILE | sed -e "s/[^a-zA-Z]/\n/g" \
    13. | grep -v ^$ | sort | grep ^$1$ | uniq -c
    14. done

    说明:

    • if 条件部分:要求至少两个参数,第一个单词文件,之后参数为要统计的单词
    • FILE=$1: 获取文件名,即脚本之后的第一个字符串
    • ((WORDS_NUM=$#-1)):获取单词个数,即总的参数个数 $# 减去文件名参数(1个)
    • for 循环部分:首先通过 seq 产生需要统计的单词个数系列,shift 是 Shell 内置变量(请通过 help shift 获取帮助),它把用户从命令行中传入的参数依次往后移动位置,并把当前参数作为第一个参数即 $1,这样通过 $1就可以遍历用户所有输入的单词(仔细一想,这里貌似有数组下标的味道)。你可以考虑把 shift 之后的那句替换成 echo $1 测试 shift 的用法

    演示:

    1. $ chmod +x statistic_words.sh
    2. $ ./statistic_words.sh index.html tinylab linux python
    3. 175 tinylab
    4. 43 linux
    5. 3 python

    再看办法二,我们只需要修改 shift 之后的那句即可:

    1. #!/bin/bash
    2. # statistic_words.sh
    3. if [ $# -lt 1 ]; then
    4. echo "ERROR: you should input 2 words at least";
    5. echo "Usage: basename $0 FILE WORDS ...."
    6. exit -1
    7. fi
    8. FILE=$1
    9. ((WORDS_NUM=$#-1))
    10. for n in $(seq $WORDS_NUM)
    11. do
    12. shift
    13. cat $FILE | sed -e "s/[^a-zA-Z]/\n/g" \
    14. | grep -v ^$ | sort | uniq -c | grep " $1$"
    15. done

    演示:

    1. $ ./statistic_words.sh index.html tinylab linux python
    2. 175 tinylab
    3. 43 linux
    4. 3 python

    说明:很明显,办法一的效率要高很多,因为它提前找出了需要统计的单词,然后再统计,而后者则不然。实际上,如果使用 grep-E 选项,我们无须引入循环,而用一条命令就可以搞定:

    1. $ cat index.html | sed -e "s/[^a-zA-Z]/\n/g" | grep -v ^$ | sort | grep -E "^tinylab$|^linux$" | uniq -c
    2. 43 linux
    3. 175 tinylab

    或者

    1. $ cat index.html | sed -e "s/[^a-zA-Z]/\n/g" | grep -v ^$ | sort | egrep "^tinylab$|^linux$" | uniq -c
    2. 43 linux
    3. 175 tinylab

    说明:需要注意到 sed 命令可以直接处理文件,而无需通过 cat 命令输出以后再通过管道传递,这样可以减少一个不必要的管道操作,所以上述命令可以简化为:

    所以,可见这些命令 sedgrepuniqsort 是多么有用,它们本身虽然只完成简单的功能,但是通过一定的组合,就可以实现各种五花八门的事情啦。对了,统计单词还有个非常有用的命令 wc -w,需要用到的时候也可以用它。

    补充:在 一书中还提到 jot 命令和 factor 命令,由于机器上没有,所以没有测试,factor 命令可以产生某个数的所有素数。如:

    1. $ factor 100
    2. 100: 2 2 5 5

    小结

    到这里,Shell 编程范例之数值计算就结束啦。该篇主要介绍了:

    • Shell 编程中的整数运算、浮点运算、随机数的产生、数列的产生
    • Shell 的内置命令、外部命令的区别,以及如何查看他们的类型和帮助
    • Shell 脚本的几种执行办法
    • 几个常用的 Shell 外部命令: sedawkgrepuniqsort
    • 范例:数字递增;求月均收入;自动获取 IP 地址;统计单词个数
    • 其他:相关用法如命令列表,条件测试等在上述范例中都已涉及,请认真阅读之

    如果您有时间,请温习之。

    后记

    大概花了 3 个多小时才写完,目前是 23:33,该回宿舍睡觉啦,明天起来修改错别字和补充一些内容,朋友们晚安!

    10 月 31 号,修改部分措辞,增加一篇统计家庭月均收入的范例,添加总结和参考资料,并用附录所有代码。