Author: Tanapoj Chaivanichanan

This entry is part 3 of 5 in the series Async-in-Dart-Series

ในการเขียนโปรแกรมทั่วๆ ไปเรามักจะส่งข้อมูลกันในรูปแบบของ sync เช่น

int getNumber() {
    return 1;
}

การเรียกใช้งานก็ง่ายๆ แบบนี้

print(getNumber());

แต่ในบทก่อนๆ เราเคยสอนเรื่องของ Generator ไปแล้ว นั่นคือการที่ส่งข้อมูลแบบหลายๆ ตัวกลับมาในรูปแบบของ sync*

ทบทวนแบบเร็วๆ! คือแทนที่จะตอบข้อมูลกลับแค่ครั้งเดียวด้วย return ก็เปลี่ยนเป็น yield แทน (yield ตอบได้หลายครั้งมากกว่า return)

Iterable<int> getNumbers() sync* {
    yield 1;
    yield 2;
    yield 3;
}

เวลารับข้อมูลจากฟังก์ชันพวกนี้ไปใช้ ก็เลยต้องใช้การวนลูปนั่นเอง

for(var num in getNumbers()) {
    print(num);
}

ถ้าสรุปเป็นรูป ก็จะได้แบบข้างล่างนี่

แต่ทั้ง 2 วิธีนี้มันเป็นการส่งข้อมูลแบบ Synchronous คือโค้ดยังทำงานเรียงๆ กันตามลำดับ และโค้ดทั้งหมดจะไม่ถูกแตกเป็นส่วนๆ เข้า Event Loop แบบ Asynchronous

ดังนั้นต่อไป... เรามาดู 2 กรณีนี้ แต่อยู่ในรูปของ Asynchronous กันบ้างดีกว่า!


ในบทที่แล้วเราพูดถึง Future กันไปแล้ว ซึ่งมันคือการเปลี่ยนจาก sync ให้กลายเป็น async นั่นเอง

แปลว่าเรายังขาดอยู่หนึ่งตัว คือ async* นั่นเอง

Single Value Zero or more Values
Sync int Iterable
Async Future Stream

สรุปง่ายๆ คือ Stream ก็คือ Iterable ที่อยู่ในรูปของ Asynchronous นั่นเอง

วิธีการดึงค่าออกมาจาก Stream

การใช้งาน Stream ก็คล้ายๆ กับ Future นั่นแหละ แต่มีรายละเอียดเยอะกว่า เพราะมันตอบได้หลายค่ามากกว่า Future ยังไงล่ะ

ขอสมมุติว่าเรามีฟังก์ชันหนึ่ง ที่ตอบค่าเป็น Stream ชื่อ getNumberStream() นะ

รอฟังค่าเรื่อยๆ ด้วย listen

สำหรับ Future เราจะใช้คำสั่ง then() ในการรอค่า แต่ Stream จะใช้ listen() แทน

Stream<int> numberStream = getNumberStream();

var subscription = numberStream.listen((num){
    print('Receive: $num');
});

listen() จะตอบกลับเป็น subscription ซึ่งเดี๋ยวจะพูดถึงอีกที
แน่นอนว่าเวลาเอาไปรัน ผลที่ได้อาจจะเป็นแบบนี้

output:
Receive: 1
Receive: 2
Receive: 3
Receive: ...

ก็คือเราได้รับตัวเลขหลายๆ ตัวนั่นเอง แต่อาจจะได้ตอนไหนก็ไม่รู้ แบบนี้

time            │     │    │
0|             1sec.  │    │
1| Receive: 1  ─┘    2sec. │
2| Receive: 2  ───────┘    │
3|                        4sec.
4| Receive: 3  ────────────┘
5|

สำหรับตัว listen() นั้นมี options เสริมให้เราใช้งานได้หลายตัว คือ

  • onError: เมื่อเกิด error ให้ทำอะไร
  • onDone: เมื่อได้รับข้อมูลครบทุกตัวแล้วให้ทำอะไร (ทำงานเมื่อ Stream ตอบข้อมูลครบหมดทุกตัวแล้ว มันจะเรียก onDone ให้ทำงาน)
  • cancelOnError: เป็นตัวกำหนดว่าถ้าเกิด error ขึ้นแล้ว (ในกรณีที่ข้อมูลยังส่งกลับมาไม่ครบทุกตัว) จะเลือกที่จะหยุดการทำงานของ Stream ไปเลย หรือจะยังให้ทำงานต่อก็ได้
var subscription = numberStream.listen(
    (num){
        print('ได้รับข้อมูล $num');
    }, 
    onError: (err){
        print('มีข้อผิดพลาดเกิดขึ้นล่ะ $err');
    },
    onDone: (){
        print('ได้รับข้อมูลครบแล้วจ้า');
    },
    cancelOnError: false,
);

ลดรูป Stream ด้วย await

เช่นเดียวกับตอน Future นะ คือเราสามารถเปลี่ยน listen() ให้ไปอยู่ในรูปแบบการเขียนแบบ Synchronous ได้ แต่ก็จะไม่ได้ตรงๆ แบบ Future นะเพราะเราต้องรับข้อมูลด้วยลูป

Stream<int> numberStream = getNumberStream();

await for(var number in numberStream) {
    print('Receive: $number');
}

Broadcast Stream

ตามปกติแล้วการใช้งาน Stream จะมีการ listen() ได้แค่ครั้งเดียวเท่านั้น

เมื่อเรา subscription ไปครั้งหนึ่งแล้ว ถ้าจะ subscription ซึ่งกับ Stream ตัวเดิมมันจะเกิด Exception ขึ้นนะ!!

Stream<int> numberStream = getNumberStream();

var subscription1 = numberStream.listen((num){
    print('Receive: $num');
});

//second subscribe -> Exception!!
var subscription2 = numberStream.listen((num){
    print('Receive: $num');
});

วิธีการแก้ (ถ้าอยาก subscription หลายครั้งจริงๆ) ให้เปลี่ยน Stream ตัวนั้นให้เป็นสิ่งที่เรียกว่า "Broadcast Stream" แทน

วิธีเปลี่ยนก็ง่ายๆ คือใช้ get property ที่ชื่อว่า asBroadcastStream แบบนี้

Stream<int> numberStream = getNumberStream().asBroadcastStream;

var subscription1 = numberStream.listen((num){
    print('First receive:  $num');
});

var subscription2 = numberStream.listen((num){
    print('Second receive: $num');
});

ทีนี้ ถ้า Stream ของเรามีการส่งตัวเลข [1 เมื่อผ่านไป1วินาที] และ [3 เมื่อผ่านไป3วินาที] ก็จะได้ผลลัพธ์แบบนี้ (สังเกตดูว่าเราได้ข้อมูลตัวละ 2 ครั้ง เพราะมี subscription 2 ตัวนั่นเอง)

time                   │     │
0|                   1sec.   │
1| First receive:  1  ─┤     │
 | First receive:  1  ─┘    3sec.
2|                           │
3| Second receive: 3  ───────┤
 | Second receive: 3  ───────┘
4|

Subscription

กลับมาที่สิ่งที่เราพูดค้างไว้ นั่นคือตอบที่เราสั่ง listen() เราจะได้สิ่งที่เรียกว่า subscription กลับมา

เพราะ Stream คือ "กระแสข้อมูล" ที่ไหลมาเรื่อยๆ มาตอนไหนก็ไม่รู้ เลยมี subscription เอาไว้ควบคุมกระแสนั้นอีกที

//หยุดการรับข้อมูล
subscription.pause();

//กลับรับข้อมูลใหม่
subscription.resume();

//แคนเซิล Stream ตัวนั้น
subscription.cancel();

แต่มีข้อควรระวังอย่างนึง คือการ pause() ไม่ใช้การไม่รับข้อมูลในจังหวะนั้น แต่เป็นการหยุดรับข้อมูลชั่วคราวเท่านั้น แปลว่า "ข้อมูลไม่ได้หายไปนะ มันแค่เข้าคิวรอเรา resume() อีกทีนั่นเอง!"

ลองดูตัวอย่างข้างล่างประกอบ ขอสมมุติว่าเรามี stream อยู่หนึ่งตัวที่จะส่งตัวเลขกลับมาเรื่อยๆ ทุกๆ 1 วินาที [1, 2, 3, 4, 5, ...] แบบนี้นะ

var subscription = stream.listen((x){
    print('Receive: $x');
});
Future.delayed(Duration(seconds: 3), (){
    subscription.pause();
});
Future.delayed(Duration(seconds: 6), (){
    subscription.resume();
});
Future.delayed(Duration(seconds: 8), (){
    subscription.cancel();
});

แล้วเราก็ตั้งค่า (โดยใช้ Future.delayed) ให้มัน..

  • pause: เมื่อผ่านไป 3 วินาที
  • resume: เมื่อผ่านไป 6 วินาที (จากตอนเริ่มโปรแกรม)
  • cancel: เมื่อผ่านไป 8 วินาที (จากตอนเริ่มโปรแกรม)

เราอาจจะคิดว่าระหว่างวินาทีที่ 3-6 เราจะไม่ได้รับข้อมูลช่วงนั้น (คือข้อมูล [4,5] หายไปเลย)

แต่ไม่ใช่นะ! เพราะเลข [4,5] นั้นยังอยู่นะ แค่มันเข้าคิวรอที่จะออกมาอยู่

เมื่อเรา resume ในวินาทีที่ 6 มันก็จะออกมาทีเดียวหมดเลย [4,5,6]

ดูอธิบายด้วย timeline ข้างล่างนี่น่าจะเข้าใจมากกว่า

time  
1| Receive: 1
2| Receive: 2
3| Receive: 3
4|            ├─ waiting until (6)
5|            │
6| Receive: 4 ┐
 | Receive: 5 ├─ 3 values in 1 sec.
 | Receive: 6 ┘
7| Receive: 7
8| Receive: 8
9|

มาลองสร้าง Stream กันบ้าง

การสร้าง Stream ทำได้หลายวิธีมากๆ แต่แบบง่ายที่สุดคือใช้วิธีแบบ Generator

แปล Iterable เป็น Stream

เราเคยสอนการสร้าง Generator ไปแล้วในบท Dart 105: มันวนซ้ำได้นะ! มาสร้าง Generator และใช้งาน Iterable กันเถอะ แต่ตอนนั้นเราเขียนมันในรูปแบบของ sync

อย่างที่พูดไปตอนต้นว่า Stream ก็คือ Iterable ที่อยู่ในรูปของ Asynchronous เราจะมาแสดงให้เห็นกัน

ขอเปิดด้วยโค้ดแบบ Iterable ในรูปของ Synchronous

โจทย์คือ... เราต้องการสร้างฟังก์ชันสำหรับดึง Data ออกมาจำนวนหนึ่งตั้งแต่ from ถึง to

Data fetchOneData(int id){
    ...
}

Iterable<int> getData(int from, int to) sync* {
    var dataList = [];
    for(var i=from; i<=to; i++){
        dataList.add(fetchOneData(i))
    }
    return dataList;
}

(ฟังก์ชัน fetchOneData() ทำอะไรไม่ต้องสนใจนะ ไม่ใช่ประเด็นของเรื่อนี้)

แต่เพื่อ performance ที่ดี เลยเราไม่โหลดข้อมูลในครั้งเดียว แต่เลือกที่จะเขียนมันในรูปแบบ Generator แทน ก็จะได้โค้ดแบบนี้..

Data fetchOneData(int id){
    ...
}

Iterable<int> getData(int from, int to) sync* {
    for(var i=from; i<=to; i++){
        yield fetchOneData(i);
    }
}

เวลาเราจะใช้งาน ก็เอาไปวนลูปได้ธรรมดาๆ แบบนี้

var dataList = getData(1, 10);

for(var data in dataList) {
    print(data);
}

ขึ้นต่อไปคือ แล้วถ้าข้อมูลของเราไม่ได้มาแบบ sync ล่ะ?

ถ้าฟังก์ชัน fetchOneData() ใช้เวลาโหลดข้อมูลนานขึ้น เราคงต้องเปลี่ยนมันเป็น Future แบบนี้

Future<Data> fetchData(int id) {
    ...
}

เมื่อเป็นแบบนี้ เราก็มีปัญหาซะแล้ว!

เพราะฟังก์ชัน getData() ที่เป็น sync ไม่สามารถเรียก fetchOneData() ที่เป็น async ได้ล่ะ!!

วิธีการแก้ก็คือเปลี่ยนฟังก์ชัน fetchOneData() ให้หลายเป็น async ยังไงล่ะ

"ด้วยการเปลี่ยน Iterable ให้กลายเป็น Stream"

สิ่งที่เราต้องทำ มีอยู่ 3 อย่าง

  1. จากเดิมที่รีเทิร์นค่าเป็น Iterable เราต้องเปลี่ยนมันเป็น Stream แทน
  2. จากเดิมที่ฟังก์ชันเป็นชนิด sync* เราต้องเปลี่ยนมันเป็น async*
  3. จากเดิมที่เรา yield ค่าทันทีได้เลย แต่เมื่อมันเป็น Future แล้วเราก็ต้องสั่งให้มันรอด้วย await
Stream<Data> getData(int from, int to) async* {
    for(var id=from; id<=to; id++) {
        yield await fetchOneData(id);
    }
}

void main() {
    fetchAllData(1, 10).listen(print);
}

จะเห็นว่า Iterable นั้นแปลงเป็น Stream ได้ง่ายๆ เลย

ลองมาดูกันอีกตัวอย่างกัน

int sumIt(List<int> items) {
    int sum = 0;
    for(var item in items) {
        sum += item;
    }
    return sum;
}

เรามีฟังก์ชัน sumIt() สำหรับหาผลรวมทั้งหมดใน List แต่ถ้าลิสต์ตัวนี้ไม่ได้ค่ามาแบบ sync ล่ะ? จะทำยังไง?

คราวนี้ให้สังเกตว่า parameter นั้นรับ List (หรือก็คือ Iterable นั่นแหละนะ) เข้ามา แล้วมันรีเทิร์นค่าแบบสเกล่าธรรมดา (คือ int ธรรมดาๆ นี่แหละ)

สำหรับเคสนี้ สิ่งที่เราต้องทำก็คือ

  1. เปลี่ยนค่าแบบสเกล่าธรรมดาให้กลายเป็น Future
  2. เปลี่ยนให้ฟังก์ชันเป็น async (ระวัง! ไม่ได้เปลี่ยนเป็น async* นะ เพราะมันไม่ได้รีเทิร์น Stream)
  3. จุดที่เป็นปัญหาคือ for loop ที่วนค่าตัว Stream .. ให้เราเติม await ลงไปข้างหน้าเป็นอันจบ
Future<int> sumIt(Stream<int> items) async {
    int sum = 0;

    await for(var item in items) {
        sum += item;
    }

    return sum;
}

เรื่องสุดท้ายคือในบางกรณี เราอาจจะมีฟังก์ชัน Stream ที่เรียกใช้งาน Stream อีกตัวก็เป็นได้นะ

Stream<Data> getFirstStream() async* {
    ...
}

Stream<Data> getSecondStream() async* {
    yield getFirstStream(); //Not Work!!
}

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

วิธีการแก้ก็เหมือนกับฟังก์ชัน sync* นั่นแหละ คือเราจะต้องวนลูปทีละตัว

Stream<Data> getFirstStream() async* {
    ...
}

Stream<Data> getSecondStream() async* {
    await for(var s in getFirstStream()){
        yield s;
    }
}

หรือแบบง่ายกว่าคือการใช้ yield* ก็ได้

Stream<Data> getFirstStream() async* {
    ...
}

Stream<Data> getSecondStream() async* {
   yield* getFirstStream(); //Ok!!
}

นอกจากวิธีสร้างแบบ Generator ที่เราใช้แนวคิดเดียวกับ Generator ใน sync แล้วยังมีอีกวิธีหนึ่งที่ให้เราคุมการไหลของข้อมูลใน Stream ได้คล่องตัวขึ้น นั่นคือใช้ StreamController

ซึ่งเราจะพูดถึงกันในบทต่อไปนะ (พร้อมกับเก็บตกเนื้อหาเกี่ยวกับ Stream กันอีกนิดหน่อยด้วย)

Series Navigation<< Async in Dart (2) เดี๋ยวจะตอบให้ในอนาคต กับการใช้ FutureAsync in Dart (4) ควบคุมข้อมูลในstreamอย่างเหนือชั้นด้วย StreamController >>
This entry is part 2 of 5 in the series Async-in-Dart-Series

ในบทที่แล้ว เราเกริ่นนำเกี่ยวกับการเขียนโปรแกรมแบบไม่ประสานเวลาหรือ Asynchronous Programming และระบบผู้อยู่เบื้องหลังการรันโค้ดของภาษา Dart อย่าง Event Loop กันไปแล้ว

ในบทนี้ เราจะมาสอนวิธีการสร้าง Async แบบง่ายที่สุด นั่นคือการใช้งานคลาสที่ชื่อว่า "Future"

Future ฉันจะตอบให้แน่นอน..ในอนาคต

