一级函数:Lambda函数和Map

什么是一级函数?

你可能听过它之前说过,一种特定的语言是功能性的,因为它有“一流的功能”。正如我在本系列关于函数式编程的第一篇文章中所说的那样,我不赞同这种流行的观点。我同意一流的函数是任何函数式语言的基本特征,但我不认为这是语言具有功能性的充分条件。有许多命令式语言也具有这一特性。但是,什么是一流的函数呢?功能描述为头等舱当它们可以像任何其他值一样处理时-也就是说,它们可以在运行时被动态地分配给一个名称或符号。它们可以存储在数据结构中,通过函数参数传入,并作为函数返回值返回。

这其实不是一个新奇的想法。函数指针从1972年开始就一直是C的一个特性。在此之前,过程引用是Algol 68的一个特性,于1970年实现,当时,它们被认为是程序性编程特性追溯到更久以前,Lisp(首次实现于1963年)是建立在程序代码和数据是可互换的概念之上的。

这些也不是模糊的特性。在C语言中,我们通常使用函数作为一流的对象。例如,在排序时:

char **array = randomStrings();

printf("Before sorting:\n");
for (int s = 0; s < NO_OF_STRINGS; s++)
    printf("%s\n", array[s]);

qsort(array, NO_OF_STRINGS, sizeof(char *), compare);

printf("After sorting:\n");
for (int s = 0; s < NO_OF_STRINGS; s++)
    printf("%s\n", array[s]);


这,这个,那,那个stdlibC中的库为不同类型的排序例程提供了一组函数。它们都能够对任何类型的数据进行排序:程序员所需要的唯一帮助就是提供一个比较数据集的两个元素并返回的函数。-11,或0,指示哪个元素大于另一个元素或它们相等。

这本质上就是战略模式!

指向字符串的指针数组的比较器函数可以是:

int compare(const void *a, const void *b)
{
    char *str_a = *(char **) a;
    char *str_b = *(char **) b;
    return strcmp(str_a, str_b);
}


然后,我们将其传递给排序函数,如下所示:

qsort(array, NO_OF_STRINGS, sizeof(char *), compare);


控件上没有括号。compare函数名使编译器发出函数指针,而不是函数调用。因此,在C中将函数视为头等对象是非常容易的,尽管接受函数指针的函数的签名非常难看:

qsort(void *base, size_t nel, size_t width, int (*compar)(const void *, const void *));


函数指针不仅用于排序。早在.NET发明之前,就有用于编写MicrosoftWindows应用程序的Win 32 API。在此之前,有Win16API。它自由地使用函数指针作为回调。应用程序在调用窗口管理器时提供了指向其自身函数的指针,当应用程序需要通知某个已经发生的事件时,窗口管理器将调用该窗口管理器。您可以认为这是应用程序(观察者)与其窗口(可观察的)之间的一个观察者模式关系-应用程序接收到了发生在其窗口上的事件的通知,例如鼠标单击和按键盘。在窗口管理器中抽象了管理窗口的工作-移动窗口,将它们堆叠在一起,决定哪个应用程序是用户操作的接收者。这些应用程序对它们共享环境的其他应用程序一无所知。在面向对象的编程中,我们通常通过抽象类和接口来实现这种解耦,但也可以使用一流的函数来实现。

所以,我们使用一流的函数已经有很长时间了。但是,公平地说,没有一种语言比简陋的Javascript更能广泛地推广作为一流公民的功能。

Lambda表达式

在Javascript中,将函数传递给用作回调的其他函数一直是一种标准做法,就像在Win 32 API中一样。这个想法是HTML DOM的一个组成部分,其中第一类函数可以作为事件侦听器添加到DOM元素中:

function myEventListener() {
    alert("I was clicked!")
}
...
var myBtn = document.getElementById("myBtn")
myBtn.addEventListener("click", myEventListener)


就像在C中一样,myEventListener函数名时,在调用addEventListener意味着它不会立即执行。相反,该函数与click事件中的DOM元素。当单击元素时,然后将调用该函数并发生警报。

