Big Data Analytics · บทที่ 5 จาก 13

Scala และ Functional Programming

บทนี้ไม่ได้มีเป้าหมายให้ทุกคนกลายเป็น Scala programmer ภายในหนึ่งบท แต่ต้องการให้มองเห็นภาษาความคิดที่อยู่ใต้ Spark: แทนที่จะสั่งเครื่องทีละก้าว เราอธิบายการแปลงข้อมูลเป็นชุดของฟังก์ชันที่ประกอบกันได้ ระบบจึงมีพื้นที่มากขึ้นในการแบ่งงาน ย้ายงาน คำนวณใหม่ และปรับแผนให้เหมาะกับคลัสเตอร์

📚
ความตั้งใจของบท: เรียน Scala เท่าที่จำเป็นเพื่ออ่าน Spark ได้ และเรียน Functional Programming ลึกพอจะเข้าใจว่าทำไม Immutability, Pure Function, Lazy Evaluation, Cache และ Algebra จึงเชื่อมการเขียนโปรแกรมบนเครื่องเดียวไปสู่การคำนวณแบบกระจาย

1Scala คืออะไร และทำไม Spark ถึงเลือกมัน

นิยาม
Scala เป็นภาษาโปรแกรมแบบ multi-paradigm ที่ผสาน Object-Oriented Programming กับ Functional Programming ในระบบชนิดข้อมูลแบบ static ออกแบบโดย Martin Odersky และทำงานบน JVM จึงใช้ไลบรารีและเครื่องมือจำนวนมากในระบบนิเวศของ Java ได้โดยตรง

ชื่อ Scala มาจากแนวคิด scalable language คือภาษาเดียวควรใช้เขียนได้ตั้งแต่ expression สั้น ๆ ไปจนถึงระบบขนาดใหญ่ จุดเด่นไม่ใช่เพียง “เขียน Java ให้สั้นลง” แต่คือการทำให้ object และ function อยู่ร่วมกันได้อย่างเป็นธรรมชาติ เราจึงใช้ class, trait และ encapsulation เมื่อเหมาะ และใช้ immutable value, higher-order function และ pattern matching เมื่อปัญหาเป็นการแปลงข้อมูล

เปรียบเหมือนภาษากลางของทีมสองสาย
ทีมหนึ่งคุ้นกับการสร้างวัตถุและแบ่งความรับผิดชอบเป็น class อีกทีมคิดเป็นฟังก์ชันและสมการ Scala ไม่ได้บังคับให้ฝ่ายใดฝ่ายหนึ่งย้ายบ้านทั้งหมด แต่สร้างสะพานให้ทั้งสองแนวคิดทำงานในโปรแกรมเดียวกัน Spark จึงใช้โครงสร้างระบบแบบ object-oriented ขณะเปิด API ให้ผู้ใช้ส่งฟังก์ชันไปแปลงข้อมูล
คุณสมบัติทำไมถึงสำคัญกับ Big Data
Object-Oriented + Functionalทุกค่าคือ object ทุกฟังก์ชันคือ value — เขียนโค้ดแบบ pipeline การแปลงข้อมูล (transformation chain) ได้กระชับ ตรงกับรูปแบบการประมวลผลข้อมูลขนาดใหญ่
Static Typing + Type Inferenceตรวจความไม่เข้ากันของ type ได้ก่อนส่งงานขึ้นคลัสเตอร์ โดยไม่ต้องเขียน type กำกับทุกตำแหน่ง แต่ compile-time checking ไม่ได้ป้องกันความผิดพลาดด้านข้อมูลหรือ logic ทั้งหมด
Immutability by conventionนิยมใช้ val และ immutable collection ช่วยลด shared mutable state แต่ Scala ยังอนุญาต var จึงไม่ได้ทำให้โปรแกรมปลอดภัยโดยอัตโนมัติ
Functions as valuesส่ง “สิ่งที่ต้องทำกับแต่ละ record” เข้าไปใน map, filter หรือ reduce ได้ Framework จึงควบคุมการกระจายงาน ส่วนผู้ใช้กำหนด logic
JVM ecosystemใช้ระบบจัดการหน่วยความจำ เครื่องมือ profiling และไลบรารีของ Java ที่พัฒนามานาน รวมถึงทำงานร่วมกับระบบ Hadoop ได้สะดวก
Pattern Matching และ Case Classอธิบายโครงสร้าง record และแยกกรณีของข้อมูลได้ชัด เหมาะกับงาน parse และ transformation แต่ต้องจัดการกรณีข้อมูลไม่ครบอย่างระมัดระวัง

ทำไม Spark ไม่ได้เลือก Scala เพียงเพราะโค้ดสั้น

ความกระชับมีประโยชน์ แต่เหตุผลที่ลึกกว่าคือ Scala เปิดให้ Spark สร้าง API ที่ดูเหมือน collection ธรรมดา ผู้ใช้เขียน data.map(...).filter(...) แต่ object ที่รับคำสั่งอาจเป็นข้อมูลกระจายหลายร้อย partition Framework เป็นผู้ตัดสินใจว่าฟังก์ชันจะรันที่ใด เมื่อใด และทำซ้ำอย่างไร

กล่าวอีกแบบหนึ่ง Scala ทำให้ “โปรแกรมที่ผู้ใช้เขียน” แยกออกจาก “กลไกที่ระบบใช้รัน” ได้ค่อนข้างสะอาด ผู้ใช้ส่ง closure เข้าไป ส่วน Spark เก็บ lineage สร้าง DAG และจัด task ลง executor นี่เป็นการแบ่งหน้าที่ที่ทำให้ API เดิมใช้ได้ทั้งข้อมูลเล็กใน local mode และข้อมูลใหญ่บนคลัสเตอร์ แม้ต้นทุนจริงจะแตกต่างกันมาก

Scala ไม่ใช่เงื่อนไขบังคับของการเรียน Spark

ปัจจุบันผู้ใช้จำนวนมากเข้าถึง Spark ผ่าน Python หรือ SQL และทำงานจริงได้ดี การเรียน Scala ในบทนี้จึงไม่ใช่การประกาศว่า “ทุกคนต้องเขียน Spark ด้วย Scala” แต่เป็นการใช้ภาษาที่แนวคิด Functional Programming ปรากฏชัด เพื่อมองกลไกใต้ API ให้ทะลุ เมื่อเข้าใจแล้ว แนวคิดเดียวกันย้ายไป PySpark, Java, SQL หรือระบบอื่นได้

แยกสามสิ่งนี้ก่อน
Scala คือภาษา, Functional Programming คือแนวทางจัดโครงสร้างโปรแกรม และ Spark คือระบบประมวลผลแบบกระจาย ทั้งสามเกี่ยวข้องกัน แต่ไม่ใช่สิ่งเดียวกัน เราเขียน Scala แบบ imperative ได้ เขียน functional style ใน Python ได้ และใช้ Spark SQL โดยแทบไม่เห็นฟังก์ชัน Scala เลยก็ได้

Static Type ช่วยอะไร และช่วยไม่ได้ตรงไหน

ถ้าฟังก์ชันต้องการ Int แต่เราเผลอส่ง String compiler อาจตรวจพบก่อนรัน ลดโอกาสเสียเวลาจองคลัสเตอร์แล้วล้มเพราะชนิดข้อมูลไม่ตรง แต่ static type ไม่รู้ว่าอายุเป็น -300 สมเหตุสมผลหรือไม่ ไม่รู้ว่า key กระจายเอียง และไม่รู้ว่าไฟล์จริงมีบรรทัดเสีย ดังนั้น type safety เป็นตาข่ายชั้นหนึ่ง ไม่ใช่ใบรับรองว่าทั้ง pipeline ถูกต้อง

