บทความทั้งหมด

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

เนื้อหาในบทนี้ เป็นคลาสเฉพาะที่มากับ Flutter Framework เท่านั้น .. ถ้าเขียน Dart ธรรมดาจะไม่มีให้ใช้นะ!!

เอาจริงๆ 99% ของคนที่ศึกษาภาษา Dart ก็เพื่อเอาไปเขียนแอพ (หรือเว็บ/เด็กส์ท็อป) แบบ cross-platform ด้วยเฟรมเวิร์ก Flutter นั่นแหละ

ตามความคิดของเรา จริงๆ Flutter น่าจะเลือกภาษา Kotlin มาใช้แทนมากกว่า แต่ก็มีเหตุผลหลายๆ อย่างนั่นแหละที่ทำให้ทำไม่ได้

สำหรับการเขียน Flutter นั้น ส่วนประกอบในแต่ละส่วนของแอพนั้นจะพูดออกแบบมาเป็น Component เอามาประกอบเข้าด้วยกันเป็นชั้นๆ เรียกว่า "Widget"

มี Widget อยู่ 2 ประเภทหลักๆ คือ

  • StatelessWidget: เป็น Widget ที่ไม่สามารถเปลี่ยนแปลงได้หลังจาก render ไปแล้ว
  • StatefulWidget: เป็น Widget ที่สามารถ render หน้าตา UI ของแอพใหม่ได้ถ้ามีการเปลี่ยนแปลงข้อมูล

ในบทความนี้เราจะโฟกัสกันที่ StatefulWidget เป็นหลักนะ หน้าตาของ StatefulWidget ก็จะเป็นประมาณนี้ (StatefulWidget จะมาพร้อมกับคลาส State ของมันเสมอ)

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key}) : super(key: key);

  @override
  MyHomePageState createState() => MyHomePageState();
}

class MyHomePageState extends State<MyHomePage> {

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Text('This is my App.'),
    );
  }
}

เมธอดหลักที่ทำหน้าที่ในการกำหนดส่วนแสดงผลหรือ UI คือเมธอด build()

แน่นอน เวลาเราเขียนแอพจริงๆ โครงสร้างมันจะเป็นการเอา Widget มาซ้อนกันหลายๆๆๆ ชั้นมากๆ เช่น

Widget build(BuildContext context) {
  return Scaffold(
    body: Center(
      child: Column(
        children: [
          Container(
            child: Row(
              children: [
                Text('This is my App.'),
              ]
            ),
          ),          
        ],
      ),
    ),
  );
}

ทีนี้ ปัญหามันก็อยู่ตรงนี้แหละ เพรายิ่งโครงสร้างซ้อนกันหลายชั้นมากๆ ถ้ามีการเปลี่ยนแปลงข้อมูล ก็ต้อง render หน้านี้ใหม่ (เรียกคำสั่ง build() ใหม่) แม้ว่าเราจะเปลี่ยนแปลงข้อมูลแค่ไม่กี่จุดเล็กๆ ก็ตาม

ลองมาดูตัวอย่างแอพมาตราฐานคือ Counter กัน (แอพตัวอย่างนี้เป็นแอพที่เวลาเราสั่ง new Flutter project มันจะสร้างให้เป็น default เลย)

แต่เราจะขอตัดมาแค่ส่วนที่จะใช้อธิบายนะ และขอแยก Widget ออกเป็นชิ้นๆ ด้วย

class MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    print('render - Scaffold');
    return Scaffold(
      body: columnWidget(),
    );
  }

  Widget columnWidget(){
    print('render - Column Widget');
    return Column(
      children: [
        incrementButtonWidget(),
        counterWidget(),
      ],
    );
  }

  Widget incrementButtonWidget(){
    print('render - Increment Button Widget');
    return RaisedButton(
      child: incrementButtonTextWidget(),
      onPressed: _incrementCounter,
    );
  }

  Widget incrementButtonTextWidget(){
    print('render - Increment Button Text Widget');
    return Text('Increment');
  }

  Widget counterWidget(){
    print('render - Counter Widget');
    return Text('count is $_counter');
  }
}

เมื่อเราเปิดหน้าแอพขึ้นมาครั้งแรก เราจะพบว่า build() เริ่มทำงาน โดยมันจะเรนเดอร์ Widget ทุกชิ้นขึ้นมาก่อน

render - Scaffold
render - Column Widget
render - Increment Button Widget
render - Increment Button Text Widget
render - Counter Widget

จากนั้น เมื่อเรากด increment จะทำให้เมธอด _incrementCounter() ทำงาน ไปทำการ setState() เพื่อเปลี่ยนค่า _counter