流行的jQuery库通过证明通过查询字符串选择DOM元素的函数简化了流程,并提供了操作元素和向元素添加事件侦听器的有用函数:

$("#myBtn").click(function() {
    alert("I was clicked!")
})


类中使用的第一类函数也是实现异步I/O的方法。XMLHttpRequest对象,它是Ajax的基础。同样的想法在Node.js中也很普遍。当您想要进行一个非阻塞函数调用时,将它传递给一个函数的引用,以便在它完成时调用您。

但是,这里还有别的东西。第二个例子不仅仅是一个一流函数的例子。它也是Lambda函数。具体而言,本部分:

function() {
    alert("I was clicked!");
}


lambda函数(通常被称为兰卜达)是一个未命名的函数。他们本可以叫他们匿名函数,这样每个人都会立刻知道他们是什么。但是,这听起来不那么令人印象深刻,所以lambda函数就是!lambda函数的要点是在那里只需要一个函数;因为它在任何地方都不需要,所以您只需要在那里定义它。不需要名字。如果你需要在其他地方重用它,然后考虑将它定义为一个命名函数,然后按名称引用它,就像我在第一个Javascript示例中所做的那样。如果没有lambda函数,使用jQuery和Node进行编程确实会令人厌烦。

LAMBDA函数以不同的方式以不同的语言定义:

在Javascript中:function(a, b) { return a + b }

在Java中:(a, b) -> a + b

在C#中:(a, b) => a + b

在Clojure中:(fn [a b] (+ a b))

在Clojure中-速记版本:#(+ %1 %2)

在Groovy中:{ a, b -> a + b }

在F#中:fun a b -> a + b

在Ruby中,所谓的“稳定”语法:-> (a, b) { return a + b }

正如我们所看到的,大多数语言都倾向于一种比Javascript更简洁的表达lambdas的方式。

地图

您可能已经在编程中使用了“map”一词来表示将对象存储为键值对的数据结构(如果您的语言称它为“字典”,那么就没有问题了)。在函数式编程中,这个词还有一个额外的含义。事实上,基本概念是一样的。在这两种情况下,一组事物被映射到另一组事物。在数据结构的意义上,映射是一个名词-键被映射到值。在编程意义上,map是一个动词-一个函数将一个值数组映射到另一个值数组。

假设你有一个功能f以及一系列的值A = [A1A2A3A4]地图f过关A手段应用f中的每一个元素A:

  • A1 → f (A1) = a1‘

  • A2 → f (A2) = a2‘

  • A3 → f (A3) = A3‘

  • A4 → f (A4) = A4‘

然后,按照与输入相同的顺序组装结果数组:

A‘=地图(fA ) = [a1‘a2‘A3‘A4‘]

逐个图

好吧,这很有趣,但是位数学。你多久会这么做一次?实际上,这比你想象的要频繁得多。像往常一样,有一个例子最能解释事情,所以让我们看一看我从下面举出来的一个简单的练习exercism.io当我学习Clojure的时候。这项运动被称为“RNA转录”,它非常简单。我们将看一看需要转录成输出字符串的输入字符串。这些基础是这样翻译的:

  • C→G

  • G→C

  • →U

  • T→A

除C、G、A、T以外的任何输入都是无效的。JUnit 5中的测试可能如下所示:

class TranscriberShould {

    @ParameterizedTest
    @CsvSource({
            "C,G",
            "G,C",
            "A,U",
            "T,A",
            "ACGTGGTCTTAA,UGCACCAGAAUU"
    })
    void transcribe_dna_to_rna(String dna, String rna) {
        var transcriber = new Transcriber();
        assertThat(transcriber.transcribe(dna), is(rna));
    }

    @Test
    void reject_invalid_bases() {
        var transcriber = new Transcriber();
        assertThrows(
                IllegalArgumentException.class,
                () -> transcriber.transcribe("XCGFGGTDTTAA"));
    }
}


而且,我们可以通过这个Java实现通过测试:

class Transcriber {