JVM Compatibility มีรายละเอียดที่ต้องตรงกัน

Scala library มี binary version อยู่ในชื่อ artifact เช่น spark-core_2.13 ส่วนท้าย _2.13 ไม่ใช่เลขตกแต่ง แต่บอกว่า library ถูกคอมไพล์สำหรับ Scala binary version ใด แอป Scala ควรใช้ version ที่เข้ากับ Spark distribution นั้น ไม่ควรเห็นคำว่า “Scala” เหมือนกันแล้วถือว่าใช้แทนกันได้ทุกเวอร์ชัน

2ทำไม Immutability ถึงสำคัญกับข้อมูลขนาดใหญ่

เวลาบอกว่า object เป็น immutable เราหมายถึงเมื่อสร้างแล้ว สถานะที่มองเห็นจาก object นั้นไม่ถูกแก้ทับ หากต้องการค่าใหม่ เราสร้าง object ใหม่หรือ collection ใหม่จากของเดิม แนวคิดนี้ดูเหมือนสิ้นเปลือง เพราะทำไมไม่แก้ช่องเดิมไปเลย? แต่ในระบบที่มีหลาย thread หลาย task และหลายสำเนาของข้อมูล ต้นทุนของการแก้ร่วมกันอาจแพงกว่าต้นทุนของการสร้างค่ารุ่นใหม่มาก

แบบ Mutable (มีปัญหาเมื่อ scale)
class Person(var name: String)

val p = new Person("John")
myStorage.store(p)
p.name = "Jane"   // แก้ค่าเดิม
myStorage.store(p)
// ถ้า myStorage เก็บ reference ไว้
// ค่าที่เก็บก่อนหน้าอาจถูกเปลี่ยนตามไปด้วย!
แบบ Immutable (ปลอดภัยกว่าเมื่อ scale)
case class Person(name: String)

val p = new Person("John")
val p2 = p.copy(name = "Jane")
// p ยังคงเป็น "John" เสมอ
// p2 เป็นค่ารุ่นใหม่ที่แยกจาก p
ทำไมเรื่องนี้จึงไม่ใช่แค่สไตล์การเขียนโค้ด
เมื่อหลาย thread แก้ object เดียวกันโดยไม่ประสานงาน ผลลัพธ์อาจขึ้นกับว่าใครเขียนก่อนหรือหลัง เกิด race condition ที่รันครั้งหนึ่งผ่าน อีกรอบหนึ่งผิด และตามรอยยาก RDD ของ Spark จึงมี semantics แบบ immutable: transformation สร้าง RDD ตัวใหม่ ไม่แก้ record ใน RDD เดิมจากส่วนกลาง

เปรียบเหมือนเอกสารฉบับลงนามแล้ว

เอกสารกับกระดานไวท์บอร์ด
Mutable state เหมือนทุกคนใช้ปากกาลบได้เขียนบนกระดานเดียวกัน คนหนึ่งอ่านยอด 500 ขณะที่อีกคนกำลังลบแก้เป็น 800 จึงต้องมีกติกาว่าใครถือปากกาอยู่ ส่วน immutable data เหมือนเอกสารที่ลงนามแล้ว ถ้าต้องแก้ เราออกฉบับใหม่และเก็บฉบับเดิมไว้ ทุกคนจึงรู้ว่าตนกำลังอ้างฉบับใด

ระบบกระจายไม่ได้ส่ง memory reference เดียวข้ามเครื่องเหมือนใน process เดียว แต่แนวคิดเรื่อง version ยังสำคัญมาก Dataset ที่ไม่ถูกแก้ทับช่วยให้ lineage อธิบายได้ว่า output รุ่นนี้เกิดจาก input และ transformation ใด เมื่อ partition หาย ระบบจึงสร้างใหม่จากสูตรเดิมได้

Immutability ลด Dependency แบบใด

คำว่า “ลด dependency” ไม่ได้แปลว่างานทุกชิ้นเป็นอิสระทันที แต่ลด write dependency ที่เกิดจากหลายงานต้องแก้ตำแหน่งเดียวกัน หากงาน A และ B อ่าน input เดียวกันโดยไม่เขียนทับ ทั้งคู่ทำพร้อมกันได้ง่ายกว่า แต่ถ้า A ต้องเขียนก่อน B อ่าน หรือทั้งคู่เขียนค่าเดียวกัน เราต้องกำหนดลำดับ ใช้ lock หรือแก้ conflict

ความสัมพันธ์ตัวอย่างผลต่อการทำงานพร้อมกัน
Read–ReadA และ B อ่าน dataset เดิมมักทำพร้อมกันได้ เพราะไม่มีใครเปลี่ยนสิ่งที่อีกฝ่ายอ่าน
Write–ReadA แก้ค่า แล้ว B ต้องอ่านค่ารุ่นใหม่B ต้องรอ A หรือระบบต้องกำหนด version ที่ B ควรเห็น
Read–WriteA อ่านค่าเดิม ขณะที่ B จะเขียนทับต้องป้องกันไม่ให้ A เห็นสถานะครึ่งเก่าครึ่งใหม่
Write–WriteA และ B แก้ค่าเดียวกันต้อง lock, serialize, merge หรือกำหนดผู้ชนะ

Immutability เปลี่ยนปัญหาจาก “ใครมีสิทธิ์เขียนช่องนี้ตอนนี้” เป็น “ค่ารุ่นใหม่เกิดจากค่ารุ่นใด” ปัญหาไม่ได้หายไป แต่เปลี่ยนจากการประสาน write บนตำแหน่งเดิม ไปเป็นการจัดการ dependency ระหว่าง version ซึ่งเหมาะกับ DAG และการคำนวณแบบ dataflow มากกว่า

ไม่แก้ที่เดิม ไม่ได้แปลว่าไม่ใช้หน่วยความจำร่วมกันเลย

Immutable collection ไม่จำเป็นต้องคัดลอกทุก element ทุกครั้ง โครงสร้างข้อมูลแบบ persistent สามารถแบ่งปันส่วนที่ไม่เปลี่ยนร่วมกันได้ เรียกว่า structural sharing เช่นการเพิ่มสมาชิกหน้ารายการอาจสร้าง node ใหม่เพียงหนึ่งตัวแล้วชี้ไปยังหางเดิม

val a = List(2, 3, 4)
val b = 1 :: a

// b มีค่า List(1, 2, 3, 4)
// ส่วนท้าย 2,3,4 สามารถใช้โครงสร้างเดิมร่วมกับ a ได้

ดังนั้น “สร้างค่าใหม่” ไม่ควรถูกตีความว่า “copy ข้อมูลทั้งหมดเสมอ” แต่ก็ไม่ฟรีทุกกรณี Array ขนาดใหญ่ที่ copy จริงย่อมมีต้นทุน และ Spark transformation ก็อาจสร้าง object จำนวนมากได้ การเลือก representation ยังคงสำคัญ

หลีกเลี่ยง Random Write แต่ไม่ใช่หลีกเลี่ยง Random Access ทุกกรณี

Functional data pipeline มักอ่านชุดข้อมูลแล้วสร้างชุดใหม่แบบ append หรือ sequential write แทนการวิ่งกลับไปแก้ record กระจัดกระจายในไฟล์เดิม วิธีนี้เข้ากับ distributed file system และ columnar storage ที่เขียนเป็นก้อน แต่ควรแยก random read ออกจาก random write: immutable array ยังอ่านตำแหน่งใดก็ได้ ส่วนสิ่งที่เราพยายามลดคือการเขียนทับตำแหน่งร่วม ซึ่งสร้าง dependency และ lock