ผลที่ได้คือเมื่อ state เปลี่ยนไป ทั้งหน้าจะต้องทำการเรนเดอร์ใหม่อีกครั้ง เมธอด build() ก็ต้องเริ่มทำงานใหม่ตั้งแต่ต้นอีกครั้ง ทำให้เราได้ผลแบบนี้ออกมาอีกที

render - Scaffold
render - Column Widget
render - Increment Button Widget
render - Increment Button Text Widget
render - Counter Widget

แต่จริงๆ แล้วส่วนที่มีการเปลี่ยนแปลงจริงๆ มีแค่วิดเจ็ต Text('count is $_counter') เท่านั้น

ทางทีม Flutter ของ Google เลยแนะนำมาว่าการเขียนแอพแบบนี้จะทำให้ประสิทธิภาพไม่ดี ถ้ามีข้อมูลแปลี่ยนเป็นบางตัว เราควรจะเรนเดอร์ใหม่เฉพาะวิดเจ็ตที่จำเป็นน่าจะดีกว่า

นั่นเลยเป็นที่มาของวิดเจ็ตพิเศษที่ชื่อว่า FutureBuilder และ StreamBuilder

FutureBuilder / StreamBuilder

มีวิธีการสร้างแบบนี้

FutureBuilder(
    future: _future,
    builder: (BuildContext context, AsyncSnapshot snapshot) {
        return ...
    },
)

StreamBuilder(
    stream: _stream,
    builder: (BuildContext context, AsyncSnapshot snapshot) {
        return ...
    },
)

ส่วนวิธีการใช้งานนั้นง่ายมาก คือวิดเจ็ตตัวไหนที่สามารถเรนเดอร์ใหม่ได้เฉพาะส่วน ให้เอา FutureBuilder ไม่ก็ StreamBuilder ครบมันลงไป แบบนี้

แล้วเมื่อไหร่จะใช้ Future / Stream

ถ้าข้อมูลของเราเปลี่ยนแปลงได้แค่ครั้งเดียว เราจะใช้ FutureBuilder เช่นต้องการโหลดข้อมูลจาก API แค่ครั้งเดียว

แต่ถ้าข้อมูลของเราเปลี่ยนได้เรื่อยๆ มากกว่า 1 ครั้ง เราจะใช้ StreamBuilder เช่นการกดปุ่มที่กดได้มากกว่า 1 ครั้ง

แน่นอนว่าเราสามารถใช้ StreamBuilder แทน FutureBuilder ได้แทบจะทุกกรณีเลย

ทีนี้ลองเอา StreamBuilder มาใช้กับโค้ด Counter ดูบ้าง

ในเคสนี้ เราเปลี่ยน Text ตรงที่แสดงตัวนับให้กลายเป็น StreamBuilder จากนั้นสร้าง StreamController ขึ้นมาหนึ่งตัว ซึ่งจะอัพเดทค่า counter แทนการสั่ง setState()

class MyHomePageState extends State<MyHomePage> {

  int _counter = 0;
  StreamController<int> controller = new StreamController<int>();

  @override
  void initState(){
    super.initState();
    controller.add(_counter);
  }

  @override
  void dispose(){
    super.dispose();
    controller.close();
  }

  void _incrementCounter() {
    //แทนที่จะใช้ setState ก็เซ็ตค่าผ่าน StreamController แทน
    controller.add(++_counter);
  }

  @override
  Widget build(BuildContext context) {
    print('render - Scaffold');
    return Scaffold(
      body: columnWidget(),
    );
  }

  Widget columnWidget(){
    print('render - Column Widget');
    return Column(
      children: [
        incrementButtonWidget(),
        counterWidget(),
      ],
    );
  }

  Widget incrementButtonWidget(){
    print('render - Increment Button Widget');
    return RaisedButton(
      child: incrementButtonTextWidget(),
      onPressed: _incrementCounter,
    );
  }

  Widget incrementButtonTextWidget(){
    print('render - Increment Button Text Widget');
    return Text('Increment');
  }

  Widget counterWidget(){
    return StreamBuilder(
      stream: controller.stream,
      builder: (context, snapshot){
        print('render - Counter Widget');
        return Text('count is ${snapshot.data}');
      },
    );
  }
}

ในกรณีนี้วิดเจ็ตทั้งหมดจะมีการเรนเดอร์แค่ครั้งแรกครั้งเดียว หลังจากนั้นถ้ามีการกดปุ่ม วิดเจ็ตตัวอื่นที่ไม่เกี่ยวข้องด้วย จะไม่มีการเรนเดอร์ใหม่ เรนเดอร์เฉพาะตัวของ StreamBuilder เท่านั้น

render - Scaffold
render - Column Widget
render - Increment Button Widget
render - Increment Button Text Widget
render - Counter Widget
render - Counter Widget
render - Counter Widget
render - Counter Widget

