Clojure 集合管道函数练习

栏目: 编程语言 · Clojure · 发布时间: 6年前

内容简介:Clojure 集合管道函数练习

TDD讨论组里的 申导 最近在B站直播了Martin Fowler的经典文章 Refactoring with Loops and Collection Pipelines 中谈到的 利用集合管道对循环进行函数式重构 。视频地址在 这里 ,申导的翻译在 这里 。组织者 小波(Seaborn Lee) 趁机出了一道关于集合管道函数 题目 。我就想啊,论函数式编程,舍 Clojure 其谁?而且我在 Clojure 很少能写出 loop... recur 这样偏底层的循环代码。话不多说,撸起袖子开工。

题目

一家澡堂有 m 个房间,每个房间有 n 个时段,现在要给用户推荐「最早的可以预约的时段」。

例子:

rooms: [
 {
room_id:1,
periods: [
 {
time:'17:00-18:00',
status:'available'
 },
 {
time:'18:00-19:00',
status:'occupied'
 }
 ]
 }, {
room_id:2,
periods: [
 {
time:'17:00-18:00',
status:'occupied'
 },
 {
time:'18:00-19:00',
status:'available'
 }
 ]
 }
]

期望返回:

{
room_id:1,
 time: '17:00-18:00'
}

解析

题目很简单,基本思路:首先过滤出每个房间 periodsstatusavailable 的时间段,然后取第一个也就是最早的时间段(默认为递增 排序 的),接着将 room_id 和这个时间段以 期望返回 的形式合并。再然后对所有合并的结果依据时间段进行一次排序( sort ),最后取第一个结果即可。

1. Clojure 解法

转换数据格式

原题中给的是json的格式,不适合在 Clojure 中处理,所以我们手工转换成需要的形式,如下:

清单1-1数据定义

(defrooms
 [{:room-id1
:periods[{:time"17:00-18:00"
:status:available}
 {:time"18:00-19:00"
:status:occupied}]}
 {:room-id2
:periods[{:time"17:00-18:00"
:status:occupied}
 {:time"18:00-19:00"
:status:available}]}])

代码

清单1-2房间最早可预约时间段

