背景

每次写这类东西都要先记录背景……有点重复无趣,但还是不能省略这个步骤,嗯。如这里所写的,因为一些需要把 Python 捡起来了。

想舒适地使用一门编程语言,自然要去领略它的独特之处。Python 除了语法简洁(相对而言)之外,还有很多有趣的特性。比如,嗯,「函数式编程」!这些年这东西很流行,「JavaScript 函数式编程」、「Python 函数式编程」……要不是 PHP 不再火了,说不定还能看到「PHP 函数式编程」,毕竟 PHP 也支持闭包,甚至还有 array_map()array_filter() 等函数,而且 PHP 7.4 之后居然可以写 fn($n) => $n 这样的东西了。

好了不扯了……来看看 Python 吧,既然很多人说它可以「函数式编程」,那证明它确实是有一定的「函数式编程」基础设施的,至少比 Go 语言更适合。迭代器、生成器、列表(元组等)推导式等等,也有 map()filter()zip() 等内置函数。lambda 表达式语法可能不够简洁,但也基本可以使用。

推导式 前篇

因为上面的背景,需要写一些东西,比如记录音名。12 个音,CDEFGAB 以及各自升降,除 B# Cb E# Fb 几个等音外不再去重,保留 C# Db 之类的。有什么好的写法呢?最好的写法肯定是全部手写一遍,但是不够「简洁」。于是复习了一下,我写出了这样奇怪的东西……

tuple(x for x in (x for y in ((lambda x: (x, x + '#', x + 'b'))(chr(x)) for x in range(ord('A'), ord('G') + 1)) for x in y) if x not in ('B#', 'Cb', 'E#', 'Fb'))

不太好看,缩进一下。

tuple(x for x in (
        x for y in (
            (lambda x: (x, x + '#', x + 'b'))(chr(x))
                for x in range(ord('A'), ord('G') + 1))
        for x in y
    ) if x not in ('B#', 'Cb', 'E#', 'Fb'))

也不太好理解,一层一层拆开看吧。首先是 lambda x: (x, x + '#', x + 'b'),很明显这个匿名函数返回一个元组,比如输入的 x 是 'D',那么返回的就是 ('D', 'D#', 'Db')。所以,最内层如下。

tuple((lambda x: (x, x + '#', x + 'b'))(chr(x))
        for x in range(ord('A'), ord('G') + 1))

# (('A', 'A#', 'Ab'), ('B', 'B#', 'Bb'), ('C', 'C#', 'Cb'), ('D', 'D#', 'Db'), ('E', 'E#', 'Eb'), ('F', 'F#', 'Fb'), ('G', 'G#', 'Gb'))

接下来就是把内部元组嵌套的元素提取出来。一个例子。

z = [(1, 2), (3, 4), (5, 6)]
[x for y in z for x in y] # [1, 2, 3, 4, 5, 6]

这里也一样,中间一层如下。

tuple(x for y in (
    (lambda x: (x, x + '#', x + 'b'))(chr(x))
        for x in range(ord('A'), ord('G') + 1))
for x in y)

# ('A', 'A#', 'Ab', 'B', 'B#', 'Bb', 'C', 'C#', 'Cb', 'D', 'D#', 'Db', 'E', 'E#', 'Eb', 'F', 'F#', 'Fb', 'G', 'G#', 'Gb')

接下来就是去除 ('B#', 'Cb', 'E#', 'Fb') 这几项了,清晰明了。

对比

尽管后面还用这个方法组合了音名与和弦记号、音程等等(这里就不再举例了),但整个推导式好像也没有特别不好理解的地方?是,确实,但是后面的一个应用让我发现,推导式这个东西很有趣。

先转移一下视线,看一下其他语言里类似操作是什么样的吧。首先试一下 C++ (一点都不「函数式」……)。

#include <algorithm>
#include <numeric>
#include <set>
#include <string>
#include <vector>

int main() {
    std::vector<char> basic(7);
    std::iota(std::begin(basic), std::end(basic), 'A');
    std::vector<std::string> result;
    auto fill = [&](const char &c) {
        std::string s(1, c);
        result.push_back(s);
        result.push_back(s + "#");
        result.push_back(s + "b");
    };
    std::for_each(basic.begin(), basic.end(), fill);
    auto filter = [](const std::string &s) {
        std::set<std::string> filter = {"B#", "Cb", "E#", "Fb"};
        return filter.find(s) != filter.end();
    };
    result.erase(std::remove_if(result.begin(), result.end(), filter), result.end());
    return 0;
}

