软件架构风格 仓库风格_功能风格–第4部分

软件架构风格 仓库风格

一流的功能II:过滤,缩小等。

在上一篇文章中,我介绍了一流函数和lambda的概念,展示了在数组上“映射”函数的技术,并将其改进为显式迭代的替代方法。 我继续断言,我们编写的大多数循环是出于将一种类型的数组映射到另一种数组的目的,或者它们是用于从数组中过滤元素,在数组中搜索元素,对其进行排序或累加总数。 我答应展示所有这些例子。 我最好兑现自己的诺言,因此,我将以经典的古代掠夺为例。

Eratosthenes筛。

这是一种用于查找素数的算法,这是由希腊学者公元前3世纪的Eratosthenes发现的。 这很简单。 要查找所有质数直到n个数,您要做的是:

  1. 迭代所有的自然数i其中2≤I≤(N / 2)。
  2. 对于每个i,标记I的每一个多作为非素,其中2 I≤ ≤ñ。
  3. 完成后,所有未标记的自然数(最多n个)都是质数。

在Java代码中,可能看起来像这样:

public class Sieve {

    private Set<Integer> sieve = new HashSet<>();
    private int limit;

    public Sieve(int limit) {
        this.limit = limit;
        for (var n = 2; n <= (limit / 2); n++)
            for (var nonPrime = (n * 2); nonPrime <= limit; nonPrime += n)
                sieve.add(nonPrime);
    }

    public List<Integer> primes() {
        var primes = new ArrayList<Integer>();
        for (var candidate = 1; candidate <= limit; candidate++)
            if (!sieve.contains(candidate))
                primes.add(candidate);
        return primes;
    }
}

构造函数创建筛子,然后primes()方法通过筛子有效地“拉伸”数字以提取素数。 我们可以用它打印出1到10,000之间的所有素数:

public static void main(String[] args) {
    var sieve = new Sieve(10000);
    var primes = sieve.primes();
    for (var prime : primes)
        System.out.print(prime + " ");
    System.out.println();
}

到目前为止,势在必行。 我选择此练习作为过滤的示例,因为毕竟,筛子是过滤器? 因此,让我们以功能样式重写筛子:

public List<Integer> primes() {
    return IntStream.range(1, limit)
            .filter(candidate -> !sieve.contains(candidate))
            .boxed()
            .collect(Collectors.toList());
}

即使您对IntStream ,它的目的也很明确:它为我们提供了从1到limit的整数流。 .boxed()调用将ints流映射到Integers流,以便我们可以在终止操作中将其收集到List中,因为您无法创建基元列表(如果您从上一篇文章中回想起,则是Java中的基元很痛苦)。

现在,我们可以将素数收集到一个Set中,这实际上可能更合适,因为我们只希望每个素数出现在输出中一次:

public Set<Integer> primes() {
    return IntStream.range(1, limit)
            .filter(candidate -> !sieve.contains(candidate))
            .boxed()
            .collect(Collectors.toSet());
}

但是,这会产生不想要的结果:

1 2 3 4099 5 2053 7 6151 11 13 2063 4111 17 8209 19 6163 2069 23 8219 [...]

我们也可以通过流传输和排序结果来解决此问题:

public static void main(String[] args) {
    var sieve = new Sieve(10000);
    sieve.primes().stream()
            .sorted()
            .forEach(prime -> System.out.print(prime + " "));
    System.out.println();
}

不过,我们可以更进一步,将整数映射到字符串,然后使用Collectors.joining将它们附加在一起并为我们插入空格:

public static void main(String[] args) {
    var sieve = new Sieve(10000);
    System.out.println(sieve.primes().stream()
            .sorted()
            .map(Object::toString)
            .collect(Collectors.joining(" ")));
}

也许您开始看到功能样式的吸引力。 只需声明您想发生的事情,然后用语言为您安排。 无需再担心击剑杆错误! 以前的代码版本在每个数字后都添加一个空格,如果对您来说重要的是不要添加空格,则对其进行修复将很尴尬。 您必须先在多少种情况下进行编程以解决问题,然后才能解决? 我经常去过那里。