จะเห็นว่าวิธีนี้ทำให้ประสิทธิภาพของแอพเพิ่มขึ้นเยอะมาก เพราะไม่จำเป็นต้องเรนเดอร์ทั้งหน้าใหม่ทุกครั้งที่มีข้อมูลเปลี่ยนแปลงเพียงเล็กน้อย

ซึ่งก็แลกมากับการที่เราต้องเขียนโค้ดเยอะขึ้นล่ะนะ (ฮา)

AsyncSnapshot

ในการใช้ทั้ง FutureBuilder และ StreamBuilder จะมีสิ่งที่เรียกว่า AsyncSnapshot ส่งมาให้ตัว เจ้าตัวนี้เป็นเหมือนกับตัวที่บอกว่าตอนนี้ข้อมูลของเรามีสถานะเป็นยังไงบ้างแล้ว

// สถานะของ future/stream ในตอนนั้น
snapshort.connectionState

// มี error เกิดขึ้นไหม
snapshop.hasError
// error คืออะไร
snapshop.error

// ได้รับ data มาแล้วรึยัง
snapshop.hasData
// data คืออะไร
snapshop.data

ConnectionState

สำหรับ Future

  • waiting: ขณะกำลังรอข้อมูล
  • done: เมื่อได้รับข้อมูลมาแล้ว

สำหรับ Stream

  • waiting: ขณะกำลังรอข้อมูล
  • active: เมื่อได้รับข้อมูลมาแล้ว แต่ stream ยังไม่ close
  • done: เมื่อสั่งให้ stream close
(BuildContext context, AsyncSnapshot snapshot) {

  // เช็กก่อนว่ามี error มั้ย
  if(snapshot.hasError){
    return Text('เกิดข้อผิดพลาดในการโหลดข้อมูล ${snapshot.error}');
  }

  // ถ้าไม่มี ตอนนี้โหลดข้อมูลเป็นยังไงบ้างแล้ว
  switch(snapshort.connectionState){
      // ข้อมูลยังไม่มา กำลังโหลดอยู่
      case ConnectionState.waiting:
          return Text('กำลังโหลดข้อมูลอยู่');

      // ข้อมูลมาเรียบร้อยแล้ว แสดงผลได้
      case ConnectionState.done:
      case ConnectionState.active:
          return Text('ข้อมูลคือ ${snapshot.data}');
  }
}

ทิ้งท้าย...

การใช้ FutureBuilder / StreamBuilder ใน Flutter เป็นสิ่งที่ช่วยเพิ่มประสิทธิภาพให้ตัวแอพไม่ต้องเรนเดอร์วิดเจ็ตที่ไม่ได้เปลี่ยนแปลงซ้ำๆ

ซึ่งคอนเซ็ปนี้จะถูกเอาไปใช้ต่อในแพทเทิร์นที่ทาง Google แนะนำมาให้ใช้กับการวางโครงสร้าง UI ใน Flutter ที่ชื่อว่า BLoC หรือ Business Logic Component ซึ่งเดี๋ยวเราจะเอามาสอนกันต่อในบทความซีรีส์ Flutter ต่อไป

ส่วนบทความซีรีส์ Async in Dart ก็ขอจบลงแค่ตอนนี้ก่อนล่ะ

Series Navigation<< Async in Dart (4) ควบคุมข้อมูลในstreamอย่างเหนือชั้นด้วย StreamController

ในโลกของ OOP เรามักจะสร้างคลาสต่างๆ ที่มีการเรียกใช้กันต่อเป็นทอดๆ มากมาย

เช่นโค้ดข้างล่างนี่

class Car {

    private Engine engine;

    public Car() {
        this.engine = new Engine();
    }

}

Car car = new Car();

เราสร้างคลาส Car และ Engine คือรถและเครื่องยนต์ขึ้นมา โดยการจะสร้างรถได้ เราจะต้องสร้างเครื่องยนต์ขึ้นมาด้วย

ในกรณีแบบนี้เราเรียกว่าคลาส Car depend on Engine (Car ต้องการ Engine) หรือแปลว่า เราไม่สามารถสร้างคลาส Car ได้ถ้าไม่มีคลาส Engine หรือ Engine นั้นเป็น "Dependency" ของคลาส Car นั่นเอง

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

เช่นถ้าเราต้องการจะเปลี่ยน Engine ไปเป็น EcoEngine เครื่องยนต์แบบอีโค่เราจะต้องแก้โค้ดในคลาส Car เท่านั้น

วิธีการแก้แบบมาตราฐานก็คือถ้าเราต้องการใช้อ็อบเจคจากคลาสอื่น อย่าnewเอง แต่ให้รับอ็อบเจคเข้ามาแทน อาจจะทาง constructure แบบนี้ก็ได้

