Dart: Records เบื้องต้น

ใน Dart 3 ขึ้นไปจะมีประเภทข้อมูลชนิดใหม่ Records (Record class) ที่ช่วยให้คนเขียนโปรแกรมสามารถส่งชุดข้อมูลที่มีมากกว่า 1 ตัว ไปให้ฟังก์ชั่น หรือคืนค่ากลับมาจากฟังก์ชั่นได้ ช่วยลดความยุ่งยากในการเขียนโปรแกรมโดยไม่ต้องใช้พวกกลุ่ม Collection เช่น List Map หรือต้องสร้างเป็น data class ในการจัดการข้อมูลที่ส่งพร้อมกันมากกว่า 1 ตัว

หลักการทำงานของ Records จะคล้ายการเก็บข้อมูลของภาษาโปรแกรมอื่น ๆ ที่มี tuple หรือ product ใน Dart ข้อมูล Records ออกแบบมาให้ค่อนข้างยืดหยุ่น สามารถปรับเปลี่ยนรูปแบบในการจัดการได้หลายแบบ ทั้งอ้างตามลำดับ แบบอ้างตามชื่อ field หรือผสมกัน

Dsmurat, penubag, Jelican9, CC BY-SA 4.0

Records เป็น immutable ไม่สามารถแก้ไขได้เมื่อสร้างข้อมูลแล้ว หากไม่ใช่จะถูกทิ้งไว้ในหน่วยความจำ รอ Garbage collector มาเก็บกวาดภายหลัง

วิธีการประกาศตัวแปรแบบ Records

ข้อมูลแบบ Records จะเป็นการนำข้อมูลประเภทอื่น ๆ เอามาอยู่รวมกัน โดยสมาชิกที่อยู่ใน Records จะไม่สามารถเพิ่มหรือลดได้หลังจากสร้างไปแล้ว ข้อมูลที่เป็นสมาชิกจะอยู่ใน (...)

การประกาศแบบเรียงตามลำดับ

การเข้าถึงสมาชิกของ Records ที่ประกาศแบบเรียงตามลำดับ จะใช้ .$1 .$2 .$3 ... เพื่อเข้าถึงสมาชิกลำดับที่ 1 2 3 และที่เหลือถ้ามี

void main() {
  var recNumber = (1, 2, 3, 4);
  print(recNumber.$1); // output → 1
  print(recNumber.$2); // output → 2
  print(recNumber.$3); // output → 3
  print(recNumber.$4); // output → 4
}

สมาชิกใน Records จะเป็นข้อมูลต่างชนิดกันได้ เพียงแต่เมื่อประกาศสมาชิกแล้ว เวลาที่ใส่ Record ใหม่ในตัวแปร ต้องเป็นรูปแบบข้อมูลแบบเดียวกันเท่านั้น

void main() {
  var recNumber = (1, 2, 3, 4);
  recNumber = ('a', 'b'); // compile error → A value of type '(String, String)' can't be assigned to a variable of type '(int, int, int, int)'.
  recNumber = (1, 2); // compile error → A value of type '(int, int)' can't be assigned to a variable of type '(int, int, int, int)'.
}

ในกรณีที่ไม่ใช่ var สามารถระบุ type annotation ดังนี้

void main() {
  var recNumber1 = (1, 2, 3);
  (int, int, int) recNumber2 = (1, 2, 3); // type annotation same as recNumber1

  print(recNumber1); // output → (1, 2, 3)
  print(recNumber2); // output → (1, 2, 3)
}

Record fields ประกาศสมาชิกแบบมีชื่อ field

จากตัวอย่างที่ผ่านมา จะเห็นว่าการเข้าถึงค่าสมาชิกของ Records จะใช้ .$1 .$2 .$3 ... เพื่อเข้าถึงสมาชิกตามลำดับ แต่ Records ยังสามารถประกาศแบบเขียนระบุชื่อ field เพื่อเข้าถึงสามาชิกตามชื่อที่ต้องการได้อีกด้วย ข้อดีของวิธีนี้คือ สามารถสลับตำแหน่งของข้อมูลได้ เพราะอิงจากชื่อ field เป็นหลัก

void main() {
  var point1 = (x: 100, y: 200);
  print(point1.x); //output → 100
  print(point1.y); //output → 200

  point1 = (y: 35, x: 20);
  print(point1.x); //output → 20
  print(point1.y); //output → 35
}

หากมีการผสมระหว่าง มีชื่อ field กับไม่มี จะอ้างอิงอย่างไร ตัว Dart จะใช้วิธีแยกการเข้าถึงทั้งสองแบบ โดยแบบไม่มีชื่อ field ก็ใช้ .$1 .$2 .$3 ... ส่วนที่ประกาศชื่อ field ก็ให้ใช้ชื่อในการเข้าถึงสมาชิก ไม่สามารถใช้ .$1 .$2 .$3 เข้าถึงได้