我真的很想强调这一点。 这可以节省程序员的劳动力。 并非数十年来人们梦dream以求的那种省力方式:人们在机器为您编写程序时用某种自然语言表达自己的愿望。 梦想被误导的原因是语法从未真正成为编程中最困难的部分。 编程一直以来都是关于分析问题并将其明确编码,以使其可以由机器执行。 功能样式不会改变这一点。 相反,功能样式可以帮助您解决已解决的问题,例如将这些字符串附加在一起,同时在它们之间插入空格 。 您不必每次都编写代码来执行此操作。

关闭。

足够的pro悔。 如果我们这样重写sieve()方法,该怎么办:

public Set<Integer> primes() {
    return IntStream.range(1, limit)
            .boxed()
            .filter(notInNonPrimesUpTo(limit))
            .collect(Collectors.toSet());
}

这里发生了什么? 那notInNonPrimesUpTo(int)方法返回一个Predicate ,这是一个接受单个参数并返回布尔值的函数:

private Predicate<Integer> notInNonPrimesUpTo(int limit) {
    var sieve = new HashSet<Integer>();
    for (var n = 2; n <= (limit / 2); n++)
        for (var nonPrime = (n * 2); nonPrime <= limit; nonPrime += n)
            sieve.add(nonPrime);
    return candidate -> !sieve.contains(candidate);
}

因此,它将构建筛子并返回一个lambda值,以测试候选对象是否在筛子中。 这不是效率很低吗? 每次测试候选人时,它都会建立筛子吗? 事实并非如此:它只能建立一次筛子。 在流上调用filter()方法时,它将一次调用notInNonPrimesUpTo ,这将返回lambda谓词。 对于流中的每个元素都执行的是lambda。 这个lambda也是一个闭包 。 纯lambda函数仅取决于其输入参数,而闭包也可以访问其创建范围内的变量(在本例中为sieve集)。 即使notInNonPrimesUpTo方法已退出,但sieve集仍在作用域内,因为lambda已“关闭”它。 只要lambda本身仍在作用域内,它关闭的资源将保持可用,并且不会被垃圾收集器回收。

让我们走得太远。

筛本身的生成又如何,也可以用流完成呢? 嗯,是…

private Predicate<Integer> notInNonPrimesUpTo(int limit) {
    Set<Integer> sieve = IntStream.range(2, (limit / 2))
            .boxed()
            .flatMap(n -> Stream.iterate(n * 2, nonPrime -> nonPrime += n)
                    .takeWhile(nonPrime -> nonPrime <= limit))
            .collect(Collectors.toSet());
    return candidate -> !sieve.contains(candidate);
}

我认为我实际上不会在Java中真正做到这一点。 对于我来说,这段代码似乎比命令版本更难理解。 仅仅因为您可以做某事并不意味着您应该做。

Stream.iterate部分很有趣,但是要解释它会超越我们自己,因此我将在以后的文章中再次讨论。 虽然flatMap值得解释。 我们这里拥有的是一个映射到数组上的函数,该函数本身返回一个数组,因此[2,3,4,5…]的映射如下:

  • 2→[4、6、8、10、12…]
  • 3→[6、9、12、15、18…]
  • 4→[8、12、16、20、24…]
  • 5→[10、15、20、25、30…]

但是我们不想要一个数组数组,我们希望将其展平为一维数组。 这就是flatMap为我们做的,因此我们得到:

[4,6,8,10,12…6,9,12,15,18…8,12,16,20,24…10,15,20,25,30…]

最后Collectors.toSet()为我们清除重复项。 这使我们成为筛子。

聚合操作。

如果您查看Java Streams API,或者如果您是.NET开发人员,则查看LINQ,您将看到其他基于搜索的其他操作:查找任何,首先查找,任何匹配等。它们都基于谓词和工作。基本上与过滤相同,因此,我不会详细说明所有内容。 相反,我现在想将我们的注意力转向聚合操作。 回想一下本系列前面的内容,我们有一个递归程序来计算阶乘,如下所示:

public static int factorial(int number) {
    if (number == 1)
        return number;
    else
        return number * (factorial(number - 1));
}

该算法属于“计算累积结果的循环”类,这是我承诺可以不循环而完成的另一种情况。 我们通过使用reduce消除Java中的此循环。 为了计算n阶乘,首先我们需要获得从1到n的整数流。 这将为我们做到:

IntStream.iterate(1, n -> n += 1)
        .takeWhile(n -> n <= 10)
        .forEach(System.out::println);