จาก Lock ไปสู่ Version

ถ้าข้อมูลก้อนเดิมถูกแก้ ทุกคนต้องตกลงว่าใครแก้ได้และผู้อ่านเห็นสถานะใด Lock เป็นคำตอบหนึ่ง แต่ทำให้บางงานต้องรอ เมื่อใช้ immutable version ผู้อ่านเก่ายังอ่าน version เดิมได้ ผู้เขียนสร้าง version ใหม่ แล้วระบบค่อยเปลี่ยน pointer หรือ metadata ให้ผู้อ่านรุ่นถัดไปเห็น วิธีนี้พบได้ใน copy-on-write, MVCC, snapshot และ data lake table format หลายชนิด แม้รายละเอียดแต่ละระบบต่างกัน

Immutability ไม่ได้ทำให้ทุกอย่างปลอดภัยเอง

ตัวแปรอาจประกาศด้วย val แต่ object ข้างในยัง mutable ได้ เพราะ val ห้ามเปลี่ยน reference ไม่ได้ห้ามแก้สถานะภายใน object เช่น val buffer = ArrayBuffer(...) ยังเรียก buffer += x ได้ นักศึกษาจึงต้องแยก “ตัวแปรชี้ไปที่เดิม” ออกจาก “ค่าข้างในเปลี่ยนไม่ได้”

val xs = scala.collection.mutable.ArrayBuffer(1, 2)
xs += 3       // ทำได้ แม้ xs เป็น val

val ys = List(1, 2)
val zs = ys :+ 3   // สร้าง List ใหม่; ys ไม่เปลี่ยน

RDD Immutable แต่โค้ดใน Closure ยังสร้างปัญหาได้

แม้ RDD ไม่ถูกแก้ทับ ผู้ใช้ยังอาจปิดล้อม mutable variable ใน closure แล้วคาดว่าทุก executor จะแก้ตัวแปรเดียวกับ driver ตัวอย่างเช่นเพิ่ม counter ธรรมดาใน foreach ผลอาจไม่กลับมาที่ driver เพราะ closure ถูก serialize และส่งสำเนาไปทำงานคนละ process หากต้องนับเพื่อ monitoring ควรใช้ accumulator ตามข้อจำกัดของมัน หรือดีกว่านั้นคือสร้างผลลัพธ์ด้วย transformation และ aggregation ที่ชัดเจน

หยุดคิดสักครู่
ถ้าต้องคำนวณยอดคงเหลือของบัญชี Immutability ไม่ได้แปลว่า “ห้ามยอดเปลี่ยน” แต่แปลว่าไม่ลบประวัติแล้วเขียนยอดใหม่ทับอย่างไร้ร่องรอย เราอาจเก็บรายการเปลี่ยนแปลงหรือสร้าง snapshot รุ่นใหม่ คำถามจึงเปลี่ยนจาก “จะแก้ตัวเลขช่องไหน” เป็น “สถานะใหม่นี้ได้มาจากเหตุการณ์ใดบ้าง”

3ไวยากรณ์พื้นฐานที่ต้องรู้ก่อนเขียน Spark

ส่วนนี้เลือกเฉพาะไวยากรณ์ที่ช่วยให้อ่าน transformation ได้ ไม่ได้พยายามแทนตำรา Scala ทั้งเล่ม เป้าหมายคือเมื่อเห็นโค้ด Spark นักศึกษาควรแยกได้ว่าอะไรคือข้อมูล อะไรคือฟังก์ชัน และอะไรคือขั้นตอนที่ยังไม่ได้ลงมือคำนวณ

Variables: var vs val

var status: String = "draft"     // เปลี่ยน reference ได้
status = "published"

val course: String = "Big Data" // เปลี่ยน reference ไม่ได้
// course = "AI"                // compile error

val year = 2026                  // compiler อนุมานว่าเป็น Int

หลักง่าย ๆ คือเริ่มด้วย val แล้วใช้ var เมื่อมีเหตุผลชัด ไม่ใช่เพราะ var เป็นของต้องห้าม แต่เพราะค่าที่ไม่เปลี่ยนลดจำนวนสถานะที่ต้องตามในหัว เวลา debug เราไม่ต้องถามว่าตัวแปรถูกแก้จากที่ใดมาบ้าง

Functions

def add(a: Int, b: Int): Int = a + b

def normalize(name: String): String =
  name.trim.toLowerCase

println(add(5, 3))              // 8
println(normalize("  DATA "))  // data

Scala ใช้ค่าของ expression สุดท้ายเป็นค่าที่คืน จึงไม่จำเป็นต้องสร้างตัวแปรชั่วคราวหรือเขียน return ทุกครั้ง รูปแบบนี้ทำให้ฟังก์ชันเล็กและมองเป็นการจับคู่ input → output ได้ชัด แต่ฟังก์ชันหลายบรรทัดก็ยังใช้วงเล็บปีกกาได้เมื่อช่วยให้อ่านง่าย

Method กับ Function Value

def square(n: Int): Int = n * n        // method
val squareFn: Int => Int = n => n*n   // function value

List(1, 2, 3).map(square)              // ส่ง method เป็นฟังก์ชัน
List(1, 2, 3).map(n => n * n)          // anonymous function

ลูกศร => อ่านว่า “รับค่าไปทางซ้าย แล้วให้ expression ทางขวาสร้างผล” ใน Spark closure นี้จะถูก serialize และส่งไป executor จึงไม่ใช่แค่ callback ที่รันใน process เดียว เราควรหลีกเลี่ยงการปิดล้อม object ขนาดใหญ่หรือ resource ที่ serialize ไม่ได้ เช่น database connection

For Loop และ Collection

for (y <- 1 to 4) {
  println("Value of y is: " + y)
}

val numbers = Vector(1, 2, 3, 4, 5)
val doubled = numbers.map(n => n * 2)
// numbers ยังเป็น Vector(1,2,3,4,5)
// doubled เป็น Vector(2,4,6,8,10)

Tuple: จับค่าหลายช่องไว้ด้วยกัน

val item = ("Bangkok", 1200.0)
val province = item._1
val amount = item._2

val (p, a) = item   // destructuring

val partial: (Double, Long) = (1200.0, 1L)
// ใช้เป็น (sum, count) สำหรับค่าเฉลี่ยแบบกระจาย

Tuple สะดวกสำหรับผลชั่วคราว เช่นคู่ (key,value) หรือสถานะ (sum,count) แต่ถ้ามีหลายช่องจนต้องจำว่า _4 คืออะไร ควรใช้ case class ที่มีชื่อ field ชัดกว่า

Case Class: Record ที่อ่านความหมายออก

case class Sale(
  province: String,
  amount: Double,
  paid: Boolean
)

val s1 = Sale("Bangkok", 500.0, paid = true)
val s2 = s1.copy(amount = 650.0)

// s1 ไม่เปลี่ยน; s2 เป็นค่ารุ่นใหม่

Case class สร้าง constructor, equality, pattern matching และ copy ให้ เหมาะกับการแทน record ที่มี schema ชัด ใน Spark Dataset case class ยังช่วยให้ได้ typed encoder แต่ไม่ได้หมายความว่า Dataset จะเร็วกกว่า DataFrame ทุกกรณี เพราะ optimizer และค่าใช้จ่ายในการแปลง object ยังต้องพิจารณา

Option: ยอมรับว่าบางค่าอาจไม่มี

def parseAge(text: String): Option[Int] =
  text.toIntOption

parseAge("42")  // Some(42)
parseAge("?")   // None

val ages = List("20", "?", "35")
val valid = ages.flatMap(_.toIntOption)
// List(20, 35)