class Car {

    private Engine engine;

    public Car(Engine engine) {
        this.engine = engine;
    }

}

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

Engine engine = new Engine();
Car car = new Car();

ถ้ามี Dependencies ไม่เยอะก็ยังไม่เท่าไหร่ แต่ถ้าเยอะๆ นี่ก็มีความปวดหัวเหมือนกัน

เช่น ถ้าเราต้องการสร้างบ้านขึ้นมา แต่บ้านก็ต้องมี ประตู หน้าต่าง กำแพง บลาๆๆ

Door door = new Door();
Window window = new Window();
Wall wall = new Wall();
Ceil ceil = new Ceil();
Floor floor = new Floor();
House house = new House(door, window, wall, ceil, floor);

ซึ่งถ้าเราย้ายโค้ดพวกนี้เข้าไปไว้ในคลาส การจะสร้าง House ขึ้นมาก็จะเหลือแค่นี้

House house = new House();

DI Service แบบคร่าวๆ

หลักการ DI (หรือ Dependency Injection) แบบคร่าวๆ คือเราจะไม่ new อ็อบเจคในคลาสแบบที่เล่าไปข้างต้น แต่เพราะวิธีการนี้มันยุ่งยากกว่า คนส่วนใหญ่เลยชอบสร้างคลาสกันแบบ new ตรงๆ ในคลาสเลย ไม่ทำการรับอ็อบเจคเข้ามาทาง Constructure

ดังนั้นเลยมีคนเห็นปัญหานี้ ในเมื่อ DI เป็นคอนเซ็ปที่ดี แต่เขียนยุ่งยากมาก ... จะดีกว่าถ้าเราสามารถสร้างอ็อบเจคที่มี dependencies เต็มไปหมดด้วยการสั่ง new ง่ายๆ

DI Service คือตัวช่วยที่ว่า ซึ่งเป็นเหมือนตัวที่จะคอยจัดการและสร้างอ็อบเจคให้เราแทน

ซึ่งการใช้งานส่วนใหญ่จะแบ่งออกเป็น 2 เฟส นั่นคือการกำหนดค่าว่า Dependencies แต่ละตัวมีวิธีสร้างยังไง หลังจากนั้นคือการสั่งให้มันสร้างอ็อบเจคกลับมาให้เรา

// Setup
DIService di = ...
di.set<Engine>(new Engine());

// Create Object
Car car = di.newObject<Car>();

แต่เอาจริงๆ บางครั้ง ในบางภาษา การจะใช้งาน DI Service พวกนี้ก็ยุ่งยากกว่าการสร้างเองตรงๆ ซะดี (ฮา) ตัวอย่างเช่น Dagger2 สำหรับภาษา Java

แต่..เอ๊ะ! นี่มัน Service Locator ตั้งหาก

ในบางภาษา หากเราเสิร์จหา library เพื่อมาช่วยเราทำ DI Service เราอาจจะเจอตัวที่ใช้แพทเทิร์นแบบ Container

แบบนี้

class Car {

    private Engine engine;

    public Car() {
        this.engine = Container.get<Engine>();
    }

}

นั่นคือ แทนที่จะรับอ็อบเจคเข้ามาผ่าน Constructure ตรงๆ แต่จะใช้วิธีการขอจากสิ่งที่เรียกว่า Container แทน (ไม่ได้ new เองนะ แต่ขออ็อบเจคมาจากคอนเทนเนอร์)

วิธีใช้งานก็คล้ายๆ กับ DI Service นั่นแหละ คือต้องทำการกำหนดค่า dependencies ซะก่อน แล้วถึงจะเรียกใช้งานได้

// Setup
Container.set<Engine>(new Engine());

// Create Object
Car car = new Car();

เอาล่ะ! ถึงผลลัพธ์จะออกมาเหมือนกันเป๊ะ! แถมคนส่วนใหญ่ยังเรียกวิธีการทั้ง 2 ตัวนี้ว่า DI ทั้งคู่อีกตั้งหาก

แต่เนื่องจากโค้ดมันเขียนไม่เหมือนกัน ดังนั้นเขาเลยตั้งชื่อแยกมันออกเป็น 2 แพทเทิร์น นั่นคือ "Dependency Injection" และ "Service Locator"

ข้อแตกต่างล่ะ?