ในการเขียนโปรแกรม ปกติเราจะทำงานกับตัวแปรตรงๆ แต่มีเทคนิคหนึ่งที่สามารถสร้างตัวห่อหุ้มตัวแปรเพิ่มอีกชั้นหนึ่ง เรียกว่า "Wrapper" หรือ "Container" (หรือถ้าคุณเป็นสาย Functional Programming จะมองว่ามันเป็น Functor ตัวนึงก็ยังได้)

ถ้านึกไม่ออกให้นึกถึง List (หรือจะเป็น Map ก็ยังได้นะ แนวคิดเดียวกัน)

var wrapper = [123];

นั่นคือค่า 123 ถูกหุ้มอยู่ในตัวแปร wrapper ... ถ้าเราเรียกใช้ค่าผ่าน wrapper ตรงๆ แน่นอนว่าเราจะไม่ได้ค่า 123 ออกมา

ถ้าจะดึงค่าออกมาใช้ ก็ต้องเรียกผ่าน wrapper[0] ถึงจะได้ค่า 123 ออกมา

Future ก็คือ wrapper ชนิดหนึ่งที่หุ้มตัวแปรเอาไว้ แต่มีความสามารถเพิ่มเติมนั่นคือมันจะไม่ส่งค่ากลับมาในทันที แต่สามารถหน่วงเวลาเอาไว้ได้ (เลยเป็นที่มาของชื่อ future ยังไงล่ะ)

Future State


Future นั้นมีสเตจหรือสถานะอยู่ 3 แบบ

  • Uncomplete: ยังไม่เสร็จ กำลังรอข้อมูลส่งกลับมา
  • Completed with a value: เสร็จแล้ว! พร้อมค่าที่ได้กลับมาด้วย
  • Completed with an error: เสร็จแล้ว! แต่ Error นะจ๊ะ!!

ส่วนใหญ่นั้น Future จะใช้กับการ

  • หน่วงเวลา (delay)
  • โหลดข้อมูล เช่นเชื่อมต่อ API หรืออ่านข้อมูลจากไฟล์
  • หรือเหตุการณ์อะไรก็ตามที่โปรเซสนานมากๆ

วิธีสร้าง Future

ในภาษา Dart มีวิธีการสร้าง Future หลายวิธีมากๆ โดยแบบที่เบสิกที่สุดคือสร้างเหมือน Object ธรรมดาตัวหนึ่ง เพราะจริงๆ Future เองก็เป็นคลาสๆ หนึ่งเช่นกัน

var f = Future<int>(...)

โดยอ็อบเจค Future นั้นต้องการฟังก์ชันสำหรับสร้าง value กลับมา แบบนี้

var f = Future<int>((){
    return 100;
})

//หรือละ type ก็ได้
var f = Future((){
    return 100;
})

value

นอกจากการสร้างอ็อบเจคแล้ว เราสามารถสร้าง Future ด้วยกาใช้ factory value

var f = Future.value(100);

delayed

ถ้าไม่อยากตอบค่ากลับในทันที ก็สามารถหน่วงเวลาตอบกลับด้วย delay ซึ่งวิธีการสร้างก็คล้ายๆ กับการสร้างอ็อบเจค แต่ต้องกำหนดพารามิเตอร์เพื่อบอกระยะเวลาที่จะให้ดีเลย์ด้วย Duration

var f = Future.delayed(Duration(minutes: 1), (){
    return 100;
});

error

ในบางครั้ง การตอบค่ากลับก็มีข้อผิดพลาดขึ้นได้ (ถ้าเขียนแบบปกติก็คือการ throw Exception() นั่นแหละ)

var f = Future.error(Exception('เกิดข้อผิดพลาด'));

วิธีการดึงค่าออกมาจาก Future

อย่างที่บอกไปว่า Future นั้นเป็น wrapper จะใช้ค่าได้ต้องดึงค่าออกมาก่อน โดยมี 2 วิธี

ดึงด้วยเมธอด then()

ตามปกติการใช้ Future จะใช้เมื่อเราไม่รู้ว่าค่าจะถูกส่งกลับมาเมื่อไหร่ ดั้งนั้นเราจะต้องสร้างฟังก์ชันที่เรียกว่า "Callback" ซึ่งจะถูกรันเมื่อค่าถูกส่งกลับมา

var f = Future.value(100);
f.then((value){
    print(value);
});

โค้ดข้างบนเวลารันแล้วก็จะได้ค่า 100 ออกมา ซึ่งดูเผินๆ แล้วก็เหมือนกับการเขียนโค้ดแบบปกติ

แต่ถ้าเราเพิ่มโค้ดเข้าไป แบบนี้

print('start');

var f = Future.value(100);
f.then((value){
    print(value);
});

print('end');

คำถามคือเมื่อเราเอาไปรัน แล้วจะได้ผลอะไรออกมา?

ตามปกติเราก็มักจะคิดว่ามันจะรันตามบรรทัด แล้วรันออกมาได้แบบนี้

start
100
end

แต่ถ้าใครอ่านเรื่อง Isolates และ Event Loop ในบทที่แล้วแล้ว น่าจะเข้าไปได้ว่าฟังก์ชัน callback นั้นจะถูกแยกออกมาเข้าคิวรอรันอยู่ใน Event Loop โดยต้องรอรันโค้ดใน thread หลักให้เสร็จก่อน

ดังนั้น เวลาเอาไปรันแล้วจะได้ผลแบบนี้

start ─┬─ run in main thread
end   ─┘
100   ─── run in second thread

แล้วถ้าเราใช้ delayed ก็จะได้ผลเหมือนกัน แต่ถูกหน่วงเวลาเอาไว้

print('start');

var f = Future.delayed(Duration(minutes: 1), (){
    return 100;
});
f.then((value){
    print(value);
})

print('end');

ก็จะได้ผลแบบนี้

start ─┬─ รันในทันที
end   ─┘
100   ─── รันเมื่อผ่านไป 1 นาที

ลดรูป Future ด้วย await

ในบางกรณีเรามีการโหลดข้อมูลจาก API หลายๆ ต่อ เช่น

Future<Data1> loadData1(){
    ...
}

Future<Data2> loadData2(int id){
    ...
}

แต่มีปัญหาคือ การจะโหลด Data2 จะต้องใช้ค่าจาก Data1 ซะก่อน การโหลดข้อมูลเลยต้องเขียนแบบนี้

// โหลด data1 ให้เสร็จก่อน
loadData1().then((data1){
    var id = data1.id;
    // จากนั้นดึง id ออกมาแล้วเอาไปใช้โหลด data2 ต่อ
    loadData2().then((data2){
        //TODO
    })
})

แน่นอนว่าการเขียนอะไรแบบนี้ทำให้อ่านยากมาก ยิ่งถ้าต้องมีการโหลด data ต่อๆ กันหลายๆๆๆ ทอด โค้ดก็จะซ้อนๆ กันเข้าไปเรื่อยๆ

แต่ถ้าเราใช้คีย์เวิร์ด await (และ async) มาช่วย ก็จะทำให้เขียนโค้ดง่ายขึ้น วิธีการใช้งานก็คือวางคีย์เวิร์ด await ไว้หน้า Future โค้ดทั้งหมดจะถูกแปลงเป็นสไตล์ sync ได้ง่ายๆ โดยมีข้อแม้คือการใช้ await จะต้องอยู่ในฟังก์ชัน async เท่านั้น

void main() async {
    var data1 = await loadData1();
    var id = data1.id;
    var data2 = await loadData2(id);
    //TODO
}

จะเห็นว่าการใช้ await และ async นั้นทำให้เราใช้งาน Future ได้ง่ายขึ้นมากๆ แต่ๆๆๆ มีเรื่องสำคัญที่ต้องจำไว้เวลาใช้ คือ...

การใช้ await ไม่ใช่การแปลงให้โค้ดที่เคยทำงานแบบ Asynchronous กลับมาทำงานแบบ Synchronous นะ!! มันยังทำงานแบบ Asynchronous เหมือนเดิมนั่นแหละ
แค่รูปแบบการเขียนมันถูกแปลงให้มาอยู่ในรูป sync เท่านั้นเอง เบื้องหลังเวลารัน มันทำงานเหมือนตอนเขียนด้วย then() ทั้งหมดนะ คอนเซ็ป Isolates และ Event Loop ก็ยังอยู่ทั้งหมด เวลาใช้งาน ต้องจำไว้ดีๆ ล่ะ

Error Handlering

นอกจากการตอบค่าคืนแบบปกติ เราสามารถสร้าง Error กลับมาได้ด้วย เช่น

var f = Future((){
    if(...){
        throw Exception('อุ๊ปส์ มีข้อผิดพลาดเกิดขึ้น');
    }
    return 100;
});

ทีนี้ เมื่อเราสร้าง Error ได้ เราก็ต้องมีการดัก/รับมือ Error ที่เกิดขึ้น แบบนี้

  • ถ้าใช้ then เราสามารถกำหนด catchError ต่อได้
  • ถ้าใช้ await ไม่มีคำสั่งแบบ catchError แต่เราใช้ try-catch แบบธรรมดาคลุมโค้ดตรงนั้นได้เลย
var future = Future(...)

//แบบ then-catch
future.then((data){
    print('ได้รับข้อมูลเรียบร้อย $data');
}).catchError((error){
    print('ไม่สำเร็จล่ะ เพราะ $error');
});

//แบบ await
try{
    var data = await future;
    print('ได้รับข้อมูลเรียบร้อย $data');
} catch(error) {
    print('ไม่สำเร็จล่ะ เพราะ $error');
}

Future Chaining

การใช้ Future นั้น คำสั่ง then จริงๆ มีการรีเทิร์นค่ากลับมาเรื่อยๆ เราสามารถสั่ง then ต่อกันได้หลายๆ ชั้น แบบนี้

var future = Future.value(10);

future.then((value1){   //value1 is 10
    return value1 + 20;
}).then((value2){       //value2 is 30
    return value2 * 2;
}).then((value3){       //value3 is 60
    return value3 + 100;
}).then((value4){       //value4 is 160
    print(value4);
})

output

160

ข้อควรระวังคือเวลาเขียนต้อง then ต่อจากตัวเดิมเสมอ ถ้าไปเริ่ม then จาก Future ตัวเดิมก็จะได้ค่าตัวนั้นนะ

var f1 = Future.value(1);
//f1 is 1

var f2 = f1.then((x) => x + 1);
//f2 is 2

f1.then((value){
    print(value);
});

f2.then((value){
    print(value);
});

f1.then((x) => x * 100).then((value){
    print(value);
});

f2.then((x) => x * 100).then((value){
    print(value);
});

output

1
2
100
200

unawaited

ถ้าเราเขียนโค้ดใน IDE บางตัวที่มีการเช็กโค้ดให้เราดีๆ การใช้คำสั่ง then อาจจะทำให้เราเจอ Warning แบบนี้ (Warning นะ ไม่ใช่ Error)

แล้วส่วนใหญ่ที่ IDE มันแนะนำให้แก้ก็คือการเติม await ลงไปข้างหน้าแบบนี้

await future().then(...);

ซึ่งพอเติมลงไป Warning ก็จะหายไป ว้าว! งั้นวิธีแก้แบบนี้ก็ถูกแล้วสินะ

ถูกอะไรล่ะ! มันไม่ Warning แล้วก็จริง แต่การทำงานของโค้ดเปลี่ยนไปโดยสิ้นเชิงเลยนะ (แบบที่บอกไปแล้วไงว่า await คือคำสั่งที่ทำให้โค้ด async กลายเป็น sync)

เหตุผลที่ IDE มันแจ้ง Warning ก็เพราะจริงๆ แล้ว then รีเทิร์นค่า Future ตัวใหม่กลับมาเสมอ เมื่อมีค่ารีเทิร์นกลับมาแต่ไม่ใช้งาน มันเลยแจ้ง Warning ไงล่ะ

วิธีแก้คือการใช้ฟังก์ชัน unawaited มาครอบเอาไว้ แล้ว Warning ก็จะหายไป

import 'package:pedantic/pedantic.dart';

void main() {
    unawaited(
        future().then(...)
    );
}

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

ลองดูโค้ดนี้

Future
    .delayed(Duration(seconds: 2), ()=> 'A')
    .then(print);

Future
    .delayed(Duration(seconds: 4), ()=> 'B')
    .then(print);

Future
    .delayed(Duration(seconds: 1), ()=> 'C')
    .then(print);

output (ตัวเลขข้างหน้าเป็นหลักเวลา)

0|    1sec.  │    │
1| C  ─┘    2sec. │
2| A  ───────┘    │
3|               4sec.
4| B  ────────────┘
5|

สังเกตว่าจุดเริ่มต้นของ Future จะเริ่มพร้อมๆ กันหมด ตั้งแต่รันโปรแกรมเลย

แต่ถ้าเราเติม await ลงไป จะทำให้ Future แต่ละตัวต้องหยุดรอกันจนกว่าตัวเดิมจะรันเสร็จ แบบนี้

await Future
    .delayed(Duration(seconds: 2), ()=> 'A')
    .then(print);

await Future
    .delayed(Duration(seconds: 4), ()=> 'B')
    .then(print);

await Future
    .delayed(Duration(seconds: 1), ()=> 'C')
    .then(print);

output (ตัวเลขข้างหน้าเป็นหลักเวลา)

0|        │
1|        1sec.
2| A  ────┤
3|        │
4|        4sec.
5|        │
6| B  ────┤
7| C  ────1sec.  
8|

Next - ในตอนต่อไป

ในบทนี้เราสอนวิธีการสร้าง Future ไป ก็เหมือนกับการใช้งานตัวแปรแบบธรรมดาแต่ตอบค่าในอนาคต

ในบทต่อไปเราจะสอนคลาสอีกชนิดหนึ่งที่ใช้ตอบค่าในอนาคตเช่นเดียวกัน แต่สามารถตอบกลับได้หลายค่าและหลายครั้งมากๆ ไม่เหมือน Future ที่ตอบได้แค่ 1 ค่าเท่านั้น นั่นคือตัวแปรประเภท Stream

Series Navigation<< Async in Dart (1) รู้จัก Isolates และ Event Loop กับการทำงานไม่ประสานเวลากันAsync in Dart (3) ตอบค่าเป็นกระแสข้อมูลด้วย Stream >>
This entry is part 1 of 5 in the series Async-in-Dart-Series

ภาษา Dart มีฟีเจอร์สำหรับทำงานกับการเขียนโปรแกรมแบบ Asynchronous เต็มรูปแบบ แต่ก่อนจะสอนการใช้งาน เรามาเกริ่นว่า Synchronous และ Asynchronous Programming เป็นยังไง

Synchronous และปัญหาการโหลดข้อมูล

ตามปกติแล้วเวลาเราเขียนโปรแกรม เราก็จะเขียนคำสั่งเรียงต่อๆ กัน แต่ในบางสถานการณ์เราอาจจะต้องมีการโหลดข้อมูลจาก Data Source ภายนอก เช่นอาจจะอ่านค่าจาก Database, โหลดข้อมูลผ่านเน็ตเวิร์คจาก API ซักตัวนึง

ถ้าเขียนโค้ดแบบธรรมดาเราก็จะได้อะไรประมาณนี้

print('start loading');
var data = loadData();
print(data);
print('done');

นั่นคือเริ่มด้วยการ loadData() แล้วพอได้ data มาก็เอามาแสดงผล

แล้วปัญหามันอยู่ตรงไหนกัน ??

เพราะการเชื่อมต่อ I/O หรือขอข้อมูลจากภายนอกจะเกิดสิ่งที่เรียกว่า "Delay" ขึ้นน่ะสิ!! เพราะการติดต่อกับแหล่งข้อมูลจากภายนอกจะต้องเสียเวลาในการเชื่อมต่อ แม้ไม่ได้มากนัก อาจจะแค่ไม่กี่ร้อย millisec แต่ก็ถือว่าเป็นระยะเวลาที่นานมากสำหรับ CPU

ถ้าเราเขียนโปรแกรมในโหมด Command Line การที่ข้อมูลจะตอบกลับมาช้าซัก 1-2 วินาที ก็คงไม่มีผลอะไรมาก (หน้าจอโปรแกรมก็ค้างรอโหลดอยู่ตรงนั้น พอโหลดเสร็จค่อยแสดงผลออกมา)

แต่โปรแกรมหรือแอพที่เราใช้ๆ กันอยู่ทุกวันนี้มันไม่ได้เป็นแบบนั้น

ถ้าลองนึกถึงโปรแกรมในปัจจุบันส่วนใหญ่แล้ว จะมีUIให้ใช้งานแบบกราฟิกโหมด .. ก็คือใช้งานผ่านการคลิกเมาส์หรือการกดปุ่มโน่นนี่นั่นแหละ

นั่นแปลว่านอกจากโปรแกรมจะต้องทำการโหลดข้อมูลแล้ว มันยังต้องควบคุมส่วนUIด้วย ทีนี้ลองคิดดูว่าแต่ถ้าเราเขียนโปรแกรมแบบเดิมๆ กับโปรแกรมที่มีUIเป็นกราฟิกโหมดจะเกิดอะไรขึ้น