难度有些大,碍于自身 C++ 水平只能这样写了。如果使用模版可能会更简洁。


学了点 C++ 的奇技淫巧,包括 C++20 rangestransform 还有模板等等,回来更新一下 C++ 的部分。

#include <algorithm>
#include <iterator>
#include <ranges>
#include <set>
#include <string>

template <typename First, typename Last, typename Inserter, typename... Func>
void multi_transform(
    First&& first, Last&& last, Inserter&& inserter, Func&&... func)
{
    (std::ranges::transform(std::forward<First>(first),
         std::forward<Last>(last),
         std::forward<Inserter>(inserter),
         std::forward<Func>(func)),
        ...);
}

int main()
{
    std::vector<std::string> result;
    auto basic = std::ranges::iota_view{'A', 'G' + 1};
    auto make_note = [&](char c) { return std::string(1, c); };
    auto make_flat_note = [&](char c) { return std::string(1, c) + "b"; };
    auto make_sharp_note = [&](char c) { return std::string(1, c) + "#"; };
    auto filter = [](const std::string& s) {
        std::set<std::string> filter = {"B#", "Cb", "E#", "Fb"};
        return filter.find(s) != filter.end();
    };
    multi_transform(basic.begin(), basic.end(), std::back_inserter(result),
        make_note, make_flat_note, make_sharp_note);
    result.erase(std::remove_if(result.begin(), result.end(), filter), result.end());
    return 0;
}

换用 Rust 试试吧,据说它相关的基础设施做得更好一些。

fn main() {
    let mut result = Vec::new();
    for i in (b'A'..=b'G').map(char::from) {
        result.push(vec![
            i.to_string(),
            i.to_string() + "#",
            i.to_string() + "b",
        ])
    }
    let result: Vec<String> = result
        .iter()
        .flatten()
        .filter(|x| {
            let x = x.as_str();
            x != "B#" && x != "Cb" && x != "E#" && x != "Fb"
        })
        .cloned()
        .collect();
}

看起来确实好那么一些,尽管写的时候偶尔会被所有权问题困扰,老生常谈,喜闻乐见了。主要的感受是 Rust 的 flatten() 用起来更方便一点点,而且和链式调用结合得很好。

再看一下 Haskell、Scala 写法吧。就不再换行缩进了,其实两者整体结构类似,具体也可以参照上面缩进的 Python 代码,效果是大致一样的。

filter (\x -> x ```notElem``` ["B#", "Cb","E#" ,"Fb"]) $ concat $ map (\(a, b, c) -> [a, b, c]) [(x, x ++ "#", x ++ "b") | x <- [[x] | x <- ['A'..'G']]]
(for (x <- for (x <- 'A' to 'G') yield x.toString) yield (x, x + "#", x + "b")).flatMap(x => List(x._1, x._2, x._3)).filter(x => !List("B#", "Cb", "E#", "Fb").contains(x))

这两种函数式编程语言有相关之处,而总体来看,所有类似的思想都一致,写法也都多少有关联。Python 的 List/Set/Tuple/Dict Comprehension、Haskell 的 List Comprehension、Scala 的 For Comprehension 代表的东西也类似。由于不熟悉编程语言理论,这里就不再展开了。

Fold

再跑题一下,来看看 Haskell 的 foldlflodr

foldl (+) 0 [1, 2, 3]
foldr (+) 0 [1, 2, 3]

结果都是 6,前者的折叠内容是 (((0 + 1) + 2) + 3),后者折叠内容是 1 + (2 + (3 + 0))。因为 (+) :: Num a => a -> a -> a,左、右似乎没什么区别。这时候,换一个做尝试。

foldr (:) [] [1, 2, 3]

结果是 [1, 2, 3],而 [1, 2, 3] 本质是 1 : (2 : (3 : [])),这里右折叠内容也是 (1 : (2 : (3 : [])))。若是以同样的方式左折叠 foldl (:) [] [1, 2, 3] 则会有类型问题,因为违反了 (:) :: a -> [a] -> [a] 的类型匹配。这里构造一个 append 如下,再使用它来左折叠。

append :: [a] -> a -> [a]
append a x = a ++ [x]

foldl append [] [1, 2, 3]

这样,折叠内容为 ((([] `append` 1) `append` 2) `append` 3),结果即为 [1, 2, 3]

推导式 后篇

动脑环节告一段落。上面说的,发现推导式很有趣的「应用」是处理 chromagram 序列小节均值和节奏瞬变点相关的,简化来描述就是这样:

