ลอง Clojure ครั้งแรกกับแบบฝึกหัด Budget List

ไปเรียน Coding Professional กับ Josepth กับ Jackson มา มีโจทย์หนึ่งเรียกว่า Budget ที่เขาให้เป็นมาลองทำด้วยวิธี pair programming เลยไปจับคู่กับอะกิ แล้วใช้ภาษา golang พอทำเสร็จก็คุยกันได้ความว่า ถ้าเอาใช้กับ map-reduce จะสวยกว่านี้อีก พี่รูฟเลยชวนให้ลองเอามาเขียนเป็น functional programming ด้วย clojure ซึ่งแน่นอน ไม่เคยเขียนเลยสักครั้ง ไม่แม้จะรู้จัก syntax มัน เลยเอามาลองกัน

โจทย์มีอยู่ว่า มี data ที่เป็นงบค่าใช้จ่ายประจำเดือน เช่น เดือน “2018-04” ตั้งงบไว้ให้ 3,000 บาท เดือน “2018-05” ตั้งงบไว้ที่ 4,000 บาท ให้หาจำนวนเงินที่สามารถใช้ได้ในช่วงวันที่กำหนด เช่น ตั้งแต่ “2018-03-16” ถึง “2018-05-28”

ข้อกำหนด ถ้าเดือนไหนไม่ได้กำหนดงบไว้ ให้มีค่าเป็น 0

รู้โจทย์แล้ว ที่เหลือก็ลองกันเลย

ลง Clojure ก่อน

เนื่องจากใช้ mac เลยเลือกลง clojure ผ่าน homebrew ง่ายดี brew install clojure ลองทดสอบด้วยคำสั่ง clj จะได้ prompt ของ REPL มันโผล่ขึ้นมาแบบนี้

Clojure 1.9.0
user=>

วิธีออกจาก REPL ก็ ctrl+c ได้เลย

เริ่มเลย

สร้าง directory เอามาใช้เขียน code ก่อน ตามที่เว็บของ clojure เขียนไว้ มันบอกว่า มันจะหา code เราผ่าน path /src ของ project เสมอ ดังนั้น เราสร้าง directory ไว้แบบนี้ /clj-budget/src ตอนเวลาเขียน code ก็เขียนไว้ใน src นั่นแหละ

ถัดมา จากโจทย์เขาเล่นกับ datetime เลยไปดูว่าจะเอา package datetime ของ clojure มาใช้ยังไง ก็เจอว่าไม่มี!! แต่มีคนทำเอาไว้แล้ว https://github.com/clj-time/clj-time วิธีเอามาใช้ ก็แค่สร้าง dependency file ขึ้น ชื่อว่า deps.edn วางไว้ใน project dir ได้เลย วิธีเขียนข้างในตอนเข้าไปดูที่ github ของ clj-time มันเขียนประมาณนี้ [clj-time "0.14.4"] นั่นคือชื่อ project กับ version เราก็เอามาเขียนลงในไฟล์ deps.edn แบบนี้

{:deps
 {clj-time {:mvn/version "0.14.4"}}}

พอเสร็จแล้วก็สั่ง clj เฉย ๆ มันก็จะโหลด clj-time มาลงให้อัตโนมัติ

ถึงตรงนี้ directory structure ของเราควรจะออกมาเป็นแบบนี้

/clj-budget/
/clj-budget/src/
/clj-budget/deps.edn

เขียน code

ตัว clojure จะใช้ () เป็นตัวจัดกลุ่มของ expression โดยถ้ามองเป็น tuple ตัว element แรกจะเป็น operation ซึ่งอาจจะเป็น operator หรือ function ก็ได้ ส่วนที่เหลือ จะเป็น argument ของคำสั่งนั้น ๆ เช่น (+ 1 2) จะได้ผลลัพธ์เป็น 3

เราสามารถ nested operation ได้อีก เช่น (/ (+ 4 2) 3) จะได้ผลลัพธ์เป็น 2

วิธีประกาศ function ใช้ defn ถ้ามี parameter ก็ใส่ตามลงไปใน [] เช่น

(defn add [a b]
    (+ a b))