Option[A] บังคับให้เราคิดถึงกรณีมีค่า (Some) และไม่มีค่า (None) แทนการปล่อย null แล้วรอให้โปรแกรมล้มไกลจากต้นเหตุ อย่างไรก็ตามข้อมูล Spark SQL มี null semantics ของตนเอง จึงต้องเข้าใจทั้ง Option ใน Scala และ null ใน DataFrame แยกกัน

Pattern Matching: แยกกรณีอย่างเปิดเผย

def label(result: Option[Int]): String = result match {
  case Some(age) if age >= 18 => "adult"
  case Some(_)              => "minor"
  case None                 => "unknown"
}

Pattern matching ช่วยให้กรณีของข้อมูลปรากฏอยู่ในโค้ด ไม่ซ่อนในชุด if ที่กระจัดกระจาย แต่ pattern ที่ไม่ครอบคลุมทุกกรณีอาจทำให้เกิด MatchError ได้ compiler ช่วยเตือนได้ดีใน sealed hierarchy แต่ข้อมูลจากภายนอกยังต้อง validate เสมอ

Collection แต่ละชนิดมีต้นทุนต่างกัน

ชนิดเหมาะกับข้อควรระวัง
Listเพิ่มด้านหน้าและเดินตามลำดับเข้าถึงตำแหน่งกลางไม่ใช่ O(1)
Vectorimmutable sequence ที่เข้าถึงตำแหน่งได้ดีโครงสร้างซับซ้อนกว่า List เล็กน้อย
Arrayข้อมูลแน่นและเข้าถึงตำแหน่งเร็วmutable; ต้องระวังเมื่อนำไปแชร์
Mapค้นหาค่าด้วย keyอย่าสับสนกับฟังก์ชัน map
Setสมาชิกไม่ซ้ำและตรวจ membershipลำดับอาจไม่ใช่ส่วนหนึ่งของความหมาย

การเรียก map บน collection คือการแปลงสมาชิก ไม่เกี่ยวกับชนิดข้อมูล Map[K,V] ที่เก็บ key/value ชื่อเหมือนกันจึงทำให้นักศึกษาใหม่สับสนได้ง่าย

อ่าน Scala ให้เหมือนอ่านสายพาน
เมื่อเห็น raw.flatMap(parse).filter(valid).map(toPair) ให้ไล่ถามทีละสถานีว่า input ของสถานีนี้เป็นชนิดอะไร output เป็นชนิดอะไร และจำนวน record เปลี่ยนหรือไม่ วิธีนี้ง่ายกว่าพยายามอ่านทั้งบรรทัดเป็นประโยคยาวในครั้งเดียว

4Higher-Order Functions: หัวใจของ Spark API

นิยาม
Higher-order function คือฟังก์ชันที่รับฟังก์ชันอื่นเป็น parameter หรือคืนค่าเป็นฟังก์ชัน — API ของ Spark แทบทั้งหมด (map, filter, reduce, flatMap) เป็น higher-order function ที่รับ "สิ่งที่จะทำกับแต่ละ record" เป็น parameter
ฟังก์ชันความหมายตัวอย่าง
mapแปลงแต่ละ element เป็นอีกค่าหนึ่ง (1 ต่อ 1)Array(1,2,3).map(n => n*n)Array(1,4,9)
filterเก็บเฉพาะ element ที่ผ่านเงื่อนไขArray(1,2,3,4).filter(n => n%2==0)Array(2,4)
flatMapแปลงแต่ละ element เป็น 0 หรือหลายค่า แล้ว "แบน" ผลลัพธ์เป็น collection เดียวArray("a b","c").flatMap(s => s.split(" "))Array("a","b","c")
reduceรวม element เป็นค่าเดียว ต้องมีข้อมูลอย่างน้อยหนึ่งค่า และ operation ควรจัดกลุ่มใหม่ได้หากจะขนานVector(1,2,3).reduce(_+_)6
foldLeftพับจากซ้ายพร้อมค่าเริ่มต้น ลำดับเป็นส่วนหนึ่งของ semanticsVector(1,2,3).foldLeft(0)(_+_)6
เชื่อมไปยัง MapReduce
ชื่อ map และ reduce สะท้อนรากเดียวกับ Functional Programming แต่ MapReduce ของ Google ไม่ได้เท่ากับการเรียก collection map แล้ว reduce บนเครื่องเดียว Framework เพิ่ม key grouping, partition, Shuffle, failure recovery และ distributed scheduling เข้ามา ชื่อเดียวกันจึงไม่ควรทำให้เรามองข้ามกลไกที่เพิ่มขึ้น

Function เป็นข้อมูลชนิดหนึ่ง

val clean: String => String = _.trim.toLowerCase
val nonEmpty: String => Boolean = _.nonEmpty

val result = List(" A ", "", " B ")
  .map(clean)
  .filter(nonEmpty)
// List("a", "b")

เมื่อฟังก์ชันเป็น value เราเก็บในตัวแปร ส่งเป็น argument และประกอบกับฟังก์ชันอื่นได้ Spark ใช้คุณสมบัตินี้รับ logic จากผู้ใช้ แล้วนำ logic เดียวกันไปรันกับ partition ต่าง ๆ ผู้ใช้จึงไม่ต้องสร้าง thread หรือส่ง message ระหว่าง worker เอง

map, flatMap และ filter เปลี่ยน Cardinality ต่างกัน

ตัวดำเนินการหนึ่ง input ให้กี่ outputภาพที่ควรนึก
mapหนึ่งต่อหนึ่งเปลี่ยนรูปซองจดหมายทุกซอง แต่จำนวนซองเท่าเดิม
filterศูนย์หรือหนึ่งด่านตรวจปล่อยผ่านหรือคัดออก
flatMapศูนย์ หนึ่ง หรือหลายเปิดกล่องหนึ่งใบแล้วเทของข้างในออกมาเป็นสายเดียว
val lines = List("big data", "scala")

lines.map(_.split(" ").toList)
// List(List("big", "data"), List("scala"))

lines.flatMap(_.split(" "))
// List("big", "data", "scala")

mapValues, groupByKey และ reduceByKey ไม่ได้มีต้นทุนเท่ากัน

บน collection ในเครื่อง ฟังก์ชันเหล่านี้อาจดูเป็นเพียง API ต่างชื่อ แต่บนข้อมูลกระจาย groupByKey มักต้องส่ง values ทุกตัวผ่าน Shuffle แล้วเก็บกลุ่มไว้ ส่วน reduceByKey สามารถรวมผลภายใน partition ก่อนส่ง หาก operation รวมย่อยได้ ปริมาณข้อมูลข้ามเครือข่ายจึงอาจต่างกันมาก

API หน้าตาคล้ายกัน ต้นทุนอาจคนละโลก
Functional chain ทำให้โค้ดอ่านเป็นสายพาน แต่ไม่ควรทำให้ลืม physical plan การเพิ่มคำสั่งเพียงหนึ่งตัวอาจสร้าง Shuffle barrier ระหว่าง stages นักศึกษาควรถามเสมอว่า transformation นี้เป็น narrow หรือ wide dependency

foldLeft ไม่ใช่ Parallel Fold โดยอัตโนมัติ

foldLeft บน collection มีลำดับชัด: เริ่มจาก seed แล้วรวมสมาชิกจากซ้ายไปขวา จึงใช้กับ operation ที่ไม่ associative ได้ เช่นการลบ แต่ลำดับนี้ทำให้แบ่งเป็นต้นไม้อย่างอิสระไม่ได้ ส่วน distributed aggregate ต้องแยกการรวมภายใน partition กับการรวมผลระหว่าง partition และกำหนด operation ที่ปลอดภัยต่อการจัดกลุ่มใหม่