Service Locator

  • คอนเซ็ปคือคลาสเป้าหมาย ขออ็อบเจคจาก Service Locator ซึ่งจะไปตามหามาให้
  • คลาสเป้าหมายเรียกใช้ Service Locator
  • มีสิ่งที่เรียกว่า Container สำหรับเก็บอ็อบเจค
  • คลาสจะขออ็อบเจคจาก Container แทนการ new ขึ้นมาเอง
  • ใช้งานและเข้าใจได้ง่าย เพราะอยากได้อ็อบเจคอะไรก็ขอเอาจาก Container ตรงๆ ได้เลย (ส่วนใหญ่ Container จะเป็นแบบ static คือเรียกจากตรงไหนในโปรแกรมเราก็ได้)
  • ปัญหาคือมันจะทำให้มี dependency เพิ่มขึ้นมาในระบบเราอีก 1 ตัวคือ Container นั่นแหละ แถมคลาสแทบทุกคลาสในระบบเรายัง depend on เจ้าคอนเทนเนอร์นี้ซะอีก
  • ถ้าเกิดอยากเปลี่ยน library สำหรับทำ Service Locator จะต้องแก้โค้ดแทบจะทั้งโปรแกรม!

Dependency Injection

  • คอนเซ็ปคือ DI Service จะเช็กดูว่าคลาสเป้าหมาย เวลาจะสร้างต้องการ dependencies อะไรไป ก็จะไปหามาให้ แล้วเอาไป new อ็อบเจคเป้าหมายขึ้นมา
  • คลาสเป้าหมายไม่ได้เรียกใช้ DI Service, แต่ DI Service เป็นฝ่ายที่จะ new อ็อบเจคขึ้นมาเอง
  • ใช้วิธี inject อ็อบเจคเข้าไปตรงๆ
  • สำหรับคลาสเป้าหมายจะไม่ต้องเปลี่ยนอะไรมากนัก เพราะ dependencies ทั้งหมดถูกส่งเข้ามาทาง Constructure อยู่แล้ว
  • เขียนเทสได้ง่ายโดยการสร้าง mock dependencies ขึ้นมาเองไม่ต้องผ่าน DI Service เลยก็ยังได้

เขียนเทสยังไง?

จริงๆ เป้าหมายของการทำ DI ทั้งหมดคือเพื่อเอามาเขียนเทสหรือการทดสอบโปรแกรมได้ง่ายๆ นะ

class Car {

    private Engine engine;

    public Car(Engine engine) {
        this.engine = engine;
    }

}

Car testCar = new Car(new TestEngine());

ซึ่งในเคสนี้ Service Locator จะ mock อ็อบเจคต่างๆ ได้ยากมากเพราะเป็นการเรียกใช้งานแบบ static คือเรียกตรงๆ ที่ Container ในคลาสเลย

ดังนั้นคำแนะนำสำหรับคนใช้แพทเทิร์น Service Locator คืออย่าเรียก Container ตรงๆ แต่ให้ inject Container นี้เข้าไปแทน

แบบนี้

class Car {

    private Engine engine;

    public Car(Container container) {
        this.engine = container.get<Engine>();
    }

}

เพราะจะทำให้เราสร้าง container สำหรับเทสอ็อบเจคขึ้นมาได้

// Develop
Container container = new Container();
container.set<Engine>(new Engine());

Car car = new Car(container);

// Testing
Container testContainer = new Container();
testContainer.set<Engine>(new TestEngine());

Car testCar = new Car(testContainer);
This entry is part 4 of 5 in the series Async-in-Dart-Series

Async in Dart (4) ควบคุมข้อมูลในstreamอย่างเหนือชั้นด้วย StreamController

ในบทที่แล้วเราสอนการสร้าง Stream แบบง่ายๆ ไปแล้ว แต่ในบางครั้งเราต้องการควบคุมข้อมูลใน Stream แบบคัสตอมมากๆ ซึ่งข้อมูลอาจจะเข้ามาจากหลายทางมากๆ การที่เรามีแค่ yield จากฟังก์ชันเดียวอาจจะไม่เพียงพอ

ดังนั้นเลยเป็นที่มาของคลาสที่ชื่อว่า StreamController ซึ่งเป็นคลาสที่เอาไว้ควบคุม Stream อีกที

StreamController

การสร้าง Stream แบบธรรมดาก็จะเป็นประมาณนี้

Stream<int> getNumberStream() async* {
    ...
}

Stream<int> numberStream = getNumberStream();

ส่วนใหญ่เราจะสร้าง Stream จากฟังก์ชันประเภท async* ซึ่งภายในฟังก์ชันก็จะมีการ yield ข้อมูลกลับมา

ทีนี้ ถ้าเราจะเปลี่ยนไปใช้ StreamController แทน ก็จะเป็นแบบนี้

StreamController<int> controller = StreamController<int>();

นั่นคือ จากปกติที่เราจะสร้างข้อมูลในฟังก์ชันเท่านั้น การสร้างด้วย StreamController คือเราไม่ต้องกำหนดว่า Stream ตัวนี้จะสร้างอะไรเลย (แค่ new ตัวคอนโทรเลอร์ขึ้นมาเฉยๆ ก็พอแล้ว)

