DEV Community

Ta
Ta

Posted on • Edited on • Originally published at tamemo.com

FP(01) – Pure, First-Class, High-Order: พื้นฐานแห่ง Functional Programming

บทความจาก https://www.tamemo.com/post/157/fp-01-fundamental/

ในโลกของ FP นั้นมีหลายสิ่งที่ต้องเรียนรู้กัน แต่เราจะมาเริ่มกับสิ่งที่เป็นพื้นฐานที่สุดกันก่อน นั่นคือ

  1. Pure Function
  2. First-Class Function
  3. High-Order Function

Pure Function

ความจริงแล้วคุณสมบัติที่สำคัญที่สุดใน FP คือ First-Class Function แต่เรื่องของ Pure Function นั้นเข้าใจง่ายกว่าเลยขอยกมาเล่าก่อนละกันนะ (แต่หากใครยังไม่แม่นเรื่องฟังก์ชันคืออะไรให้ไปอ่านที่นี่ก่อนนะ)

สำหรับวิชาคณิตศาสตร์แล้ว ฟังก์ชันคือสิ่งที่ รับค่าจำนวนหนึ่งเข้าไป (input) แล้วทำการคำนวณให้ได้ผลลัพธ์ 1 ค่าแล้วตอบกลับมา (output)

เช่น

f(x) = x + 10
Enter fullscreen mode Exit fullscreen mode

นั่นหมายความว่าเราสร้างฟังก์ชัน f ขึ้นมาซึ่งจะรับค่าเข้าไปแล้วเอาไป +10 เวลาจะเรียกใช้งานก็เช่น f(20) ก็จะได้ค่าเท่ากับ 20+10 = 30 ...ตรงไปตรงมามาก ซึ่งถ้าเราจะเขียนแบบภาษาโปรแกรม เราก็จะได้โค้ดแบบนี้

function plusTen(x){
    return x + 10
}
Enter fullscreen mode Exit fullscreen mode

แต่การเขียนฟังก์ชันในการเขียนโปรแกรมนั้น เราสามารถสร้างฟังก์ชันที่ไม่รับ input (หรือก็คือ parameter นั่นแหละ) อะไรเลย มีแต่ return ค่ากลับมาเลย หรือแม้แต่สร้างฟังก์ชันที่ไม่ตอบค่าอะไรกลับมาเลยก็ยังไงนะ

// ตอบ 10 ทุกรอบที่มีการเรียกใช้งาน ไม่ค่อยมีประโยชน์เท่าไหร่
function justTen(){
    return 10
}

// รับค่าเข้าไปคำนวณเล่นๆ ไม่ตอบอะไรกลับมาเลย ดูไร้ประโยชน์มาก
function justReceive(x){
  x = x + 10
}
Enter fullscreen mode Exit fullscreen mode

📢 Note: ดังนั้นการสร้าง Pure Function ที่ดี ต้องรับ input เข้าไปอย่างน้อย 1 ตัว แล้ว return ค่ากลับ 1 ค่าเสมอ
ฟังก์ชันนั้นถึงจะเอามาใช้งานได้อย่างมีประโยชน์นะ

Side-Effect

แต่! สำหรับการเขียนโปรแกรมเราสามารถเขียนอะไรได้มากกว่านั้น นั่นคือการทำให้ฟังก์ชันนั้นมี "Side-Effect" คือการที่ฟังก์ชันสามารถยุ่งกับค่าตัวแปรภายนอกได้ เช่น

function displayPlusTen(x){
    print(x + 10)
}
Enter fullscreen mode Exit fullscreen mode

นั่นคือเราเปลี่ยนจากการรีเทิร์นค่ากลับเป็นเอาค่าที่คำนวณได้ print ค่าออกมาแทน

นอกจากนี้ Side-Effect ยังรวมถึงการรับ input เข้ามาจากผู้ใช้ หรือการเปลี่ยนแปลงค่าตัวแปรระดับ global ด้วย

// Side-Effect จากการรับค่าจากผู้ใช้เข้ามา
function readX(){
  var x = input('enter x: ')
  ...
}