List(10, 3, 2).foldLeft(0)(_ - _)
// ((0 - 10) - 3) - 2 = -15

// ถ้าแบ่ง (10-3) และ (2) แล้วค่อยรวม
// ความหมายอาจไม่เหมือนเดิม การลบจึงไม่ใช่ reduction ที่จัดต้นไม้ได้ตรง ๆ

reduce ต้องการ Associativity; Commutativity ช่วยเมื่อ order ไม่แน่นอน

การบวกจำนวนเต็ม, max, min และ set union เหมาะกับ parallel reduction เพราะเปลี่ยนวงเล็บได้ ส่วนการต่อ string เปลี่ยนวงเล็บได้แต่สลับลำดับไม่ได้ หาก framework ไม่รับประกัน order ผลลัพธ์อาจต่างกัน นักศึกษาจึงต้องแยกสองคำถาม: จัดกลุ่มใหม่ได้หรือไม่ และ สลับตำแหน่งได้หรือไม่

Function Composition: สร้างชิ้นเล็กแล้วต่อกัน

val trim: String => String = _.trim
val lower: String => String = _.toLowerCase
val normalize = trim.andThen(lower)

normalize("  Spark ") // "spark"

ฟังก์ชันเล็กช่วยทดสอบง่ายและประกอบใหม่ได้ แต่การแตกทุก expression เป็นฟังก์ชันจิ๋วจนตามชื่อไม่ทันก็ไม่ได้ทำให้โค้ดดีขึ้น หลักคือแยกตามความหมายและขอบเขตการทดสอบ ไม่ใช่แยกเพื่อให้ดูเป็น functional

Currying และ Partial Application: เติมข้อมูลทีละชุด

def above(min: Double)(value: Double): Boolean =
  value > min

val abovePassing = above(50.0)
List(30.0, 65.0, 80.0).filter(abovePassing)
// List(65.0, 80.0)

แนวคิดนี้ช่วยสร้างฟังก์ชันเฉพาะจากฟังก์ชันทั่วไป เช่นเติม configuration ก่อน แล้วส่งฟังก์ชันที่เหลือให้ transformation แต่ไม่จำเป็นต้องใช้ currying ทุกแห่ง ไวยากรณ์ที่ฉลาดเกินไปอาจเพิ่มภาระผู้อ่านมากกว่าลดโค้ด

5จาก Collection สู่ Spark: โค้ดสั้นลง แต่ระบบข้างใต้ใหญ่ขึ้น

ตัวอย่างเดิมมักนำ Java MapReduce มาเทียบกับ Scala Spark แล้วสรุปว่า Scala ดีกว่าเพราะจำนวนบรรทัดน้อยกว่า การเปรียบเทียบนี้ให้ภาพเพียงครึ่งเดียว เพราะเราเปลี่ยนทั้งภาษาและระดับ abstraction พร้อมกัน Java ตัวอย่างแรกต้องจัดการ Hadoop API ส่วน Scala ตัวอย่างหลังใช้ Spark API ที่สูงกว่า ความสั้นจึงไม่ได้มาจาก syntax ของ Scala เพียงอย่างเดียว

Scala Collection บนเครื่องเดียว
val lines = List(
  "big data big",
  "scala data"
)

val counts = lines
  .flatMap(_.split("\\s+"))
  .map(word => (word, 1))
  .groupMapReduce(_._1)(_._2)(_ + _)

// Map(big -> 2, data -> 2, scala -> 1)
Spark RDD บนหลาย Partition
val lines = sc.textFile("input/*.txt")

val counts = lines
  .flatMap(_.split("\\s+"))
  .filter(_.nonEmpty)
  .map(word => (word.toLowerCase, 1L))
  .reduceByKey(_ + _)

counts.saveAsTextFile("output/word-count")

สองตัวอย่างดูคล้ายกัน แต่ความหมายทางกายภาพต่างกัน Collection อยู่ใน process เดียว ส่วน Spark RDD แบ่งเป็น partition และ reduceByKey อาจสร้าง Shuffle ข้ามเครื่อง บรรทัดที่เพิ่มเพียงจุดเดียวจึงอาจใช้เวลาหลายนาทีและย้ายข้อมูลหลายกิกะไบต์

ตัวอย่างค่าเฉลี่ยที่ถูกต้อง: ส่ง State ไม่ส่งค่าเฉลี่ยย่อย

// ข้อมูล: (ชื่อ, อายุ)
val ages = sc.parallelize(Seq(
  ("Ann", 20.0),
  ("Ann", 40.0),
  ("Bob", 30.0)
))

val averageByName = ages
  .map { case (name, age) =>
    (name, (age, 1L))        // (sum, count)
  }
  .reduceByKey {
    case ((sumA, countA), (sumB, countB)) =>
      (sumA + sumB, countA + countB)
  }
  .mapValues { case (sum, count) =>
    sum / count
  }

// Ann -> 30.0, Bob -> 30.0

จุดสำคัญไม่ใช่ความสั้น แต่คือ state (sum,count) มีโครงสร้างที่รวมผลย่อยได้ นี่คือ monoidification จากบทก่อน Spark จึงรวมใน partition ก่อน แล้วค่อยรวมข้าม partition โดยไม่ต้องส่งข้อมูลอายุทุกค่ามายังเครื่องเดียว

อย่าใช้ collect เป็นเครื่องหมายวรรคตอนท้ายทุกตัวอย่าง

collect() ดึงข้อมูลทั้งหมดกลับ driver เหมาะกับผลขนาดเล็กเพื่อดูหรือทดสอบ แต่ถ้าผลใหญ่กว่าหน่วยความจำ driver โปรแกรมอาจล้ม การเขียนตัวอย่างที่ลงท้ายด้วย collect ทุกครั้งทำให้นักศึกษาติดนิสัยนำข้อมูลกลับเครื่องตนเอง ซึ่งสวนทางกับหลัก “ส่ง code ไปหา data”

Actionใช้เมื่อข้อควรระวัง
take(n)ดูตัวอย่างจำนวนเล็กตัวอย่างไม่แทน distribution ทั้งหมด
count()ต้องการจำนวน recordยังต้อง scan ข้อมูลตาม lineage
collect()มั่นใจว่าผลเล็กพอสำหรับ driverเสี่ยง OutOfMemory และ network burst
save...ผลใหญ่และต้องใช้ต่อต้องเลือก partition และ format ให้เหมาะ

Closure ถูกส่งไป Executor

class Cleaner {
  val hugeDictionary = loadHugeDictionary()

  def run(lines: RDD[String]) =
    lines.map(line => hugeDictionary.normalize(line))
}

closure ใน map อ้างถึง hugeDictionary และอาจดึง object รอบข้างติดไป serialize ด้วย หาก dictionary ใหญ่และเหมือนกันทุก task ควรพิจารณา broadcast variable หรือจัดโครงสร้างให้ closure จับเฉพาะสิ่งที่จำเป็น หาก object serialize ไม่ได้ job จะล้มก่อนหรือระหว่างส่ง task

Serialization คือค่าขนส่งที่ซ่อนอยู่

ฟังก์ชันเหมือนจดหมาย แต่สัมภาระอาจเต็มรถ
เรามักพูดว่า code เล็กกว่า data จึงส่ง code ไปหา data แต่ closure ไม่ได้มีเพียงบรรทัดฟังก์ชัน มันอาจหอบ object ที่อ้างถึงติดไปด้วย เหมือนจดหมายหนึ่งซองที่แนบตู้เอกสารทั้งใบ หาก closure capture ใหญ่ ประโยชน์ของการย้าย code อาจถูกกินด้วย serialization และ network overhead