void main() {
  var point1 = ('no field1', x: 100, y: 200, 'no field2');
  print(point1.$1); //output → no field1
  print(point1.$2); //output → no field2
  print(point1.x); //output → 100
  print(point1.y); //output → 200

  point1 = ('no field1', 'no field2', y: 200, x: 100); // OK same as above
  point1 = (y: 200, x: 100, 'no field1', 'no field2'); // OK same as above
  point1 = (y: 200, 'no field1', x: 100, 'no field2'); // OK same as above
}

การประกาศ แบบไม่มีชื่อ field แต่มีชื่อข้อมูล

หากต้องการใช้งานแบบลำดับแต่ไม่มีชื่อ field อาจทำให้สับสนว่าข้อมูลใน Records คืออะไร เขียนชื่อของข้อมูลเอาไว้ จะช่วยให้กลับมาอ่านและแก้ไขโปรแกรมได้ง่ายกว่า ตัวอย่างต่อไปนี้จะเป็นการประกาศแบบต่าง ๆ ที่สามารถทำได้ และทำไม่ได้ (compile error)

void main() {
  // declare as name of member in Records
  (int pointX, int pointY) point1 = (10, 20);
  print(point1.$1); //output → 10
  print(point1.$2); //output → 20
  print(point1.pointX); // compile error!!
  print(point1.pointY); // compile error!!

  // declare as field of member in Records
  ({int pointX, int pointY}) point2 = (pointX: 10, pointY: 20);
  print(point2.pointX); // output → 10
  print(point2.pointY); // output → 20
  print(point2.$1); // compile error!!
  print(point2.$2); // compile error!!

  // mix
  (int pointX, {int pointY}) point3 = (10, pointY: 20);
  print(point3.$1); // output → 10
  print(point3.pointY); // output → 20
}

จากตัวอย่างจะเห็นว่า หากประกาศแบบโดยตั้งชื่อข้อมูลเฉย ๆ ไม่ได้ประกาศเป็น field เวลาเข้าถึงสมาชิกต้องใช้ .$1 .$2 .$3 ... เสมอ ไม่สามารถใช้ชื่อที่ตั้งได้ วิธีการนี้จะเน้นเพื่อประกาศสำหรับให้เข้าใจว่าข้อมูลคืออะไร แต่ตอนใช้ไม่ต้องการเขียนชื่อ field ให้อ้างตามลำดับข้อมูล อยู่ที่จุดประสงค์ในการใช้งานและออกแบบโปรแกรม

การใช้ Records กับฟังก์ชั่น

หากไม่มี Records แต่ข้อมูลที่ประมวลผลและคืนจากฟังก์ชั่น มีค่ามากกว่า 1 ตัว ปกติจะใช้พวก Collection ต่าง ๆ เช่น List Map หรือ Set ในการส่งข้อมูลกลับมา สำหรับ Records มีความเรียบง่ายและจัดการข้อมูลได้ดีกว่า เพราะสามารถกำหนด field และประเภทของข้อมูลกลับมาได้ จุดนี้ยังช่วยลด bug ที่เกิดจากการส่งข้อมูลกลับมาไม่ตรงกับที่คาดหวังเอาไว้ (พวกส่งกลับมาผสมกับหลายอย่าง เลยต้องประกาศเป็น dynamic)

(int, int) swapNumber((int, int) input) {
  (int, int) result = (input.$2, input.$1);
  return result;
}

void main() {
  var testNum = (1, 2);
  print(swapNumber(testNum)); // output → (2, 1)
}

จากตัวอย่างจะเป็นการเขียนฟังก์ชั่น swapNumber เพื่อสลับตัวเลขที่อยู่ใน Records แล้วคือกลับมาเป็น Records เนื่องจาก Records สามารถกำหนดประเภทข้อมูลของสมาชิกเป็นอะไรก็ได้ จึงทำให้ผู้เขียนโปรแกรมสามารถออกแบบได้ตามความเหมาะสมว่าจะใช้ข้อมูลประเภทใด หากลองดัดแปลงตัวอย่าง ที่เดิมรับข้อมูลเป็น int ให้เป็น num การใช้งานก็จะกว้างขึ้น แต่ก็ยังสามารถควบคุมประเภทข้อมูลได้ (ไม่ประกาศเป็น dynamic)

(num, num) swapNumber((num, num) input) {
  (num, num) result = (input.$2, input.$1);
  return result;
}