คำตอบคือ .. จังหวะที่โปรแกรมเราสั่งโหลดข้อมูล และมันกำลังรอให้ข้อมูลตอบกลับมา อยู่นั้น CPUจะไม่สามารถปลีกตัวไปทำงานอย่างอื่นได้เลย แม้แต่การจัดการกับUI (แปลว่าโปรแกรมเราจะดูเหมือนค้างไปเลย กดปุ่มอะไรก็ไม่ได้ ถ้าเป็นแอพในมือถือก็จะสกอร์หน้าขึ้นลงอะไรไม่ได้เลย ค้างอยู่เฉยๆ แบบนั้นเลย) โปรแกรมจะกลับมาทำงานต่อได้อีกทีก็ต้องโน้นเลย คำสั่งที่ทำการโหลดข้อมูลทำงานเสร็จแล้วนั่นแหละ!

Asynchronous โปรแกรมที่ทำงานกระโดดไปกระโดดมา

ความจริงแล้ว Asynchronous แปลว่าการทำงานแบบไม่ประสานเวลา (ตรงข้ามกับ Synchronous ที่แปลว่าการทำงานแบบประสานเวลา)

นั่นแปลว่าโค้ดที่เราเขียนอาจจะไม่ได้รันตามลำดับบรรทัดก็เป็นได้ เช่นตัวอย่างข้างล่างนี่

// Synchronous
void main(){
    print('start loading');
    var data = loadData();
    print(data);
    print('done');
}

// Asynchronous
void main(){
    print('start loading');
    loadData().then((data){
        print(data);
    });
    print('done');
}

จากโค้ด จะเห็นว่าสำหรับฟังก์ชัน loadData() ที่มีการทำงานค่อนข้างนาน เราจะไม่หยุดรอผลลัพธ์จากมัน แต่จะวางฟังก์ชันอีกตัวเอาไว้บอกว่า เมื่อdataกลับมา จะให้ทำอะไรต่อไป หรือเรารู้จักกันในชื่อ "Callback Function" นั่นเอง ซึ่งสามารถกำหนดได้ด้วยคำสั่ง .then() นะ

จากโค้ดด้านบน เมื่อฟังก์ชัน loadData() ทำงานเสร็จ มันจะข้ามไปทำงานบรรทัดต่อไปซึ่งก็คือ print("done") ต่อทันทีโดยไม่ต้องรอให้ data กลับมาก่อน

Asynchronous Programming ฉบับภาษาDart!

Isolates

ในภาษาDart, โค้ดทั้งหมดจะถูกรันอยู่ในสิ่งที่เรียกว่า "Isolates" ซึ่งเป็นเหมือนพื้นที่เล็กๆ บนเมโมรี่ ที่โปรแกรมหรือ Isolates ภายนอกไม่สามารถเข้ามาได้ (private)

ในภาษาอื่นๆ เช่น c/c++, Java จะสามารถสร้าง Thread ขึ้นมาได้ (ซึ่งส่วนใหญ่แล้วจะต้องควบคุมเทรดพวกนี้ด้วยตัวเอง)

อธิบาย Thread แบบรีบๆ

สโคปเนื้อหาในบทความนี้ไม่ได้รวมเรื่อง Thread แต่จะพออธิบายแบบสั้นๆ สำหรับคนยังไม่รู้จักมัน

Thread เป็นเหมือนโปรแกรมย่อยในโปรแกรมหลักของเราอีกทีนึง ในภาษาส่วนใหญ่จะมีฟีเจอร์ให้เราแยกโค้ดของเราออกเป็นโปรแกรมย่อยๆ แล้วรันมันพร้อมกันได้อยู่แล้ว โดยโปรแกรมหลักของเราจะถูกรันอยู่บนสิ่งที่เรียกว่า "Main Thread" (หรือเรียกว่า "UI Thread") เสมอ เป็นเทรดตัวหลักที่เอาไว้ควบคุมการแสดงผลบนUI แต่ถ้าเราต้องการเชื่อมต่อ Data Source ภายนอกที่กินเวลาค่อนข้างนานเราจะเป็นต้องสร้างเทรดตัวใหม่ขึ้นมา (เป็น Background Thread) แล้วโยนงานโหลดข้อมูลให้เทรดตัวนี้ไปทำงานแทน, UI Thread จะได้จัดการงานแสดงผลให้ผู้ใช้ต่อไปได้โดยหน้าจอไม่ค้าง .. ต่อมาเมื่อ Background Thread ตัวนั้นโหลดข้อมูลเสร็จแล้ว ก็จะส่งข้อมูลตัวนั้นกลับมาให้ Main Thread เพื่อแสดงผลขึ้นบนหน้าจอหรืออะไรต่อไป

ส่วนงานแบบไหนถือว่าเป็นงานที่ยาวจนทำให้ UI ค้างได้ไม่ได้มีกำหนดตายตัว เช่นใน Android กำหนดไว้ว่าUI ควรจะเรนเดอร์ที่อัตรา 60Hz นั่นแปลว่างานๆ หนึ่งต้องทำงานให้เสร็จใน 16.67ms เท่านั้น ถ้าเกินกว่านั้นควรแยกไปทำงานในเทรดตัวใหม่แทน

Event Loop

สำหรับภาษา Dart ถูกออกแบบมาให้ใช้งานง๊าย~ง่าย อยากเขียนAsyncงั้นเหรอ ไม่ต้องสร้างThreadอะไรเองแบบแมนนวลหรอก เราจัดให้!

ภาษา Dart นั้นจะแบ่งโค้ดของเราเป็น Isolates แยกๆ กันให้ด้วยตัวเองโดยโปรแกรมเมอร์ไม่ต้องกำหนดอะไรเลย

ทีนี้, เมื่อโค้ดของเราถูกแบ่งออกเป็นหลายๆ Isolates แล้วมันจะจัดลำดับการทำงานของโค้ดยังไงล่ะ

คำตอบคือภาษา Dart นั้นมีสิ่งที่เรียกว่า "Event Loop" จัดการอยู่เบื้องหลัง (ใช้เขียน JavaScript น่าจะคุ้นๆ กับสิ่งนี้เพราะแนวคิดมันเหมือนกันเลย)

Event Loop นั้นเหมือนกับผู้ที่หยิบโค้ดDartของเราไปรันแบบทีละตัวๆ แต่เพราะภาษาDartสามารถสร้าง Isolates ออกมาเรื่อยๆ กี่ตัวก็ได้

แต่เพราะ Event Loop นั้นมีอยู่แค่ตัวเดียว Isolates ที่มีอยู่หลายตัวก็เลยต้องรอกันอยู่ในคิว รอให้ Event Loop ค่อยๆ หยิบไปรันทีละตัว

แล้วDartมีหลักการแยกโค้ดออกเป็น Isolates ส่งเข้า Event Loop ยังไงนะ

ในภาษาDartจะมีคำสั่งมากมาย ทุกคำสั่งจะมีการทำงานแบบ Synchronous ทั้งหมด ยกเว้นคลาสที่ชื่อว่า Future และ Stream ที่เมื่อมันทำงานปุ๊บ มันจะกลายเป็น Asynchronous ทันที

(ส่วน Future และ Stream ใช้งานยังไง เดี๋ยวเราจะพูดกันในบทต่อๆ ไป)

ฟังก์ชันไหนรีเทิร์นค่ากลับมาเป็น Future หรือ Stream จะถูกแยกออกไปอยู่ใน Isolates ตัวใหม่ทันที

ลองมาดูตัวอย่างกัน

สมมุติว่าเรามีฟังก์ชันที่รีเทิร์นค่า Future ชื่อ future() นะ

void main(){
    print('main-1');

    future().then((){
        print('future A');
    });

    print('main-2');

    future().then((){
        print('future B-1');
        future().then((){
            print('future C');
        });
        print('future B-2');
    });

    print('main-3');
}

จากตัวอย่างข้างบน เรามีฟังก์ชันหลักคือ main ที่มีคำสั่งจำนวนหนึ่งอยู่ข้างใน แต่ให้ลองสังเกตฟังก์ชัน future() ทั้ง 2 ตัว, ซึ่ง future() ทั้งสองตัวนี้จะถูกรันเมื่อฟังก์ชัน main ทำงานนั่นแหละ แต่ผลของ future() ที่เขียนอยู่ใน .then() นั้นจะยังไม่ถูกทำงานในตอนนั้น แต่จะถูกแยกออกไปเป็น Isolates ตัวใหม่ เข้าคิวไว้รออยู่ให้ Event Loop มาหยิบไปรัน

ก็จะได้ output เรียงตามลำดับนี้

main-1
main-2
main-3
future A
future B-1
future B-2
future C

ตอนต่อไป

ในบทต่อไปเราจะมาพูดกันต่อว่าถ้าเราต้องการสร้างโค้ดที่เป็น Async เราต้องจะเขียนยังไง กับตัวที่ง่ายที่สุดนั่นคือ Future หรือพูดง่ายๆ คือ "เดี๋ยวจะทำงานโค้ดนี้ในอนาคต"

Series NavigationAsync in Dart (2) เดี๋ยวจะตอบให้ในอนาคต กับการใช้ Future >>
This entry is part 5 of 5 in the series Dart-Series

ในบทก่อนๆ เรารู้จักตัวแปรประเภท List กันมาแล้ว แต่ในภาษา Dart (และภาษาสมัยใหม่อื่นๆ ด้วย) จะมีตัวแปรอีกชนิดนึงที่ "สามารถนำมาวนลูปได้" หรือ "สามารถ access ค่าเป็นลำดับเรียงต่อกันได้"

ตามปกติเราสามารถสร้างลิสต์ได้แบบนี้

List<int> items = [1, 2, 3, 4];

แต่ถ้าเราลองเข้าไปดู source code ของคลาส List เราจะพบว่ามัน extends มาจากคลาสๆ หนึ่งที่มีชื่อว่า Iterable

แปลว่าเราสามารถสร้างลิสต์แบบนี้ได้

Iterable<int> items = [1, 2, 3, 4];

ซึ่งทั้ง 2 แบบสามารถเอามาวน loop ได้ด้วยนะ

List<int> items1 = [1, 2, 3, 4];
for(var item in items){
    print(item);
}
//output: 1 2 3 4

Iterable<int> items2 = [1, 2, 3, 4];
for(var item in items){
    print(item);
}
//output: 1 2 3 4

ได้ผลเหมือนกันเลยนี่นา? แล้วถ้ามันทำได้เหมือนกันแบบนี้มันจะมี 2 คลาสทำไมกัน แปลว่ามันต้องมีความต่างกันล่ะ

และนั่นแหละ คือหัวข้อที่เราจะมาพูดกันในบทความนี้ คือเรื่องของ Generator และ Iterable

by Lazy ขี้เกียจไว้ก่อน จะใช้แล้วค่อยทำ

จากบทที่ 3 เราเคยพูดไปแล้วว่าวิธีการสร้าง List มีหลายวิธีมาก หนึ่งในนั้นคือการสร้างด้วย Generator

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

List<int> list = List<int>.generate(10, (index){
    return index + 1;
});
// List [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

แต่ลองดูโค้ดต่อไปนี้

var list = List<int>.generate(3, (index){
    print('creating item from List');
    return index + 1;
});

var iter = Iterable<int>.generate(3, (index){
    print('creating item from Iterable');
    return index + 1;
});

คำถามคือ...ถ้าเอาโค้ดแค่นี้เลยนะ มารัน -> จะได้ output ออกมาเป็นยังไง?

คำตอบคือ...

creating item from List  ━┓
creating item from List   ┣━ 3ครั้งจากListอย่างเดียว
creating item from List  ━┛

นั่นคือฟังก์ชันสำหรับสร้างไอเทมของ List ถูกรันจนครบ 3 ครั้งเลย แต่กลับกันคือ Iterable นั้นไม่ถูกรันเลยซักครั้ง!

แต่ถ้าเราเขียนโค้ดต่อไปอีกหน่อย เป็นแบบนี้

var list = List<int>.generate(3, (index){
    print('creating item from List');
    return index + 1;
});

var iter = Iterable<int>.generate(3, (index){
    print('creating item from Iterable');
    return index + 1;
});

print('first item of list: ${list[0]}');
//หรือจะเรียกให้เหมือน iterable คือ list.elementAt(0) ก็ได้
print('first item of iter: ${iter.elementAt(0)}');
//iterable ไม่มีคำสั่ง [] นะ

output กลับได้เป็น

creating item from List      ━┓
creating item from List       ┣━ 3 ครั้ง
creating item from List      ━┛
first item of list: 1
creating item from Iterable  ━━━ 1 ครั้ง
first item of iter: 1

เราจะพบว่าฟังก์ชันของ Iterable เริ่มทำงานแล้วล่ะนะ แต่ทำงานแค่ครั้งเดียว!?

ส่งนี้เรียกว่า "Lazy Evaluation" นั่นคือเราจะยังไม่สร้างไอเทมในทันทีที่ถูกสั่งให้สร้าง แต่ถ้าไหร่ก็ตามที่ถูกเรียกใช้ค่อยสร้างตอนนั้น

ถ้าจำลองสถาณการณ์ของโค้ดเมื่อกี้:

  • สำหรับ List: เปิดมา ไม่ต้องพูดพร่ำทำเพลง สร้างมันรวดเดียวเลย 3 ไอเทม (ยังไม่มีคนเรียกใช้เลยนะ สร้างไว้ก่อน)
  • สำหรับ Iterable: เปิดมา ยังไม่ทำอะไรทั้งนั้นแหละ (Lazy) แต่เมื่อมีการเรียกไอเทม elementAt(0) มันพบว่าไอเทมตัวที่ 0 น่ะยังไม่ถูกสร้างขึ้นมาเลย แต่เขาจะใช้งานแล้วนี่นา -> งั้นก็สร้างซะตอนนี้เลย!

แต่อ่านมาถึงตรงนี้ เราก็อาจจะงงๆ สงสัยกันว่า แล้วจะทำแบบนี้เพื่ออะไรกัน?

ลองดูตัวอย่างต่อไปครับ...

สร้าง Iterable ด้วยฟังก์ชัน Generator

สมมุติว่าเราจะสร้างฟังก์ชันสำหรับสร้างเลข 1-1,000,000 (หนึ่งถึงหนึ่งล้าน) ขึ้นมาหนึ่งตัวเพื่อเอาไปวนลูปอะไรสักอย่างนึง

for(var i in getNumbers()){
    print(i);
}

ทั่วๆ ไปเราก็อาจจะนึกถึงฟังก์ชันที่สร้าง List คืนมา แบบนี้

List<int> getNumbers(){
    return [for(var i=1; i<=1000000; i++) i];
}

ดังนั้นเวลาโค้ดทำงาน

  1. ฟังก์ชันสร้างตัวเลขทั้ง 1 ล้านตัวขึ้นมาก่อน
  2. ส่งกลับไปให้ลูปทำการวนปริ๊นค่าทั้ง 1 ล้านตัวนั่นออกมา

แต่ปัญหาจะเกิดขึ้น ถ้าลูปของเรามันสามารถหยุดกลางคันได้

for(var i in getNumbers()){
    print(i);
    if(i >= 10) break; //ถึง 10 เมื่อไหร่ก็จบลูปได้
}

ทีนี้ โค้ดก็จะทำงานเปลี่ยนไป

  1. ฟังก์ชันสร้างตัวเลขทั้ง 1 ล้านตัวขึ้นมาก่อน
  2. ส่งกลับไปให้ลูปทำการวนปริ๊นค่า
  3. แต่คราวนี้ ปริ๊นไปแค่ 10 ตัวก็จบลูปแล้ว (ตัวเลขที่เหลืออีก 999,900 ก็ไม่ได้ใช้ แต่สร้างมาแล้วนะ)

ถ้าถามว่าโลจิคแบบนี้มันโอเคมัน มันก็ได้อยู่นะ โลจิคไม่ได้เปลี่ยนไป แต่ในแง่ประสิทธิภาพ performance ของโปรแกรมนี่ไม่ผ่านแน่นอน

เพราะแบบนี้ ถ้าเราสร้างฟังก์ชัน getNumbers() ด้วย Iterable จะทำให้ประหยัด performance มากๆ เพราะถึงจะกำหนดว่าต้องสร้างเลข 1-1,000,000 แต่เวลาเรียกใช้ ใช้แค่10ตัว มันก็จะสร้างไอเทมขึ้นมาแค่10เท่าที่ต้องใช้ ไม่ต้องสร้างจริงทั้งหนึ่งล้านตัว

sync* ฟังก์ชันที่หยุดกลางคันได้

นอกจากวิธีใช้ factory function Iterable.generate สร้าง Iterable ขึ้นมา จริงๆ ยังมีอีกวิธีในการสร้างมัน นั่นคือการใช้ "Generator Function"

ถ้าเราสร้างฟังก์ชันที่ตอบ int ธรรมดาก็จะได้โค้ดหน้าตาแบบนี้

int getNumber(){
    return 1;
}

แต่ถ้าเราจะตอบกลับเป็น Iterable เราจะต้องเขียนฟังก์ชันแบบนี้