通常,数学家以相反的方式定义阶乘,即向下计数:

5! = 5×4×3×2×1

无论是向下计数还是向上计数,我们都得到相同的结果,因此我们最好向上计数,因为它更简单。 为了计算阶乘,我们使用reduce作为流的终止操作:

public static int factorial(int number) {
    return IntStream.iterate(1, n -> n += 1)
            .takeWhile(n -> n <= number)
            .reduce(1, (acc, next) -> acc * next);
}

reduce方法有两个参数,分别是:

  • 身份值(1)
  • 为流中的每个元素执行的lambda本身具有两个参数:
    • 一个参数接收当前流元素的值。

reduce方法的最终结果是流中最后一个元素上的lambda函数的结果。 如果您想知道lambda参数沿哪个方向走,文档会指出该函数必须是关联的,因此并不重要。 无论身份值是什么,它都必须满足以下条件:当将其与任何其他值一起传递到累加器函数时,将返回另一个值作为结果。 用数学术语定义身份,以便:

fx ,身份)= x

对于乘法和除法,标识值为1;对于加法和减法,标识值为零。

在C#中,该操作称为“ Aggregate而不是“减少”,但其他方面基本相同:

public static int Factorial(int number)
{
    return Enumerable.Range(1, number)
            .Aggregate(1, (acc, next) => acc * next);
}

机器人的爱。

但是不要认为减少仅限于计算算术总数。 您可以将一种元素类型的数组缩减为完全不同类型的结果。 为了说明这一点,让我们使用我们喜欢在Codurance上使用的另一个编程练习,我们将其称为Mars Rover。

我们正在模拟漫游车。 它的“世界”是一个整数坐标的网格,它可以指向四个基本方向中的任何一个:北,东,南或西。 流动站可以向左转,可以向右转,也可以向前移动。 它使用类似于LAARA的指令序列进行编程,这意味着:左,前,前,右,前。

在Clojure中,我们可以定义一个函数来制造机器人:

(defn robot [coordinates bearing]
  (hash-map :coordinates coordinates, :bearing bearing))

这样,创建机器人就可以为我们提供一个简单的数据结构,以表示机器人的状态,如下所示:

user=> (robot {:x -2 :y 1} :east)
{:coordinates {:x -2, :y 1}, :bearing :east}

在Clojure中,哈希图文字是由大括号定义的,该括号包含许多键值对,例如{:key1 value1 :key2 value2 ...} 。 以冒号开头的名称(例如:east )只是符号; 他们只代表自己。 不需要声明它们,因为除了名称之外,它们没有其他值。 可以比较符号,即(= :foo :foo)为true,而(= :foo :bar)为false,这使它们便于使用地图键和其他用途。

现在,要能够转弯,我们需要了解根据机器人的轴承左右旋转的效果。 因此,让我们建立一个数据结构来保存旋转:

(def rotations
  {:north {:left :west, :right :east}
   :east {:left :north, :right :south}
   :south {:left :east, :right :west}
   :west {:left :south, :right :north}})

这告诉我们在指向四个方向中的任何一个时向左或向右旋转后,机器人的新轴承将是什么。 使用它,我们可以定义一个使机器人右转的函数:

(defn turn-right [robot]
  (let [bearing (:bearing robot)]
    (assoc robot :bearing (-> rotations bearing :right))))

这三行代码中发生了很多事情。 为了帮助您理解它,首先要知道的是,您可以从映射中获取给定键的值,如下所示:

user=> (def a-map {:key1 "value 1" :key2 "value 2"})
#'user/a-map
user=> (:key1 a-map)
"value 1"
user=> (:key2 a-map)
"value 2"

这就是(:bearing robot)如何获取机器人的当前方位。 ->符号称为“线程优先”宏; 它是(:right (bearing rotations))的简写形式,换句话说,获得与机器人当前轴承相对应的旋转,然后获得与机器人右旋转后的新轴承相对应的旋转。 线程优先和线程最后的宏是Clojure对Lisp形式末尾出现的紧密联系的回答,某些人对此语言感到反感。 它们还允许以从左到右的顺序编写组合函数,有些人可能会觉得更自然(我愿意)。

assoc函数的行为就像在地图中添加或替换键值对一样。 在这种情况下,似乎会更新机器人的轴承。 当然,Clojure中的所有数据结构都是不可变的,因此它真正要做的是创建一个新映射,同时保持原始映射不变。

