Dart 105: มันวนซ้ำได้นะ! มาสร้าง Generator และใช้งาน Iterable กันเถอะ

ในบทก่อนๆ เรารู้จักตัวแปรประเภท 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

15 Total Views 3 Views Today
Ta

Ta

สิ่งมีชีวิตตัวอ้วนๆ กลมๆ เคลื่อนที่ไปไหนโดยการกลิ้ง .. ถนัดการดำรงชีวิตโดยไม่โดนแสงแดด
ปัจจุบันเป็น Senior Software Engineer อยู่ที่ Centrillion Technology
งานอดิเรกคือ เขียนโปรแกรม อ่านหนังสือ เขียนบทความ วาดรูป และ เล่นแบดมินตัน

You may also like...