Built-in Expression มักเปิดทางให้ระบบ Optimize มากกว่า UDF

เมื่อใช้ DataFrame expression เช่น lower, sum, when หรือ SQL operator ระบบเห็นความหมาย ชนิดข้อมูล และ null behavior จึงวางแผนได้ ส่วน UDF เป็นกล่องดำมากกว่า อาจขวาง code generation หรือ optimization บางชนิด ดังนั้นหลักปฏิบัติที่ดีคือใช้ built-in function ก่อน แล้วใช้ UDF เมื่อ logic ไม่มีตัวแทนที่เหมาะ

ความกระชับไม่เท่ากับความชัดเจนเสมอไป

สายพานที่ยาวเกินไป
โค้ด chain ยาวสิบห้าขั้นอาจสั้นกว่าการประกาศตัวแปรกลาง แต่ debug ยากและไม่เห็น schema หลังแต่ละช่วง ควรตั้งชื่อ dataset ตามความหมาย เช่น validSales, dailyTotals และตรวจ schema/plan ตามจุดที่สำคัญ ความเป็น functional ไม่ได้บังคับให้เขียนทุกอย่างเป็นบรรทัดเดียว

Local Collection API คล้าย Spark API แต่ไม่เหมือนกัน

การฝึกด้วย List หรือ Vector ช่วยเข้าใจชนิดและการประกอบฟังก์ชัน แต่ไม่แสดง partition, Shuffle, retry หรือ serialization จึงควรใช้ local collection เพื่อทดสอบ logic ขนาดเล็ก แล้วทดสอบ Spark local mode เพื่อดู execution semantics ก่อนขึ้นคลัสเตอร์จริง

6Functional Programming ช่วยลดการรอได้อย่างไร

Functional Programming ในวิชานี้ไม่ได้มีไว้เพื่อให้โค้ดดูสั้นเท่านั้น ประโยชน์สำคัญคือช่วยบอกระบบว่า “ต้องการคำนวณอะไร” โดยลดการผูกติดกับลำดับคำสั่ง เมื่องานไม่มีการแก้ตัวแปรร่วมกันโดยไม่จำเป็น ระบบจึงแบ่งงาน สลับลำดับ และนำไปรันหลายเครื่องได้ง่ายขึ้น

แนวคิดผลต่อการคำนวณแบบกระจาย
Pure functioninput เดิมให้ output เดิม จึงย้ายงานไปเครื่องอื่นหรือคำนวณใหม่หลังเครื่องล้มได้ง่าย
Immutabilityแต่ละงานไม่แย่งกันแก้ข้อมูลก้อนเดียว ลดปัญหาการล็อกและผลลัพธ์ที่ขึ้นกับจังหวะเวลา
Mapแปลงข้อมูลแต่ละชิ้นอย่างอิสระ จึงกระจายให้หลาย worker ทำพร้อมกันได้
Reduceรวมผลย่อยกลับมา หาก operation จัดกลุ่มใหม่ได้ ระบบจะรวมเป็นต้นไม้แทนการรอต่อแถว
ประเด็นที่ควรจำ
Functional Programming ไม่ได้ทำให้ทุกโปรแกรมขนานได้โดยอัตโนมัติ แต่ช่วยเปิดเผยส่วนที่เป็นอิสระต่อกัน และช่วยให้ระบบรู้ว่าส่วนใดแบ่งทำพร้อมกันได้อย่างปลอดภัย

การลดเวลารอมีอย่างน้อยสี่แบบ

วิธีลดการรอFunctional idea ที่ช่วยสิ่งที่ยังขวางได้
ทำหลาย record พร้อมกันPure map ไม่มี dependency ข้าม recordข้อมูลเอียง งานต่อ record ไม่เท่ากัน หรือ resource จำกัด
รวมผลเป็นต้นไม้Associative operation และ Monoidoperation ขึ้นกับลำดับ หรือ state ผลย่อยใหญ่
เลื่อนงานที่ยังไม่จำเป็นLazy evaluationเมื่อ action ต้องการผลจริง งานยังต้องถูกทำ
ไม่ทำงานเดิมซ้ำCache/Persist ผลที่ใช้หลายรอบหน่วยความจำไม่พอ หรือค่าจัดเก็บแพงกว่าคำนวณใหม่

Pure Function: สูตรเดิม Input เดิม ควรได้ผลเดิม

Pure function มีสองใจความ: ผลลัพธ์ขึ้นกับ argument ที่รับ และการเรียกฟังก์ชันไม่สร้าง side effect ที่มองเห็นภายนอก เช่นไม่เขียนไฟล์ ไม่แก้ global variable และไม่ส่งข้อความ หาก function pure ระบบสามารถย้าย task ไปเครื่องอื่น รันซ้ำ หรือจำผลไว้ได้ง่ายกว่า

// ใกล้เคียง pure
def tax(amount: Double, rate: Double): Double =
  amount * rate

// impure: ผลขึ้นกับเวลา และยังเขียนออกหน้าจอ
def stamp(message: String): String = {
  println(message)
  s"${System.currentTimeMillis}: $message"
}

คำว่า pure เป็นแบบจำลองเชิงเหตุผล ในระบบจริงฟังก์ชันอาจใช้ CPU และ memory ซึ่งเป็นผลทางกายภาพอยู่แล้ว เราสนใจว่าในระดับ semantics ของโปรแกรม การแทนการเรียกฟังก์ชันด้วยค่าผลลัพธ์จะไม่เปลี่ยนพฤติกรรมส่วนอื่น คุณสมบัตินี้เรียกว่า referential transparency

Pure ไม่ได้แปลว่า Deterministic เสมอ หาก Input ที่แท้จริงถูกซ่อน

ฟังก์ชันที่อ่านเวลาปัจจุบัน random number หรือ configuration ภายนอก แม้หน้าตารับ argument เดียว แต่จริง ๆ มี input ซ่อนอยู่ หากต้องการทำให้เหตุผลง่ายขึ้น เราสามารถส่ง clock, seed หรือ configuration เข้ามาเป็น argument อย่างเปิดเผย การทดสอบจึงกำหนด input ได้และการ retry ไม่เปลี่ยนผลอย่างคาดไม่ถึง

Immutability, Lazy Evaluation และ Cache เป็นคนละแกน

สามแนวคิดนี้เชื่อมกันแน่นจนมักถูกพูดเหมือนเป็นเรื่องเดียวกัน แต่จริง ๆ ตอบคนละคำถาม:

แนวคิดตอบคำถามอะไรถ้าไม่มีจะเกิดอะไร
Immutabilityค่าที่สร้างแล้วถูกแก้ทับหรือไม่lineage และ cache อาจอ้างค่าที่เปลี่ยนไปแล้ว ต้องติดตาม write dependency
Lazy Evaluationลงมือคำนวณเมื่อใดแต่ละคำสั่งอาจทำงานทันทีจนระบบมองไม่เห็นแผนรวม
Cacheผลที่คำนวณแล้วจะเก็บไว้ใช้ซ้ำหรือไม่action รอบถัดไปอาจคำนวณ lineage เดิมซ้ำ
เปรียบเหมือนการทำอาหาร
Immutability คือไม่เทซอสกลับไปแก้ขวดต้นฉบับ แต่สร้างชามผสมรุ่นใหม่ Lazy Evaluation คือจดรายการขั้นตอนไว้ก่อน ยังไม่เปิดเตาจนมีคนสั่งอาหารจริง ส่วน Cache คือทำซอสที่ใช้ซ้ำแล้วแช่ไว้ ไม่ต้องเริ่มตำเครื่องใหม่ทุกจาน ทั้งสามช่วยกันได้ แต่ทำหน้าที่คนละอย่าง