使机器人左转的功能是相似的,如果我们愿意,我们当然可以提取常用功能:

(defn turn-left [robot]
  (let [bearing (:bearing robot)]
    (assoc robot :bearing (-> rotations bearing :left))))

我们可以轻松地在REPL中测试车削功能:

user=> (turn-left (robot {:x 0 :y 0} :north))
{:coordinates {:x 0, :y 0}, :bearing :west}

user=> (turn-left (robot {:x 0 :y 0} :west))
{:coordinates {:x 0, :y 0}, :bearing :south}

user=> (turn-left (robot {:x 0 :y 0} :south))
{:coordinates {:x 0, :y 0}, :bearing :east}

user=> (turn-left (robot {:x 0 :y 0} :east))
{:coordinates {:x 0, :y 0}, :bearing :north}

user=> (turn-right (robot {:x 0 :y 0} :north))
{:coordinates {:x 0, :y 0}, :bearing :east}

user=> (turn-right (robot {:x 0 :y 0} :east))
{:coordinates {:x 0, :y 0}, :bearing :south}

user=> (turn-right (robot {:x 0 :y 0} :south))
{:coordinates {:x 0, :y 0}, :bearing :west}

user=> (turn-right (robot {:x 0 :y 0} :west))
{:coordinates {:x 0, :y 0}, :bearing :north}

请注意,它从不更改坐标,仅更改方位。 这为我们提供了可以现场启动的机器人。 为了移动,我们还需要根据其方位来了解向前移动对其位置的影响:

(def translations
  {:north {:delta-x 0, :delta-y 1}
   :east {:delta-x 1, :delta-y 0}
   :south {:delta-x 0, :delta-y -1}
   :west {:delta-x -1, :delta-y 0}})

现在我们可以编写一个函数来使机器人沿其当前方位向前移动:

(defn move-ahead [robot]
  (let [{ {x :x, y :y} :coordinates, bearing :bearing} robot]
    (let [{delta-x :delta-x, delta-y :delta-y} (translations bearing)]
      (assoc robot :coordinates {:x (+ x delta-x), :y (+ y delta-y)}))))

那里正在进行一些稍微复杂的破坏,但是希望它已经足够清楚了。 我们从机器人获取坐标和方位,然后将坐标进一步分解为x和y分量。 然后,我们根据机器人的方位查找要应用的平移。 最后,我们返回一个新机器人,其方位与原始机器人相同,并且坐标为(x +Δx,y +Δy)。 我们可以再次在REPL中测试此功能:

user=> (move-ahead (robot {:x 0 :y 0} :north))
{:coordinates {:x 0, :y 1}, :bearing :north}

user=> (move-ahead (robot {:x 0 :y 0} :east))
{:coordinates {:x 1, :y 0}, :bearing :east}

user=> (move-ahead (robot {:x 0 :y 0} :south))
{:coordinates {:x 0, :y -1}, :bearing :south}

user=> (move-ahead (robot {:x 0 :y 0} :west))
{:coordinates {:x -1, :y 0}, :bearing :west}

最后,我们需要能够处理一系列指令并确定机器人的最终状态。 为此,我们需要一个可以解码指令并将其应用到机器人的函数:

(defn do-step [robot next-step]
  (case next-step
    \A (move-ahead robot)
    \L (turn-left robot)
    \R (turn-right robot)))

然后只需简单地迭代指令序列,就可以跟踪机器人状态,并在每条指令上调用do-step以获得下一个机器人状态:

(defn simulate [steps initial-robot]
  (loop [remaining-steps steps, robot initial-robot]
    (if (empty? remaining-steps)
      robot
      (recur (rest remaining-steps) (do-step robot (first remaining-steps))))))

所以…?

可是等等! 这只是另一个计算累积结果的循环。 它是可还原的,就好像您没有猜到一样。 当然可以 因此,这也将起作用:

(defn simulate [steps initial-robot]
  (reduce do-step initial-robot steps))

好吧,当然,我听到你说了,但这不过是时髦的神奇Clojure,对吗? 不是这样! 您可以在C#中做完全相同的事情:

