Archive

Posts Tagged ‘pipe’

Shell like data processing in Python — using decorators

October 3rd, 2009 1 comment

前面的文章展示了管道的好处,以及在 Python 程序中利用管道的思想。但是前面文章里的代码还有一点缺陷,看下面的 shell 脚本和 Python 代码的比较:

find logdir -name "access-log*" | \
xargs cat | \
grep '[^-]$' | \
awk '{ total += $NF } END { print total }'
lines = grep('[^-]$', cat(find('logdir', 'access-log*')))
col = (line.rsplit(None, 1)[1] for line in lines)
print sum(int(c) for c in col)

Python 中 find, cat, grep 的调用用一层层的括号嵌套起来进行调用,执行顺序是从最内部的括号开始,可读性没有 shell 脚本好。可不可能在 Python 脚本中用类似 shell 脚本里的语法来提高可读性?用运算符重载就可以做到了。最初的想法来自这里,这个例子用的方法是重载或运算符 “|”。新的 Python 代码如下,我把这样的代码称为 pipe syntax (抽取最后一列并求和的代码不变。)

lines = find('logdir', 'access-log*') | cat | grep('[^-]$')

用运算符重载的方法把一个函数放在管道中必须自己定义一个类,在 override 的 __ror__() 函数中完成实际的工作。每次都要定义一个类我觉得不够方便,因此我用 decorator 来进行简化。

简单来说 decorator 通过用其他对象替换原来的函数来改变函数的行为。对 decorator 的介绍可以参考 Bruce Eckel 的两篇文章,分别介绍了无参数有参数的 decorator 如何创建,无参数的那篇文章还介绍了 decoractor 的作用。Bruce Eckel 认为 decorator 就像是宏一样,可以改变函数的语义。他还认为 Python 的 decorator 就像 Lisp 的宏一样 powerful。对这一点我不太赞同,两者差别还是很大的,decorator 其实只是提供了在运行时动态地替换函数的功能,而 Lisp 的宏是在编译时生成代码,要说 powerful 肯定还是 Lisp 的宏更强,但 decorator 更简单而且也已经足够 powerful 了。

使用 decorator 来定义 cat 的代码如下,函数前的 “@” 是为了支持 decorator 而引入的新的语法:

@pipeable
def grep(iter, match):
    if callable(match):
        fun = match
    else:
        fun = re.compile(match).match
    return ifilter(fun, iter)
 
# Without @pipeable before the function definition of grep,
# we can use the following code to achieve the same effect.
grep = pipeable(grep)

pipeable 其实只是一个普通的 Python 对象,可以是一个函数,也可以是一个类。如果是函数,那么 grep 就是给它的参数;如果是类,grep 是给它的初始化函数的参数。pipeable(grep) 必须返回一个能够调用的对象(含有 __call__() 方法),除此之外没有其他要求。可见,decorator syntax 只是语法糖而已,但这个语法糖使得我们可以在函数之前加上修饰,而且从代码上一下就可以看出 grep 可以用在管道中。

下面首先描述 pipeable 修饰函数的要求和修饰后的行为,然后再看 pipeable 的实现。

可以用 pipeable 进行修饰的函数只有一个要求,第一个参数必须支持遍历操作。如果这个函数之后还有其他管道,那么函数的返回值也需要支持遍历。

被 pipeable 修饰之后,函数的行为如下:

  1. 可以像没有修饰过时一样调用,函数行为不变
  2. 调用时给定除了第一个参数以外的所有参数,当函数对象出现在 “|” 右侧时,将左侧对象作为函数的第一个参数,与之前的参数一起完成函数的调用。

用来实现 pipe syntax 的 decorator 如下(完整代码shelike.py)。

from functools import update_wrapper
class pipeable:
    def __init__(self, method):
        self.func = method
        self.args = []
        self.kwds = {}
        self.reqlen = 0
        # Since we need to allow classes to be used in pipe, there are cases that
        # method is not a function.
        if hasattr(self.func, 'func_code'):
            self.reqlen = self.func.func_code.co_argcount
        # makes the wrapper object looks like the wrapped function
        update_wrapper(self, method)
 
    def __call__(self, *args, **kwds):
        curlen = len(args)
        # An ugly hack to handle classes.
        if curlen == self.reqlen or 0 == self.reqlen:
            return self.func(*args, **kwds)
        elif curlen != self.reqlen - 1:
            raise TypeError('Arguments number wrong.')
 
        cpy = deepcopy(self)
        cpy.args = args
        cpy.kwds = kwds
        return cpy
 
    def __ror__(self, iter):
        return self.func(iter, *self.args, **self.kwds)