ทำไม Immutability จึงทำให้ Laziness ปลอดภัยขึ้น

สมมุติเราบันทึกแผนว่า “นำราคาทุกชิ้นคูณ 1.07” แต่ก่อนลงมือ มีคนแก้ชุดราคาเดิมบางค่า หาก source mutable ผลขึ้นกับจังหวะว่า action เริ่มเมื่อใด นักพัฒนาตามเหตุผลยากขึ้น เมื่อ dataset เป็น immutable แผนที่สร้างไว้ยังอ้างค่ารุ่นเดิมอย่างชัดเจน การเลื่อนคำนวณจึงไม่เปลี่ยนความหมายเพราะข้อมูลถูกแก้กลางทาง

ทำไม Immutability จึงทำให้ Cache เชื่อถือได้ขึ้น

Cache มีความหมายก็ต่อเมื่อผลที่เก็บยังตรงกับ input หาก input ถูกแก้ทับ ระบบต้อง invalidate cache และตอบคำถามว่า cache ใดพึ่งพาค่าเดิมบ้าง แต่ถ้า input เป็น version ที่ไม่เปลี่ยน ผล cache ของ version นั้นยังถูกต้องเสมอ เมื่อมีข้อมูลใหม่ เราสร้าง version ใหม่และ cache แยกได้

Lazy ไม่ได้แปลว่าไม่คำนวณ

Transformation ที่ไม่ถูกใช้ต่ออาจไม่ต้องคำนวณเลย และหลาย transformation อาจถูก pipeline ใน stage เดียว แต่เมื่อ action ต้องการผล งานที่จำเป็นยังต้องทำ Laziness ลดงานที่ไม่จำเป็นและเปิดทางให้ optimize ไม่ได้เสกให้ต้นทุนของงานจำเป็นหายไป

val raw = sc.textFile("sales.csv")      // กำหนดแหล่งข้อมูล
val paid = raw.filter(isPaid)            // ยังอาจไม่อ่านไฟล์
val amounts = paid.map(parseAmount)      // ยังสร้าง lineage

val n = amounts.count()                  // action: เริ่มคำนวณ
val total = amounts.reduce(_ + _)        // หากไม่ cache อาจคำนวณซ้ำ

Cache ตรงไหนดี

ในตัวอย่างข้างบน amounts ถูกใช้สอง actions ถ้าการอ่านและ parse แพง เราอาจ persist ก่อน action แรก แต่ถ้า dataset ใหญ่เกิน memory หรือใช้เพียงครั้งเดียว cache อาจเพิ่ม overhead ควรดู reuse count, cost of recomputation, ขนาดข้อมูล และ storage level ไม่ใช่ cache เพราะเห็นว่า “น่าจะเร็ว”

จาก Random Write ไปสู่ Append + Transform

Data pipeline มักเก็บ input รุ่นเดิมแล้วเขียน output ไปตำแหน่งใหม่ ช่วยหลีกเลี่ยงการแก้กระจัดกระจายและ write lock บนก้อนเดิม อีกทั้งย้อนตรวจ lineage ได้ แต่ข้อแลกคือใช้พื้นที่มากขึ้น ต้องจัดการ version และลบข้อมูลเก่าตามนโยบาย ความคิดแบบ functional จึงไม่ได้ลบต้นทุน เพียงเปลี่ยนต้นทุนจาก synchronization ไปสู่ storage และ metadata management

ลด Dependency ไม่เท่ากับไม่มี Dependency

map แต่ละ partition อาจอิสระ แต่ reduceByKey ต้องรอข้อมูลจากหลาย partition ผ่าน Shuffle DAG จึงยังมีขอบ dependency และ stage barrier สิ่งที่ Functional Programming ช่วยคือทำให้ dependency ปรากฏในโครงสร้างการคำนวณแทนที่จะซ่อนอยู่ในการแก้ global state

Side Effect ควรย้ายไปขอบระบบ

โปรแกรมที่มีประโยชน์ต้องอ่านและเขียนอะไรสักอย่าง จึงไม่มีทาง pure ทุกส่วน หลักปฏิบัติคือให้แกน transformation เป็น pure เท่าที่ทำได้ แล้วรวม I/O ไว้ที่ขอบ เช่นอ่าน input ตอนต้นและ commit output ตอนท้าย ทำให้ส่วนใหญ่ของ pipeline ทดสอบด้วยข้อมูลใน memory ได้ และจุดที่ต้องจัดการ retry/idempotency มีจำนวนน้อยลง

Monoid เชื่อม Functional Programming กับ Distributed Systems

เมื่อ operation มี identity และ associative เราสามารถรวมผลย่อยภายใน partition แล้วรวมข้าม partition เป็นต้นไม้ได้ โครงสร้างทาง Algebra จึงไม่ได้อยู่เพื่อความสวยงาม แต่บอก scheduler ว่างานแบบใดเปลี่ยนวงเล็บได้โดยคำตอบไม่เปลี่ยน Sum, count, max, histogram และ top-k state เป็นตัวอย่าง ส่วนค่าเฉลี่ยต้องแปลงเป็น (sum,count) ก่อน

Functional Style ช่วย Optimizer เมื่อความหมายไม่เป็นกล่องดำ

Pure function ช่วยให้มนุษย์และระบบ reasoning ได้ แต่ Spark optimizer ไม่สามารถอ่านความหมาย Scala closure ทั่วไปได้ทั้งหมด DataFrame expression และ SQL operator จึงให้ข้อมูลแก่ optimizer มากกว่า UDF เพราะบอก schema, operator และ null semantics อย่างเปิดเผย ลำดับวิวัฒนาการจึงไม่ใช่เพียง imperative → functional แต่ยังเป็น opaque function → algebraic expression ที่ระบบวิเคราะห์ได้

ข้อจำกัดที่ต้องยอมรับ
บางงานมี state ตามธรรมชาติ เช่น streaming session, model training, graph traversal หรือ transaction เราไม่ควรฝืนทำเหมือนไม่มี state แต่ควรทำให้ state มีขอบเขต มี version มีวิธีรวม และมี semantics เมื่อ retry ชัดเจน Functional Programming เป็นเครื่องมือจัดระเบียบ state ไม่ใช่คำสั่งให้ปฏิเสธการมีอยู่ของ state

7เมื่อยุคเปลี่ยน: Scala ในปี 2026 เทียบกับช่วงที่เริ่มใช้ Spark

แล้ว · ~2020-2023
ตอนนี้ · 2026
Scala 2.x เป็นเวอร์ชันหลักที่ใช้คู่กับ Spark ทุกเวอร์ชัน
Scala 3 พัฒนาต่อเนื่องและปรับทั้ง syntax กับ type system แต่ Spark รุ่นหลักปัจจุบันใช้ Scala 2.13 ผู้เรียนต้องตรวจ version ที่ Spark distribution ถูกคอมไพล์มา ไม่ควรเลือก Scala version จากความใหม่เพียงอย่างเดียว
การเปรียบเทียบมักสรุปสั้น ๆ ว่า Scala เร็ว ส่วน Python ช้า เพราะมีค่าใช้จ่ายข้าม JVM–Python
PySpark ใช้ Apache Arrow และ vectorized interfaces ลดค่าถ่ายโอนข้อมูลได้มากขึ้น Spark 4.2 เปิด Arrow optimization ให้ regular Python UDF โดยปริยายแล้ว จึงไม่ควรตัดสินจากภาษาอย่างเดียว ต้องดูว่าใช้ built-in expression, row UDF, Arrow UDF หรือ Pandas UDF และดู physical plan จริง
RDD API เป็นภาพแทนหลักของการเรียน Spark
DataFrame และ SQL เป็นเส้นทางหลักของงานวิเคราะห์จำนวนมาก เพราะ schema และ relational operator เปิดให้ optimizer ทำงานได้มากกว่า RDD closure ที่เป็นกล่องดำ แต่ RDD ยังมีคุณค่าในการสอน partition, lineage และ dependency