StreamController ไม่ใช่ Stream นะ ไม่สามารถเอาไปใช้แทน Stream ได้ แต่สามารถดึง Stream ออกมาจากตัวมันได้

ให้คิดง่ายๆ ว่ามันคือ Controller ที่ถือ Stream เอาไว้ข้างในอีกทีก็ได้

สำหรับอ็อบเจค StreamController ที่เราสร้างขึ้นมา จะมี Stream อยู่ข้างใน เราสามารถดึงออกมาใช้งานได้ด้วยการสั่ง .stream ตรงๆ เลย

Stream stream = controller.stream;
stream.listen((value) {
    print('value: $value');
});

//หรือ

controller.stream.listen((value) {
    print('value: $value');
});

แล้วข้อมูลจะมาจากไหน? ใช้คำสั่ง add ยังไงล่ะ

StreamController นั้นไม่ได้เป็นฟังก์ชัน ดังนั้นมันจะไม่มีโลจิคการสร้างข้อมูลอะไรอยู่กับตัวมันทั้งนั้น!

StreamController เป็นแค่ตัวกลางสำหรับใส่ข้อมูลเข้า Stream เท่านั้น

import 'dart:async';

var controller = StreamController<int>();

void main() {
    controller.stream.listen((x) {
        print(x);
    });

    ...
}

void addNumberToStream(int x) {
    controller.add(x);
}

จากโค้ดด้านบน เรามีการสร้าง StreamController ขึ้นมา 1 ตัว แล้วจัดการ listen() มันใน main (listen ไว้ก่อน ยังไม่มีข้อมูลอะไรเข้ามาทั้งนั้น)

ทีนี้ เราก็ไปสร้างฟังก์ชันอีกตัวหนึ่งชื่อ addNumberToStream(int x) ซึ่งจะมีการเพิ่มค่าเข้า Stream ผ่านคำสั่ง add()

เมื่อฟังก์ชัน addNumberToStream(int x) ถูกเรียก StreamController ก็จะส่งค่านั้นไปให้ listener ที่รอค่าอยู่นั่นเอง

เราสามารถส่งข้อมูลกี่ครั้งก็ได้ผ่านคำสั่ง add()

ส่วนใหญ่ use case ที่เราต้องใช้ StreamController แทนการสร้างฟังก์ชัน async* แบบธรรมดาจะเป็นการเขียนโปรแกรมแบบ Event-Driving เพราะเราไม่รู้ว่าข้อมูลจะเข้ามาเมื่อไหร่ ตอนไหนที่ผู้ใช้จะกดปุ่ม อะไรแบบนั้น เราเลยสร้างฟังก์ชันเตรียมไว้ก่อนไม่ได้ ทำได้แค่ให้ controller คอยส่งข้อมูลมาให้เมื่อเกิด Event เท่านั้น

คำเตือน! StreamController มักจะไม่รู้จุดสิ้นสุด!

การใช้งาน Stream ธรรมดา เราสามารถเปลี่ยนจากการใช้ callback ด้วย listen() ไปใช้ await เพื่อเปลี่ยนโค้ดให้อยู่ในรูปแบบ sync แทนได้

Stream<int> numberStream = getNumberStream();

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

ซึ่งมีข้อควรระวังในการใช้ await คือ Stream ตัวนั้นจะต้องเป็น "Finite Stream" หรือสตรีมที่รู้ว่ามีจุดสิ้นสุดแน่ๆ !

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

import 'dart:async';

var controller = StreamController<void>();

void main() {

    print('start');

    await for(var _ in controller.stream) {
        print('clicked!');
    }

    print('end');

    ...
}

void onClick() {
    controller.add(null);
}

ในกรณีแบบนี้ โค้ดจะถูกบล็อกอยู่ที่ลูป (โปรแกรมจะไม่หลุดไปยังคำสั่งปริ้น end เลย) เพราะไม่มีการปิดสตรีม

ดังนั้นถ้าฟังก์ชันเรามีคำสั่งให้ทำอะไรบ้างอย่างต่อไป เราไม่ควรเขียนมันในรูป await นะ ใช้ listen() จะดีกว่า

ยังมี addError และ close อีกนะ

นอกจากการ add ข้อมูลเข้า Stream แล้วเรายังสามารถสั่งอีก 2 คำสั่งกับคอรโทรเลอร์ได้ คือการโยน Exception หรือสั่งปิด Stream นั่นเอง

import 'dart:async';

var controller = StreamController<int>();