由于修改后的对象需要支持 “|” 运算符,pipeable 用类的形式实现更方便。(看了 Bruce Eckel 的文章后我也偏好用类来创建 decorator。)

  1. __init__() 接受要修饰的函数,保存起来以备后面调用,这个函数在函数被修饰时执行,实际上就是创建一个pipeable 对象,把原先函数名赋给这个对象。最后的 update_wrapper 把被修饰函数的 __name__, __doc__, __module__ 属性拷贝到创建的对象上,这样这个对象看起来就更加像原先的函数。(否则这些属性的值的都是这个对象的值。)
  2. __call__() 在被修饰函数/替换的对象被调用时执行,这里根据参数个数直接调用原函数或者把参数保存起来
  3. __ror__() 就是实现 pipe syntax 的关键,把 “|” 左边的序列和其他参数组合起来完成被修饰函数的调用。有些 ugly hack 是为了处理 class 作为初始化参数的情况

要把 shell 里的常用命令一个个用 Python 再实现一下也比较麻烦,因此我还实现了一个函数用来把 Python 里的数据转成字符串直接调用 shell 命令来处理,处理完再转成一行一行的 Python 字符串。比较下用 pipeable 和自己定义一个 class 的实现代码(现在的处理方式是把输入一次性全部转成字符串通过 OS 的管道传给外部程序,数据量大的话内存开销比较大,但我没有想到好的解决办法。)

@pipeable
def shell(iter, cmd=None):
    pipe = Popen(cmd, shell=True, stdin = PIPE, stdout = PIPE)
    return pipe.communicate(''.join(iter))[0].splitlines(True)
 
class shell:
    def __init__(self, cmd):
        self.cmd = cmd
    def __ror__(self, iter):
        pipe = Popen(self.cmd, shell=True, stdin = PIPE, stdout = PIPE)
        return pipe.communicate(''.join(iter))[0].splitlines(True)

有了这个管道,就可以把求和的任务直接用 awk 来完成了。

sumtmp = lines | shell("awk '{ total += $NF } END { print total }'") | aslist
print int(sumtmp[0])

当然,如果不想依赖外部程序,可以写一个提取字符串第 n 列/最后一列的函数,作为 tr 的参数放入管道(这个函数的名字不太好,其实就是 map,代码见shelike.py),最后转成整数以后再用 sum 求和即可。

print (lines | tr(last_column) | tr(int) | sum)

我很喜欢看这样的代码,有点函数式的味道,而且可读性也很好 :)

有兴趣的话 decorator modulePythonDecoratorLibrary 提供了更多使用 decorator 的例子,推荐。

编写 Unix 管道风格的 Python 代码

January 28th, 2009 1 comment

先推荐一份幻灯片,David Beazley ("Python essiential reference", PLY 的作者) 在 PyCon’2008 上报告的幻灯片,强烈推荐!!这篇文章的很多内容都来自或者受这份幻灯片的启发而来。

在上一篇文章里介绍了 Unix 管道的好处,那可不可以在写程序时也使用这样的思想呢?当然可以。看过 SICP 就知道,其实函数式编程中的 map, filter 都可以看作是管道思想的应用。但其实管道的思想不仅可以在函数式语言中使用,只要语言支持定义函数,有能够存放一组数据的数据结构,就可以使用管道的思想。

一个日志处理任务

这里直接以前面推荐的幻灯片里的例子来说明,应用场景如下:

  • 某个目录及子目录下有一些 web 服务器的日志文件,日志文件名以 access-log 开头
  • 日志格式如下

    81.107.39.38 - ... "GET /ply/ply.html HTTP/1.1" 200 97238
    81.107.39.38 - ... "GET /ply HTTP/1.1" 304 -
    

    其中最后一列数字为发送的字节数,若为 ‘-’ 则表示没有发送数据

  • 目标是算出总共发送了多少字节的数据,实际上也就是要把日志记录的没一行的最后一列数值加起来

我不直接展示如何用 Unix 管道的风格来处理这个问题,而是先给出一些“不那么好”的代码,指出它们的问题,最后再展示管道风格的代码,并介绍如何使用 generator 来避免效率上的问题。想直接看管道风格的,点这里

问题并不复杂,几个 for 循环就能搞定:

sum = 0
for path, dirlist, filelist in os.walk(top):
    for name in fnmatch.filter(filelist, "access-log*"):
        # 对子目录中的每个日志文件进行处理
        with open(name) as f:
            for line in f:
                if line[-1] == '-':
                    continue
                else:
                    sum += int(line.rsplit(None, 1)[1])