จะรับ a, b มาเป็น parameter และ return ค่ากลับไป (ตรงนี้จะสังเกตเห็นได้ว่า ใน clojure ไม่ต้องมีการบอก return ให้ มันก็จะ return ให้อยู่แล้ว

วิธีประกาศตัวแปร ตอนที่ทำแรก ๆ ใช้ def ตลอด เพราะรู้จักตัวเดียว ตอนหัดเขียน ไม่ได้ใช้วิธีเรียน clojure ทั้งหมดแล้วค่อยเขียน เริ่มจากเขียนเลย แล้วอยากได้อะไรค่อยไปหา เลยกลายเป็นรู้จักแค่เป็นเรื่อง ๆ ไป ตอนหาวิธีประกาศตัวแปร ก็ได้ def มานี่แหละ เช่น (def x 1) แปลว่าให้ x เป็น 1

ตอนสั่งรัน เราก็แค่สั่ง clj -m budgetlist เท่านี้ก็ได้ผลลัพธ์ละ

หน้าตา code ที่ได้ครั้งแรก

(ns budgetlist
  (:require [clj-time.core :as t]
            [clj-time.periodic :as p]
            [clj-time.format :as f]))

(def yyyy-mm-formatter (f/formatter "yyyy-MM"))
(def budget-pack {
  "2018-02" 2800
  "2018-04" 3000
})

(defn time-range
  [start end step]
  (let [inf-range (p/periodic-seq start step)
        below-end? (fn [t] (t/within? (t/interval start end) t))]
    (take-while below-end? inf-range)))

(defn number-of-days-in-the-month
  [dt]
  (t/day (t/last-day-of-the-month- dt)))

(defn budget-by-day
  [day]
  (def days (number-of-days-in-the-month day))
  (/ (get budget-pack (f/unparse yyyy-mm-formatter day) 0) days)
)

(defn find-budget-between
  [from to]
  (def r (time-range from (t/plus to (t/days 1)) (t/days 1)))
  (reduce + (map budget-by-day r))
)

(defn -main []
  (def from (t/date-time 2018 2 1))
  (def to (t/date-time 2018 4 6))
  (print (find-budget-between from to))
  )

ก็โอเคนะ ทำงานได้อย่างที่ต้องการ หลังจากที่ส่งให้เวหาดู เวหาก็มี comment ที่น่าสนใจกลับมา comment แรกคือ อย่าใช้ def เพื่อประกาศตัวแปร เพราะมันเป็นการประกาศระดับ namespace ให้ใช้ let เพื่อประกาศตัวแปรใน scope ที่ต้องการก็พอ โอ้ววว มันมี let สำหรับใช้ใน scope ด้วย เลยได้ออกมาเป็น version 2

หน้าตา code เวอร์ชั่น 2

เปลี่ยนมาใช้ let แล้ว จัด format นิดหน่อย

(ns budgetlist
  (:require [clj-time.core :as t]
            [clj-time.periodic :as p]
            [clj-time.format :as f]))

(def yyyy-mm-formatter (f/formatter "yyyy-MM"))
(def budget-pack {"2018-02" 2800 "2018-04" 3000})

(defn time-range
  [start end step]
  (let [inf-range (p/periodic-seq start step)
        below-end? (fn [t] (t/within? (t/interval start end) t))]
    (take-while below-end? inf-range)))

(defn number-of-days-in-the-month
  [dt]
  (t/day (t/last-day-of-the-month- dt)))

(defn budget-by-day
  [day]
  (let [days (number-of-days-in-the-month day)]
    (/ (get budget-pack (f/unparse yyyy-mm-formatter day) 0) days)))

(defn find-budget-between
  [from to]
  (let [r (time-range from (t/plus to (t/days 1)) (t/days 1))]
    (reduce + (map budget-by-day r))))

(defn -main []
  (def from (t/date-time 2018 2 1))
  (def to (t/date-time 2018 4 6))
  (print (find-budget-between from to)))

หลังจากนั้นพี่รูฟเข้ามาอ่าน แล้วก็แนะนำว่า ไป threading macro เลย โอ้ววว ศัพท์ใหม่ ก็ค้นพบว่า threading macro มันคือการแปลงให้ nested calls ของ clojure กลายเป็น sequence ที่อ่านง่ายขึ้น ก็ไปลองแปลงดู ได้เป็น version 3

หน้าตา code version 3

ได้รู้จัก operator ง่าย ๆ มา 3 ตัวสำหรับ threading macro คือ ->, ->> และ as-> ทั้ง 3 ตัวมันเอาไว้ประกาศ threading macro เหมือนกัน แต่แตกต่างกันตรงที่ว่า

  • -> มันจะเอาผลลัพธ์จาก expression ก่อนหน้า ไป ต่อข้างหน้า parameter list ของ expression ถัดไป หรือเรียกว่าเอาไปเป็น parameter ตัวแรกของ expression ถัดไป
  • ส่วน ->> มันจะเอาไปต่อเป็น parameter ตัวสุดท้าย
  • ส่วน as-> มันจะเอาไปต่อตรงไหนก็ได้ที่ไม่ใช่ตัวแรกและตัวสุดท้าย วิธีการใช้ไอ้ตัวนี้คือ เอาไปประกาศเป็น alias อีกชื่อ แล้วเอาอีกชื่อไปหย่อนใส่ expression ถัด ๆ ไปได้เลย
(ns budgetlist
  (:require [clj-time.core :as t]
            [clj-time.periodic :as p]
            [clj-time.format :as f]))

(def yyyy-mm-formatter (f/formatter "yyyy-MM"))
(def budget-pack {"2018-02" 2800 "2018-04" 3000})

(defn time-range
  [start end step]
  (let [inf-range (p/periodic-seq start step)
        below-end? (fn [t] (t/within? (t/interval start end) t))]
    (take-while below-end? inf-range)))

(defn number-of-days-in-the-month [dt]
  (->> dt
       (t/last-day-of-the-month-)
       (t/day)))

(defn budget-by-day [day]
  (let [days (number-of-days-in-the-month day)]
    (as-> day d
      (f/unparse yyyy-mm-formatter d)
      (get budget-pack d 0)
      (/ d days))))

(defn find-budget-between
  [from to]
  (let [r (time-range from (t/plus to (t/days 1)) (t/days 1))]
    (->> r
         (map budget-by-day)
         (reduce +))))

(defn -main []
  (def from (t/date-time 2018 2 1))
  (def to (t/date-time 2018 4 6))
  (print (find-budget-between from to)))

ถัดมา เวหาก็แนะนำเรื่องการตั้งชื่อตัวแปร เพราะว่าจากที่ตั้งไว้ตอนแรก มันอ่านเข้าใจยาก เขาก็แนะนำให้ inline ไปเลย แถมมันใช้แค่ครั้งเดียวด้วย ไม่ต้องตั้งหรอก เลยได้ออกมาเป็นเวอร์ชั่นที่ 4

หน้าตา code version 4

(ns budgetlist
  (:require [clj-time.core :as t]
            [clj-time.periodic :as p]
            [clj-time.format :as f]))

(def yyyy-mm-formatter (f/formatter "yyyy-MM"))
(def budget-pack {"2018-02" 2800 "2018-04" 3000})

(defn time-range
  [start end step]
  (let [inf-range (p/periodic-seq start step)
        below-end? (fn [t] (t/within? (t/interval start end) t))]
    (take-while below-end? inf-range)))

(defn number-of-days-in-the-month [dt]
  (->> dt
       (t/last-day-of-the-month-)
       (t/day)))

(defn budget-by-day [day]
  (as-> day d
    (f/unparse yyyy-mm-formatter d)
    (get budget-pack d 0)
    (/ d (number-of-days-in-the-month day))))

(defn find-budget-between
  [from to]
  (->> (time-range from (t/plus to (t/days 1)) (t/days 1))
       (map budget-by-day)
       (reduce +)))

(defn -main []
  (print (find-budget-between (t/date-time 2018 2 1) (t/date-time 2018 4 6))))

จนถึงตอนนี้ code ชุดนี้ก็แก้ปรับหน้าตามา 4 ครั้งแล้ว อ่านง่ายขึ้นจริง ๆ

สนุกดีครับ สำหรับ clojure ครั้งแรก เปิดโลกมากทีเดียว ต้องขอขอบคุณ ป้อ พี่รูฟ เวหา สำหรับ comment ต่าง ๆ ด้วยนะครับ

อ้างอิง

Facebook thread ที่คุยกันเรื่องนี้ https://www.facebook.com/chonla/posts/10216803643986152

Git repo ของ clojure script นี้
https://github.com/chonla/clj-budgetlist

Leave a Reply

Your email address will not be published. Required fields are marked *