(defn the-earliest-available-room [rooms]
 (->> rooms
 (map
 (juxt first (fn [{:keys [periods]}]
 (->> periods
 (filter #(= (:status %) :available))
 (ffirst)))))
 (map #(into {} %))
 (sort-by :time)
 (first)))

(the-earliest-available-room rooms)
-> {:room-id 1, :time "17:00-18:00"}

这段代码和上面的解析是一一对应的关系。为了让程序清晰,符合管道的用法,这里使用了thread last宏( ->> ),它的作用是把前面一个 form 作为后一个 form 的最后一个参数。与之呼应的是thread first宏( -> ),它的作用类似,不过会传成第一个参数。

我们先看 (map (juxt ...) ...) 这一段代码。 juxt 是一个非常有意思的函数,而且超级实用。它的文档描述如下:

(doc juxt)
->
clojure.core/juxt
 [f]
 [f g]
 [f g h]
 [f g h & fs]
Added in 1.1
 Takes a set of functions and returns a fn that is the juxtaposition
 of those fns. The returned fn takes a variable number of args, and
 returns a vector containing the result of applying each fn to the
 args (left-to-right).
 ((juxt a b c) x) => [(a x) (b x) (c x)]

它的神奇之处在于可以对同一个参数应用不同的函数,而且还能将应用的结果全部收集起来。想想题目的解析中提及的以 期望返回 的形式合并,如果我们应用 juxt 函数,就能得到 [(:room-id 1) (:time "17:00-18:00")] 这样的中间结果。

(juxt first (fn ...))first 用于提取 :room-id ,而后面的 lambda 表达式则用于提取 :time 。解法很直观,筛选出 :status:available 的时间段,然后使用 (ffirst) 取第一个map的首个entry。如: {:time "17:00-18:00" :status :available} ,那么应用 (ffirst) 的结果就是 [:time "17:00-18:00"]

接下来,又进行了一次map操作,这次的目的是把元组的entries,转换为map。举个例子: [[:room-id 1] [:time "17:00-18:00"]] => {:room-id 1 :time "17:00-18:00"} 。转换成map之后,方便以 :time 对结果进行排序 (sort-by :time) ,最后取出第一个元素 (first) ,即我们期望的返回。

写完之后,我很想再写个TDD版本的。话不多说,继续撸袖子。

2. Clojure TDD 解法

环境准备

  • 生成工程

    进入命令行,输入 lein new midje the-earliest-available-period-of-bathroom ,leiningen会生成基于 midje 这个测试框架的工程。

  • Git

    git init
    > .gitignore
    .lein*
    .nrep*
    target/
    这里ctrl-c退出
    git add.
    git commit --message "init commit"
    

我使用了 zshoh-my-zsh ,自带了很多git操作的alias,可以通过 alias |grep git 查看。后续的git操作都会使用alias。

  • 自动测试

    输入 lein repl ,然后 (use 'midje.repl) ,最后输入 (autotest) 。这样一旦文件修改保存,测试就会自动触发。

  • Emacs

    用来写代码的。

Tasking(任务拆分)

先不急着敲代码,我们先从测试的角度看看完成这个需求需要哪几步?

  • [ ] 单间澡堂有一个可用时间段
  • [ ] 单间澡堂有多个可用时间段
  • [ ] 所有澡堂(包含输入为空)没有可用时间段
  • [ ] 多间澡堂都有可用时间段
  • [ ] 多间澡堂中有的有可用时间段,有得没有可用时间段

第1个任务

  • [ ] 单间澡堂有一个可用时间段

1. 写测试

(def room-1 {:room-id 1
 :periods [{:time "17:00-18:00"
 :status :available}
 {:time "18:00-19:00"
 :status :occupied}]})

(def room-2 {:room-id 2
 :periods [{:time "17:00-18:00"
 :status :occupied}
 {:time "18:00-19:00"
 :status :available}]})

(facts "about `the-earliest-avaible-period-of-bathroom`"
 (fact "should recommand if there is only one room with available period"
 ;; 1号
 (the-earliest-available-recommand [room-1]) => {:room-id 1 :time "17:00-18:00"})
 ;; 2号
 (the-earliest-available-recommand [room-2]) => {:room-id 2 :time "18:00-19:00"}))

2. 写实现

(defnthe-earliest-available-recommand [rooms]
 {:room-id1:time"17:00-18:00"})

针对1号测试,这个实现有点“荒诞”,术语 hard code 说的就是这个,但是眼下足够了。不过此时,应该再写一个类似的测试来去除 hard code ,即2号测试。

相应地,我们要修改实现。

defn the-earliest-available-recommand [rooms]
 (let [{:keys [room-id periods]} (first rooms)
 available-periods (filter #(#{:available} (:status %)) periods)]
 (merge {:room-id room-id}
 (select-keys (first available-periods) [:time]))))

3. 关闭并提交

  • [x] 单间澡堂有一个可用时间段
    ga .
    gcmsg "one available room"
    

第2个任务

  • [ ] 单间澡堂有多个可用时间段

1. 写测试

(def room-3 {:room-id 3
 :periods [{:time "17:00-18:00"
 :status :occupied}
 {:time "18:00-19:00"
 :status :available}
 {:time "19:00-20:00"
 :status :available}]})
... 
(fact "should recommand the earliest one if there is only one room with multiple available periods"
 (the-earliest-available-recommand [room-3]) => {:room-id 3 :time "18:00-19:00"})

保存,发现测试还是跑过了。原因在于我们默认了 period 是递增排序的。看看有没有重构点?实现太简单了,那就欢欢喜喜地跳过实现步骤。

2. 关闭并提交

  • [x] 单间澡堂有多个可用时间段
    ga .
    gcmsg "one room with multiple available periods"
    

第3个任务

  • [ ] 所有澡堂(包含输入为空)没有可用时间段

    1. 写测试

    (def non-available-room {:room-id 4
     :periods [{:time "17:00-18:00"
     :status :occupied}
     {:time "18:00-19:00"
     :status :occupied}
     {:time "19:00-20:00"
     :status :occupied}]})
    
    (fact "should show `:no-available-room` if there is no available room"
     (the-earliest-available-recommand []) => :no-available-room
     (the-earliest-available-recommand [non-available-room]) => :no-available-room))
    

这回肯定挂掉。

2. 写实现

(defnthe-earliest-available-recommand [rooms]
 (let[{:keys[room-id periods]} (firstrooms)
 available-periods (filter#(#{:available} (:status%)) periods)]
 (if(seqavailable-periods)
 (merge{:room-idroom-id}
 (select-keys(firstavailable-periods) [:time]))
:no-available-room)))

这里使用了 Clojure 中判断集合是否为空较为常用的手法 (seq ) ,如果集合非空,那么返回集合本身;反之,返回nil,nil在逻辑上是false。测试通过。

3. 关闭并提交

  • [x] 所有澡堂(包含输入为空)没有可用时间段
    ga .
    gcmsg "no available room"
    

第4个任务

  • [ ] 多间澡堂都有可用时间段

1. 写测试

(fact"should recommand the earliest if there has more than one room and each has available periods"
 (the-earliest-available-recommand[room-1 room-2]) => {:room-id1:time"17:00-18:00"}
 (the-earliest-available-recommand[room-2 room-1]) => {:room-id1:time"17:00-18:00"}
 (the-earliest-available-recommand[room-2 room-3]) => {:room-id2:time"18:00-19:00"}
 (the-earliest-available-recommand[room-1 room-2 room-3]) => {:room-id1:time"17:00-18:00"})

2. 写实现

(defnthe-earliest-available-recommand [rooms]
 (if(seqrooms)
 (first(sort-by:time
 (map(fn[room]
 (let[{:keys[room-id periods]} room
 available-periods (filter#(#{:available} (:status%)) periods)]
 (if(seqavailable-periods)
 (merge{:room-idroom-id}
 (select-keys(firstavailable-periods) [:time]))
:no-available-room)))
 rooms)))
:no-available-room))

到这里,我们开始使用 (map ) 函数处理多个房间的内容。注意,当输入房间是空集合的时候,这里需要相应地做 (seq rooms) 判空处理,否则会返回nil,而不是我们想要的 :no-available-room

3. 关闭并提交

  • [x] 多间澡堂都有可用时间段
    ga .
    gcmsg "more than one room"
    

4. 重构

代码写到这里,再不重构就说不过去了。另外,管道没看到,倒是看到一堆括号。

我们使用thread last (->> ) 做一次重构:

(defnthe-earliest-available-recommand [rooms]
 (if(seqrooms)
 (->>rooms
 (map(fn[room]
 (let[{:keys[room-id periods]} room
 available-periods (filter#(#{:available} (:status%)) periods)]
 (if(seqavailable-periods)
 (merge{:room-idroom-id}
 (select-keys(firstavailable-periods) [:time]))
:no-available-room))))
 (sort-by:time)
 first)
:no-available-room))

还行,至少没那么多嵌套了。提交一次。

ga .
gcmsg "[refactor] usemacrothread-last->>topipe"

继续重构,使用我们的 juxt 函数。

(defnthe-earliest-available-recommand [rooms]
 (letfn[(period[{:keys[periods]}]
 (->>periods
 (filter#(#{:available} (:status%)))
 ffirst
 (#(or% [:time::non-available]))))]
 (->>rooms
 (map(fn[room]
 (applyconj {} ((juxtfirst period) room))))
 (remove#(#{::non-available} (:time%)))
 (sort-by:time)
 first
 (#(or%:no-available-room)))))

看上去还行,不过不爽的是 (#(or % [:time ::non-available])) ,为了迎合 (->> ) 给这个方法包了一层,因为我是想将前面表达式的结果放到 (or ) 第一个参数的位置,但是 (->> ) 会让它出现在最后一个参数的位置。有没有什么好看的方法解决呢?当然有!

(defnthe-earliest-available-recommand [rooms]
 (letfn[(period[{:keys[periods]}]
 (->periods
 (->>(filter#(#{:available} (:status%)))
 ffirst)
 (or[:time::non-available])))]
 (->rooms
 (->>(map(fn[room]
 (applyconj {} ((juxtfirst period) room))))
 (remove#(#{::non-available} (:time%)))
 (sort-by:time)
 first)
 (or:no-available-room))))

我们可以使用 (-> ) 来做到这点,顿时觉得世界干净了不少。再提交一次。

ga .
gcmsg "[refactor] usejuxttoextractneededfields"

第5个任务

  • [ ] 多间澡堂中有的有可用时间段,有得没有可用时间段

    1. 写测试

    (fact"should recommand the earliest available room even if there has non available room"
     (the-earliest-available-recommand[room-1 non-available-room]) => {:room-id1:time"17:00-18:00"}
     (the-earliest-available-recommand[room-2 non-available-room]) => {:room-id2:time"18:00-19:00"})
    

测试直接通过,又可以跳过实现代码了。不过,这也预示着我们的测试是有覆盖的,也需要花时间整理这些测试用例。在那之前,先提交一下。

2. 关闭并提交

  • [x] 多间澡堂中有的有可用时间段,有得没有可用时间段
    ga .
    gcmsg "mixed non-available and available rooms"
    

为第3个任务补上测试用例

  • [x] 所有(包含多个)澡堂(包含输入为空)没有可用时间段
(fact "should show `:no-available-room` if there is no available room"
 (the-earliest-available-recommand []) => :no-available-room
 (the-earliest-available-recommand [non-available-room]) => :no-available-room
 (the-earliest-available-recommand [non-available-room non-available-room]) => :no-available-room))

这里的第3个用例其实和第2个有重叠,我们待会整理掉。先提交一下。

ga .
gcmsg "multiple non-available rooms"

整理测试

在前面进行的任务当中,我们发现有两次没有写实现测试就通过的情况。这说明测试用例是有覆盖的。

  • 第2个任务的测试用例其实覆盖了第1个任务的测试用例,所以可以直接删去后者;
  • 第5个任务的测试用例覆盖了第4个任务的部分测试用例,所以可以合并到一起。

整理下来,最终的测试变成下面这样:

(facts"about `the-earliest-avaible-period-of-bathroom`"
 (fact"should recommand the earliest one if there is only one room with multiple available periods"
 (the-earliest-available-recommand[room-3]) => {:room-id3:time"18:00-19:00"})

 (fact"should show `:no-available-room` if there is no available room"
 (the-earliest-available-recommand[]) =>:no-available-room
 (the-earliest-available-recommand[non-available-room non-available-room]) =>:no-available-room)

 (fact"should recommand the earliest if there has more than one room and each may have available periods"
 (the-earliest-available-recommand[room-1 room-2]) => {:room-id1:time"17:00-18:00"}
 (the-earliest-available-recommand[room-2 room-1]) => {:room-id1:time"17:00-18:00"}
 (the-earliest-available-recommand[room-2 room-3]) => {:room-id2:time"18:00-19:00"}
 (the-earliest-available-recommand[room-1 room-2 room-3]) => {:room-id1:time"17:00-18:00"}
 (the-earliest-available-recommand[room-1 non-available-room]) => {:room-id1:time"17:00-18:00"}))

文档

The final goal of any engineering activity is some type of documentation.

更新README.md文件,其中描述程序解决的问题以及运行步骤,当然包含设计思路那更好了。提交一下。

ga .
gcmsg "update readme"

美化代码

代码是诗行 - by lambeta

什么是好看的代码?除了清晰明了,格式也必须产生美感。

(defnthe-earliest-available-recommand [rooms]
 (letfn[(period[{:keys[periods]}]
 (->periods
 (->>(filter#(#{:available} (:status%))))
 (ffirst); 统一套上括号
 (or[:time::non-available])))]
 (->rooms
 (->>(map(juxtfirst period))
 (map#(into{} %)); 合并单独提出来
 (remove#(#{::non-available} (:time%)))
 (sort-by:time)
 (first)); 统一套上括号
 (or:no-available-room))))

顺眼不少,最后提交一下。

ga .
gcmsg "[refactor] beautify pipe format"

[1] 本文样例


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

CGI 程序设计自学通

CGI 程序设计自学通

(美)格里高利 / 徐丹/等 / 机械工业出版社 / 1998-08 / 28.00元

本书集中讨论CGI编程,以便利用一起来看看 《CGI 程序设计自学通》 这本书的介绍吧!

随机密码生成器
随机密码生成器

多种字符组合密码

URL 编码/解码
URL 编码/解码

URL 编码/解码

SHA 加密
SHA 加密

SHA 加密工具