利用 os.walk 这个问题解决起来很方便,由此也可以看出 python 的 for 语句做遍历是多么的方便,不需要额外控制循环次数的变量,省去了设置初始值、更新、判断循环结束条件等工作,相比 C/C++/Java 这样的语言真是太方便了。看起来一切都很美好。

然而,设想以后有了新的统计任务,比如:

  1. 统计某个特定页面的访问次数
  2. 处理另外的一些日志文件,日志文件名字以 error-log 开头

完成这些任务直接拿上面的代码过来改改就可以了,文件名的 pattern 改一下,处理每个文件的代码改一下。其实每次任务的处理中,找到特定名字为特定 pattern 的文件的代码是一样的,直接修改之前的代码其实就引入了重复。

如果重复的代码量很大,我们很自然的会注意到。然而 python 的 for 循环实在太方便了,像这里找文件的代码一共就两行,哪怕重写一遍也不会觉得太麻烦。for 循环的方便使得我们会忽略这样简单代码的重复。然而,再怎么方便好用,for 循环无法重用,只有把它放到函数中才能进行重用

(先考虑下是你会如何避免这里的代码的重复。下面马上出现的代码并不好,是“误导性”的代码,我会在之后再给出“更好”的代码。)

因此,我们把上面代码中不变的部分提取成一个通用的函数,可变的部分以参数的形式传入,得到下面的代码。

def generic_process(topdir, filepat, processfunc):
    for path, dirlist, filelist in os.walk(top):
        for name in fnmatch.filter(filelist, filepat):
            with open(name) f:
                processfunc(f)
 
sum = 0
# 很遗憾,python 对 closure 中的变量不能进行赋值操作,
# 因此这里只能使用全局变量
def add_count(f):
    global sum
    for line in f:
        if line[-1] == '-':
            continue
        else:
            sum += int(line.rsplit(None, 1)[1])
 
generic_process('logdir', 'access-log*', add_count)

看起来不变和可变的部分分开了,然而 generic_process 的设计并不好。它除了寻找文件以外还调用了日志文件处理函数,因此在其他任务中很可能就无法使用。另外 add_count 的参数必须是 file like object,因此测试时不能简单的直接使用字符串。

管道风格的程序

下面考虑用 Unix 的工具和管道我们会如何完成这个任务:

find logdir -name "access-log*" | \
xargs cat | \
grep '[^-]$' | \
awk '{ total += $NF } END { print total }'

find 根据文件名 pattern 找到文件,cat 把所有文件内容合并输出到 stdout,grep 从 stdin 读入,过滤掉行末为 ‘-’ 的行,awk 提取每行最后一列,将数值相加,最后打印出结果。(省掉 cat 是可以的,但这样一来 grep 就需要直接读文件而不是只从标准输入读。)

我们可以在 python 代码中模拟这些工具,Unix 的工具通过文本来传递结果,在 python 中可以使用 list。

def find(topdir, filepat, processfunc):
    files = []
    for path, dirlist, filelist in os.walk(top):
        for name in fnmatch.filter(filelist, filepat):
            files.append(name)
    return files
 
 def cat(files):
    lines = []
    for file in files:
        with open(file) as f:
            for line in f:
                lines.append(line)
    return lines
 
 def grep(pattern, lines):
    result = []
    import re
    pat = re.compile(pattern)
    for line in lines:
        if pat.search(line):
            result.append(line)
    resurn result
 
lines = grep('[^-]$', cat(find('logdir', 'access-log*')))
col = (line.rsplit(None, 1)[1] for line in lines)
print sum(int(c) for c in col)

有了 find, cat, grep 这三个函数,只需要连续调用就可以像 Unix 的管道一样将这些函数组合起来。数据在管道中的变化如下图(简洁起见,过滤器直接标在箭头上 ):

data-pipe

看起来现在的代码行数比最初直接用 for 循环的代码要多,但现在的代码就像 Unix 的那些小工具一样,每一个都更加可能被用到。我们可以把更多常用的 Unix 工具用 Python 来模拟,从而在 Python 代码中以 Unix 管道的风格来编写代码。

不过上面的代码性能很差,多个临时的 list 被创建。解决的办法是用 generator,因为篇幅比较长,具体做法放到下一篇文章中。

Tags: ,

为什么 Unix 的管道是好东西

January 19th, 2009 2 comments

Unix 的管道实在是一个好用的东西,利用管道可以很方便的把一些简单的工具结合起来完成一些复杂的任务。这里不想解释什么是 Unix 的管道,也不举具体的例子来展示管道的方便之处,只是想通过一个现实中管道的例子来说明为什么管道是好东西,为下一篇文章写管道的思想在写程序时的应用做准备。