Iterable<int> getNumbers() sync* {
    yield 1;
}

จุดสังเกตคือมี 2 อย่างที่เปลี่ยนไป นั่นคือ

  • เราไม่ได้ใช้คีย์เวิร์ด return อีกต่อไป แต่ใช้ yield แทน
  • มีการกำหนดฟังก์ชันเป็น sync* ด้วยนะ (จะมาพร้อม yield เสมอ คือถ้าต้องการใช้คำสั่ง yield ในฟังก์ชันจะต้องประกาศให้ฟังก์ชันเป็น sync* เสมอ)

ความแตกต่างระหว่าง yield กับ return คือทันทีที่ return ทำงาน ฟังก์ชันจะจบการทำงานทันที แต่สำหรับ yield นั้นจะยังไม่หยุดจนกว่าจะจบฟังก์ชัน เช่น

Iterable<int> getNumbers() sync* {
    yield 1;
    yield 2;
    yield 3;
}

for(var number in getNumbers()){
    print(number);
}
//output: 1 2 3

ฟังก์ชันทั่วๆ ไปจะเป็นฟังก์ชันประเภท sync แต่เราจะถือว่าไม่ต้องเติมคีย์เวิร์ดนี้ลงไปนะ

ภาษาทั่วไป การประกาศฟังก์ชันเป็น Generator จะใช้ * เติมไม่ก่อนก็หลังฟังก์ชัน เช่น function* f() แต่ใน Dart จะต้องระบุคีย์เวิร์ด sync* ลงไปด้วย เพราะเดี๋ยวในบทต่อๆ ไปเราจะเจอกับ async* ด้วย!!
แต่เดี๋ยวขอเก็บไว้ก่อนนะ ไว้ค่อยกลับมาพูดกัน

ข้อดีอีกอย่างของ sync* function คือเราสามารถเขียนโค้ดแบบธรรมดาได้เลย เช่น

ex. ฟังก์ชันสำหรับสร้างลำดับเลขฟีโบนัคชี (อ่านเพิ่มเติมใน Fibonacci Number)

Iterable<int> fibonacci() sync* {
    var first = 1;
    var second = 1;
    yield first;
    yield second;
    while(true){
        var next = first + second;
        yield next;
        first = second;
        second = next;
    }
}

จะเห็นว่าเราจะเขียนโค้ดได้แบบไม่ต้องแคร์เลยว่าการวนลูปของเราจะต้องจบเมื่อไหร่ สามารถวนลูปไปได้เรื่อยๆ เลย แล้วอยากจะตอบค่ากลับก็ yield ได้เลย

แต่ถ้าเขียน infinity iterable แบบนี้ เวลาใช้งานต้องระวัง!! เพราะจะต้องมีการกำหนดขนาดก่อนใช้เสมอ เช่น

for(var number in fibonacci().take(10)){
    print(number);
}

แบบนี้เราใช้ฟังก์ชัน take ในการดึงค่าฟีโบนัคชี 10 ตัวแรกเท่านั้นพอ

Nested sync* function ซ้อนกันก็ยังได้นะ

ถ้าเรามีฟังก์ชัน Generator 2 ตัว

Iterable<int> threeTime(int x) sync* {
    for(var i=0; i<3; i++){
        yield x;
    }
}

Iterable<int> oneToThree() sync* {
    for(var i=1; i<=3; i++){
        for(var item in threeTime(i)){
            yield item;
        }
    }
}

เราสร้างฟังก์ชันมา 2 ตัว threeTimeจะรับเลขไปหนึ่งตัว แล้วปริ๊นเลขตัวนั้น 3 ครั้ง, oneToThree วนลูปเรียกฟังก์ชันแรกอีก 3 ครั้ง

ถ้าเรารัน oneToThree ก็จะได้ output แบบนี้

10, 11, 12, 20, 21, 22, 30, 31, 32

แต่เราสามารถเขียนย่อได้อีก โดยใช้คำสั่ง yield*

Iterable<int> oneToThree() sync* {
    for(var i=1; i<=3; i++){
       yield* threeTime(i);
    }
}

ก็คือ ถ้าจะใช้ sync เรียก sync จะต้องสั่งด้วย yield* นั่นเอง


ก็จบลงไปแล้วกับซีรีส์แนะนำภาษา Dart เบื้องต้น

ต่อไปจะเป็นบทความเกี่ยวกับการใช้ Async ในภาษา Dart กัน ... แล้วหลังจากนั้นก็จะเป็นซีรีส์การนำภาษา Dart มาเขียน Application แบบ cross-platform ด้วยเฟรมเวิร์คที่ชื่อว่า Flutter

Series Navigation<< Dart 104: การสืบทอดคลาส Inheritance และ Mixin
This entry is part 4 of 5 in the series Dart-Series

จากบทที่แล้วเราพูดถึงการสร้างคลาสในภาษา Dart ไปแล้ว แต่ยังไม่ได้พูดถึงหลักการสำคัญหนึ่งของ OOP นั่นคือการทำ Inheritance เลย ซึ่งจะมาต่อกันในบทนี้

ในบทความนี้จะเน้นพูดถึงความแตกต่างหลังๆ ของการทำ inheritance ในภาษา Dart เป็นหลัก ไม่ได้เน้นสอนเรื่องการทำ class inheritance นะครับ

Inheritance การสืบทอดคุณสมบัติจากคลาสสู่คลาส

เวลาเราสร้างคลาส ตามหลักการ OOP บอกเอาไว้ว่าให้ออกแบบในรูปของ Abstraction ซึ่งจะทำให้เราสามารถส่งต่อความสามารถของคลาสหนึ่ง ไปให้อีกคลาสหนึ่งได้ ถ้าพวกมันเป็นของชนิดเดียวกัน (เรามักใช้คำว่า is-a)

เช่น

class Animal {

    String name = 'สัตว์';

    void eat(){
        print('สัตว์กิน');
    }
}

var animal = Animal();
animal.eat();

เราสร้างคลาส Animal ขึ้นมา โดยมีความสามารถหลักๆ คือ eat ได้

ต่อมา ถ้าเราต้องการสร้างคลาสนกหรือ Bird ซึ่งนกเนี่ย ก็จัดว่าเป็นสัตว์ จะต้อง eat ได้แบบที่สัตว์ทำได้

ในกรณีนี้ เราจะไม่เขียนเมธอด eat ซ้ำลงไปในคลาส Bird (ไม่ copy-paste นั่นแหละนะ) แต่เราจะใช้วิธีที่เรียกว่า Inheritance หรือการสืบทอดคุณสมบัติแทน ด้วยคีย์เวิร์ด extends

class Bird extends Animal{

    @override
    String name = 'นก';

    void fly(){
        print('นกบิน');
    }
}

var animal = Animal();  
print(animal.name);     // สัตว์
animal.eat();           // สัตว์กิน
animal.fly();           // Compile Error!

var bird = Bird();      
print(bird.name);       // นก
bird.eat();             // สัตว์กิน
bird.fly();             // นกบิน

เราเรียกคลาสที่ extends คลาสอื่นมาว่า Child Class ส่วนคลาสที่เป็นต้นฉบับจะเรียกว่า Parent Class

เช่นในเคสนี้ Animal=Parent, Bird=Child

สังเกตว่า Bird สามารถเรียกใช้เมธอดของ Animal ได้ทั้งหมด โดยที่ไม่ต้องเขียนเมธอดนั้นซ้ำลงในคลาส Bird เลย

แต่ถึงแม้ Bird จะเรียกใช้งานเมธอดของ Animal ได้ แต่ Animal ไม่สามารถเรียกใช้งานเมธอดที่เขียนเพิ่มในคลาส Bird ได้นะ

Method & Properties Overriding

ในบางกรณี ถึงแม้Parentจะมีการเขียนทั้ง Method และ Properties ไปแล้ว แต่คลาสChildก็อาจจะไม่ได้อยากได้ค่าแบบนั้น เราสามารถเขียนค่าใหม่ทับลงไปได้ เรียกว่าการ Overriding

class Vehicle {

    String name = 'vihicle';

    void move(){
        print('$name move');
    }

    void addFuel(int fuel){}
}

class Airplane extends Vehicle {

    int _fuel = 0;

    @override
    String name = 'Airbus A380';

    @override
    void addFuel(int fuel){
        _fuel = fuel;
    }

    void fly(){
        print('$name fly');
    }
}

จากโค้ดข้างบน Vehicle มี

  • Properties: name
  • Method: move(), addFuel()

เราทำการ extends ไปเป็นคลาส Airplane แต่ต้องการจะกำหนด name ซะใหม่ แล้วก็ต้องการกำหนดว่าเครื่องบินจะ move ยังไงซะใหม่ด้วย

การสามารถเขียนค่าพวกนั้นซ้ำอีกรอบได้ เรียกว่าการ Override เมื่อเขียนทับไปแล้ว โค้ดจาก Parent จะไม่ถูกนำมาใช้กับคลาสนี้ ซึ่งส่วนใหญ่จะมีการเติม annotation @override เอาไว้ก่อนชื่อเพื่อบอกว่าค่านี้เรากำลังโอเวอร์ไรด์อยู่นะๆ

เกิดอะไรขึ้น? เมื่อเราextendsมากกว่า1ครั้ง

เรื่องนึงที่มือใหม่หัด extends หลายๆ คนจะพลาดกัน นั่นก็คือถ้าเรามีการ extends คลาสหลายคลาสต่อๆ กันโดยที่บางเมธอดก็ถูกคลาสบางคลาส override ทับไป เวลาเรียกใช้งาน จะเกิดอะไรขึ้น?

เริ่มจากตัวอย่างแรกเบสิกๆ กันก่อน

ขอกำหนดให้เรามีคลาสอะไรสักอย่างอยู่คลาสหนึ่ง มีเมธอด f1() ซึ่งเรียก f2(), f3(), f4() ต่อกันเป็นทอดๆ แบบนี้

เนื่องจากมีฟังก์ชันเชื่อเดียวกันอยู่คนละคลาส เดี๋ยวจะสับสนกัน เลยขอกำหนดว่า เราจะเรียก f1() ในคลาส A ว่า A::f1() นะ

สำหรับคลาส A นั้น ไม่ยาก!

เมื่อเราเรียก A::f1() มันก็จะเรียกใช้ตามลำดับนี้

A::f1() -> A::f2() -> A::f3() -> A::f4()

ทีนี้มาดูคลาส B กันบ้าง

คลาส B นั้นมีการ override เมธอดทับลงไป 2 ตัวคือ B::f2(), B::f4() ตามคอนเซ็ป คลาสจะหาเมธอดในตัวมันเองก่อน ถ้าไม่เจอถึงจะขยับขึ้นไปดูที่ Parent ต่อ เลยได้ลำดับการเรียกใช้งานเมธอดตามภาพข้างบน

A::f1() -> B::f2() -> A::f3() -> B::f4()

หลักการคิดนี้ก็ใช้กับคลาส C ได้เช่นกัน นั่นคือไม่ว่าเมธอดนั้นจะถูกเรียกที่ Parent ชั้นไหนก็ตาม เวลาหาว่าเมธอดนี้จะเรียกใช้จากใคร จะเริ่มที่คลาสตัวเองเสมอ (หาไม่เจอ แล้วค่อยขยับขึ้นไปยัง Parent ทีละชั้นๆ)

C::f1() -> B::f2() -> A::f3() -> C::f4()

สำหรับรายละเอียดเพิ่มเติม สามารถอ่านได้ในบทความที่เราเคยเขียนไว้ เกี่ยวกับ OOP ที่ All About OOP – ตอนที่ 2 เจาะลึก Inheritance เมื่อคลาสมีผู้สืบทอดได้ ได้เลย!

Abstract Class คลาสไม่สมบูรณ์

ถ้าเราดูตัวอย่างที่ผ่านมากับคลาส Vehicle เรา จะเห็นว่ามีเมธอดที่ชื่อ addFuel(int) ถูกประกาศเอาไว้ โดยไม่ได้เขียนอะไรเอาไว้เลย!

class Vehicle {
    void addFuel(int fuel){}
    ...
}

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

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

เรื่องนี้เลยเป็นที่มาของ Abstract Class นั่นเอง!

Abstract Class จะเน้นเล่นกับเมธอด เพราะเป็นส่วนที่มีทั้ง abstraction และ body

abstract class Vehicle {
    void addFuel(int fuel); //ในเมื่อไม่รู้ ก็ไม่ต้องเขียนลงไป
    ...
}

แอบสเตร็กคลาสคือคลาสที่อนุญาตให้เราประกาศแค่ชื่อของเมธอดได้โดยไม่ต้องเขียน body แต่อย่างใด

คำถาม? ถ้าเราแค่ประกาศชื่อเมธอด ไม่ได้เขียนว่ามันจะรันยังไง แล้วเวลาสั่งรันมันจะทำงานได้ยังไงล่ะ?

คำตอบ! ไม่ได้ยังไงล่ะ!!

var calculator = Calculator();
//Compile Error: Abstract classes can't ne instantiated!

เรามันเป็นคลาสที่ไม่สมบูรณ์ เลยมีกฎว่า ห้ามสร้าง object จาก Abstract Class นะ

วิธีการใช้งานแอบสเตร็กคลาสเราจะต้องทำการ extends มันไปเป็นคลาสลูก แบบนี้

class Airplane extends Vehicle {
}
//Compile Error: Missing concrete implementations of 'Vehicle.addFuel'

ซึ่งหากเรา extends มาแล้วแต่ไม่ยอมเติมเมธอดที่หายไป มันจะแจ้งเออเรอร์ประมาณนี้ ทำให้เราต้องเขียนเมธอดเพิ่มลงไปนั่นเอง

class Airplane extends Vehicle {
    @override
    void addFuel(int fuel){
        ...
    }
    ...
}

class ธรรมดาที่เราเขียนๆ กันเราจะเรียกมันว่า Concrete Class ซึ่งไม่สามารถมี Abstract Method ได้ จะต้องเขียนวิธีการรันทั้งหมดลงไป

Implementation การเติมเต็มส่วนที่ขาดหาย

สำหรับภาษาอื่นๆ interface คือสิ่งที่คล้ายๆ กับคลาส แต่จะมีประกาศไว้แค่ abstraction โดยไม่มี body (เอาง่ายๆ มันคือแอบสเตร็กคลาสที่ทุกเมธอดเป็น abstract ทั้งหมด)

// ตัวอย่างในภาษา Java
class MyClass {
    // method: abstraction with body
    void f(){
        print("hello world!");
    }
}

interface MyInterface {
    // method: only abstraction
    void f();
}

แต่ภาษา Dart นั้นไม่มี interface แต่ถือว่า "คลาสทุกคลาส สามารถเป็น interface ได้"

การใช้งานอินเตอร์เฟซจะคล้ายๆ กับการ extends แต่เปลี่ยนไปใช้คีย์เวิร์ด implements แทน

class A {
    int f() => 1;
}

class B extends A {}
B().f() //1

class C implements A {}
//Compile Error: Missing concrete implementations of 'A.f'

สังเกตดูว่า แม้คลาส A จะเป็น Concrete Class ที่สมบูรณ์แล้ว แต่ถ้าเราสั่ง implements มันจะแจ้งประหนึ่งว่า A::f() ของเรานั้นไม่ได้เขียน body อะไรเอาไว้เลย

class C implements A {
    int f() => 2
}

อาจจะคิดว่า แล้วแบบนี้ implements มันจะมีประโยชน์อะไรน่ะ?

ในแง่การสร้างคลาสก็ไม่ค่อยจะมีประโยชน์อะไรหรอก แต่เราสามารถใช้งานมันในมุมของ Polymorphism แทน

เหมือนเดิมนะ คือถ้าใครยังไม่รู้จัก Polymorphism อ่านต่อได้ที่ All About OOP – ตอนที่ 3 Polymorphism หลากรูป หลายลักษณ์

Mixin จับพวกมันมาผสมกัน!

ในภาษาสมัยใหม่ส่วนมาก เรามักจะไม่สามารถทำสิ่งที่เรียกว่า Multiple Inheritance ได้ (ตัวอย่างภาษาที่ทำได้คือ C++)

เหตุผลคือการที่เราทำการ extends จากหลายๆ คลาสจะเป็นอะไรที่สร้างความมึนงงเวลาเขียนโปรแกรมมาก และมันนำมาสู่การเกิดบั๊กยังไงล่ะ!

มาดูตัวอย่างกัน

class Bird extends Animal {
    ...
}

class Airplane extends Vehicle {
    ...
}

เรามีคลาส 2 คลาสคือ Bird และ Airplane ซึ่งทั้งสองนั้นมันสามารถบินได้ทั้งคู่เลย

เราก็เลยสร้างคลาสใหม่ ชื่อว่า FlyObject เอาไว้เป็นตัวกลาง หวังว่าจะส่งต่อความสามารถนี้ให้กับทั้ง Bird และ Airplane

class FlyObject {
    void fly(){ print('fly~'); }
}

แต่มันก็เกิดปัญหาขึ้นจนได้!

นั่นคือเราไม่สามารถ extends คลาสหลายๆ คลาสซ้อนกันได้