// Side-Effect จากการอ่านหรือเขียนค่าตัวแปรภายนอก
var count = 0
function increment(){
  count = count + 1
}
Enter fullscreen mode Exit fullscreen mode

Side-Effect จะเกิดขึ้นเมื่อฟังก์ชันมีการอ่าน/เขียนตัวแปรนอกฟังก์ชัน หรือมีการติดต่อกับ I/O (input หรือ output) ภายนอก ไม่ว่าจะเป็นการอ่านค่าจากผู้ใช้, การแสดงผลทางหน้าจอ, การอ่าน/เขียน Database, หรือแม้แต่การเชื่อมต่อ API

ปัญหาของ Side-Effect คือมันทำให้ฟังก์ชันไม่เพียว! พอฟังก์ชันนั้นไม่ใช่ Pure Function สิ่งที่ตามมาก็คือ state ที่มากมาย ยากต่อการเทสโปรแกรม

จากตัวอย่างข้างบน เราสร้างฟังก์ชัน countIt() ขึ้นมาเพื่อนับเลขไปเรื่อยๆ ทุกครั้งที่มีการเรียกใช้งาน ทีนี้ให้ลองดูที่โปรแกรมบรรทัด (1), (2), และ (3) เราจะพบว่าผลลัพธ์จากฟังก์ชัน countIt() นั้นเปลี่ยนไปเรื่อยๆ ตามตัวแปรภายนอกของโปรแกรม หรือที่เราเรียกว่า State ของโปรแกรมนั่นเอง

ถ้า state ของโปรแกรมในขณะนี้รันมาถึงจังหวะที่ count=1 ฟังก์ชัน countIt() ก็จะเปลี่ยน state เป็น count=2 แล้วตอบคำตอบเป็น 2

นั่นแสดงว่าเราไม่สามารถคาดเดาผลลัพธ์ที่ countIt() จะตอบกลับมาได้เลย ซึ่งมีโอกาสที่จะทำให้เกิดบั๊กตอนเขียนโปรแกรมได้สูงมาก ยิ่งถ้าโค้ดของเราไม่ค่อยเป็นระเบียบด้วยนะ

แล้ว method ใน OOP ล่ะ?

สำหรับภาษายุคใหม่ๆ ที่ขาดไม่ได้เลยก็คือฟีเจอร์การเขียนโปรแกรมแบบ Object-Oriented นั่นเอง แล้วสำหรับภาษา OOP แท้ๆ เช่น Java เนี่ยจะกำหนดว่าทุกสิ่งทุกอย่างจะต้องอยู่ในรูปของ class เท่านั้น ไม่สามารถสร้างฟังก์ชันลอยๆ อยู่ข้างนอกได้

ใน OOP ไม่มีฟังก์ชัน แต่จะเรียกว่า method แทน

ส่วนความแตกต่างระหว่าง function vs method ก็คือเมธอดสามารถใช้งานตัวแปรภายในของคลาสที่เราเรียกว่า properties ได้นั่นเอง

class People {
  var name
  function setName(name){
    this.name = name
  }
  function sayHi(){
    return 'my name is ${name}'
  }
}
Enter fullscreen mode Exit fullscreen mode

โดยส่วนใหญ่แล้ว method มักจะมีการ"ยุ่งเกี่ยว"กับตัวแปรภายนอกแบบนี้เสมอ ซึ่งไม่ใช่เรื่องผิดอะไรในโลกของ OOP แต่ถ้าในมุมของ FP แล้วนั้น ทำให้เมธอดส่วนใหญ่ไม่มีคุณสบมัติของ pure function ยังไงล่ะ

แล้วปัญหาของการที่เราไม่เขียนเมธอดให้เป็น pure function จะทำให้เกิดปัญหาอะไร?

ลองดูตัวอย่างต่อไป

class WickedService {
  var x = 1
  function foo(){
    ...  
  }
  function displayX(){
    print(x) // 1
    foo()
    print(x) // ???
  }
}
Enter fullscreen mode Exit fullscreen mode

ลองดูที่เมธอด displayX() จะเห็นว่ามีคำสั่งปริ๊นค่า x อยู่ สมมุติว่าครั้งแรกที่คำสั่งนี้ทำงานค่า x มีค่าเป็น 1