void main() {
  (int, double) testNum = (1, 2.5);
  print(swapNumber(testNum)); // output → (2.5, 1)
}

การแตกข้อมูลใน Records มาใส่ในตัวแปรด้วยวิธีการ Destructuring

การ Destructuring เป็นวีธีการนำเอาสมาชิกแต่ละตัวของ Records มาใส่ในตัวแปรที่ต้องการ เพื่อความสะดวกในการใช้งาน ตัวอย่างต่อไปนี้จะเอาผลที่ได้จากฟังก์ชั่น swapNumber() มาใส่ในตัวแปร numA และ numB ตามลำดับ

(num, num) swapNumber((num, num) input) {
  (num, num) result = (input.$2, input.$1);
  return result;
}

void main() {
  (int, double) testNum = (1, 2.5);
  var (numA, numB) = swapNumber(testNum); // Destructuring to numA and numB
  print(numA); // output → 2.5
  print(numB); // output → 1
}

จากตัวอย่าง ผลที่ได้จาก swapNumber() จะมีค่าเป็น Records (2.5, 1) วิธี Destructuring จะเป็นการกำหนดรูปแบบข้อมูลที่จะแตกมาเก็บในตัวแปรที่ต้องการ โดยรูปแบบข้อมูลต้นทางและปลายทางต้องเหมือนกันถึงจะทำได้ จากตัวอย่างข้างบน สามารถเขียนแบบที่ไม่ใช้วิธี Destructuring ได้ดังนี้

void main() {
  (int, double) testNum = (1, 2.5);
  var result = swapNumber(testNum);
  var numA = result.$1;
  var numB = result.$2;
  print(numA); // output → 2.5
  print(numB); // output → 1
}

ในกรณีที่ Records เก็บข้อมูลแบบ field ซึ่งไม่สามารถเข้าถึงแบบลำดับได้ ดังนี้วิธีการ Destructuring จำเป็นต้องระบุชื่อ field ที่จะนำมาใส่ในตัวแปร กำหนดโดย : นำหน้าชื่อ field ที่ต้องการ

void main() {
  var point1 = (x: 10, y: 20, z: 0);
  var (:x, :y, :z) = point1;
  print(x); // output → 10
  print(y); // output → 20
  print(z); // output → 0
}

Dsmurat, penubag, Jelican9, CC BY-SA 4.0

ข้อจำกัดของวิธี Destructuring กับ Records ที่ระบุชื่อ field ตัวแปรที่ได้จะเป็นชื่อเดียวกับ field ที่ระบุ ดังนั้น หากชื่อตัวแปรซ้ำกับตัวแปรที่ประกาศไปก่อนหน้า จะเกิด compile error ขึ้น วิธีแก้ไขอาจทำ code block {...} ครอบส่วนที่จะใช้งานเพื่อให้เป็น local variable ใน block นั้นไม่อ้างอิงออกไปข้างนอก block

การเปรียบเทียบว่า Records เท่ากันด้วย ==

ตัวแปรที่เป็น Records สามารถนำมาตรวจสอบว่าเท่ากันหรือไม่ด้วย == ผลการเปรียบเทียบจะเป็น true ก็ต่อเมื่อ โครงสร้างของ Records เหมือนกัน สมาชิกเมื่อเทียบแต่ละตัวเท่ากันหมดทุกตัว

  var point1 = (1, 2);
  var point2 = (1, 2);
  print(point1 == point2); // output → true

  (int x, int y, int z) point = (1, 2, 3);
  (int r, int g, int b) color = (1, 2, 3);
  print(point == color); // output → true
  
  ({int x, int y, int z}) point = (x: 1, y: 2, z: 3);
  ({int r, int g, int b}) color = (r: 1, g: 2, b: 3);
  print(point == color); // output → false (fields name dose not same) 

การสร้าง Records ที่มีสมาชิกเพียง 1 ตัว

ในกรณีที่ต้องการสร้าง Records แต่สมาชิกตอนออกแบบมีแค่ 1 ตัว วางแผนไว้ว่าในอนาคตจะมีการเพิ่มสมาชิกเข้าไปอีก วิธีการให้ใส่ , หลังสมาชิกตัวแรก เพื่อให้ compiler รู้ว่ามันเป็นข้อมูลแบบ Records

  var a = (1,); // OK
  (int,) b = (1,); // OK
  (int) c = (1); // error → A record type with exactly one positional field requires a trailing comma.

การใช้งาน Records กับ Patterns

การประยุกต์ใช้งานตัว Records ยังสามารถใช้การตรวจสอบกับ Patterns ใน Dart ได้อีกด้วย แต่คงไม่ขอพูดถึงใน note ตัวนี้