// Compile Error!
class Bird extends Animal, FlyObject {
    ...
}

// Compile Error!
class Airplane extends Vehicle, FlyObject {
    ...
}

extends หลายคลาสแล้วเป็นอะไรเหรอไง?

การอนุญาตให้ extends หลายคลาส อาจจะทำให้เกิดเหตุการแบบนี้ คือคลาส Parent ทั้ง 2 คลาสดันมีเมธอดชื่อเดียวกัน!

class A {
    int f() => 1;
}

class B {
    int f() => 2;
}

class C extends A, B {} //(จริงๆ เคสนี้ต้อง Compile Error! นะ)

C().f() ??

ทีนี้เวลาคลาสลูกเรียกใช้ f() ก็ไม่รู้แล้ว ว่าจะให้ใช้ f() ของคลาส A::f() หรือ B::f()

แต่ในภาษา Dart มีฟีเจอร์ที่เรียกว่า mixin ("มิกซ์-อิน") เอาไว้แก้ปัญหานี้ได้

ในตัวอย่างเรามีคลาส Airplane กับ Bird ที่ทำการ extends มาจากคลาสอื่นเรียบร้อยไปแล้ว (แปลว่า extends เพิ่มอื่นไม่ได้แล้ว)

แต่เราสามารถเปลี่ยนจากคีย์เวิร์ด extends ไปเป็น with ก็สามารถเพิ่มลงไปกี่ตัวก็ได้

class Vehicle {
    void move(){ ... }
}
class Animal {
    void eat(){ ... }
}
class FlyObject {
    void fly(){ ... }
}

class Airplane extends Vehicle with FlyObject {}
class Bird     extends Animal  with FlyObject {}

var airplane = Airplane();
airplane.move();
airplane.fly();

var bird = Bird();
bird.eat();
bird.fly();

หรือจะทำการ with จากหลายๆ ตัวก็ยังไง

class Parent1 { void f1(){ ... } }
class Parent2 { void f2(){ ... } }
class Parent3 { void f3(){ ... } }

class Child with Parent1, Parent2, Parent3 {
    ...
}

var child = Child();
child.f1();
child.f2();
child.f3();

แต่ก็ไม่ใช่ว่าไม่มีข้อจำกัดนะ นั่นคือคลาสที่จะนำมาสร้างเป็น Mixin นั้นจะต้อง ไม่ extends คลาสอื่นมา

class Parent1 { ... }
class Parent2 extends Parent1 { ... }

class Child with Parent1, Parent2 {}
//Compile Error: class 'Parent2' cannot be use as mixin because it extends from other class

เรื่องสุดท้ายที่จะพูดถึงเกี่ยวกับ Mixin คือจะเกิดอะไรขึ้น เมื่อเรา with จาก 2 คลาสที่มีเมธอดเดียวกัน

class A {
    String f() => 'from A';
}

class B {
    String f() => 'from B';
}

class AfollowByB with A, B {}
class BfollowByA with B, A {}

AfollowByB().f() // 'from B'
BfollowByA().f() // 'from A'

การจะทำแบบนี้ได้มีข้อจำกัด (อีกแล้ว) นั่นคือเมธอดทั้ง 2 ตัวจะต้องมี return-type และ parameters ที่เหมือนกันเป๊ะทุกอย่าง

ตัวคอมไพเลอร์ของ Dart จะเลือกเมธอดหลังเสมอ (อ่านจากซ้ายไปขวา ดังนั้น เมธอดด้านขวาจะ override ทับตัวทางซ้าย)

เช่นถ้าเราสั่ง with A, B เมธอด B::f() จะทับ A::f()


สรุป

สำหรับภาษา Dart ก็มีฟีเจอร์ในการทำ inheritance เทียบเท่ากับภาษาสมัยใหม่ทั่วๆ ไปแต่อาจจะมีรูปแบบการเขียนต่างกันเล็กน้อย เช่นการไม่มี interface แต่ก็แทนที่ด้วยการที่เราสามารถ implements จากคลาสตรงๆ ได้เลย

Series Navigation<< Dart 103: มารู้จัก Class และ Object สไตล์ภาษาDartกันเถอะDart 105: มันวนซ้ำได้นะ! มาสร้าง Generator และใช้งาน Iterable กันเถอะ >>
This entry is part 3 of 5 in the series Dart-Series

Dart ถือเป็นภาษาสมัยใหม่ (ที่มีรูปแบบsyntaxแบบค่อนข้างเก่า🤔) ดังนั้นขาดไม่ได้เลยที่จะต้องมีฟีเจอร์ของ Object-Oriented Programming ใส่เข้ามาอย่างแน่นอน

สิ่งที่บทความนี้จะโฟกัสคือการใช้ OOP ในภาษา Dart เท่านั้น แต่ไม่ได้มาสอน OOP ในบทความนี้นะ

📢 Note: ภาษา Dart มีหลักๆ 2 เวอร์ชัน ในบทความนี้จะพูดถึง Dart version 2 เป็นหลัก

Class and Object

การสร้าง class และ object ใน Dart ถือว่าไม่ค่อยมีอะไรแปลกจากภาษาทั่วไป โดยเฉพาะถ้าใครเคยเขียน Java มาก่อนจะเรียนรู้ได้ไม่ยากเลย

class People {
    int id;
    String name;

    void hello(){
        print('Hi, my name is $name');
    }
}

ส่วน object เวลาสร้างนั้นเราสามารถละคีย์เวิร์ด new ก็ได้ แบบนี้

var people = new People();  // จะใส่ new ก็ได้
var people = People();      // ตามมาตราฐานจะละ 

people.name = 'Ana';
people.hello();

📢 Note: หากเราสร้างตัวแปรแบบ local scope ที่มีชื่อเดียวกับ properties (แต่สำหรับDartจะเรียกว่า field) เช่น

class People {
    int id;     // <-- [id1]
    void foo(){
        int id; // <-- [id2]
        id = 1; // id ตรงนี้จะหากถึง [id2]
    }
}

เนื่องจากตัวแปรประเภท local scope หรือตัวแปรระดับฟังก์ชันจะถูกหาก่อนตัวแปรภายนอกที่เป็นของคลาสเสมอ วิธีแก้คือใช้คีย์เวิร์ด this ซึ่งหมายถึงตัว object เข้ามาช่วย

class People {
    int id;     // <-- [id1]
    void foo(){
        int id; // <-- [id2]
        this.id = 1; // id ตรงนี้จะหากถึง [id1]
    }
}

private vs public

ในภาษา Dart ไม่มีคีย์เวิร์ด private หรือ public ที่เอาไว้กำหนดว่า field หรือ method นี้สามารถเรียกใช้งานจากภายนอกคลาสได้หรือเปล่า นั่นหมายความว่าค่าทุกค่าจะถูกกำหนดเป็น public ยกเว้นกรณีเดียวคือเราตั้งชื่อ field หรือ method นั้นให้ขึ้นต้นด้วย _ (underscore)

class MyClass {
    int _privateField;
    int publicField;

    void _privateMethod() => 0
    void publicMethod() => 0
}

แล้วตามคอนเซ็ปของ OOP นั้นจะมีหลักการ encapsolution ข้อมูลภายใน โดยหากเราต้องการจะ access เข้าไปใช้ข้อมูลพวกนั้นเราจะต้องสร้าง method ที่เรียกว่า getter และ setter ขึ้นมา แบบนี้

class People {
    String _name;

    void setName(String name) => _name = name;
    String getName() => _name;
}

อย่างไรก็ตามล่ะ ... การสร้างทั้ง field แล้วเอา method มาครบมันอีกครั้งเป็นการเขียนสไตล์ของภาษา OOP ยุคเก่านิดนึง (เช่น Java 😜)

สำหรับใน Dart จะมีสิ่งที่เรียกว่า...

Getter Setter fields

ทั้งสองตัวนี้คล้ายๆ กัน "getter setter method" จะต่างกันตรงที่ Getter Setter field ถือว่าเป็น field (ก็ตามชื่อมันละนะ)

field พวกนี้จะถือเป็นค่าพิเศษที่สร้างในรูปแบบของ method แต่ยังถือว่าเป็น field ด้วยคียเวิร์ด get และ set

class People {
    String _name;

    // Getter
    int get name => _name;

    // Setter
    void set name(String name) => _name = name;
    // หรือแบบย่อ คือไม่ต้องใส่ void (Recommend)
    set name(String name) => _name = name;
}

ข้อสังเกตคือการสร้าง Getter, Setter จะมีรูปแบบเหมือน method มากกว่า field แต่ในส่วนของ Getter นั้นจะถูกบังคับให้ไม่เขียน parameter หรือ () ที่ตามหลัง ส่วน Setter นั้นมีข้อแนะนำว่าไม่ต้องใส่ return-type void

และเนื่องจากมันมีรูปแบบการเขียนแบบฟังก์ชัน เราสามารถเพิ่มโค้ดอย่างอื่นลงไปอีกก็ได้ เช่น

class People {
    String _name;

    int get name {
        print('call get name with value = $_name');
        return _name;
    }

    set name(String name){
        print('call set name with value = $name');
        _name = name;
    }
}

เมื่อทำแบบนี้ทุกครั้งเวลาเราเซ็ตค่าหรือขอค่า people.name ก็จะมีการทำงาน print เกิดขึ้นด้วย ซึ่งสามารถเอาไปประยุกต์ใช้งานกับอย่างอื่นได้อีกมากมายนะ

Constructor

คอนสตรักเตอร์คือการกำหนดค่าเริ่มต้น ตั้งแต่ครั้งแรกที่สร้าง object ขึ้นมาเลย ซึ่งในภาษา Dart สามารถทำได้หลายวิธีมากๆ

แต่แบบมาตราฐานจะเหมือนกับภาษา Java เป๊ะๆ นั่นคือสร้าง method ที่ไม่มี return-type และชื่อเหมือนกับชื่อคลาส

Initializing Field

class People {
    int _id;
    String _name;

    People(int id, String name){
        _id = id;
        _name = name;
    }
}

var people = People(1, 'Ana');

แต่ในกรณีแบบนี้ ถ้าคลาสเรามี field หลายตัวมากๆ เราจำเป็นต้องเขียนการกำหนดค่าเยอะมาก ดังนั้นDartเลยมี syntax แบบ่อซึ่งสั้นเป็นพิเศษ ซึ่งจะแปลงโค้ดจากข้างบนให้เหลือแค่

class People {
    int _id;
    String _name;

    People(this._id, this._name);
}

var people = People(1, 'Ana');

นั่นคือบอกไปเลยว่า ค่าที่ได้มาให้เอาไปใส่ใน field ที่กำหนดตรงๆ เลย (ต้องมี this นำหน้าตัวแปร)

หรือจะเอามาผสมกับ Named Parameter ก็ได้ เช่น

class People {
    int id;
    String name;

    People({this.id, this.name});
}

var people = People(id: 1, : 'Ana');

⚠️ Warning!: สำหรับ Named Paremeter นั้นไม่สามารถใช้กับ field แบบ private ได้ (พวกตัวแปรที่ขึ้นด้วย _) ในเคสนี้อาจจะต้องเขียนยาวหน่อยหากต้องการสร้าง field แบบ private

class People {
    int _id;
    String _name;

    People({int id, String name}){
        _id = id;
        _name = name;
    }
}

var people = People(id: 1, : 'Ana');

แต่! ก็ยังมีปัญหาอยู่ดี ถ้าเราประกาศตัวแปรภายนอกเป็น final!!

Initializer

ตามหลักการเขียนโค้ดที่ดีสไตล์ Functional Programming (ใครยังไม่รู้จักว่า FP คืออะไร, ต่างกับ OOP รึเปล่า ไปอ่านกันได้ที่นี่) เราควรจะใช้ตัวแปรแบบ immutable หรือเปลี่ยนแปลงค่าไม่ได้ เพื่อป้องกันการมี state ของโค้ดเยอะเกินไปจนเกิดบั๊ก!

เราก็เลยแก้โค้ดด้านบนอีกรอบเป็นแบบนี้

class People {
    final int _id;
    final String _name;

    People({int id, String name}){
        _id = id;
        _name = name;
    }
}

และผลที่ได้ก็คือ Compile Error!! ด้วยสาเหตุว่า All final variables must be initialized, but _id and _name are not

ก็งงกันไปสิ!? ประกาศ final แถมกำหนดค่าใน constructor ด้วยแต่ทำไมบอกว่าไม่ได้กำหนดค่า

"คำตอบคือ construction state ของ Dart นั้นจบก่อนจะทำงาน body ของ constructor"

เพื่อความเข้าใจลองดูนี่

class People {
    final int _id;
    final String _name;

    // constructor header
    People({int id, String name})    
    // constructor body
    {
        _id = id;
        _name = name;
    }
}

Dart ถือว่าถ้าต้องมีการ initialize หรือประกาศค่าที่จำเป็น (เช่นในกรณีของเรา คือการกำหนดค่าให้ตัวแปร final) จะต้องทำให้เสร็จในส่วนของ header ก่อนที่ body จะเริ่มทำงาน

วิธีการแก้คือ ภาษาDartอนุญาตให้เรากำหนดค่าได้ในส่วน header เลย แบบนี้

class People {
    final int _id;
    final String _name;

    People({int id, String name}) :
        this._id = id,
        this._name = name ;

}

หลัง header ให้เราใส่ : ลงไป หลังจากนั้นจะเป็น statement ที่อนุญาตให้เรากำหนอค่า initialize ตัวแปรยังไงก็ได้ (และใช่! ถ้าคุณคิดว่า syntax มันแปลกๆ ก็อาจจะต้องลองใช้ซักพักกว่าจะชิน)

Named constructors

ภาษา Dart ไม่สามารถทำ Method Overloading หรือการสร้างเมธอดหลายตัวแต่ต่าง parameter กันได้ (ตัวอย่างภาษาที่ทำได้คือ Java) เพราะDartมี Optional Parameter ให้ใช้งานแทน

แต่สำหรับคอนสตรักเตอร์ถ้าเราต้องการสร้าง constructor หลายๆ ตัวเอาไว้สำหรับการสร้างวัตถุหลายแบบ เราสามารถสร้างสิ่งที่เรียกว่า Named constructors ขึ้นมาใช้งานได้

class Point {
    int x, y;

    Point(this.x, this.y);

    Point.origin() {
        x = 0;
        y = 0;
    }
}

var point1 = Point(10, 20);
var point0 = Point.origin();

เช่นตัวอย่างนี้เราสร้าง origin ขึ้นมาเป็น constructor อีกหนึ่งตัว เวลาจะสร้าง object ก็สามารถเลือกได้ว่าจะใช้ default constructor แบบธรรมดาหรือใช้ origin ที่เราสร้างขึ้นมาเองก็ได้

Factory constructors

นอกจากเราจะสร้าง object จากการใช้ constructorแบบธรรมดาได้แล้ว ในภาษานี้ยังมีวิธีสร้าง object อีกแบบนั่นคือการใช้ factory ซึ่งใช้หลักการเดียวกับ Factory Pattern นั่นคือทำยังไงก็ได้ให้ตอบ object กลับไป

class Logger{
    String name;

    factory Logger(){
        return Logger.withName('MyLogger');
    }

    Logger.withName(this.name);

    void log(String message){
        print('LOG: $message');
    }
}

var logger1 = Logger();
var logger2 = Logger.withName('logger-2');

จะเห็นว่า factory นั้นทำงานเหมือนกับ constructor ทุกอย่างนั่นคือกำหนดและสร้าง object แต่ข้อแตกต่างคือ constructor จะกำหนดค่าให้ตัวเอง (เพราะตัว constructor ถือเป็นเมธอดตัวหนึ่งของ object อยู่แล้ว) แต่ว่า factory จะเป็นการสร้าง object ชิ้นใหม่ขึ้นมาเลย

⚠️ Warning!: สำหรับ constructor นั้นสามารถเรียกใช้ this ได้เพราะถือว่าเป็นส่วนหนึ่งของ object อยู่แล้ว แต่ factory นั้นจะไม่สามารถเรียกใช้ this ได้เลย

อาจจะประยุกต์เอาไปใช้กับ Singleton Pattern ก็ได้ มาดูตัวอย่างกัน

//logger.dart
class Logger{
    void log(String message){
        print('LOG: $message');
    }
}

//module1.dart
var logger = Logger();
logger.log('log from module 1');

//module2.dart
var logger = Logger();
logger.log('log from module 2');

สมมุติให้เรามีคลาส Logger (หน้าที่คือเอาไว้ปริ๊น log ข้อมูลนั่นแหละนะ) ปัญหาคือถ้าเราต้องการเอา Logger ไปใช้หลายไฟล์เราจะต้องสร้าง object ใหม่ทุกรอบ

ในกรณีนี้ ถ้าเราต้องการให้ Logger มี instance ได้แค่ตัวเดียวเท่านั้น เราอาจจะนำ Single Pattern มาใช้งาน แบบนี้

//logger.dart
class Logger{
    static Logger _instance;
    static Logger getInstance() => _instance ??= Logger();