ดังนั้นความคาดหวังของเราคือถ้าเราปริ๊นค่า x อีกทีนึง ค่าที่ได้ก็ควรจะเป็น 1 เหมือนเดิม เพราะในเมธอดนี้ไม่ได้มีการเซ็ตค่า x ใหม่เลย

แต่ก็ไม่ได้เป็นแบบนั้นทุกครั้ง! เพราะในระหว่างที่ displayX() ทำงานอยู่นั้น มีการเรียกใช้ foo() ขั้นกลางด้วย และมีโอกาสที่เมธอด foo() จะทำการเซ็ตค่า x ใหม่ทำให้มันเปลี่ยนแปลงไประหว่างที่ displayX() กำลังทำงานอยู่

เชื่อว่าคนที่เขียนโปรแกรมแบบ OOP มาแล้วน่าจะเคยเจอบั๊กที่เกิดการเหตุการณ์แบบนี้ คือเรากำลังเขียนโปรแกรมอยู่ในเมธอดหนึ่งอยู่ แล้วอยู่ๆ ค่าที่ชัวร์ว่ามันน่าจะเป็นค่านี้แน่ๆ ก็เปลี่ยนแปลงไปโดยไม่รู้สาเหตุ (ก่อนจะไปพบว่าจริงๆ มีเมธอดอีกตัวหนึ่งแอบเปลี่ยนค่ามัน) กว่าจะหาเจอก็เสียเวลาไปครึ่งวันซะแล้ว!

Purity คุณสมบัติแห่งความบริสุทธิ์

เพราะว่า pure function ไม่อนุญาตให้มี Side-Effect จากภายนอกมายุ่งกับโลจิคการทำงานภายในทั้นสิ้น มันเลยมีคุณสมบัติอีกอย่างนั่นคือ...

ไม่เรียกใช้ฟังก์ชันกี่ครั้ง ถ้าinputเหมือนเดิม outputก็ต้องเหมือนเดิมตามไปด้วย!

เช่น ถ้าเรามีฟังก์ชันตัวนึง ที่ใส่ค่า 6 เข้าไปแล้วมันตอบค่า 7 กลับมา แบบนี้

f(6) => 7
Enter fullscreen mode Exit fullscreen mode

ไม่ว่าเราจะเรียก f(6) อีกกี่ครั้ง มันก็จะต้องตอบ 7 อย่างแน่นอน

f(6) => 7
f(6) => 7 ไงล่ะ!
f(6) => ยังคงเป็น 7
f(6) => ก็ยัง 7 อยู่นะ
Enter fullscreen mode Exit fullscreen mode

คุณสมบัตินี้เรานี่แหละที่เราเรียกว่า purity หรือการ memorize คำตอบของฟังก์ชันไว้ได้ (จำคำตอบไว้ได้ เพราะไม่ว่าจะเรียกฟังก์ชันกี่รอบ มันก็ต้องได้คำตอบเดิม)

ซึ่งถ้าฟังก์ชันของเราทำงานเยอะกว่าจะได้คำตอบมา จะเป็นอะไรที่ดีมาก เพราะหลังจากได้คำตอบมาแล้ว หากเราเรียกฟังก์ชันเดิมซ้ำพร้อมกับ input เดิมเราจะไม่ต้องรันฟังก์ชันนั้นอีก (เพราะมีการจำคำตอบหรือ memorize ไว้แล้วไงล่ะ)

Pure Function จะมีคุณสมบัติ Purity หรือการที่คำตอบจะต้องออกมาเหมือนเดิมทุกรอบ (ถ้า input เหมือนเดิม) ซึ่งจะนำมาสู่คุณสมบัติ Memorize ที่เป็นจดจำคำตอบไว้ ซึ่งจะมีประโยชน์มากหากฟังก์ชันนั้นมีการทำงานที่หนักกว่าจะได้คำตอบมา (เช่นฟังก์ชันที่ O(n) เยอะๆ )

📢 Note: ในวิชา Data Structure and Algorithm เรื่องนี้เป็นเรื่องเดียวกับแนวคิดของ Dynamic Programming นั่นเอง!