    private Map<Character, Character> pairs = new HashMap<>();

    Transcriber() {
        pairs.put('C', 'G');
        pairs.put('G', 'C');
        pairs.put('A', 'U');
        pairs.put('T', 'A');
    }

    String transcribe(String dna) {
        var rna = new StringBuilder();
        for (var base: dna.toCharArray()) {
            if (pairs.containsKey(base)) {
                var pair = pairs.get(base);
                rna.append(pair);
            } else
                throw new IllegalArgumentException("Not a base: " + base);
        }
        return rna.toString();
    }
}


用函数样式编程的关键是,毫不奇怪地,将可能表示为函数的所有内容转换为一个函数。所以,让我们这样做:

char basePair(char base) {
    if (pairs.containsKey(base))
        return pairs.get(base);
    else
        throw new IllegalArgumentException("Not a base " + base);
}

String transcribe(String dna) {
    var rna = new StringBuilder();
    for (var base : dna.toCharArray()) {
        var pair = basePair(base);
        rna.append(pair);
    }
    return rna.toString();
}


现在,我们可以用地图作为动词了。在Java中,在Streams API中提供了一个函数:

char basePair(char base) {
    if (pairs.containsKey(base))
        return pairs.get(base);
    else
        throw new IllegalArgumentException("Not a base " + base);
}

String transcribe(String dna) {
    return dna.codePoints()
            .mapToObj(c -> (char) c)
            .map(base -> basePair(base))
            .collect(
                    StringBuilder::new,
                    StringBuilder::append,
                    StringBuilder::append)
            .toString();
}


那么,让我们批评一下这个解决方案。可以说的最好的事情就是循环已经消失了。如果你想一想,循环是一种文书活动,我们真的不应该去关注大部分时间。通常,我们循环是因为我们想为集合中的每个元素做一些事情。我们真的这里要做的是获取这个输入序列并从它生成一个输出序列。流为我们处理迭代的基本管理工作。事实上,它是一种设计模式-一种功能性设计模式-但是,我现在还不想提它的名字。我还不想把你吓跑。

我不得不承认,代码的其余部分并没有那么好,这主要是因为Java中的原语不是对象。第一点不伟大的地方是:

mapToObj(c -> (char) c)


我们必须这样做,因为Java对原语和对象的处理方式不同,而且尽管语言确实为原语设置了包装类,但是无法直接从字符串中获取字符对象的集合。

另一个不那么令人敬畏的地方是:

.collect(
        StringBuilder::new,
        StringBuilder::append,
        StringBuilder::append)


还不清楚为什么要打电话append两次。我稍后会解释,但现在时机不对。

我不打算为这个密码辩护-这太糟糕了。如果有一种方便的方法从字符串中获取一个字符流对象,甚至是一个字符数组,那么就没有问题了,但我们还没有得到一个。在Java中,处理原语不是FP的亮点。想想看,它甚至对OO编程都没有好处。所以,也许我们不应该那么痴迷于原语。如果我们把它们设计在代码之外呢?我们可以为基础创建一个枚举:

enum Base {
    C, G, A, T, U;
}

而且,我们有一个类作为一个包含一系列碱基的一流集合:

class Sequence {

    List<Base> bases;

    Sequence(List<Base> bases) {
        this.bases = bases;
    }

    Stream<Base> bases() {
        return bases.stream();
    }
}


现在,Transcriber 看起来是这样的:

class Transcriber {

    private Map<Base, Base> pairs = new HashMap<>();

    Transcriber() {
        pairs.put(C, G);
        pairs.put(G, C);
        pairs.put(A, U);
        pairs.put(T, A);
    }

    Sequence transcribe(Sequence dna) {
        return new Sequence(dna.bases()
                .map(pairs::get)
                .collect(toList()));
    }
}