ควรเรียน Scala 2 หรือ Scala 3

ถ้าเป้าหมายคือเขียนแอปกับ Spark ให้เริ่มจาก Scala binary version ที่ Spark รุ่นนั้นรองรับ ปัจจุบัน Spark 4.x distribution หลักใช้ Scala 2.13 ส่วนถ้าเป้าหมายคือเรียนภาษา Scala และ Functional Programming ระยะยาว Scala 3 มี syntax และคุณสมบัติรุ่นใหม่ที่น่าสนใจ ทั้งสองเส้นทางไม่ขัดกัน แต่ควรแยก “ภาษาที่อยากเรียน” ออกจาก “binary compatibility ที่ระบบงานกำหนด”

Scala, PySpark หรือ SQL: เลือกจากงาน ไม่ใช่เลือกทีม

ทางเลือกจุดแข็งสิ่งที่ต้องระวัง
Scalaเข้าถึง JVM API โดยตรง, static type, เหมาะกับ library และ typed Dataset บางงานlearning curve และ binary compatibility
PySparkเชื่อม Python data/ML ecosystem และทดลองได้สะดวกPython boundary, serialization และชนิด UDF ที่เลือก
SQL/DataFramedeclarative, optimizer เห็น semantics และผู้ใช้หลายกลุ่มเข้าถึงได้logic เฉพาะทางบางอย่างเขียนยาก และ SQL สั้นไม่ได้แปลว่า plan เบา

ในระบบจริงหนึ่งโครงการอาจใช้ทั้งสามแบบ เช่นใช้ SQL สำหรับ transformation หลัก, PySpark สำหรับ workflow และ ML, และ Scala สำหรับ extension ที่ต้องอยู่ใกล้ engine การเลือกภาษาจึงไม่ควรกลายเป็นสงครามศาสนา เพราะคอขวดมักอยู่ที่ Shuffle, skew, I/O และ plan มากกว่าจำนวนตัวอักษรใน syntax

อย่าใช้ UDF ถ้า Built-in Function ทำได้

ข้อแนะนำนี้สำคัญกว่าคำถามว่า UDF ภาษาใดเร็วกว่า Built-in expression เปิดให้ Spark วิเคราะห์และ optimize ได้เต็มกว่า อีกทั้งมักรองรับ columnar execution และ code generation ได้ดีกว่า เมื่อจำเป็นต้องใช้ Python UDF จึงค่อยพิจารณา Arrow หรือ vectorized API ตามชนิดงานและทดสอบด้วยข้อมูลจริง

ภาษาเปลี่ยน แต่ Algebra ยังอยู่

จาก Scala collection ไป RDD, จาก RDD ไป DataFrame และจาก DataFrame ไป SQL ชื่อ API อาจเปลี่ยน แต่คำถามเดิมยังอยู่: transformation นี้เปลี่ยนจำนวน record อย่างไร? แบ่งตาม key ใด? ต้อง Shuffle หรือไม่? aggregate รวมย่อยได้หรือไม่? cache แล้วคุ้มหรือไม่? คนที่ตอบคำถามเหล่านี้ได้จะย้าย framework ได้เร็วกว่าคนที่จำ syntax ได้เพียงชุดเดียว

8สรุปและขั้นตอนถัดไป

Scala ช่วยให้แนวคิด Functional Programming ปรากฏเป็นภาษาที่อ่านได้ชัด แต่สาระของบทนี้ไม่ได้จบที่เครื่องหมาย => หรือการเลือก val แทน var สิ่งที่ต้องติดตัวไปคือความสามารถในการมองโปรแกรมเป็น transformation และ dependency graph

เมื่อข้อมูลไม่ถูกแก้ทับ เราลด write dependency และอ้าง version ได้ชัด เมื่อฟังก์ชันมี input/output ที่เปิดเผย เรารันซ้ำและย้ายงานได้ง่าย เมื่อการคำนวณเป็น lazy ระบบเห็นแผนก่อนลงมือ และเมื่อผลบางก้อนถูกใช้ซ้ำ เราเลือก cache ได้ แต่ไม่มีแนวคิดใดให้ประโยชน์ฟรี: Immutability แลกด้วย version/storage, Laziness อาจทำให้งงว่า action ใดเริ่มงาน และ Cache อาจแย่งหน่วยความจำ

ดังนั้นความสัมพันธ์ที่ควรจำคือ:

บทถัดไปจะพูดถึง Superlinear Speedup ซึ่งดูเหมือนขัดกับสามัญสำนึกว่าใช้ CPU แปดตัวควรเร็วได้ไม่เกินแปดเท่า จุดเชื่อมจากบทนี้คือการเปลี่ยนรูปการคำนวณและการจัดข้อมูลอาจลด cache overhead หรือปริมาณงานที่เครื่องต้องทำจริงได้ การขนานจึงไม่ได้มีเพียง “แบ่งงานเดิมเป็นหลายส่วน” แต่บางครั้งเปลี่ยนพฤติกรรมของระบบทั้งชุด

คำถามซ้อมสอบ
  1. อธิบายว่าทำไม Scala ถึงเหมาะกับ Spark มากกว่าภาษาที่เน้น mutable state แบบ imperative ล้วน ๆ
  2. เขียนตัวอย่างโค้ด Scala ที่แสดงปัญหาของ mutable object เมื่อถูกแชร์ระหว่าง thread และแก้ปัญหาด้วย immutable design
  3. อธิบายความหมายของ map, filter, flatMap, reduce พร้อมยกตัวอย่างผลลัพธ์
  4. อธิบายว่าทำไม foldLeft จึงไม่ใช่ parallel reduction โดยอัตโนมัติ และยกตัวอย่าง operation ที่เปลี่ยนวงเล็บไม่ได้
  5. เปรียบเทียบ Word Count บน Scala collection กับ Spark RDD แม้โค้ดหน้าตาคล้ายกัน แต่ execution ต่างกันอย่างไร
  6. เชื่อมโยง pure function และ immutability กับการแบ่งงาน การคำนวณใหม่ และการลดการใช้ shared mutable state
  7. อธิบายว่า Immutability, Lazy Evaluation และ Cache เป็นคนละแนวคิด แต่ช่วยกันสร้าง data pipeline ที่คำนวณซ้ำและปรับแผนได้อย่างไร
  8. ออกแบบสถานะผลย่อยสำหรับหาค่าเฉลี่ยและ variance โดยไม่ส่งข้อมูลดิบทั้งหมด แล้วอธิบายว่าทำไมสถานะนั้นรวมแบบขนานได้
  9. ยกตัวอย่าง closure ที่จับ object ใหญ่เกินจำเป็น และเสนอวิธีลด serialization หรือใช้ broadcast อย่างเหมาะสม
  10. อธิบายว่าทำไม built-in DataFrame expression มักเปิดให้ optimizer ทำงานได้มากกว่า UDF
  11. เปรียบเทียบ Scala, PySpark และ SQL โดยพิจารณา semantics, ecosystem, optimizer และค่าใช้จ่ายข้าม runtime แทนการสรุปเพียงว่าภาษาใดเร็วกว่า