แต่ถ้าการเรียกใช้งานแต่ละครั้ง ให้ค่ากลับมาไม่เหมือนกัน แบบนี้

f(6) => 7 //ครั้งแรกตอบแบบนี้
f(6) => 3 //ครั้งที่สองตอบ 3 แทน
f(6) => 5 //ต่อไปเปลี่ยนไปตอบ 5 !
Enter fullscreen mode Exit fullscreen mode

แบบนี้ไม่ใช่ pure function ก็จะทำให้ไม่มีคุณสบมัติ purity ไปด้วย


First-Class Function

ชื่อของหัวข้อนี้มาจากคำว่า first class citizen หรือประชากรชั้นหนึ่ง ไม่ใช่คนต่างชาติหรือต่างด้าว ก็คือประชาชนของประเทศนั่นแหละ ซึ่งก็จะมีสิทธิพื้นฐานคือทำได้ทุกอย่าง

ทีนี้ถ้าในมุมของการเขียนโปรแกรม การจะเป็นประชากรชั้นหนึ่งได้นั้นหมายความว่าเราจะต้องสร้าง type ชนิดนั้นจะต้องเซ็ตเป็นตัวแปรได้ = First-Class Function ก็คือการที่เราสามารถกำหนดให้ฟังก์ชันกลายเป็นตัวแปรได้นั่นเอง

function add(x,y){
  return x + y
}

// call function: ผลลัพธ์ก็จะได้ 1 + 2 = 3
var a = add(1, 2)

// กำหนดตัวแปร f ให้เท่ากับฟังก์ชัน, แต่ไม่ได้เป็นการรันฟังก์ชันนะ สังเกตดูว่าไม่มี () ต่อหลัง
var f = add
// ตัวแปร f ในตอนนี้มีค่าเป็นตัวฟังก์ชัน add นั่นเอง
var b = f(1, 2)
Enter fullscreen mode Exit fullscreen mode

การสร้างฟังก์ชันแบบปกติจะประกอบด้วยการ declare คือการกำหนดว่าฟังก์ชันจะทำงานยังไง และการ call หรือการเรียกใช้งานฟังก์ชันที่สร้างขึ้นมา

แต่ก็มีข้อแตกต่างระหว่างการ declare ฟังก์ชันกับการสร้างฟังก์ชันใส่ตัวแปร
นั่นคือการ declare ฟังก์ชันนั้น ส่วนใหญ่จะมีสโคปการใช้งานแบบ global แต่ถ้าใช้แบบตัวแปร ก็จะใช้กฎเดียวกับการสร้างตัวแปร นั่นคือก่อนเราสร้างตัวแปรเราจะใช้งานมันไม่ได้

// ตรงนี้สามารถเรียกใช้ f() ได้
// แต่เรียกใช้ g() ไม่ได้เพราะยังไม่ถึงบรรทัดที่ประกาศตัวแปร

function f(){ ... }

var g = function(){ ... }

// ตั้งแต่ส่วนนี้ไป ถึงจะเรียกใช้ g() ได้
Enter fullscreen mode Exit fullscreen mode

High-Order Function

คุณสมบัตินี้จริงๆ เป็นคุณสมบัติที่ต่อเนื่องจาก First-Class Function นั่นคือเมื่อพอเรากำหนดฟังก์ชันเป็นตัวแปรได้ เราก็สามารถส่งฟังก์ชันผ่าน parameter ของฟังก์ชัน (อีกตัวหนึ่ง) หรือจะเป็นการรีเทิร์นฟังก์ชันกลับมาก็ยังได้

function calculate(a, b, operator){
  return operator(a, b)
}

function add(x, y){
  return x + y
}
function sub(x, y){
  return x - y
}

calculate(2, 1, add) // 2 + 1 = 3
calculate(2, 1, sub) // 2 - 1 = 1
Enter fullscreen mode Exit fullscreen mode

ตามตัวอย่างข้างบน เราสามารถสร้างฟังก์ชัน calculate ที่รับฟังก์ชันอีกตัวหนึ่งเข้ามาเพื่อทำการโปรเซสค่า โดยที่ไม่ต้องรู้ว่ามันทำการคำนวณยังไง เราสามารถเปลี่ยนแปลงการคำนวณของ calculate ได้ง่ายๆ โดยการสร้างฟังก์ชันใหม่แล้วใส่เข้าไปกี่ตัวก็ได้ เช่น add, sub (คอนเซ็ปของ encapsulation ของฟังก์ชันไงล่ะ!)