private readonly Dictionary<Bearing, Rotation> _rotations = new Dictionary<Bearing,Rotation>
{
    {Bearing.North, new Rotation(Bearing.West, Bearing.East)},
    {Bearing.East, new Rotation(Bearing.North, Bearing.South)},
    {Bearing.South, new Rotation(Bearing.East, Bearing.West)},
    {Bearing.West, new Rotation(Bearing.South, Bearing.North)}
};

private readonly Dictionary<Bearing, Coordinates> _translations = new Dictionary<Bearing, Coordinates>
{
    {Bearing.North, new Coordinates(0, 1)},
    {Bearing.East, new Coordinates(1, 0)},
    {Bearing.South, new Coordinates(0, -1)},
    {Bearing.West, new Coordinates(-1, 0)}
};

private Robot TurnLeft(Robot robot)
{
    var rotation = _rotations[robot.Bearing];
    return new Robot(robot.Coordinates, rotation.Left);
}

private Robot TurnRight(Robot robot)
{
    var rotation = _rotations[robot.Bearing];
    return new Robot(robot.Coordinates, rotation.Right);
}

private Robot MoveAhead(Robot robot)
{
    var delta = _translations[robot.Bearing];
    return new Robot(
        new Coordinates(
        robot.Coordinates.X + delta.X,
        robot.Coordinates.Y + delta.Y), 
        robot.Bearing);
}

private Robot DoStep(Robot robot, char nextStep)
{
    switch (nextStep)
    {
        case 'L': return TurnLeft(robot);
        case 'R': return TurnRight(robot);
        default: return MoveAhead(robot);
    }
}

驱动机器人的命令性代码可能是:

public Robot Simulate(string instructions, Robot initialRobot)
{
    var robot = initialRobot;
    foreach (var step in instructions)
        robot = DoStep(robot, step);
    return robot;
}

但是我们可以很容易地做到:

public Robot Simulate(string instructions, Robot initialRobot)
{
    return instructions.Aggregate(
        initialRobot, 
        (robot, step) => DoStep(robot, step));
}

MapReduce。

我不能不提起这件事,因为您几乎可以肯定已经听说过这个词。 这是一种“大数据”技术,与功能编程一起,是当今最时尚的主题之一。 在最后两集中,我们研究了称为“ map”和“ reduce”的技术,因此您可以相当合理地认为您现在知道MapReduce的工作原理。 但是,它稍微复杂一些。 MapReduce通过三个或四个常规处理步骤来处理数据,尽管它们看起来都很熟悉。 我们将其称为MapReduce,因为FilterMapShuffleReduce的环不一样,我想:

  1. 筛选数据集(如有必要)以仅获取您感兴趣的记录。
  2. 通过对每个记录应用某些功能来转换数据集(“映射”步骤)。
  3. 将转换后的数据集中的记录分组在一起。 有时将其称为“随机”步骤,但其他说明(如MongoDB文档)将此操作与map步骤结合在一起。 分组的工作方式是为每个记录计算一些标识值,然后将它们用作哈希图中的键-每个键都与一组共享相同键的记录关联。
  4. 通过以与上述相同的方式对每个数组应用归约函数,汇总转换后的数据集(“归约”步骤)。 MapReduce操作的最终结果是一组键值对。

如果您一直在密切关注本系列的最后两集,那么现在您将知道如何进行所有操作。 因此,请确保完善您的简历,并让自己成为数据科学家的高薪工作!

下次。

希望到现在为止,我已经开始说服您,循环是一种仅在真正需要时才应使用的结构,在大多数情况下您不需要。 LINQ,Streams和其他现代语言中的类似功能可以为您实现很多这些用例,而无需进行太多的键入和管理工作。 就像我们在上一篇文章开头看到的排序示例所看到的那样,这些语言可以解决已经解决的部分问题:如何有效地对数组进行排序,搜索项目,删除重复项,可以按某些键等进行分组。您可以自由地专注于问题域特有的方面。 因此,代码中的信噪比得到了改善,使其更整洁,更易理解,并且更不容易出现错误。

在前两篇文章中,我们对一流的功能进行了详尽的介绍。 下次,我们将介绍一个紧密相关的概念,即高阶函数。 我们还将看一下函数的组成,我将尝试解释可怕的Monad模式。 实际上,我希望我不仅能够帮助您理解它,而且可以说服您使用它。

翻译自: https://www.javacodegeeks.com/2018/09/the-functional-style-part-4.html

软件架构风格 仓库风格

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值