void main() {
    controller.stream.listen(
        (x) {
            print(x);
        },
        onDone: () {
            print('จบแล้วจ้า!');
        },
        onError: (e){
            print('error is $e');
        },
        cancelOnError: false,
    );

    print('start');
    controller.add(1);
    controller.add(2);
    controller.add(3);
    controller.addError(Exception('ตู้มมม'));
    controller.add(4);
    controller.add(5);
    controller.close();
    print('end');
}

output:

start
end
1
2
3
error is Exception: ตู้มมม
4
5
จบแล้วจ้า!

การสั่ง addError ไม่ได้ทำให้ Stream หยุดทำงานนะ ไม่งั้นต้องเพิ่มอ็อบชัน cancelOnError: true ลงไปด้วย (ถ้าไม่เซ็ต ดีฟอลต์จะเป็น false คือถึงเจอ Exception ฉันก็ไม่หยุดนะ)

ข้อสังเกตอีกอย่างคือ listen() นั้นเป็น Isolates นะ มันจะปริ้น start และ end (อยู่ใน Isolates ของ ฟังก์ชัน main) ก่อน

ใครงงว่า Isolates คืออะไร ย้อนกลับไปอ่านบทความแรกของซีรีส์นี้อีกทีนะ

สุดท้าย การใช้งาน StreamController นั้นมีอีกเรื่องที่ต้องระวัง คือ...

Stream อาจจะไม่ได้ถูก subscription ในทันที

ลองดูตัวอย่างโค้ดนี้

Stream<int> counter1(int maxCount) {
    var controller = StreamController<int>();
    int counter = 0;

    Timer.periodic(Duration(seconds: 1), (Timer timer) {
        counter++;
        controller.add(counter);
        if (counter >= maxCount) {
            timer.cancel();
            controller.close();
        }
    });

    return controller.stream;
}

เราตั้งให้ฟังก์ชัน counter1 ทำการ add ตัวเลขเข้าไปเรื่อยๆ ทุกๆ 1 วินาที (ฟังก์ชัน Timer.periodic() เอาไว้สำหรับสั่งงานที่ทำงานซ้ๆ กันทุกๆ ช่วงเวลาหนึ่ง คล้ายๆ กับ setInterval ใน JavaScript)

เวลาใช้งาน ก็ใช้ listen() ตามปกติหรือใช้ for loop แล้วเติม await ลงไปก็ได้

void main() {
    //start!
    var counterStream = counter1(5);

    //listen
    counterStream.listen((n){
        print(n);
    });
}

แบบนี้ถ้าเอาไปรัน ก็จะได้ output เป็นเลข 1, 2, 3, 4, 5 ตัวใน 5 วินาที ... อันนี้มาตราฐาน ตามคอมมอนเซ้นส์ ไม่มีอะไรแปลก

แต่ถ้าเรามีการสั่ง delay ก่อนที่เราจะ listen() แบบนี้

void main() {
    //start!
    var counterStream = counter1(5);

    //waiting 2 sec.
    await Future.delayed(const Duration(seconds: 2));

    //listen
    counterStream.listen((n){
        print(n);
    });
}

คำถามคือถ้าเขียนโค้ดแบบนี้ เราคาดหวังว่า output จะเป็นยังไง?

  • โปรแกรมก็น่าจะหยุดรอ 2 วินาที
  • แล้วก็ค่อยๆ ปริ้นเลข 1, 2, 3, 4, 5 ทีละวิ

แต่มันไม่ใช่แบบนั้นน่ะสิ!

การทำงานของโปรแกรมคือหลังจาก delay 2 วินาทีแล้ว มันจะทำการปริ้นเลข 1, 2 ออกมาในทันที (ดูรูปข้างล่างประกอบนะ)

สำหรับการใช้งาน Stream นั้นมันจะทำงานทันที แม้ว่าจะยังไม่มีใครมารอ listen มันเลยก็ตาม

แบบในเคสนี้ เราเรียกฟังก์ชัน counter1() ให้ทำงาน แล้วทำการ delay มันซะ 2 วินาทีแล้วค่อย listen แต่ตัว counter นั้นดันทำงานก่อนไปแล้ว ไม่ได้เริ่มทำงานตอนที่เรา listen

แต่ถ้าเราต้องการให้มันทำงานเมื่อสั่ง listen จังหวะนั้นเลย (แบบ counter2 ในรูปข้างบน) เราจะต้องเซ็ตค่าตอนสร้าง StreamController

ในการสร้าง StreamController เราสามารถกำหนดอ็อบชันเพิ่มได้คือ

  • onListen
  • onPause
  • onResume
  • onCancel

วิธีการก็คือเราจะต้องสร้าง Stream ที่ตอนแรกยังไม่เริ่มนับ counter แล้วสั่งให้มันเริ่มนับตอน onListen นั่นเอง