หรือจะใช้ในอีกรูปแบบหนึ่งคือการรีเทิร์นฟังก์ชันกลับมาก็ได้เช่นกัน แบบนี้

function priceCalculatorBuilder(type){
  if(type == NORMAL)    return function(unit){ return unit * 10 }
  if(type == DISCOUNT)  return function(unit){ return unit * 10 - 100 }
  if(type == FREE)      return function(unit){ return 0 }
}
Enter fullscreen mode Exit fullscreen mode

สมมุติเราต้องการฟังก์ชันชื่อ priceCalculator เอาไว้คำนวณจำนวนเงิน แต่ปัญหาคือมีสูตรการคำนวณหลายแบบเหลือเกิน

แทนที่จะสร้างฟังก์ชันตรงๆ ที่มีโลจิคการคำนวณมากมายอยู่ข้างใน เราก็เลือกที่จะสร้าง priceCalculatorBuilder หรือก็คือฟังก์ชันที่เอาไว้สร้างเจ้า priceCalculator อีกทีหนึ่งแทน

สังเกตดูว่าสิ่งที่ฟังก์ชันรีเทิร์นกลับคือฟังก์ชันอีกตัวหนึ่ง นั่นหมายความว่า การเรียนใช้ง่านในครั้งแรกจะยังไม่ได้ค่าจำนวนเงินออกมา แต่จะเป็นฟังก์ชันที่เอาไว้คำนวณเงินแทน (ตาม type ที่ใส่เข้าไปว่าจะให้คำนวณแบบไหน)

var priceCalculator = priceCalculatorBuilder(type)
print(priceCalculator(5))
Enter fullscreen mode Exit fullscreen mode

อธิบายเพิ่มเติมเกี่ยวกับแนวคิดของฟังก์ชันในโค้ดนี้กันหน่อย

ตามปกติแล้ว ฟังก์ชันมีคุณสมบัติ encapsulation (หรือเรียกว่า การห่อหุ้ม ในภาษาไทย😳) แปลง่ายๆ คือการหุ้มโลจิคของการคำนวณเอาไว้ภายใน แต่ priceCalculator นั้น ตัวมันไม่ได้หุ้มโลจิคการคำนวณไว้ตรงๆ เพราะจริงๆ มันเป็นแค่ทางผ่านเท่านั้น โลจิคอีกส่วนหนึ่งถูกหุ้มอยู่ใน priceCalculatorBuilder นั่นเอง

สรุป

  1. ความจริงคอนเซ็ปของ Pure Function นั้นง่ายมาก นั่นคือ input --> process --> output โดยห้ามมีค่าภายนอกหรือ I/O มาเขียนข้องเลย...แม้แต่นิดเดียวกันไม่ได้ !
  2. ปัญหาคือตั้งแต่เราเริ่มเขียนโปรแกรมกัน เราไม่ค่อยได้สร้าง Pure Function กันเท่าไหร่ มักจะสร้างให้มันมี Side-Effect เล็กๆ น้อยๆ เสมอ (เอาง่ายๆ เช่นการ print() ไงล่ะ)
  3. First Class Function คือการยอมให้เราเก็บฟังก์ชันลงในตัวแปรได้ ซึ่งถือว่าเป็นข้อสำคัญ ที่หากเราจะเขียน FP แล้ว ภาษาโปรแกรมนั้นจะต้องมีคุณสมบัติข้อนี้
  4. High Order Function ไม่มีอะไรมาก พอฟังก์ชันกลายเป็นตัวแปรได้แล้ว มันก็สามารถถูกส่งไปผ่าน parameter หรือจะรีเทิร์นค่ากลับมาก็ยังได้

Next~

ในบทต่อไป เราจะพูดกันต่อในเรื่องของการสร้างฟังก์ชันนิรนามและการปิดล้อม (Closure) ของฟังก์ชัน

Top comments (0)