正如其名字暗示的,Unix 的管道其实跟现实生活中的管道非常类似。要注意的是这里讨论的管道不只是为了传送液体(或者气体),还可能对液体进行一些处理。构成管道最主要的东西其实就是一节一节管子/过滤器。液体从管道的一端流入,经过各个过滤器的处理,从另一端流出时可能已经是另外的液体了。流出什么样的液体取决于流入的液体和组成管道的过滤器,很显然决定管道功能的是构成管道的每一个过滤器

现在假设我要进行污水处理,污水里有沙子,还有各种溶解在其中的物质。我可以做一个只含一个过滤器的管道,这个过滤器可以把沙子和溶解物一次性全部过滤掉。

污水处理-一个过滤器

污水处理-一个过滤器

但如果再来一批污水,只含有沙子,那用之前的这个过滤器显然太浪费了;或者,新的污水含有原先污水中没有的溶解物,那只用原先的过滤器是不够的。

比较好的做法是用一个过滤器过滤沙子,每种溶解物用一个过滤器(现实中的污水处理当然不可能这么简单,这里只是以此为例子),污水里需要过滤什么就在管道上加上什么过滤器。

污水处理-多个过滤器

污水处理-多个过滤器

这样做的好处有几个:

  1. 每个过滤器做起来都比较简单,因此每个过滤器的制造成本降低了
  2. 过滤器更可能被重复使用
  3. 易于处理各种不同的污水,只需要重新组合下过滤器就可以了,而不需要为每种污水设计一个过滤器

Unix 的管道其实就是模仿了现实生活中的管道,只不过流经管道的是数据(多数时候是文本),构成过滤器的是 Unix 下的那些小工具。Unix 中使用管道的好处与污水处理的例子里是类似的

  1. 每一个工具只完成一个很简单的任务,因此每个工具都很容易实现
  2. 每个工具更有可能被用到,而工具重用的成本几乎为 0
  3. 更加灵活(见下面的例子)

有些管道的用法真是让人感叹,比如现在的 Linux 版 QQ 收到消息是没有声音的,有人用
tcpdump, sed, aplay 组合自己山寨了这个功能出来。还有听小百合的 lv 说他用 mplayer,gzip, netcat 等工具做了一个视频监控系统出来。QQ 的那个例子做法如下:

sudo tcpdump -i ppp0 -l udp src port 8000 and ip[0x20]=0x17 | \
    sed -u '/.*$/s//aplay msg.wav/' | sh

前面说过,管道的功能是由过滤器决定的。光由操作系统提供一个构建管道的机制是没有什么大用处的Unix 管道的强大关键还在于那些小工具的设计

从一开始,那些小工具的设计就考虑到了通过管道来配合使用(我觉得应该是管道的存在影响了这些工具的设计,而不是反过来,不知道 Unix 的历史是否的确如此),因此这些工具有几个显著的特点

  1. 功能简单,有的工具简单到单独看觉得根本就没什么用
  2. 以文本作为交互的数据,人能读,程序也能读
  3. 功能上尽可能正交,每个工具完成自己的任务,不跟其他工具的功能重复(awk,sed 这样的程序功能上还是有些重复的,当然有时候一件事情能用多种方式完成也是好事。)

软件开发的成本与其复杂度不是线性关系,再加上每个工具都得到了更多的重用,组合起来还可以实现新的功能,考虑到这几点,使用管道可以节省的系统工具开发成本就不是一点点了。当然,光节省成本而不好用那也没意义,但事实是管道很有用。

每个工具都只执行简单的任务也有一点麻烦,一些相对简单的任务也可能需要调用好几个工具,此时可以把常用的命令组合可以存到脚本里。这就相当于把管道存起来,以后直接用这个管道而不用再重新把过滤器搭起来了。另一个方面是用户需要花相对来说比较多的时间来学习这些工具,对于经常要使用电脑的人来说,考虑下学习这些工具以后能得到的收益,花些时间是完全值得的。(对那些偶尔使用的人来说可能就不划算了。)

关于管道和 Unix 的设计哲学,在 Eric S. Raymond 的 "The Art of UNIX Programming" 中有着更多的介绍。(该死的 GFW 把 Raymond 的主页都墙了。)

Unix 管道的好处我们已经看到了,那可不可以在写程序时也使用这样的思想呢?当然可以!下一篇文章我就要谈在程序中如何使用管道的思想。以 Python 为例,介绍 generator,还有为什么 lazy evaluation 也是好东西 :)

Tags: ,