这样好多了。这,这个,那,那个pairs::get是方法引用;它引用get方法的实例分配给pairs变量。通过为基创建类型,我们设计了无效输入的可能性,因此需要basePair方法消失,异常也会消失。这是Java的一个优势,它本身不能在函数契约中强制执行类型。更重要的是,StringBuilder也消失了。当您需要迭代一个集合、以某种方式处理每个元素以及构建一个包含结果的新集合时,Java流是很好的。这可能在你生命中写的循环中占了相当大的比例。大部分家务活,不是手头真正工作的一部分,都是为你做的。

在Clojure

撇开输入不足不谈,Clojure比Java版本要简洁一些,并且它给我们提供了在字符串的字符上进行映射的难度。Clojure中最重要的抽象是序列;所有集合类型都可以视为序列,字符串也不例外:

(def pairs {\C, "G",
            \G, "C",
            \A, "U",
            \T, "A"})

(defn- base-pair [base]
  (if-let [pair (get pairs base)]
    pair
    (throw (IllegalArgumentException. (str "Not a base: " base)))))

(defn transcribe [dna]
  (map base-pair dna))


此代码的业务端是最后一行。(map base-pair dna)-这是值得指出的,因为你可能已经错过了。意思是map这,这个,那,那个base-pair函数对dna字符串(表现为序列)。如果我们希望它返回一个字符串而不是一个列表,这就是map给我们,唯一需要的改变是:

(apply str (map base-pair dna))


在C#中

让我们试试另一种语言。C#中解决方案的命令式方法如下所示:

namespace RnaTranscription
{
    public class Transcriber
    {
        private readonly Dictionary<char, char> _pairs = new Dictionary<char, char>
        {
            {'C', 'G'},
            {'G', 'C'},
            {'A', 'U'},
            {'T', 'A'}
        };

        public string Transcribe(string dna)
        {
            var rna = new StringBuilder();
            foreach (char b in dna)
                rna.Append(_pairs[b]);
            return rna.ToString();
        }
    }
}


同样,C#没有向我们介绍我们在Java中遇到的问题,因为C#中的字符串是可枚举的,而且所有的“原语”都可以被视为具有行为的对象。

我们可以一种更实用的方式重写程序,就像这样,结果显示它比JavaStreams版本要少得多。对于Java流中的“map”,请改为C#中的“select”:

public string Transcribe(string dna)
{
    return String.Join("", dna.Select(b => _pairs[b]));
}


或者,如果您愿意,可以使用LINQ作为其语法糖:

public string Transcribe(string dna)
{
    return String.Join("", from b in dna select _pairs[b]);
}


我们为什么要循环?

你可能知道这个主意。如果您想到以前编写循环的时间,通常您会尝试完成以下工作之一:

  • 将一种类型的数组映射为另一种类型的数组。

  • 通过查找满足某种谓词的数组中的所有项进行筛选。

  • 确定数组中的任何项是否满足某些谓词。

  • 从数组中累积计数、和或其他类型的累积结果。

  • 将数组的元素按特定顺序排序。

大多数现代语言中可用的函数式编程特性允许您完成所有这些功能,而无需编写循环或创建集合来存储结果。功能风格可以让你省去那些家务工作,专注于真正的工作。此外,功能样式允许您将操作链接在一起,例如,如果需要的话:

  1. 将数组的元素映射到另一种类型。

  2. 过滤掉一些映射的元素。

  3. 对过滤过的元素进行排序。

在命令式风格中,这需要多个循环或一个循环,其中包含大量代码。不管是哪种方式,它都涉及大量的行政工作,这些工作掩盖了项目的真正目的。在功能风格中,您可以分发管理工作,并直接表达您的意思。稍后,我们将看到更多的例子,功能风格可以使您的生活更轻松。

下次

当我学习函数式编程和习惯JavaStreams API时,每次我写一个循环时,我会做的下一件事就是考虑如何将它重写为流。这通常是可能的。在C#中,ReSharper VisualStudio插件自动建议您进行这种重构。既然我已经内化了功能风格,我就直奔流程,除非我真的需要一个循环,否则就不需要循环了。在下一篇文章中,我们将继续探索一流的函数,以及如何使用函数样式使代码更具表现力。filterreduce。继续关注!