    void log(String message){
        print('LOG: $message');
    }
}

//module1.dart
var logger = Logger.getInstance();
logger.log('log from module 1');

//module2.dart
var logger = Logger.getInstance();
logger.log('log from module 2');

ซึ่งก็สามารถทำได้ ไม่ผิดอะไร แต่เราสามารถเปลี่ยนไปใช้ factory แทนได้แบบนี้

//logger.dart
class Logger{
    static Logger _instance;
    factory Logger() => _instance ??= Logger();

    void log(String message){
        print('LOG: $message');
    }
}

//module1.dart
var logger = Logger();
logger.log('log from module 1');

//module2.dart
var logger = Logger();
logger.log('log from module 2');

สังเกตว่าคำสั่ง factory นั้นจะทำงานเหมือนกับ constructor แบบในกรณีนี้ ในmodule1และmodule2นั้นจะไม่รู้เลยว่าการที่เขาสร้าง object ขึ้นมานั้นจริงๆ ไม่ได้เป็นการสร้างใหม่ แต่เป็นการเรียกใช้จาก shared instance นั่นเอง

equals และการทำ Operator Overriding

สำหรับ object ในภาษาโปรแกรมส่วนใหญ่จะเก็บค่าแบบ reference (คือเก็บเป็น pointer ไม่ได้เก็บเป็น value) การจะเช็กว่า object เท่ากันไหมเลยไม่สามารถใช้ == ได้

เช่นถ้าเป็นภาษา Java ก็จะต้องใช้ equals แทน เช่น

String string1 = "Centrillion Tech";
String string2 = "Centrillion Tech";
string1 == string2 //maybe true or false
string1.equals(string2) //true

สำหรับภาษา Dart นั้นจะใช้การสร้าง equals ขึ้นมาก็ได้ แต่โดยมาตราฐานของภาษาจะใช้การ override แบบพิเศษ นั่นคือ "Operator Overriding" แทน โดยใช้คีย์เวิร์ด operator นำหน้า

class People {
    int id;
    String name;

    //แบบสร้าง equals
    bool equals(dynamic other) {
        if (other is! Person) return false;
        Person person = other;
        return (person.id == id &&
            person.name == name);
    } 

    //แบบ override ==
    @override
    bool operator ==(dynamic other) {
        if (other is! Person) return false;
        Person person = other;
        return (person.id == id &&
            person.name == name);
    } 
}

ซึ่งพอเรา override == ไปแล้วเราสามารถจับ object มาเช็กว่ามันเท่ากันรึเปล่าโดยใช้ == ตรงๆ ได้เลย

⚠️ Warning!: ตามหลักการของภาษาที่ใช้โครงสร้าง OOP แนวเดียวกับภาษา Java ทุกครั้งที่เราเขียน equals เราจะโดยบังคับให้เขียน hashCode ด้วยเสมอ ไม่งั้นเวลา object ไปใช้งานใน HashMap จะเกิดปัญกาขึ้นได้

class People {
    int id;
    String name;

    @override
    int get hashCode {
        int result = 17;
        result = 37 * result + id.hashCode;
        result = 37 * result + name.hashCode;
        return result;
    }

    //แบบ override ==
    @override
    bool operator ==(dynamic other) {
        if (other is! Person) return false;
        Person person = other;
        return (person.id == id &&
            person.name == name);
    } 
}

เอาจริงๆ คือถ้าต้องมาเขียนทั้ง equals และ hashCode เองทั้งหมดทุกคลาสน่ะ ไม่ไหวแน่ๆ ... ดังนั้นเราขอแนะนำให้คุณ generate code ด้วยฟีเจอร์ของ IDE แทนจะดีกว่านะ


เนื้อหา Class-Object ยังมีอีกมาก แต่ในเนื้อหาบทความนี้เลือกมาเฉพาะที่เห็นว่าแตกต่างจากภาษาอื่นๆ ในบทต่อไปเราจะมาพูดกันเรื่องการทำ Inheritance และ Abstract Class กัน

Series Navigation<< Dart 102: โครงสร้างข้อมูลพื้นฐาน List และ MapDart 104: การสืบทอดคลาส Inheritance และ Mixin >>

ในบทความที่เรา เราแนะนำการใช้งาน List ไปแล้ว ในบทความนี้จะมาพูดกันต่อถึงเมธอดที่ควรรู้สำหรับการใช้งาน List ให้ดี/ง่ายยิ่งขึ้นกัน

แต่ก่อนอื่น เราจะอธิบายคำศัพท์ที่จะใช้ในบทความนี้ซะก่อน

Immutable vs Mutable

เมธอดของ List แต่ละตัวมีรูปแบบการทำงานที่ไม่เหมือนกัน โดยจะแบ่งเป็น

  • Immutable - คำสั่งประเภทนี้จะไม่เปลี่ยนแปลง List (แต่ให้ผลลัพธ์ผ่านการ return)
  • Mutable - คำสั่งประเภทนี้จะเปลี่ยนค่าของ List ตรงๆ

ซึ่งในบทความนี้จะใช้สัญลักษณ์แบบนี้ 😤 Immutable | 😱 Mutable

Result Type

เมธอดแต่ละตัวมีการตอบผลลัพธ์ (return) กลับมาไม่เหมือนกัน แต่แบ่งได้เป็นประมาณนี้

  • List - ตอบกลับมาเป็น List เหมือนเดิม
  • Iterable - ตอบกลับเป็น Iterable (เป็นโครงสร้างแบบ abstract คือสามารถเอามาวนลูปเพื่อขอค่าทีละตัวได้ ซึ่งส่วนใหญ่ทำงานแบบ lazy นั่นคือถ้ายังไม่มีการเรียกใช้จะไม่ทำงาน ต่างจาก List ที่ทำงานทันที)
  • Scala - ตอบกลับมาเป็นค่าชนิดอื่นเลย เช่น int, bool, String
  • void - ไม่ตอบค่าอะไรกลับมาเลย

ซึ่งในบทความนี้จะใช้สัญลักษณ์แบบนี้ 🍇 List | 🍒 Iterable | 🍐 Scala | 🥚 void

หมวดการสร้าง

generate()

😤 Immutable | 🍇 List

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

List<int> list = List<int>.generate(10, (i) => i + 1);
// List [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

List Comprehension

😤 Immutable | 🍇 List

ซึ่งแทนที่จะใช้ .generate() เราสามารถสร้างลิสต์โดยใช้การวน for แทนก็ได้

List<int> list = [ for(var i=0; i<10; i++) i + 1 ];
// List [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

หมวดการคัดลอก/เพิ่ม/ลบ

form(), of()

😤 Immutable | 🍇 List

เป็น 2 คำสั่งที่ใช้สร้างลิสต์จากลิสต์อีกตัวหนึ่ง ซึ่งมีข้อแตกต่างกันเล็กน้อยนั่นคือถ้าเราสั่งด้วย from ผลที่ได้จะเป็นไทป์แบบ dynamic แต่ถ้าใช้ of ผลที่ได้จะได้ไทป์ของลิสต์ต้นฉบับมาด้วย

var list1 = new List.from(<int>[1, 2, 3]); 
// list1 is List<dynamic>
var list2 = new List.of(<int>[1, 2, 3]); 
// list2 is List<int>

List<String> list3 = new List.from(<int>[1, 2, 3]); 
// Ok until runtime.
List<String> list4 = new List.of(<int>[1, 2, 3]); 
// Compile Time Error!

add(), addAll()

😱 Mutable | 🥚 void

ใช้เพื่อเพิ่ม item ลงไปในลิสต์ โดยใช้ add สำหรับเพิ่ม item แบบทีละตัวและใช้ addAll สำหรับการเพิ่มหลายๆ ตัวในรูปแบบของ List หรือ Iterable

List<int> list1 = [1, 2, 3];
list1.add(4);
// list1 is [1, 2, 3, 4]

List<int> list2 = [1, 2, 3];
list2.addAll([4, 5]);
// list2 is [1, 2, 3, 4, 5]

followedBy()

😤 Immutable | 🍒 Iterable

เป็นการสร้างลิสต์ตัวใหม่โดยเอาลิสต์อีกตัวมาต่อกัน แต่จะไม่ใช่การ add หรือ addAll ที่เพิ่ม item เข้าไปในลิสต์ แต่เป็นการสร้างลิสต์ตัวใหม่ออกมาแทน

List<int> list1 = [1, 2, 3];
Iterable<int> list2 = list1.followedBy([4, 5]);

//list1 is List [1, 2, 3]
//list2 is Iterable (1, 2, 3, 4, 5)

แต่ใน Dart การต่อ List สามารถใช้การ + หรือ ... (spread operator) แทนได้

List<int> list1 = [1, 2, 3];
List<int> list2 = list1 + [4, 5];
//list1 is [1, 2, 3]
//list2 is [1, 2, 3, 4, 5]

List<int> list3 = [1, 2, 3];
List<int> list4 = [...list1, ...[4, 5], 6];
//list3: [1, 2, 3]
//list4: [1, 2, 3, 4, 5, 6]

หมวด map/filter/reduce

forEach()

😤 Immutable | 🥚 void

ใช้ในการวนลูป item ทุกตัวในลิสต์ในรูปแบบการสร้างฟังก์ชันขึ้นมา (ซึ่งมาผลเทียบกับการวน for แบบธรรมดานั่นแหละ)

var list = [1, 2, 3];
list.forEach((item) => print(item));

// เขียนแบบใช้ลูป
var list = [1, 2, 3];
for(var item in list){
    print(item);
}

map()

😤 Immutable| 🍒 Iterable

ใช้ในการแปลง item ทุกค่าในลิสต์ให้กลายเป็นอีกค่าหนึ่งด้วยวิธีการเดียวกันทั้งหมด โดยสร้างฟังก์ชันขึ้นมา

เช่น ในตัวอย่างข้างล่าง เราต้องการคูณเลขทุกตัวในลิสต์ด้วย 10 ก็ให้สร้าง (x) => x * 10 ฟังก์ชันที่รับตัวเลขเข้าไปหนึ่งตัวแล้วตอบค่านั้นเอาไปคูณด้วย 10 กลับมา

List<int> num1 = [1, 2, 3, 4, 5];

Iterable<int> num2 = num1.map((x) => x * 10);

List<int> num3 = num1.map((x) => x * 10).toList();

// num2 is Iterable (10, 20, 30, 40, 50)
// num3 is List [10, 20, 30, 40, 50]

// เขียนแบบใช้ลูป
List<int> num2 = <int>[];
for(var x in num1){
    num2.add(x * 10);
}

ข้อควรระวังคือ map รีเทิร์นค่ากลับมาเป็น Iterable ไม่ใช่ List ดังนั้นถ้าเราต้องการผลลัพธ์เป็นลิสต์เหมือนเดิมจะต้องสั่ง toList() อีกที

where() (หรือ filter)

😤 Immutable | 🍒 Iterable

ฟังก์ชัน where หรือในภาษาอื่นมักจะเรียกว่า filter มีไว้ทำการเลือกเฉพาะ item ที่ตรงกับเงื่อนไข โดยจะต้องสร้างฟังก์ชันประเภท predicate หรือฟังก์ชันที่ตอบค่าเป็น bool (true = เลือกไอเทมนี้, false = ไม่เอาไอเทมนี้)

เช่นหากเราต้องการเลือกเฉพาะตัวเลขที่เป็นเลขคู่ ก็ต้องสร้าง predicate function แบบนี้ (x) => x % 2 == 0

List<int> num1 = [1, 2, 3, 4, 5];

Iterable<int> num2 = num1.where((x) => x % 2 == 0);

List<int> num3 = num1.where((x) => x % 2 == 0).toList();

// num2 is Iterable (2, 4)
// num3 is List [2, 4]

// เขียนแบบใช้ลูป
var num1 = [1, 2, 3, 4, 5];
var num2 = <int>[];
for(var x in num1){
    if(x % 2 == 0){
        num2.add(x);
    }
}

เช่นเดียวกับ map คือ where รีเทิร์นค่ากลับเป็น Iterable

ส่วนกลับของ where คือ removeWhere ซึ่งจะลบตัวที่ตรงกับ predicate ทิ้งออกไปแทน

ข้อควรระวังคือ removeWhere เป็น Mutable นะ!

firstWhere() และ singleWhere()

😤 Immutable | 🍐 Scala

นอกจากนี้ ภาษาDartยังมีฟังก์ชันสำหรับเลือกค่าที่ตรงเงื่อนไขอีก 2 ตัวคือ firstWhere และ singleWhere ซึ่งจะตอบตัวเลขที่ตรงเงื่อนไขกลับมาแค่ตัวเดียวเท่านั้น

ข้อแตกต่างระหว่าง firstWhere และ singleWhere คือการใช้ singleWhere จะต้องมีค่านั้นเพียงตัวเดียวเท่านั้น ถ้ามีตัวที่ตรงกับเงื่อนไขหลายตัวจะเจอ Error: Bad state: Too many elements

List<int> list = [1, 2, 3, 4, 5];
int num = num1.firstWhere(
    (x) => x % 2 == 0,
    orElse: () => null
);

List<int> list = [1, 2, 3, 4, 5];
int num = num1.singleWhere(
    (x) => x % 2 == 0,
    orElse: () => null
);

reduce(), fold()

😤 Immutable | 🍐 Scala
reduce จะใช้ในกรณีที่เราต้องการรวม item ทุกตัวในลิสต์ให้ออกมาเป็นค่าใหม่ค่าเดียวเท่านั้นด้วยวิธีการอะไรบางอย่าง ซึ่งเราต้องเขียนขึ้นมาด้วยฟังก์ชันที่เรียกว่า combine

เช่นหากเราต้องการเอา item ทุกตัวในลิสต์มา + กัน ให้เขียน combine function ว่า (x, y) => x + y นั่นคือหากเรามีตัวเลขสองตัว ให้เอาสองตัวนั้นมาบวกกัน

List<int> list = [1, 2, 3, 4, 5];
int num = list.reduce((x, y) => x + y);

// num is 1 + 2 + 3 + 4 + 5
// num is 15

// เขียนแบบใช้ลูป
int num = list.first;
for(var x in list.skip(1)){
    num += x;
}

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

List<int> list = [1, 2, 3, 4, 5];
int num = [100, ...list].reduce((x, y) => x + y);
// num is 100+ 1 + 2 + 3 + 4 + 5

แต่ก็อย่างที่เห็นค่ามันไม่สวยเลย! ในเคสนี้เราสามารถเปลี่ยนจาก reduce ไปใช้ fold แทนได้ แบบนี้

List<int> list = [1, 2, 3, 4, 5];
int num = list.fold(100, (x, y) => x + y);
// num is 100+ 1 + 2 + 3 + 4 + 5

ลองมาดูตัวอย่างกันอีก

เช่นเราต้องการหาค่าที่มากที่สุดในลิสต์เราอาจจะเขียน combine แบบนี้

import 'dart:math';

List<int> list = [1, 2, 3, 4, 5];
int maxNum = list.reduce((x, y) => max(x, y));

และไม่จำเป็นจะต้องใช้กับลิสต์ของ int เท่านั้น สามารถเอาไปแอพพลายใช้งานกับ String ก็ยังได้ เช่นต้องการจะเชื่อมสตริง (concat) เข้าด้วยกันด้วย - ก็เขียนแบบนี้ได้

List<String> list = ['A', 'B', 'C'];
String str = list.reduce((x, y) => '$x-$y');
// str is "A-B-C"

หมวด Utility

length, isEmpty

🍐 Scala

length ใช้เพื่อเช็กว่าตอนนี้ในลิสต์มี item อยู่กี่ตัว ส่วน isEmpty ใช้เช็กว่าตอนนี้ item ในลิสต์ไม่มีเลยใช่หรือไม่

List<int> list = [1, 2, 3, 4, 5];
print(list.length);     // 5
print(list.isEmpty);    // false

sublist(), getRange()

😤 Immutable |
sublist() = 🍇 List, getRange() = 🍒 Iterable

ใช้สำหรับตัดลิสต์ย่อยออกมาโดยเราต้องกำหนด index ที่เริ่มและจบ (start, end) ข้อควรระวังคือ end จะไม่ถูกรวมอยู่ในคำตอบด้วย

List<int> list = [1, 2, 3, 4, 5];
list.sublist(1, 3);     // [2, 3]
list.getRange(1, 3);    // (2, 3)

first, last

🍐 Scala

ใช้ขอ item ตัวแรกสุดและตัวท้ายสุดของลิสต์ มีค่าเท่ากับ list[0] และ list[list.length - 1]

List<int> list = [1, 2, 3, 4, 5];
print(list.first);      // 1
print(list.last);       // 5

take(), skip(), takeWhile(), skipWhile()

😤 Immutable | 🍒 Iterable

คล้ายกับ getRange แต่เป็นการเลือก/ข้ามจาก head หรือหัวแถวของลิสต์แทน

สำหรับ takeWhile กับ skipWhile จะเป็นการสร้างฟังก์ชันเงื่อนไขขึ้นมาเช็กแทน

List<int> list = [1, 2, 3, 4, 5];