设有一个序列 b,包含多个维度。现在给出记录着序列 b 划分小节的间断点序列 a。划分 b 的小节,并求每段的均值。 例如,b = [[0, 1, 2, ... 17], [-18, -17, -16, ..., 0]],2 × 18。 a = [3, 9],即将 b 按照 [0, 3), [3, 9), [9, 17] 分段。 最后结果为 [[1.0, 5.5, 12.5], [-17.0, -12.5, -5.5]]

问题不困难,可以开始写了。

import numpy as np

b = [[x for x in range(0, 18)], [x for x in range(-18, 0)]]
b = np.array(b)

a = np.array([3, 9])
a = np.insert(a, 0, 0)
a = np.append(a, len(b[0]) - 1)
def compute(a, b) -> list:
    full = []
    for i in range(len(b)):
        one = []
        for j in range(len(a) - 1):
            one.append(b[i][a[j]:a[j+1]].mean())
        full.append(one)
    return full

写完以后,我发现……不知为什么,看着这一段的样子:初值、重复、填充,总是会联想到学习相关概念时见过的 Haskell 的……对,就是上面的 foldlflodr

这一段看起来长得……结构也和 Python 的列表推导式有点相似,动手改一下?

def compute_x(a, b) -> list:
    return [[b[i][a[j]:a[j+1]].mean() for j in range(len(a) - 1)] for i in range(len(b))]

两个的计算结果完全一致。而之前在和人聊起 Python 的时候,他提到 Python 列表推导式的写法展开成字节码,是先创建一个空的 list() 再进行跳转循环,循环内容就是 append() 等等。回过头来,看一眼自己上面写的东西,我可能手动展开了一次列表推导式……

看字节码。第一段。

  2           0 BUILD_LIST               0
              2 STORE_FAST               2 (full)

  3           4 SETUP_LOOP              98 (to 104)
              6 LOAD_GLOBAL              0 (range)
              8 LOAD_GLOBAL              1 (len)
             10 LOAD_FAST                1 (b)
             12 CALL_FUNCTION            1
             14 CALL_FUNCTION            1
             16 GET_ITER
        >>   18 FOR_ITER                82 (to 102)
             20 STORE_FAST               3 (i)

  4          22 BUILD_LIST               0
             24 STORE_FAST               4 (one)

  5          26 SETUP_LOOP              62 (to 90)
             28 LOAD_GLOBAL              0 (range)
             30 LOAD_GLOBAL              1 (len)
             32 LOAD_FAST                0 (a)
             34 CALL_FUNCTION            1
             36 LOAD_CONST               1 (1)
             38 BINARY_SUBTRACT
             40 CALL_FUNCTION            1
             42 GET_ITER
        >>   44 FOR_ITER                42 (to 88)
             46 STORE_FAST               5 (j)

  6          48 LOAD_FAST                4 (one)
             50 LOAD_METHOD              2 (append)
             52 LOAD_FAST                1 (b)
             54 LOAD_FAST                3 (i)
             56 BINARY_SUBSCR
             58 LOAD_FAST                0 (a)
             60 LOAD_FAST                5 (j)
             62 BINARY_SUBSCR
             64 LOAD_FAST                0 (a)
             66 LOAD_FAST                5 (j)
             68 LOAD_CONST               1 (1)
             70 BINARY_ADD
             72 BINARY_SUBSCR
             74 BUILD_SLICE              2
             76 BINARY_SUBSCR
             78 LOAD_METHOD              3 (mean)
             80 CALL_METHOD              0
             82 CALL_METHOD              1
             84 POP_TOP
             86 JUMP_ABSOLUTE           44
        >>   88 POP_BLOCK

  7     >>   90 LOAD_FAST                2 (full)
             92 LOAD_METHOD              2 (append)
             94 LOAD_FAST                4 (one)
             96 CALL_METHOD              1
             98 POP_TOP
            100 JUMP_ABSOLUTE           18
        >>  102 POP_BLOCK

  8     >>  104 LOAD_FAST                2 (full)
            106 RETURN_VALUE