Stream<int> counter2(int maxCount) {
    StreamController<int> controller;
    Timer timer;
    int counter = 0;

    //ฟังก์ชันสำหรับเริ่มรับเลข
    void startTimer() {
        timer = Timer.periodic(
            Duration(seconds: 1), 
            (_) {
                counter++;
                controller.add(counter);
                if (counter == maxCount) {
                    timer.cancel();
                    controller.close();
                }
            },
        );
    }

    //ฟังก์ชันสำหรับหยุดนับเลข
    void stopTimer() {
        if (timer != null) {
            timer.cancel();
            timer = null;
        }
    }

    controller = StreamController<int>(

        //เริ่มทำงาน startTimer() เมื่อ listen นะ
        onListen: startTimer,

        onPause: stopTimer,
        onResume: startTimer,
        onCancel: stopTimer,
    );

    return controller.stream;
}
Series Navigation<< Async in Dart (3) ตอบค่าเป็นกระแสข้อมูลด้วย StreamAsync in Dart (5) รู้จักกับ FutureBuilder/StreamBuilder, ตัวช่วยสร้าง Async-Widget ใน Futter >>

Deno เป็นเอ็นจิน runtime ที่เอาไว้รันภาษา JavaScript (และ TypeScript) คล้ายๆ กับ V8-Engine ที่ใช้ใน Node.js

สร้างโดย Ryan Dahl ที่เป็นผู้เริ่มโครงการ Node.js ตั้งแต่ปี 2009
ตอนแรกพัฒนาด้วยภาษา Go แต่เปลี่ยนไปเขียนด้วยภาษา Rust แทนในภายหลัง (V8 ใช้ C++)

ฟีเจอร์เด่นๆ ของ Deno

  • Support TypeScript: นี่น่าจะเป็นข้อดีอันดับต้นๆ เลยล่ะ เพราะตามกฎการเขียน JavaScript ที่กล่าวไว้ว่า "คุณไม่ควรเขียน JavaScript!...ให้เขียน TypeScript แทน" (ฮา)
  • Security by default: แอพพลิเคชันที่รัน จะไม่สามารถเข้าถึงระบบ File System, Network, หรือพวก Environment ได้เลยถ้ายังไม่มีการกำหนดสิทธิตอนรันให้ เช่นต้องใส่ flag --allow-net เพิ่ม
  • มี build-in utilities ให้ใช้ เช่น dependency inspector, code formatter
  • สำหรับ standard modules นั้นมีกลุ่ม reviewer คอยเช็กความถูกต้องและเทสเพื่อการันตีว่ามันทำงานได้เวิร์คจริงๆ นะ

มี Node.js อยู่แล้ว จะสร้าง Deno มาทำไม?

Ryan Dahl ผู้สร้าง Deno บอกเอาไว้ว่า
JavaScript นั้นเปลี่ยนไปมากหลังจาก Node.js ถูกสร้างขึ้นตั้งแต่ปี 2009
อารมณ์ก็คล้ายๆ กับภาษา PHP ที่ไม่ได้ออกแบบโครงสร้างที่ดีมาตั้งแต่ต้น พอจะต่อเติมอะไรเข้าไปเพิ่มก็ทำได้ยาก

เช่น Module System นั้นออกแบบมาไม่ดีตั้งแต่แรก, มี API เก่าที่ควรเลิกใช้ได้แล้วแต่ก็ยังต้องทำงานอยู่, ความปลอดภัย

Ryan Dahl บอกว่าถึงแม้ Deno มันจะหน้าตาดูคล้ายๆ Node.js แต่มันถือว่าเป็นคนละตัวกันเลยนะ ตอนนี้ Deno ยังใหม่มาก อาจจะยังไม่เหมาะกับการใช้งานในบางโปรเจค โดยเฉพาะโปรเจคที่ยังต้องพึ่ง library จาก npm อยู่

installation

เข้าไปดูจาก https://deno.land/ ได้เลย

Hello World

ตัวอย่างการสร้าง server แบบง่ายๆ จะเห็นว่ามันเป็น JavaScript นั่นแหละ แต่มันไม่ใช่ Node.js

import { serve } from "https://deno.land/[email protected]/http/server.ts";

const s = serve({ port: 8000 });
console.log("http://localhost:8000/");

for await (const req of s) {
  req.respond({ 
    body: "Hello World\n" 
  });
}

Deno เพิ่งออกเวอร์ชัน 1.0 ตอนนี้ก็ต้องรอดูกันต่อไป ว่าไดโนเสาร์ตัวนี้จะมาแทน Node.js ได้ในอนาคตหรือไม่?

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()){
        tield 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 กันเถอะ >>