li.take(2)  // (1, 2)
li.skip(2)  // (3, 4, 5)

li.takeWhile((x) => x < 3)  // (1, 2)
li.skipWhile((x) => x < 3)  // (3, 4, 5)

contains(), any(), every()

🍐 Scala

ใช้สำหรับหาว่ามี item ที่ต้องการหรือไม่

สำหรับ any (ข้อแค่ตรงเงื่อนไขอย่างน้อย 1 ตัว) และ every (ไอเทมทุกตัวในลิสต์จะต้องตรงเงื่อนไขทั้งหมด) จะต้องสร้างฟังก์ชัน test ขึ้นมา

List<int> list = [1, 2, 3, 4, 5];

list.contains(3);               // true
list.any((x) => x % 2 == 0);    // true
list.every((x) => x % 2 == 0);  // false

sort()

😱 Mutable | 🥚 void

ใช้สำหรับเรียงข้อมูลในลิสต์

List<int> list = [4, 2, 1, 5, 3];
list.sort();

// list is [1, 2, 3, 4, 5]

ในกรณีที่ต้องการเรียงแบบอื่นเช่น descending list หรือลิสต์ที่เรียงจาก มาก -> น้อย แทนหรือต้องการ sort ลิสต์ของ object ที่ไม่ใช่ Primitive Type เราจะต้องสร้างฟังก์ชัน comparator ขึ้นมา โดยให้ยึดหลักการว่า ตัวแรก-ตัวที่สองเสมอ เช่น

List<int> list = [4, 2, 1, 5, 3];
list.sort((first, second) => -(first-second));
// นำมาลบกัน แต่ใส่ค่า - เอาไว้เพื่อให้เรียงจากมากไปน้อยแทน

class Score{
    int point;
    People(this.point);
}
var list = [Score(20), Score(5), Score(10))];
list.sort((first, second) => first.point - second.point);

shuffle()

😱 Mutable | 🥚 void

เมื่อมีการสั่งให้เรียงข้อมูลด้วย sort แล้วก็ต้องมีคำสั่งที่เป็นส่วนกลับกัน นั่นคือ shuffle หรือการสลับตำแหน่งลิสต์แบบ random (คล้ายสับกองการ์ดให้เรียงแบบมั่วๆ นั่นแหละ)

ซึ่งการสั่ง shuffle แต่ละครั้งก็จะให้ผลออกมาไม่เหมือนกัน เพราะมัน random นะ

List<int> list = [1, 2, 3, 4, 5];

list.shuffle();
// list is [4, 2, 1, 5, 3]

list.shuffle(); 
// call again!
// list is [3, 4, 5, 2, 1]

แต่ถ้าอยากให้ผลการสลับออกมาเหมือนเดิมเราสามารถใส่ seed ซึ่งเป็นอ็อบเจก Random ลงไปได้ โดยถ้า seed เป็นเลขเดิมจะได้ผลลัพธ์ออกมาเหมือนเดิม

import 'dart:math';

List<int> list = [1, 2, 3, 4, 5];

list.shuffle(Random(100));
// list is [2, 5, 1, 3, 4]

list.shuffle(Random(100));
// call again!
// list is [2, 5, 1, 3, 4] same result!

reversed

😤 Immutable | 🍒 Iterable

คำสั่งสำหรับสร้างลิสต์ตัวใหม่ที่กลับหัว item ทั้งหมดแทน

List<int> list = [1, 2, 3, 4, 5];
var re = list.reversed;

// list is List [1, 2, 3, 4, 5]
// re is Iterable [5, 4, 3, 2, 1]
This entry is part 2 of 5 in the series Dart-Series

โครงสร้างข้อมูลหรือ Data Structure เป็นสิ่งที่ต้องมีในทุกภาษาโปรแกรม เพราะในการเขียนโปรแกรมจริงๆ การที่เรามีแค่ variable ไม่พอจะที่จัดการข้อมูลที่มากมายและซับซ้อนในแอพของเรา

Data Structure: List และ Map

โครงสร้างข้อมูลใน Dart มี 2 ประเภทหลักๆ คือ

  • List: เก็บข้อมูลแบบ linear, มีค่าเท่ากับ array ในบางภาษา
  • Map: เก็บข้อมูลแบบ key-value, มีค่าเท่ากับ dictionary หรือ hash-table ในบางภาษา

List

การสร้างลิสต์ของข้อมูลขึ้นมาทำได้โดยการประกาศตัวแปรชนิด List

List ใน Dart ไม่ใช่โครงสร้างแบบ fixed-length นั่นคือสามารถเพิ่ม/ลบ element ในลิสต์ได้ผ่านคำสั่ง เช่น add(), remove(), removeAt()

List myList = [10, 20, 30, 40];

ซึ่งจะได้ค่าดังนี้

print(myList);      // [10, 20, 30, 40]

print(myList[0]);   // 10
print(myList[1]);   // 20
print(myList[2]);   // 30
print(myList[3]);   // 40

แต่ก็ตามสไตล์ภาษามี type ทั่วๆ ไปคือการสร้าง List โดยที่ไม่ระบุอะไรนั้นมีความเสี่ยงอย่างมาก! เพราะ compiler จะไม่รู้ว่า element แต่ละตัวข้างในเป็นตัวแปรชนิดอะไร จึงช่วยเราเช็กค่าไม่ได้เลย --> ต้องไปเสี่ยงดวงตอน runtime กันเอง

List myList = [1, 'hi', true];

int x    = myList[0];  // ok!
String s = myList[1];  // ok!
bool b   = myList[2];  // ok!

int y    = myList[1];  // Runtime Error! TypeError: String is not a subtype of type int

ในภาษา Dart มีฟีเจอร์ที่เรียกว่า Type Interface หรือ Generic ให้ใช้ นั่นคือการส่ง type เข้าไปใน class หรือ function ได้โดยใช้สัญลักษณ์ ``

List<int> myList = [10, 20, 30, 40];  
// แบบนี้ ok!

var myList = <int>[10, 20, 30, 40];  
// แบบนี้ก็ ok!

//แต่...

List<int> myList = [10, 20, 'hi'];
//Error: A value of type 'String' can't be assigned to a variable of type 'int'.
//  List<int> myList = [10, 20, 'hi'];
//                               ^

การประกาศ List โดยไม่มี Generic จะถูกมองว่าเป็น List นั่นคืออนุญาตให้เก็บตัวแปรชนิดอะไรก็ได้

loop

การวนลูปสามารถทำได้ด้วยวิธีใช้ตัว counter แบบดั้งเดิม และนับขนาดของลิสต์ด้วย .length

List<int> numbers = [10, 20, 30, 40];
for(var i = 0; i < numbers.length; i++){
    print(numbers[i]);
}

หรือใช้ for-in

List<int> numbers = [10, 20, 30, 40];
for(var number in numbers){
    print(number);
}

การเพิ่มหรือลบ item ออกจาก List

การเพิ่ม item ลงใน List จะใช้คำสั่ง

  • add - ใช้สำหรับเพิ่ม item ที่ตำแหน่งท้ายสุดของลิสต์
  • insert - ใช้สำหรับเพิ่ม item ลงที่ตำแหน่งตรงกลางลิสต์
var list = ['A', 'B', 'C'];

list.add('D');          // เพิ่ม D เข้าไปท้ายสุดของลิสต์
print(list);            // [A, B, C, D]

list.insert(0, 'E');    // เพิ่ม E เข้าไปที่ตำแหน่งที่ 0
print(list);            // [E, A, B, C, D]

list.insert(3, 'F');    // เพิ่ม F เข้าไปที่ตำแหน่งที่ 3
print(list);            // [E, A, B, F, C, D]

list.insert(6, 'G');    // เพิ่ม G เข้าไปที่ตำแหน่งที่ 6
print(list);            // [E, A, B, F, C, D, G]

ในภาษา Dart ไม่มีคำสั่ง pop ซึ่งเอาไว้เพิ่ม item เข้าไปที่ตำแหน่งแรกสุดของลิสต์ แต่เราสามารถใช้การ insert ในตำแหน่งที่ 0 แทนได้

แต่ถ้า item ที่เราต้องการเพิ่มลงไปเป็น List (หรือ Iterable) เราจะใช้คำสั่ง addAll และ insertAll แทน

var list = ['A'];

list.addAll(['B', 'C']);
print(list);            // [A, B, C]

list.insertAll(2, ['D', 'E']);
print(list);            // [A, B, D, E, C]

ส่วนการลบ item ออกจากลิสต์จะใช้คำสั่ง remove

List<String> list;

list = ['A', 'B', 'C', 'D', 'E'];
list.remove('A');
print(list);    // [B, C, D, E]

list = ['A', 'B', 'C', 'D', 'E'];
list.removeAt(0);
print(list);    // [B, C, D, E]

list = ['A', 'B', 'C', 'D', 'E'];
list.removeRange(1,3);
print(list);    // [A, D, E]

var numbers = [1, 2, 3, 4, 5];
numbers.removeWhere( (number) => number % 2 == 0 );
print(numbers);    // [1, 3, 5]

final List และ const List

เราสามารถสร้างลิสต์ที่ไม่สามารถเปลี่ยนแปลงค่าได้ด้วยการใช้ final และ const ซึ่งมีข้อแตกต่างกันคือ

var list = const [1, 2, 3];

// const List ไม่สามารถแก้ไขหรือเพิ่ม/ลดข้อมูลได้
list.add(4);        // Error!

// แต่เราสามมรถกำหนดค่าให้ตัวแปร list ใหม่ได้
list = [5, 6, 7];

ส่วน final นั้นจะเป็นแบบนี้

final list = [1, 2, 3];

// final ถูกเซ็ตที่ตัวแปร List ไม่ใช่ค่าของลิสต์
list.add(4);        // Ok!

// แต่เราไม่สามมรถกำหนดค่าใหม่ให้ตัวแปร list ใหม่ได้
list = [5, 6, 7];   // Error!

และเราสามารถสร้างลิสต์ที่เป็นทั้ง final และ const ได้

final list = const [1, 2, 3];

Spread Operator

เราสามารถใช้ ... เพื่อแยกลิสต์ได้ หากใช้เขียน JavaScript มาก่อนน่าจะคุ้นกับคำสั่งนี้

var list1 = [1, 2, 3, 4];
var list2 = [0, ...list1, 5];
//list2 is [0, 1, 2, 3, 4, 5]

และเพราะตัวแปรใน Dart สามารถเป็น null ได้ เราสามารถใช้คำสั่ง null-aware spread operator ได้

var list1;
var list2 = [0, ...?list1, 5];
//list2 is [0, 5]

นอกจากนี้เรายังสามารถใช้คำสั่ง if และ for ในลิสต์ได้ด้วย

var nav = [
  'Home',
  'Furniture',
  'Plants',
  if (promoActive) 'Outlet'
];

var listOfInts = [1, 2, 3];
var listOfStrings = [
  'number 0',
  for (var i in listOfInts) 'number $i'
];

Map

map เป็น Data Structure อีกตัวหนึ่ง ซึ่งเป็นแบบ key-value (คล้ายๆ List ที่สามารถกำหนดชื่อของ index ได้)

Map data = {'id': 1, 'name': 'Ann'};
print(data['id']);      // 1
print(data['name']);    // Ann

และเหมือนกับ List คือถ้าเราไม่ได้กำหนดชนิดข้อมูลมันจะถูกกำหนดอัตโนมัติให้เป็น Map

ดังนั้นคำแนะนำในการใช้ Map คือควรกำหนดชนิดของ key และ value ทุกครั้ง

Map<String, int> score = {'A': 100, 'B': 200};
//หรือ
var score = <String, int>{'A': 100, 'B': 200};

การเพิ่มหรือลบ item ออกจาก Map

การเพิ่ม item ลงใน Map นั้นทำง่ายกว่า List เพราะเราสามารถกำหนดค่าลงไปตรงๆ ได้เลย

Map<String, int> data = {'A': 100};
data['B'] = 200;

ส่วนการลบข้อมูลออกเราจะใช้คำสั่ง remove ซึ่งจะใช้การกำหนดด้วย key ไม่ใช่ value

Map<String, int> data = {'A': 1, 'B': 2};
data.remove('A');
print(data);    // {B: 2}

หรือถ้าต้องการตั้งเงื่อนไขการลบเองก็สามารถใช้คำสั่ง removeWhere

Map<String, int> data = {
    'A': 1, 
    'B': 2, 
    'C': 3,
};
data.removeWhere((key, value){
    return value==2 || key=='A';
});
print(data);    // {C: 3}

final Map และ const Map

เหมือนกับ List นั่นคือเราสามารถเราสามารถกำหนด const Map ซึ่งจะไม่สามารถเปลี่ยนแปลงค่าอะไรได้เลย

final constantMap = const {
  2: 'helium',
  10: 'neon',
  18: 'argon',
};

.map() method

เมธอด map เป็นคนละตัวกันคลาส Map นะ เป็นเมธอดที่ใช้ได้กับทั้ง List และ Map เลย

วิธีการใช้ map คือหากเราต้องการจะแปลงค่าทุกค่าใน List หรือ Map ให้เป็นอีกค่าหนึ่ง เช่นถ้าเรามี items ตัวหนึ่งที่ต้องการเอาค่าทุกค่าไป x 10

จากปกติที่เราเขียนกันแบบใช้ลูปธรรมดา

List<int> items = [1, 2, 3, 4];
List<int> items10;
for(var item in items){
    items10.add(item * 10);
}

// items10 is [10, 20, 30, 40]

ก็สามารถเขียนเป็นแบบนี้ได้

List<int> items = [1, 2, 3, 4];
List<int> items10 = items.map((item) => item * 10).toList();

// items10 is [10, 20, 30, 40]

เวลาเขียนให้สร้างฟังก์ชันสำหรับบอกว่า ถ้าเรามี item หนึ่ง จะแปลงให้มันเป็นค่าอะไรเท่านั้นพอ

แต่ map ของ List นั้นไม่ได้รีเทิร์นค่ากลับมาเป็น List แต่ให้ค่ากลับมาเป็น Iterable นั่นคือถ้าเราต้องการให้มันกลับมาเป็น List จะต้องใช้คำสั่ง toList() ต่ออีกที

ส่วนถ้าเป็น Map อาจจะใช้ยากขึ้นหน่อยเพราะจะต้องเขียนทั้ง key และ value และต้องตอบค่ากลับมาเป็น MapEntry แทน

Map<String, int> data = {
    'A': 1, 
    'B': 2, 
    'C': 3,
};

Map<String, int> data2 = data.map((key, value){
    return MapEntry('#$key', value * 10);
});

// data2 is {#A: 10, #B: 20, #C: 30}

Convert List-Map

เราสามารถแปลง List --> Map ได้โดย

List<String> data = ['A', 'B', 'C'];

Map<int, String> m = data.asMap();
// m is {0: A, 1: B, 3: C}

ซึ่ง key ของ Map ที่ได้ออกมาจะเป็น int และได้ค่ามาจาก index ของ List นั้นๆ

ส่วนการแปลง Map --> List นั้นง่ายกว่าแต่เราจะต้องเลือกว่าจะเอา List of key หรือ List of value

Map<String, int> m = {'A': 10, 'B': 20, 'C': 30};

m.keys      // [A, B, C]
m.values    // [10, 20, 30]
Series Navigation<< Dart 101: ทำความรู้จักภาษา Dart ฉบับโปรแกรมเมอร์Dart 103: มารู้จัก Class และ Object สไตล์ภาษาDartกันเถอะ >>
This entry is part 1 of 5 in the series Dart-Series

ปี 2011 กูเกิลได้เปิดตัวภาษาโปรแกรมตัวใหม่ชื่อว่าภาษา Dart (เวอร์ชันแรก)

โครงสร้างของภาษา DART คล้ายกับ C/C++ และ Java โดยที่จะมีความเป็นภาษาแบบ Structure Programming แต่ก็ยังมีความสามารถแบบภาษาประเภท Object Oriented Programming ด้วย นั่นคือมี class และ inheritance ให้ใช้งาน

เป้าหมายของการสร้างภาษา Dart ขึ้นมา กูเกิลบอกว่าอยากสร้างภาษาเชิงโครงสร้างที่ยืดหยุ่นมากพอ (structured yet flexible language) และเป็นการออกแบบตัวภาษาไปพร้อมกับตัว Engine สำหรับรันภาษาเลยเพื่อแก้ปัญหาโปรแกรมทำงานช้าและกินmemory ซึ่งเป้าหมายของภาษา Dart คือเป็นภาษาที่เรียนรู้ง่าย และทำงานได้บนอุปกรณ์พกพาขนาดเล็ก มือถือ ไปจนถึงserver

ซึ่งสิ่งที่เด่นที่สุดสำหรับภาษา Dart ในตอนนี้คือเป็นภาษาที่ใช้ในการสร้าง Application ด้วยเฟรมเวิร์ก Flutter นั่นเอง!

ในตอนนี้ Dart มีออกมาแล้ว 2 เวอร์ชันคือ Dart1 และ Dart2, ในบทความนี้จะพูดถึง Dart2 เป็นหลัก