第二段。

  2           0 LOAD_CLOSURE             0 (a)
              2 LOAD_CLOSURE             1 (b)
              4 BUILD_TUPLE              2
              6 LOAD_CONST               1 (<code object <listcomp> at 0x7f6af7865e40, file "<stdin>", line 2>)
              8 LOAD_CONST               2 ('compute_x.<locals>.<listcomp>')
             10 MAKE_FUNCTION            8
             12 LOAD_GLOBAL              0 (range)
             14 LOAD_GLOBAL              1 (len)
             16 LOAD_DEREF               1 (b)
             18 CALL_FUNCTION            1
             20 CALL_FUNCTION            1
             22 GET_ITER
             24 CALL_FUNCTION            1
             26 RETURN_VALUE

Disassembly of <code object <listcomp> at 0x7f6af7865e40, file "<stdin>", line 2>:
  2           0 BUILD_LIST               0
              2 LOAD_FAST                0 (.0)
        >>    4 FOR_ITER                38 (to 44)
              6 STORE_DEREF              0 (i)
              8 LOAD_CLOSURE             1 (a)
             10 LOAD_CLOSURE             2 (b)
             12 LOAD_CLOSURE             0 (i)
             14 BUILD_TUPLE              3
             16 LOAD_CONST               0 (<code object <listcomp> at 0x7f6af7865c90, file "<stdin>", line 2>)
             18 LOAD_CONST               1 ('compute_x.<locals>.<listcomp>.<listcomp>')
             20 MAKE_FUNCTION            8
             22 LOAD_GLOBAL              0 (range)
             24 LOAD_GLOBAL              1 (len)
             26 LOAD_DEREF               1 (a)
             28 CALL_FUNCTION            1
             30 LOAD_CONST               2 (1)
             32 BINARY_SUBTRACT
             34 CALL_FUNCTION            1
             36 GET_ITER
             38 CALL_FUNCTION            1
             40 LIST_APPEND              2
             42 JUMP_ABSOLUTE            4
        >>   44 RETURN_VALUE

Disassembly of <code object <listcomp> at 0x7f6af7865c90, file "<stdin>", line 2>:
  2           0 BUILD_LIST               0
              2 LOAD_FAST                0 (.0)
        >>    4 FOR_ITER                36 (to 42)
              6 STORE_FAST               1 (j)
              8 LOAD_DEREF               1 (b)
             10 LOAD_DEREF               2 (i)
             12 BINARY_SUBSCR
             14 LOAD_DEREF               0 (a)
             16 LOAD_FAST                1 (j)
             18 BINARY_SUBSCR
             20 LOAD_DEREF               0 (a)
             22 LOAD_FAST                1 (j)
             24 LOAD_CONST               0 (1)
             26 BINARY_ADD
             28 BINARY_SUBSCR
             30 BUILD_SLICE              2
             32 BINARY_SUBSCR
             34 LOAD_METHOD              0 (mean)
             36 CALL_METHOD              0
             38 LIST_APPEND              2
             40 JUMP_ABSOLUTE            4
        >>   42 RETURN_VALUE

很直观,不难理解。都 BUILD_LIST 两次,其中的 append 手写的是 LOAD_METHOD (append),列表推导式是 LIST_APPPEN。列表推导式的好处(只对机器来说),大概是生成的字节码比较简洁?

总结

想发表一些浅薄无知的看法,又怕贻笑大方,就不多抒发感情了。只从上面一点点的例子来看,不谈数学结构和「人体工学」,只对机器而言,「函数式编程」最主要的就是描述方法,把步骤丢给计算机计算。惰性求值也好什么也好,最后计算机还是按指令一步一步走的(流水线也算是一步一步吧……)。

不一定每个人都擅长、喜爱、经受过专业训练并熟练掌握 λ 演算、组合子逻辑等等抽象概念。有时候,仅从直觉上去分析步骤,按照指令式的思想完成一些任务,效果不一定不好。所谓的「快速排序多简单,看我 Haskell 一行搞定」,我猜想,各种写法最后到计算机上生成的指令也大都相似吧。

最后再写一个彩蛋好了。

array_values(array_filter(call_user_func_array('array_merge', array_map(fn($n) => [$n, $n . '#', $n . 'b'], range('A', 'G'))), fn($n) => (in_array($n, ['B#', 'Cb', 'E#', 'Fb']) ? false : true)))

说真的,我在写这个之前完全没有想到 PHP 写这个东西会这么流畅,一定要拆开看看。

array_values(
    array_filter(
        call_user_func_array('array_merge',
            array_map(fn($n) => [$n, $n . '#', $n . 'b'], range('A', 'G'))
        ),
        fn($n) => (in_array($n, ['B#', 'Cb', 'E#', 'Fb']) ? false : true)
    )
)

嗯,结论已经很明显了。一起大声说: PHP 是……