หากต้องการลองเล่นภาษา Dart ดู สามารถเข้าไปดาวน์โหลดตัว installer ได้ที่ dart.dev
หรือลองเขียนภาษา Dart แบบ online เลยได้ที่ DartPad

Hello World!

ตัวอย่างโปรแกรมของ Dart นั้นหน้าตาคล้ายๆ กับภาษา C มาก ถ้าใครเคยเขียนภาษา C หรือภาษาตระกูล C มาก่อน (เช่น C++, C#, Java) จะคุ้นกับ syntax พวกนี้ทำให้เรียนรู้ได้ไม่ยาก

Dart เป็นภาษากลุ่ม Compiler นั่นคือจำต้อง Compile ก่อนเอาโปรแกรมไปรัน ไม่เหมือนภาษากลุ่ม Script ที่ใช้ interpreter ในการรันตัว source code ตรงๆ

void main(){
    print("Hello World!");
}

ตัวโปรแกรมจำเริ่มทำงานที่ฟังก์ชัน main เป็นหลัก เราไม่สามารถเขียน statement นอกฟังก์ชันได้

การแสดงผลมาตราฐานจะใช้คำสั่ง print (คำสั่งนี้ auto-newline เสมอนะ)

เรื่องหนึ่งที่ควรจำคือภาษา Dart นั้นการเขียน ; (semi-colon) ไม่ใช่ optional คือจำเป็นต้องใส่ ; ทุกครั้งหลังจบ statement ไม่สามารถละ ; ได้แบบภาษาตระกูล C ยุคใหม่ๆ เช่น JavaScript หรือ Kotlin

Comment

การใส่คอมเมนท์ทำได้เหมือนภาษา C ทุกอย่างคือ

  1. // สำหรับ inline comment
  2. เปิดด้วย /* และปิดด้วย */ สำหรับ multi-line comment (ไม่สามารถ nested ได้นะ)
int x; //ตั้งแต่ตรงนี้ไป เป็นส่วนของคอมเมนท์

/*
    ในนี้
    ทั้งหมด
    เป็นคอมเมนท์
*/

Variable และ Data Type

type คำอธิบาย ตัวอย่าง
int เลขจำนวนเต็ม 0, 1, -5, 86400
double เลขทศนิยม 0.0, 0.1, 0.14, -12.34
num เลขทศนิยม หรือ เลขทศนิยม 123, 0.123
bool ค่าทางตรรกศาสตร์ true, false
String สายอักขระ (ประโยค) 'hello world!', "This is a book" <-- ในภาษา Dart สามารถใช้ได้ทั้ง " (double quote) และ ' (single quote) แต่เขาแนะนำให้ใช้ ' หรือ single quote กันนะ
dynamic ตัวแปรชนิดเปลี่ยนแปลงได้ 1, 0.14, true, 'Hi!'

ตัวแปรของ Dart ทั้งหมดเป็นแบบ reference type ทั้งหมด ทำให้สามารถมีค่าเป็น null ได้ทั้งหมด

int x;
double d;
bool isDone;
String name;

//ตัวแปรทั้งหมดมีค่าเป็น null เพราะยังไม่ได้กำหนดค่า

แต่ใน Dart ยังมีชนิดของตัวแปรแบบพิเศษ ซึ่งไม่จำเป็นต้องประกาศ type เลย แต่ตัวภาษาจะ auto assign ชนิดของตัวแปรให้เอง

type คำอธิบาย
var เป็นการละ type เอาไว้ให้โปรแกรมกำหนดให้ (ตาม value)
final เหมือน var แต่ไม่สามารถเปลี่ยนแปลงค่าได้
const ค่าคงที่

ข้อแตกต่างระหว่าง dynamic vs var คือ

dynamic เป็นการบอกว่าตัวแปรนี้ เก็บค่าชนิดไหนก็ได้ เปลี่ยนแปลงได้เรื่อยๆ (หากใครเคยเขียนภาษา script น่าจะคุ้นกัน) แน่นอนว่าการใช้ dynamic มีความเสี่ยงทำให้เกิด runtime error! ได้เพราะ Compiler ไม่สามารถช่วยเช็กชนิดของตัวแปรได้เลย

var จะเป็นการกำหนดชนิดตัวแปรในจังหวะที่ประกาศตัวแปร โดยดูชนิดตัวแปรจาก value ตอนนั้นเลย หลังจากนั้นตัวแปรจะถูกกำหนดเป็น type นั้นไปตลอด ไม่สามารถเปลี่ยนแปลงได้แล้ว

dynamic d = 1;      // ตอนนี้ค่า d เก็บค่า int
d = 'new value!';   // Ok! ค่า d เปลี่ยนไปเก็บค่า String แทน
d = true;           // Ok! ค่า d เปลี่ยนไปเก็บค่า bool แทน

var v = 1;          // สร้างตัวแปร v ซึ่ง value ในด้านขวาเป็น int ดังนั้นจะมีผลเท่ากับการเขียนว่า int v = 1; นั่นเอง
v = 'new value';    // Error: A value of type 'String' can't be assigned to a variable of type 'int'

ข้อแตกต่างระหว่าง final vs const คือ

final เป็นการกำหนดว่าตัวแปรนี้ ไม่สามารถเปลี่ยนแปลงค่าได้ กำหนดค่าแล้วกำหนดเลย (immutable) ซึ่งเป็นตัวแปรประเภท runtime ดังนั้นเราสามารถกำหนดค่า final จากตัวแปรหรือฟังก์ชันอื่นได้

const เป็นการประกาศค่าคงที่ โดยค่าที่กำหนดให้จะต้องเป็น literal เท่านั้น (เช่น 10, 'value') เพราะเป็นตัวแปรที่กำหนดค่าตั้งแต่ตอน compile-time

int x = 10;

final int f1 = 1;       //กำหนดตัวแปร int ให้เป็นค่าคงที่
final f2 = 'final-val'; //ใช้เหมือน var คือไม่กำหนด type ก็ได้
final f3 = x + 20;      //กำหนดค่าจากกโดยคำนวณมาจากตัวแปรอื่นอีกที

const int c1 = 1;       //กำหนดตัวแปร int ให้เป็นค่าคงที่
const c2 = 'const-val'; //ใช้เหมือน var คือไม่กำหนด type ก็ได้
const c3 = x + 20;      //Error: Not a constant expression. เพราะ x เป็นตัวแปรที่ value มาตอน runtime ไม่สามารถกำหนดให้ const ได้

Math Operation

การใช้ +, -, *, / และ % เหมือนกับภาษาอื่นๆ แต่มีข้อควรระวังที่ตัว /

สำหรับภาษาอื่นถ้าเรานำ int / int ผลที่ออกมาจะได้เป็น int แน่นอน แต่สำหรับ Dart นั้นการหารจะได้ค่าออกมาเป็น double เสมอ

int x = 4 / 2;        // Error: A value of type 'double' can't be assigned to a variable of type 'int'.

int y = (int)(4 / 2); // Error: case แบบภาษา C ไม่ได้ด้วยนะ

วิธีการแก้คือใช้ operation ~/ คือการหารแล้วปัดจุดทิ้ง หรือใช้คำสั่ง as หรือจะใช้คำสั่ง toInt() ก็ได้

int x = 4 ~/ 2;             // Ok! แบบนี้ได้
int x = 4 / 2 as int;    // Ok! แบบนี้ได้
int x = (4 / 2).toInt();    // Ok! แบบนี้ได้

คำเตือน! ระวังสับสนกับคำสั่ง int.parse() กับ int.tryParse() ที่ใช้แปลง String --> int, เราไม่สามรถใช้ 2 คำสั่งนี้ในการแปลง double --> int ได้นะ

String Concatenate การต่อสตริง

การต่อสตริงใช้เครื่องหมาย + เหมือนภาษาทั่วๆ ไป แต่ก็มีข้อควรระวัง (อีกแล้ว!) คือไม่สามารถต่อสตริงกับตัวแปรที่ไม่ใช่สตริงได้!

int x = 100;
print('x is ' + x);     // Error: A value of type 'int' can't be assigned to a variable of type 'String'.

เราจะต้องแปลงตัวแปรที่ต้องการจะต่อสตริงให้เป็น String ซะก่อน หรือทำ String Interpolation ซะก่อน โดยใช้ตัว $ เพื่อระบุว่าตรงนี้เป็นตัวแปร (ถ้ามี expression ด้วยให้ครอบด้วย ${})

int x = 100, y = 200;
print('x is ' + x.toString());     // x is 100
print('x is $x');                  // x is 100
print('x is ${x}');                // x is 100

print('x + y is ${x + y}');        // x + y is 300

Null Handling

ตัวแปรใน Dart เป็นแบบ reference ดังนั้นเลยสามารถเป็นค่า null ได้ทุกตัวเลย ภาษาDartเลยมี operation สำหรับจัดการค่า null พวกนี้มาให้เราใช้งานด้วย

?? Null Coalescing

เป็นการเช็กว่าตัวแปรตัวนี้ ถ้ามีค่าเป็น null ให้ใช้ค่า default ที่กำหนดให้แทน

output = input ?? defaultValue;

// เป็น short-hand ของ...

if (input != null) {
    output = input;
} else {
    output = defaultValue;
}

เช่น

int number = ...;

int x = number ?? 1; // กำหนด x = number แต่ถ้า number เป็น null ให้กำหนด x = 1 แทน

?. Null Conditional

หากตัวแปรของเราเป็น object ซึ่งสามารถเรียกใช้งาน method ต่างๆ ได้ ... แต่ถ้า object ตัวนั้นเป็น null ก็จะเกิดปัญหา Null Pointer Exception ได้

object?.action();

// เป็น short-hand ของ...

if (object != null) {
    object.action();
}

เช่น

class People{
    void sayHi(){ print("hi!"); }
}
void main(){
    People people = ...;
    people?.sayHi();    // ถ้า people เป็น object ก็จะมีการปริ้นค่า "hi!" ออกมา แต่ถ้า people เป็น null คำสั่งนั้นก็จะไม่ถูกสั่งให้ทำงานเลย
}

??= Null Coalescing Assignment

หากไม่ชัวร์ว่าตัวแปรตัวนั้นเป็น null รึเปล่า สามารถใช้ ??= กำหนดค่า default ลงไปได้

variable ??= defaultValue

// เป็น short-hand ของ...

variable = variable ?? defaultValue;

// หรือใช้ Ternary Operator

variable = variable != null ? variable : defaultValue;

// หรือเขียนแบบ if-else

if (input == null) {
    output = defaultValue;
}

Flow Control

if-else

if (condition) {
    // TODO
} else {
    // TODO
}

switch case

switch (command) {
  case 'PENDING':
    executePending();
    break;
  case 'APPROVED':
    executeApproved();
    break;
  case 'DENIED':
    executeDenied();
    break;
  default:
    executeUnknown();
}

ข้อควรระวัง! switch ในภาษา Dart ต้องมี break ตอนจบ case ทุกครั้ง ถ้าไม่ใส่ลงไป โปรแกรมจะไม่หยุดทำงาน แล้วรันคำสั่งบรรทัดต่อไปต่อเลย

Loop: while, do-while

while (!isDone()) {
  doSomething();
}

do {
  printLine();
} while (!atEndOfPage());

เมื่อภาษาทั่วๆ ไป มีตัวcontrolเสริมคือ break และ continue ให้ใช้งานด้วย

Loop: for

for (var i = 0; i < 5; i++) {
  print(i);
}
// output: 0 1 2 3 4

หรือใช้งานแบบ for-each สำหรับวนลูปทุก element ใน list

var numbers = [0, 1, 2, 3, 4];
for (var number in numbers) {
  print(number);
}
// output: 0 1 2 3 4

Function

การสร้างฟังก์ชันในภาษา Dart มี syntax เหมือนภาษา C แต่สามารถละ type ทิ้งไปได้

เช่น

int add(int x, int y) {
    return x + y;
}

// สามารถเขียนย่อได้ว่า

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

Arrow Function

และหากเคยเขียนภาษา JavaScript มา มีหลายครั้งที่เราสร้างฟังก์ชันที่มี return statement เดียวเท่านั้น เราก็สามารถเขียนย่อโดยใช้ Arrow Function ได้ ... และแน่นอน Dart ก็ทำได้เหมือนกัน โดยใช้ =>

int add(int x, int y) {
    return x + y;
}

// สามารถเขียนย่อได้ว่า

add(x, y) => x + y;

Optional Parameter

เราสามารถกำหนดค่าเริ่มต้นให้ parameter ได้โดยใช้ [] ครอบ parameter ที่อยากประกาศให้เป็น optional

int add(int x, [int y = 1]) {
    return x + y;
}

add(10, 20);    // result: 30
add(10);        // ไม่เซ็ตค่า y, ดังนั้น y = 1 result: 11

Named Parameter

บางกรณี การสร้างฟังก์ชันที่มี parameter เยอะมาก ตอนที่เรียกใช้ฟังก์ชันอาจจะงงเรื่องลำดับตัวแปรได้

int setConfig(
    String basePath,
    String appPath,
    int retry,
    int maxThread,
    String defaultController
) {
    // TODO
}

setConfig("/", "/app", 10, 4, "Main");

ในกรณีนี้เราสามาร้ถตั้งชื่อ parameter แต่ละตัวได้ โดยใช้ {}

int setConfig({
    String basePath,
    String appPath,
    int retry,
    int maxThread,
    String defaultController
}) {
    // TODO
}

setConfig(
    basePath: "/", 
    appPath: "/app", 
    retry: 10, 
    maxThread: 4, 
    defaultController: "Main"
);

ซึ่งตัว parameter ทั้งหมด สามารถสลับตำแหน่งกันได้

ข้อควรระวัง! ตอนประกาศฟังก์ชันต้องมี {} ครอบตัวแปร แต่ตอนเรียกใช้งานฟังก์ชัน ห้ามใส่ {} ลงไปนะ

การใช้งาน Named Parameter จะถือว่าเป็น optional ทั้งหมด (แปลว่าไม่ใส่ค่าก็ได้) ซึ่งก็จะได้ค่าเป็น null

หากต้องการให้เวลาเรียกใช้งานฟังก์ชัน จำเป็นต้องใส่ค่านั้นลงไปเสมอจะต้องใช้ annotation @required เข้ามาช่วย

ซึ่ง @required นั้นอยู่ใน package ชื่อ meta ที่ต้องติดตั้งเพื่อก่อนจะใช้งาน โดยการเพิ่ม dependency ในไฟล์ pubspec.yaml

dependencies:
  meta: ^1.1.8

เวลาใช้งานก็...

import 'package:meta/meta.dart';

int setConfig({
    @required String basePath,
    @required String appPath,
    int retry,
    int maxThread,
    String defaultController
}) {
    // TODO
}

แบบนี้หมายความว่า parameter basePath และ appPath นั้นจำเป็นต้องใส่ทุกครั้งที่เรียกใช้งานฟังก์ชัน

First Class Function

ตามสไตล์ภาษาสมัยใหม่ เราสามารถจับฟังก์ชันใส่ตัวแปรได้

int getNumber() => 123;

void main(){
    var func = getNumber;   // ไม่ใช่ getNumber() นะ,ไม่มี () 
    print(func());          // output: 123
}

หรือเราจะกำหนดว่าตัวแปรฟังก์ชันจะเป็น type อะไรและมี parameter อะไรบ้างก็ได้

โดยใช้รูปแบบการกำหนด type ดังนี้

return-type Function(params-type)

void func1(){ ... }
int func2(){ ... }
String func3(int x){ ... }

void main(){
    void Function() f1 = func1;
    int Function() f2 = func2;
    String Function(int) f3 = func3;
}

และยังใช้ได้กับ method อีกด้วย เช่น

class People{
    String sayHi() => "Hi!";
}

void main(){
    People p = People();
    String Function() f = p.sayHi;
    print(f());     // output: Hi!
}

สรุป

บทความนี้ได้แนะนำภาษา Dart ซึ่งเราจะเห็นว่าภาษานี้นั้นให้อารมณ์เหมือนภาษาตระกูล C ที่มีการปรับอะไรให้เป็นภาษาสมัยใหม่มากขึ้น แต่ก็ยังไม่ทิ้งความเป็น Structure Language อยู่

หากใครเขียน C หรือหรือแม้แต่ภาษา(ที่เคย)โมเดิร์นอย่าง Java มาก่อน จะพบว่ามันมีความคล่องตัวในการเขียนมากขึ้น แต่ถ้าเอาไปเทียบกับภาษายุคใหม่อย่างเช่น Kotlin, Swift ก็ยังเรียกว่าสู้ไม่ได้ อาจจะมีอะไรขัดใจอยู่หลายอย่างเวลาเขียน

สรุปคือเป็นภาษาที่ค่อนข้างธรรมดา ไม่ได้ Wow! อะไรมาก แต่เราต้องใช้มัน หากต้องการจะต่อยอดไปเขียน Flutter ต่อไป

ในบทต่อไปเราจะมาดู Data Structure ในภาษา Dart กันต่อ

Series NavigationDart 102: โครงสร้างข้อมูลพื้นฐาน List และ Map >>