3bugs logo

Flutter Projects EP.1 เกมทายเลข - Console App

header-image

ภาษาที่ใช้เขียนโค้ดใน Flutter คือภาษา Dart ดังนั้นในบทนี้เราจะมาทำความคุ้นเคยกับภาษานี้กันก่อน ด้วยการสร้างแอพ ประเภท console application (คอนโซล แอพพลิเคชัน) ซึ่งหมายถึงแอพที่ผู้ใช้ต้องใช้งานผ่าน command line หรือก็คือแอพที่มีส่วนติดต่อผู้ใช้เป็นแบบ text ล้วน (command-line interface - CLI)

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

เนื้อหาในบทนี้จะทำให้ผู้อ่านได้เรียนรู้ฟีเจอร์พื้นฐานที่สำคัญของภาษา Dart โดยที่ยังไม่มี Flutter เข้ามาเกี่ยวข้อง จากนั้นในบทถัดไปเราจะนำแอพเกมทายเลขนี้มาปรับให้เป็น Flutter application ที่สามารถรันได้บน mobile device, web browser ฯลฯ ซึ่งจะทำให้แอพมีความน่าสนใจยิ่งขึ้น

TIP

สามารถดูหรือดาวน์โหลด source code ที่สมบูรณ์ของแอพในบทนี้ได้ที่ bit.ly/4bK5mbI

สร้างโปรเจค

  1. ให้สร้าง Dart project ใหม่ชื่อ guess_the_number โดยพิมพ์คำสั่งที่ command line ดังนี้
dart create guess_the_number

คำสั่งนี้จะสร้างโปรเจค ซึ่งก็คือโฟลเดอร์ชื่อ guess_the_number ขึ้นมาภายในโฟลเดอร์ปัจจุบัน

  1. ทำการ cd (change directory) เข้าไปยังโฟลเดอร์ guess_the_number แล้วเปิดโฟลเดอร์ guess_the_number ขึ้นมาใน VS Code ด้วยคำสั่ง code .
cd guess_the_number
code .

ภายในโฟลเดอร์ guess_the_number มีไฟล์ที่สำคัญ 3 ไฟล์

  • ไฟล์ bin/guess_the_number.dart
  • ไฟล์ lib/guess_the_number.dart
  • ไฟล์ pubspec.yaml

ไฟล์ bin/guess_the_number.dart

ไฟล์นี้มี top-level function ชื่อ main() ซึ่งเป็น entry point ของแอพ หมายถึงเมื่อรันแอพ การทำงานจะเริ่มต้นจากฟังก์ชัน main() นี้

ภาพ 1-1

NOTE

Top-level function หมายถึงฟังก์ชันที่ไม่ได้เป็นส่วนหนึ่งของคลาส คือไม่ได้ถูกกำหนด (define) ไว้ภายในคลาส ทำให้เรียกใช้ได้โดยไม่ต้องสร้าง instance ของคลาสขึ้นมาก่อน

ไฟล์ lib/guess_the_number.dart

โฟลเดอร์ lib ใช้เก็บไฟล์ต่างๆที่เป็นฟังก์ชันการทำงานของแอพ ตอนนี้มีไฟล์ guess_the_number.dart อยู่เพียงไฟล์เดียว ไฟล์นี้ถูก import เข้าไปยังไฟล์ bin/guess_the_number.dart อีกที ตามโค้ดบรรทัดแรกในไฟล์ bin/guess_the_number.dart

ภาพ 1-2

ไฟล์ pubspec.yaml

ข้อมูลในไฟล์นี้คือ metadata ของแอพ เช่น

  • ชื่อแพคเกจของแอพ
  • คำอธิบายเกี่ยวกับแอพ
  • เวอร์ชันของแอพ
  • เวอร์ชันของภาษา Dart ที่แอพของเราต้องการ
  • ชื่อและเวอร์ชันของแพคเกจ (Dart package) ที่มีการใช้งานในแอพของเรา
ภาพ 1-3

NOTE

Dart package คือไลบรารี (library) หรือทูล (tool) ที่เผยแพร่อยู่บนเว็บไซต์ pub.dev เราสามารถระบุคำสั่งในไฟล์ pubspec.yaml เพื่อโหลดแพคเกจที่ต้องการเข้ามาเป็นส่วนหนึ่งในโปรเจคของเรา เพื่อที่เราจะเรียกใช้ฟังก์ชันการทำงานที่แพคเกจเหล่านั้นเตรียมไว้ให้

เมื่อแอพของเรามีการใช้งานแพคเกจใด กล่าวได้ว่าแพคเกจนั้นคือ dependency ของแอพเรา

ทุกครั้งที่มีการแก้ไขไฟล์ pubspec.yaml เราจะต้องรันคำสั่ง dart pub get ที่โฟลเดอร์หลักของโปรเจค เพื่อโหลดแพคเกจตามชื่อและเวอร์ชันที่ระบุในไฟล์ pubspec.yaml มายังโปรเจคของเรา

ทดลองรันแอพ

เปิด Terminal ใน VS Code (เมนู Terminal > New Terminal) แล้วพิมพ์คำสั่งต่อไปนี้เพื่อรันแอพ

dart run
ภาพ 1-4

จะมีข้อความบอกว่ากำลัง build แอพ เมื่อ build เสร็จจะมีข้อความบอกว่า build เสร็จแล้ว จากนั้นแอพจะเริ่มทำงาน

Building package executable...
Built guess_the_number:guess_the_number.
Hello world: 42!

ข้อความบรรทัดสุดท้าย คือผลการทำงานของโค้ดในไฟล์ bin/guess_the_number.dart ร่วมกับโค้ดในไฟล์ lib/guess_the_number.dart โดยไฟล์หลังมีการกำหนด top-level function ชื่อ calculate() ซึ่งจะ return ค่า 6 * 7 (6 คูณ 7) กลับไป

  • ไฟล์ lib/guess_the_number.dart
int calculate() {
  return 6 * 7;
}

ส่วนไฟล์ bin/guess_the_number.dart จะทำการ import ไฟล์ lib/guess_the_number.dart เข้ามา แล้วเรียกใช้ฟังก์ชัน calculate() จากนั้นนำค่าผลลัพธ์ที่ได้ไปแทรก (interpolate) ต่อท้ายสตริง 'Hello world: ' แล้วจึง print ค่าสตริงทั้งหมดออกมา

  • ไฟล์ bin/guess_the_number.dart
import 'package:guess_the_number/guess_the_number.dart' as guess_the_number;

void main(List<String> arguments) {
  print('Hello world: ${guess_the_number.calculate()}!');
}

ขอให้สังเกตโค้ดบรรทัดแรกของไฟล์ bin/guess_the_number.dart ที่เป็นการ import ไฟล์ lib/guess_the_number.dart

import 'package:guess_the_number/guess_the_number.dart' as guess_the_number;

ความหมายของ package:guess_the_number คือการระบุถึงแพคเกจที่ชื่อ guess_the_number ซึ่งก็คือชื่อแพคเกจของแอพนี้ ตามข้อมูลที่กำหนดไว้ในไฟล์ pubspec.yaml (บรรทัด name: guess_the_number)

ส่วนถัดมา guess_the_number.dart คือการระบุถึงไฟล์ guess_the_number.dart ในโฟลเดอร์ lib

ในภาษา Dart นั้น แต่ละไฟล์ที่อยู่ในโฟลเดอร์ lib ถือว่าเป็น "ไลบรารี" ดังนั้นไฟล์ lib/guess_the_number.dart ก็คือไลบรารีตัวหนึ่ง การระบุ as guess_the_number ตอน import คือการกำหนด prefix ให้กับไลบรารีนี้ว่า guess_the_number ดังนั้นเมื่อจะเรียกใช้ฟังก์ชัน calculate() จึงต้องเขียนโค้ดว่า guess_the_number.calculate() ไม่ใช่ calculate() เฉยๆ

แต่หากไม่มีการกำหนด prefix ก็จะสามารถเรียกใช้ฟังก์ชันต่างๆในไลบรารีนั้นได้โดยเพียงแค่ระบุชื่อฟังก์ชัน ไม่ต้องมี prefix นำหน้า

อย่างไรก็ตาม ถ้าหากเรา import ไลบรารี 2 ตัวที่มีชื่อฟังก์ชัน/คลาสตรงกัน ก็จำเป็นต้องกำหนด prefix ให้กับไลบรารีอย่างน้อย 1 ตัว จึงจะสามารถเรียกใช้ฟังก์ชัน/คลาสที่ชื่อตรงกันจากทั้งสองไลบรารีได้ ดังตัวอย่าง

import 'package:package1/lib1.dart';
import 'package:package2/lib2.dart' as lib2;

// เรียกฟังก์ชัน hello() ใน lib1.dart
hello();

// เรียกฟังก์ชัน hello() ใน lib2.dart
lib2.hello();

เริ่มต้นเขียนโค้ดเกมทายเลข

ที่ไฟล์ lib/guess_the_number.dart ให้ลบโค้ดเดิมออกทั้งหมด แล้วพิมพ์โค้ดต่อไปนี้

import 'dart:io';
import 'dart:math';

void playGame() {

}

โค้ด 2 บรรทัดแรกคือการ import ไลบรารีของภาษา Dart ที่ใช้ทำงานเกี่ยวกับ input/output และไลบรารีที่เกี่ยวกับฟังก์ชันทางคณิตศาสตร์ ตามลำดับ

จากนั้นเป็นการสร้างฟังก์ชัน playGame() ที่ใช้เริ่มเกมใหม่ เราจะค่อยๆเพิ่มโค้ดเข้าไปในส่วน body ของฟังก์ชันนี้

NOTE

ใน GitHub โค้ดที่สมบูรณ์ของไฟล์ lib/guess_the_number.dart ที่อธิบายในหัวข้อนี้ จะอยู่ในไฟล์ lib/guess_the_number_old.dart

รับค่า max จากผู้ใช้

เกมทายเลขของเราจะสุ่มคำตอบเป็นเลขจำนวนเต็มในช่วง 1 ถึง max โดยเราจะให้ผู้ใช้กำหนดเองว่าอยากได้ค่า max เป็นเท่าไร

พิมพ์โค้ดในฟังก์ชัน playGame() ดังนี้

int? max;
do {
  stdout.write('\nEnter a maximum number to random: ');
  var input = stdin.readLineSync();
  max = int.tryParse(input!);
} while (max == null);

:one: คือการประกาศตัวแปร max เป็นชนิด int? ซึ่งหมายความว่าตัวแปร max จะใช้เก็บข้อมูลเลขจำนวนเต็ม (integer) และสามารถเป็นค่า null ได้

ภาษา Dart มีคุณสมบัติ null safety โดยเมื่อเราประกาศตัวแปรเป็น type ต่างๆ ตัวแปรจะไม่สามารถเป็นค่า null ได้ นอกจากเราจะระบุเพิ่มเติมลงไปว่าตัวแปรนั้นสามารถเป็น null ได้ด้วย

ถ้าหากเราต้องการให้ตัวแปรเป็น null ได้ด้วย จะต้องระบุ ? ต่อท้ายชื่อ type ที่ประกาศให้กับตัวแปรนั้น ดังตัวอย่าง

int i = 11;  // i เป็นตัวแปรชนิด int และไม่สามารถเป็นค่า null ได้
i = null;    // บรรทัดนี้จะเกิด error (compile-time error)
print(i);

int? j = 22; // j เป็นตัวแปรชนิด int และสามารถเป็นค่า null ได้
j = null;    // ไม่มี error
print(j);

NOTE

เมื่อคุณใช้ VS Code ในการเขียนโค้ด และมีการติดตั้ง Dart extension แล้ว ด้วยความสามารถของ Dart analyzer ที่มีอยู่ใน Dart extension จะทำให้ VS Code สามารถแจ้ง compile-time error ให้คุณทราบได้ทันทีในขณะที่คุณกำลังเขียนโค้ด ดังนั้นเราจึงอาจเรียก compile-time error ว่า edit-time analysis error ก็ได้

:three: แสดงข้อความบอกให้ผู้ใช้ป้อนค่า max ที่ต้องการ โดยใช้เมธอด stdout.write() (เมธอด write() ของคลาส stdout) ซึ่งจะแสดงข้อความโดยไม่มีการขึ้นบรรทัดใหม่เหมือนอย่างฟังก์ชัน print()

:four: รอรับ input จากผู้ใช้ทางคีย์บอร์ด โดยใช้เมธอด stdin.readLineSync() ซึ่งเมธอดนี้จะ return ค่ากลับมาหลังจากผู้ใช้กด Enter ค่าที่ return จะเป็นชนิด String? คือเป็นข้อมูลสตริงและอาจเป็นค่า null ได้ เรานำค่าที่ stdin.readLineSync() return กลับมานี้ไปเก็บไว้ในตัวแปร input ที่ประกาศด้วยคีย์เวิร์ด var

ใน Dart นอกจากการประกาศตัวแปรโดยใช้ type annotation เช่น int i, String s, Random rand แล้ว เรายังสามารถประกาศตัวแปรโดยใช้คีย์เวิร์ด var ได้ เพื่อให้ Dart ระบุ type ให้เองตาม "type ของค่าที่นำมากำหนดให้ตัวแปร" เราเรียกสิ่งนี้ว่า type inference ดังตัวอย่าง

var result = 6 * 7;  /* ตัวแปร result จะถูก infer type เป็น int ตาม
                        type ของค่า 6 * 7 ที่นำมากำหนด */
result = 'Hello';    /* บรรทัดนี้จะเกิด compile-time error เพราะตัวแปร
                        result ถูก infer type เป็น int ในตอนประกาศแล้ว
                        จึงไม่สามารถเก็บค่าชนิดอื่นได้ */
result = 555;        // ไม่มี error

NOTE

เงื่อนไขสำคัญของการทำ type inference ก็คือ จะต้องมีการกำหนดค่าเริ่มต้นให้ตัวแปรทันทีในตอนประกาศ

กรณีประกาศตัวแปรด้วย var แต่ไม่ได้กำหนดค่าเริ่มต้นในตอนประกาศ ตัวแปรจะถูก infer type เป็น dynamic ซึ่งจะทำให้ตัวแปรนั้นสามารถเก็บค่าชนิดใดก็ได้ แบบเดียวกับตัวแปรในภาษา JavaScript และ Python

import 'dart:math';

var myVar;           /* ตัวแปร name จะถูก infer type เป็น dynamic
                        เทียบเท่ากับการเขียนว่า dynamic myVar; */
myVar = 'Messi';     // ไม่มี error
myVar = 10;          // ไม่มี error
myVar = 3.14;        // ไม่มี error
myVar = Random();    // ไม่มี error
myVar = [1, 2, 3];   // ไม่มี error

ควรใช้ตัวแปรชนิด dynamic อย่างระมัดระวัง และใช้เมื่อจำเป็นจริงๆเท่านั้น เนื่องจาก Dart จะยกเลิกการตรวจสอบ type ของตัวแปรชนิด dynamic ทำให้เราพลาดโอกาสที่จะเจอ error ในตอนเขียนโค้ด (compile-time error) แต่ error อาจไปเกิดในตอนรัน (runtime error) แทน ดังตัวอย่าง

import 'dart:math';

var myVar;           // dynamic variable
myVar = Random();    // ไม่มี error
myVar = [1, 2, 3];   // ไม่มี error
myVar.nextInt(100)   // เกิด runtime error

ตัวอย่างนี้ไม่เกิด compile-time error แต่เมื่อรันจะเกิด runtime error ที่บรรทัดสุดท้าย เนื่องจากตอนเรียก myVar.nextInt() นั้นตัวแปร myVar เก็บออบเจคชนิด List เอาไว้ แต่ List ไม่มีเมธอด nextInt()

:five: ทำการแปลงค่าที่เก็บในตัวแปร input จาก String เป็น int ด้วยเมธอด int.tryParse() เมธอดนี้จะ return ค่า int หากแปลงค่าได้ และจะ return ค่า null หากไม่สามารถแปลงได้ กรณีที่ไม่สามารถแปลงค่าเป็น int ได้ก็เช่น ผู้ใช้ป้อนค่าที่ไม่ใช่ตัวเลขล้วน (มีตัวอักษรปน) หรือเป็นเลขที่มีจุดทศนิยม เป็นต้น

เครื่องหมาย ! หลังตัวแปร input คือ null assertion operator เราใช้เครื่องหมายนี้เพื่อบอก Dart analyzer ว่าตัวแปร input (ซึ่งถูก infer type ในบรรทัดก่อนหน้านี้ให้เป็นตัวแปรชนิด String?) จะไม่เป็นค่า null อย่างแน่นอน

อย่างไรก็ตาม ถ้าหากปรากฏในตอนรันว่าตัวแปร input เป็นค่า null ก็จะทำให้เกิด runtime error ขึ้น แต่เราจะไม่สนใจกรณี input เป็นค่า null ในที่นี้

เราครอบ :three:-:five: ด้วย do-while loop ซึ่งจะวนรับ input จากผู้ใช้ไปเรื่อยๆ จนกว่าผู้ใช้จะป้อนค่าที่สามารถแปลงเป็น int ได้

แก้ไขไฟล์ bin/guess_the_number.dart

ถัดไป ให้แก้ไขไฟล์ bin/guess_the_number.dart ให้เป็นดังนี้

import 'package:guess_the_number/guess_the_number.dart';

void main(List<String> arguments) {
  playGame();
}

เราแก้ไขบรรทัด import โดยลบ as guess_the_number ออก แล้วแก้ไขโค้ดในฟังก์ชัน main() ให้เรียกไปยังฟังก์ชัน playGame() ที่เราเขียนไว้ในไฟล์ lib/guess_the_number.dart

ให้รันแอพอีกครั้งโดยพิมพ์คำสั่ง dart run ที่ Terminal แล้วลองป้อนค่าต่างๆเข้าไปดู

ภาพ 1-5

คุณจะพบว่าถ้าป้อนค่าที่ไม่ใช่รูปแบบของเลขจำนวนเต็ม แอพจะไม่รับค่านั้น และจะขอให้ป้อนใหม่ อันเนื่องมาจากการทำงานของ do-while loop ในฟังก์ชัน playGame() ที่อธิบายไว้ข้างต้น

ทำการสุ่มเลขคำตอบ

ให้กลับไปที่ไฟล์ lib/guess_the_number.dart แล้วเพิ่มโค้ดต่อไปนี้ลงไปในฟังก์ชัน playGame() ต่อจากโค้ดที่เขียนไว้ก่อนหน้านี้

var answer = Random().nextInt(max) + 1;
var guessCount = 0;
var isCorrect = false;

print('');
print('╔════════════════════════════════════════');
print('║            GUESS THE NUMBER            ');
print('╟────────────────────────────────────────');

:one: คือการสร้างตัวแปร answer เพื่อเก็บค่าจำนวนเต็มที่สุ่มขึ้นมาด้วยเมธอด nextInt() ของคลาส Random

เนื่องจาก nextInt() เป็น instance method เราจึงต้องสร้าง instance (ออบเจค) ของคลาส Random ก่อน แล้วจึงเรียกใช้เมธอด nextInt() จาก instance นั้น

ในภาษา Dart การสร้าง instance ของคลาสทำได้โดยเรียกไปยัง constructor ของคลาส ซึ่งเป็นฟังก์ชันที่มีชื่อเดียวกับชื่อคลาส โดยเราจะระบุคีย์เวิร์ด new หรือไม่ก็ได้ ดังตัวอย่าง

var rand1 = new Random();
var rand2 = Random();

โค้ด 2 บรรทัดข้างต้นคือการสร้าง instance ของคลาส Random เหมือนกัน แต่ในหนังสือเล่มนี้จะยึดตามแบบหลัง คือไม่เขียน new เมื่อสร้าง instance ของคลาส

เมธอด nextInt(n) ของคลาส Random จะสุ่มเลขจำนวนเต็มตั้งแต่ 0 จนถึงเลขจำนวนเต็มก่อนค่า n ที่เราระบุเป็นอาร์กิวเมนต์ พูดง่ายๆคือ ค่าที่สุ่มขึ้นมาจะเป็นเลขจำนวนเต็มเลขใดเลขหนึ่ง ตั้งแต่ 0 จนถึง n-1

ดังนั้นเพื่อให้ค่าที่สุ่มได้เป็นเลขจำนวนเต็มตั้งแต่ 1 ถึง max (รวม 1 และ max ด้วย) เราจึงต้องบวก 1 เพิ่มเข้าไป ดังโค้ด Random().nextInt(max) + 1

:two: สร้างตัวแปร guessCount เพื่อเก็บจำนวนครั้งที่ผู้ใช้ทายเลข โดยกำหนดค่าเริ่มต้นเป็น 0 เราจะเพิ่มค่าตัวแปรนี้ขึ้น 1 ในแต่ละครั้งที่ผู้ใช้ทายเลข

:three: สร้างตัวแปร isCorrect เพื่อเก็บสถานะว่าผู้ใช้ทายถูกหรือยัง โดยกำหนดค่าเริ่มต้นเป็น false ตัวแปรนี้จะถูกเปลี่ยนค่าเป็น true เมื่อผู้ใช้ทายถูก เราจะใช้ตัวแปรนี้เป็นเงื่อนไขของ loop ที่จะให้ผู้ใช้ทายเลขซ้ำๆหากยังทายไม่ถูก หรือออกจาก loop หากทายถูกแล้ว

:five:-:eight: แสดงข้อความ header ของเกม โดยมีการใช้ตัวอักษรวาดกรอบ (box-drawing characters) เช่น , , , ด้วย ตัวอักษรเหล่านี้เป็นส่วนหนึ่งของค่าสตริงที่เราพิมพ์ออกมาด้วยฟังก์ชั่น print() มันไม่ได้มีความหมายพิเศษในภาษา Dart แต่อย่างใด

รับตัวเลขที่ผู้ใช้ทาย

พิมพ์โค้ดต่อไปนี้ลงไปในฟังก์ชัน playGame() ต่อจากโค้ดที่เขียนไว้ก่อนหน้านี้

do {
  stdout.write('║ Guess the number between 1 and $max: ');
  var input = stdin.readLineSync();
  var guessedNumber = int.tryParse(input!);
  if (guessedNumber == null) {
    continue;
  }

  // โค้ดสำหรับตรวจสอบว่าผู้ใช้ทายถูกหรือไม่
  // ...
} while (!isCorrect);

print('║                 THE END                ');
print('╚════════════════════════════════════════');

โค้ดที่ใช้รับเลขที่ทาย มีหลักการเหมือนกับโค้ดที่ใช้รับค่า max ก่อนหน้านี้ ข้อมูลที่ผู้ใช้ป้อนเข้ามาจะถูกแปลงเป็น int แล้วเก็บลงตัวแปร guessedNumber ซึ่งหากแปลงเป็น int ไม่ได้ (ค่าที่ผู้ใช้ป้อนไม่ใช่รูปแบบของเลขจำนวนเต็ม) ตัวแปร guessedNumber จะเป็นค่า null และเราจะข้ามไปยังรอบถัดไปของ do-while loop ด้วยคำสั่ง continue ทันที โดยไม่ทำโค้ดที่ใช้ตรวจสอบว่าผู้ใช้ทายถูกหรือไม่ (โค้ดส่วนนี้ยังไม่ได้เขียน)

เงื่อนไขตรงส่วน while ของ loop คือ !isCorrect ซึ่งหมายความว่า loop จะวนซ้ำไปเรื่อยๆ หากผู้ใช้ทายไม่ถูก (isCorrect เป็นค่า false) และจะออกจาก loop เมื่อผู้ใช้ทายถูกแล้ว (isCorrect เป็นค่า true) ตัวแปร isCorrect ถูกกำหนดค่าเริ่มต้นในตอนประกาศเป็น false ดังโค้ดที่เราเขียนก่อนหน้านี้ ส่วนการเปลี่ยนค่าเป็น true จะเกิดขึ้นในส่วนของโค้ดที่จะเขียนถัดไป

หลังจากผู้ใช้ทายถูก เราจะพิมพ์ข้อความว่า "THE END" เป็นอันสิ้นสุดการทำงานของฟังก์ชัน playGame()

ตรวจสอบว่าผู้ใช้ทายถูกหรือไม่

พิมพ์โค้ดต่อไปนี้ลงไปภายใน do-while loop ก่อนบรรทัด } while (!isCorrect);

guessCount++;
if (guessedNumber < answer) {
  print('║ Too low! Try again.');
  print('╟────────────────────────────────────────');
} else if (guessedNumber > answer) {
  print('║ Too high! Try again.');
  print('╟────────────────────────────────────────');
} else {
  isCorrect = true;
  print('║ Bravo! The number is $answer.');
  print('║ You got it in $guessCount guesses!');
  print('╟────────────────────────────────────────');
}

เราเพิ่มค่าตัวแปร guessCount ขึ้น 1 จากนั้นใช้ if-else ในการตรวจสอบเลขที่ผู้ใช้ทายเข้ามาว่า น้อยกว่า, มากกว่า หรือเท่ากับคำตอบ แล้วแสดงข้อความที่เหมาะสมออกไป โดยกรณีที่ทายถูกเราจะเปลี่ยนค่าตัวแปร isCorrect เป็น true, แสดงข้อความทวนคำตอบ รวมถึงข้อความที่บอกผู้ใช้ว่าทายไปทั้งหมดกี่ครั้ง

การเขียน string literal ในภาษา Dart จะใช้เครื่องหมาย " หรือ ' ครอบข้อความก็ได้ นอกจากนี้เรายังสามารถแทรก (interpolate) ค่าตัวแปรลงไปใน string literal โดยใช้เครื่องหมาย $ นำหน้าชื่อตัวแปร ดังตัวอย่าง

var name = 'Messi';
var msg = "Hello, $name!";
print(msg); // Hello, Messi!

ถ้าหากค่าที่จะแทรกลงใน string literal เป็นนิพจน์ (expression) แทนที่จะเป็นตัวแปรเดี่ยวๆ ก็ให้ใช้ ${} ครอบ expression ไว้ ดังตัวอย่าง

var text = "Hello Dart!";
var msg = "The length of '$text' is ${text.length}.";
print(msg); // The length of 'Hello Dart!' is 11.

var a = 8;
var b = 9;
msg = "The sum of $a and $b is ${a + b}.";
print(msg); // The sum of 8 and 9 is 17.

msg = "$a is ${a == b ? 'equal' : 'not equal'} to $b.";
print(msg); // 8 is not equal to 9.

ส่วนของ a == b ? 'equal' : 'not equal' ในตัวอย่างสุดท้ายคือ conditional expression ที่จะให้ค่าเป็นสตริง 'equal' ถ้าหาก a มีค่าเท่ากับ b (เงื่อนไข a == b เป็น true) แต่จะให้ค่าเป็นสตริง 'not equal' ถ้าหาก a ไม่เท่ากับ b (เงื่อนไข a == b เป็น false)

Conditional expression เป็นสิ่งที่ใช้บ่อยใน Flutter ดังที่คุณจะได้เห็นในบทถัดๆไป

โค้ดทั้งหมดในไฟล์ lib/guess_the_number.dart จนถึงตอนนี้

import 'dart:io';
import 'dart:math';

void playGame() {
  int? max;
  do {
    stdout.write('\nEnter a maximum number to random: ');
    var input = stdin.readLineSync();
    max = int.tryParse(input!);
  } while (max == null);

  var answer = Random().nextInt(max) + 1;
  var guessCount = 0;
  var isCorrect = false;

  print('');
  print('╔════════════════════════════════════════');
  print('║            GUESS THE NUMBER            ');
  print('╟────────────────────────────────────────');

  do {
    stdout.write('║ Guess the number between 1 and $max: ');
    var input = stdin.readLineSync();
    var guessedNumber = int.tryParse(input!);
    if (guessedNumber == null) {
      continue;
    }

    guessCount++;
    if (guessedNumber < answer) {
      print('║ Too low! Try again.');
      print('╟────────────────────────────────────────');
    } else if (guessedNumber > answer) {
      print('║ Too high! Try again.');
      print('╟────────────────────────────────────────');
    } else {
      isCorrect = true;
      print('║ Bravo! The number is $answer.');
      print('║ You got it in $guessCount guesses!');
      print('╟────────────────────────────────────────');
    }
  } while (!isCorrect);

  print('║                 THE END                ');
  print('╚════════════════════════════════════════');
}

ทดลองเล่นเกม

ให้รันแอพโดยพิมพ์คำสั่ง dart run ที่ Terminal แล้วลองเล่นเกมดู

ภาพ 1-6

สร้างคลาส Game

ภาษา Dart มีคุณสมบัติการเขียนโปรแกรมแบบ object-oriented เราสามารถสร้างคลาส (class) เพื่อรวบรวมตัวแปรและฟังก์ชันต่างๆที่เกี่ยวข้องกันเข้าไว้ด้วยกัน แล้วสร้างออบเจค (object) ขึ้นมาจากคลาสนั้นเพื่อใช้งาน

NOTE

หนังสือเล่มนี้จะเรียกตัวแปรที่อยู่ภายในคลาสว่า ฟีลด์ (field) หรือพร็อพเพอร์ตี (property) และเรียกฟังก์ชันที่อยู่ภายในคลาสว่า เมธอด (method)

ในหัวข้อนี้ เราจะทำการ refactor โค้ดของเกมทายเลข โดยสร้างคลาสชื่อ Game ที่มีฟีลด์ เช่น maxRandom, answer และมีเมธอด เช่น guess() ที่ใช้ทายเลข

NOTE

การ refactor โค้ด (code refactoring) คือการปรับปรุงโค้ดที่เขียนไว้แล้วให้ดีขึ้น โดยที่การทำงานของโปรแกรมหรือแอพยังคงเหมือนเดิม ตัวอย่างการ refactor ก็เช่น

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

ให้สร้างไฟล์ใหม่ในโฟลเดอร์ lib ชื่อ game.dart แล้วพิมพ์โค้ดต่อไปนี้ลงไป

import 'dart:math';

class Game {
  final int maxRandom;
  final int answer;

  // constructor
  Game() {

  }
}

โค้ดข้างต้นคือการสร้างคลาสชื่อ Game ที่มีฟีลด์ maxRandomn และ answer ฟีลด์ทั้งสองเป็นตัวแปรแบบ final หมายถึงตัวแปรที่เมื่อถูกกำหนดค่าแล้ว เราจะไม่สามารถเปลี่ยนแปลงค่าของมันได้หลังจากนั้น

การใช้ฟีลด์แบบ final เป็นวิธีที่มักถูกใช้ในการสร้าง property ของคลาสที่โค้ดภายนอกคลาสสามารถอ่านค่าไปใช้ได้ แต่ไม่สามารถแก้ไขค่าได้ (read-only property) คำถามคือ เราจะกำหนดค่าเริ่มต้นให้กับมันได้อย่างไร?

การกำหนดค่าเริ่มต้นให้กับฟีลด์แบบ final ของคลาสจะทำได้ 3 วิธี

  • วิธีที่ 1: กำหนดค่าทันทีในตอนประกาศ เช่น

    final int maxRandom = 100;
    
  • วิธีที่ 2: กำหนดค่าโดยใช้ initializing formal parameters ของ constructor เช่น

    class Point {
      final double x;
      final double y;
    
      Point(this.x, this.y);
    }
    
    // ตัวอย่างการสร้างออบเจคจากคลาส Point
    // void main() {
    //   var point = Point(3, 4);
    // }
    

    ในตัวอย่างนี้ constructor ของคลาส Point มีพารามิเตอร์ x และ y ที่เป็น type double ทั้งคู่ (type ของพารามิเตอร์จะถูก infer จาก type ของฟีลด์ที่มีชื่อเดียวกัน) และค่าที่ถูกส่งเข้ามาในตอนสร้างออบเจคจะถูกกำหนดต่อไปให้กับฟีลด์ x และ y ของคลาสโดยอัตโนมัติ

  • วิธีที่ 3: กำหนดค่าโดยใช้ initializer list ของ constructor เช่น

    class Point {
      final double x;
      final double y;
    
      Point(double x, double y) : x = x, y = y;
    }
    
    // ตัวอย่างการสร้างออบเจคจากคลาส Point
    // void main() {
    //   var point = Point(3, 4);
    // }
    

    คลาส Point ในตัวอย่างนี้ใช้ initializer list กำหนดค่าให้กับฟีลด์ x และ y ของคลาส โดยใช้ค่าที่ถูกส่งเข้ามาที่พารามิเตอร์ x และ y ของ constructor ตามลำดับ ผลจึงเหมือนกับการใช้ initializing formal parameters ในตัวอย่างก่อนหน้านี้ทุกประการ

TIP

ความจริงแล้ว วิธี initializing formal parameters ถือเป็น "syntactic sugar" ของวิธี initializer list ที่ทำให้เราเขียนโค้ดได้สั้นลง สำหรับกรณีที่เราต้องการนำค่าจากพารามิเตอร์ของ constructor (ที่ถูกส่งเข้ามาในตอนสร้างออบเจค) มากำหนดให้กับฟีลด์ของคลาสโดยตรง แต่ถ้าหากเราต้องการทำอะไรนอกเหนือจากนี้ เช่น ต้องการตรวจสอบหรือคำนวณค่าที่ถูกส่งเข้ามาก่อนที่จะกำหนดให้กับฟีลด์ ก็จะต้องใช้ initializer list แทน

ปรับปรุง Constructor ของคลาส Game

ให้ปรับปรุง constructor ของคลาส Game ตามโค้ดต่อไปนี้

import 'dart:math';

class Game {
  final int maxRandom;
  final int answer;

  // constructor
  Game(this.maxRandom) : answer = Random().nextInt(maxRandom) + 1 {
    print('The answer is $answer.');
  }
}

// ตัวอย่างการสร้างออบเจคจากคลาส Game
// void main() {
//   var game = Game(100);
// }

จากโค้ด เรากำหนดค่าเริ่มต้นให้กับฟีลด์ maxRandom ด้วย initializing formal parameter (this.maxRandom) และกำหนดค่าเริ่มต้นให้กับฟีลด์ answer ด้วย initializer list โดยใช้ค่าจำนวนเต็มในช่วง 1 ถึง maxRandom ที่สุ่มขึ้นมา

สำหรับส่วน body ของ constructor (ภายในเครื่องหมาย { }) ตอนนี้เราใช้เพียงคำสั่ง print() พิมพ์ค่าของฟีลด์ answer ออกมาที่ console ซึ่งจะช่วยให้เราสามารถตรวจสอบค่าที่สุ่มขึ้นมาได้ในช่วงทดสอบโปรแกรม

CAUTION

  • การกำหนดค่าเริ่มต้นให้กับฟีลด์แบบ final ไม่สามารถทำใน body ของ constructor ได้
  • การทำงานของ initialzing formal parameters และ initializer list จะเกิดขึ้นก่อนที่ body ของ constructor จะทำงาน

เรื่องของ Named Parameter

เมื่อเราสร้างฟังก์ชันที่มีพารามิเตอร์ เช่น

void printUser(String name, int age) {
  print('You are $name and you are $age years old.');
}

การเรียกใช้ฟังก์ชันจะต้องส่งค่าให้กับพารามิเตอร์ต่างๆ ตามลำดับที่กำหนดไว้ในฟังก์ชัน ดังตัวอย่าง

printUser('Promlert', 49); // You are Promlert and you are 49 years old.

พารามิเตอร์ในรูปแบบนี้เรียกว่า positional parameter ซึ่งหมายถึงพารามิเตอร์ที่เราต้องส่งค่าตามลำดับที่กำหนดไว้ในฟังก์ชัน ตอนเรียกใช้ฟังก์ชัน

ภาษา Dart ยังมีพารามิเตอร์อีกรูปแบบหนึ่งคือ named parameter การระบุ named parameter จะทำได้โดยครอบพารามิเตอร์ด้วย {} ดังตัวอย่าง

void printUser({String? name, int? age}) {
  print('You are $name and you are $age years old.');
}

การเรียกใช้ฟังก์ชันที่มี named parameter เราสามารถส่งค่าให้กับ named parameter ต่างๆด้วยลำดับก่อนหลังอย่างไรก็ได้ ไม่จำเป็นต้องส่งตามลำดับที่กำหนดไว้ในฟังก์ชัน ดังตัวอย่าง

printUser(age: 49, name: 'Promlert'); // You are Promlert and you are 49 years old.

สังเกตว่าการส่งค่าให้กับ named parameter จะใช้รูปแบบ ชื่อพารามิเตอร์: ค่า เพื่อระบุว่าจะส่งค่าใดให้กับพารามิเตอร์ตัวใด

Named parameter นั้นเป็น optional หมายถึงเราจะส่งค่าหรือไม่ก็ได้ ซึ่งหากไม่ได้ส่งค่า พารามิเตอร์ก็จะมีค่าเป็น null โดยอัตโนมัติ กรณีนี้ type ที่เราประกาศให้กับ named parameter จึงต้องเป็น nullable type (สามารถเป็น null ได้) กล่าวคือ ต้องมี ? ตามหลังชื่อ type ที่ระบุให้กับพารามิเตอร์ ดังเช่นพารามิเตอร์ name และ age ในตัวอย่างข้างต้น

ถ้าหากต้องการประกาศ named parameter ให้เป็น type ที่ไม่สามารถเป็น null ได้ (non-nullabe type) เรามี 2 ทางเลือกคือ

  • วิธีที่ 1: กำหนดค่าดีฟอลต์ให้กับพารามิเตอร์ ดังตัวอย่าง

    void printUser({String? name, int age = 25}) {
      print('You are $name and you are $age years old.');
    }
    

    ตัวอย่างนี้พารามิเตอร์ age ถูกประกาศเป็น type int และมีค่าดีฟอลต์เป็น 25 ดังนั้นถ้าหากเราไม่ส่งค่ามาให้ในตอนเรียกใช้ฟังก์ชัน พารามิเตอร์ age ก็จะมีค่าเป็น 25 โดยอัตโนมัติ ดังตัวอย่าง

    printUser(name: 'Promlert'); // You are Promlert and you are 25 years old.
    
  • วิธีที่ 2: ใช้ required กำกับข้างหน้าพารามิเตอร์ เพื่อกำหนดเป็น required parameter คือบังคับว่าต้องส่งค่ามาให้กับพารามิเตอร์นั้นในตอนเรียกใช้ฟังก์ชัน ดังตัวอย่าง

    void printUser({required String name, required int age}) {
      print('You are $name and you are $age years old.');
    }
    

    ตัวอย่างนี้พารามิเตอร์ name และ age เป็น required parameter ดังนั้นจะต้องส่งค่ามาให้กับพาราเตอร์ทั้งสองในตอนเรียกใช้ฟังก์ชัน มิฉะนั้นจะเกิด compile-time error

    /* บรรทัดนี้จะเกิด compile-time error เนื่องจากไม่ได้ส่งค่าให้กับ
       พารามิเตอร์ age ที่เป็น required parameter */
    printUser(name: 'Promlert');
    

ฟังก์ชันหนึ่งสามารถมีทั้ง positional parameter และ named parameter ได้ แต่มีเงื่อนไขว่า positional parameter ทั้งหมดจะต้องมาก่อน แล้วจึงตามด้วยส่วนของ named parameter ดังตัวอย่าง

void printUser(String name, int age,
    {String? address, String? email}) {
  print('You are $name and you are $age years old.');
  if (address != null) {
    print('Your address is $address');
  }
  if (email != null) {
    print('Your email is $email');
  }
}

ตัวอย่างการเรียกใช้ฟังก์ชัน

printUser('Promlert', 49, email: 'promlert@gmail.com');

ข้อความที่จะถูกพิมพ์ออกมาคือ

You are Promlert and you are 49 years old.
Your email is promlert@gmail.com

ปรับปรุง Constructor ของคลาส Game ให้ใช้ Named Parameter

ให้ปรับปรุงคลาส Game ตามโค้ดต่อไปนี้

import 'dart:math';

class Game {
  static const defaultMaxRandom = 100;
  final int maxRandom;
  final int answer;

  // constructor
  Game({this.maxRandom = defaultMaxRandom})
      : answer = Random().nextInt(maxRandom) + 1 {
    print('The answer is $answer.');
  }
}

เราปรับพารามิเตอร์ maxRandom ของ constructor ไปเป็น named parameter โดยครอบด้วย {}

และเนื่องจากพารามิเตอร์ maxRandom เป็น type int (ตาม type ที่ประกาศให้กับฟีลด์ maxRandom) ซึ่งไม่รับค่า null เราจึงจำเป็นต้องกำหนดค่าดีฟอลต์ หรือไม่ก็ต้องระบุ required เพื่อบังคับว่าต้องส่งค่า

ในที่นี้เลือกกำหนดค่าดีฟอลต์ โดยใช้ค่าจากตัวแปรแบบ const ชื่อ defaultMaxRandom

หลังจากปรับปรุงคลาส Game ตามนี้แล้ว เราสามารถส่งค่าหรือไม่ส่งค่าให้กับพารามิเตอร์ maxRandom ก็ได้ในตอนสร้างออบเจคจากคลาส Game ดังตัวอย่าง

var game1 = Game();
var game2 = Game(maxRandom: 50);

ตัวอย่างแรกสร้างออบเจคโดยไม่ส่งค่าให้กับ maxRandom ดังนั้นฟีลด์ maxRandom จะมีค่าเป็น 100 ตามค่าดีฟอลต์ที่กำหนด

ส่วนตัวอย่างหลังสร้างออบเจคโดยส่งค่า 50 ให้กับ maxRandom ดังนั้นฟีลด์ maxRandom ก็จะมีค่าเป็น 50

final vs. const

Dart มีตัวแปรที่ไม่สามารถเปลี่ยนค่าได้อยู่ 2 ประเภท คือ final และ const โดยตัวแปรแบบ final จะถูกกำหนดค่าได้เพียงครั้งเดียว แล้วจะไม่สามารถเปลี่ยนค่าได้อีก ดังที่อธิบายแล้ว

ส่วนตัวแปรแบบ const คือ compile-time constant ซึ่งชื่อตัวแปรที่ปรากฏในโค้ดจะถูกแทนที่ด้วยค่าของมันตั้งแต่ช่วง compile time

ถ้าหากต้องการสร้างตัวแปรแบบ const ที่ระดับคลาส จะต้องระบุให้เป็น static ด้วย เนื่องจาก Dart ไม่อนุญาตให้ instance variable เป็น const ได้ (แต่สามารถเป็น final ได้) เช่น

class Game {
  static const defaultMaxRandom = 100; // ok
  const name = 'Guess the Number'; // compile-time error
  final int maxRandom; // ok
  final int answer; // ok

  void play() {
    const tooLowText = 'Too low! Try again.'; // ok
    final tooHighText = 'Too high! Try again.'; // ok
  }
}

เมื่อประกาศตัวแปรแบบ const จะต้องกำหนดค่าทันที และค่าที่นำมากำหนดต้องเป็น literal หรือตัวแปรแบบ const เท่านั้น ไม่สามารถกำหนดค่าจากการเรียกฟังก์ชัน/เมธอด หรือค่าจากการคำนวณในช่วง run time ได้ ดังตัวอย่าง

const minutesPerHour = 60; // ok
const secondsPerMinute = 60; // ok
const secondsPerHour = minutesPerHour * secondsPerMinute; // ok
const currentTime = DateTime.now(); // compile-time error

จากตัวอย่าง ค่าที่กำหนดให้ secondsPerHour มาจากการคำนวณก็จริง แต่เป็นการคำนวณในช่วง compile time โดยใช้ค่าจาก const อื่นๆอีกที ดังนั้นจึงระบุ secondsPerHour เป็น const ได้ ในขณะที่บรรทัดสุดท้ายจะเกิด compile-time error เนื่องจากค่าที่กำหนดให้ currentTime มาจากการเรียกเมธอด DateTime.now() ซึ่งเมธอดจะทำงานและให้ค่าออกมาในช่วง run time

Private Members

ภาษา Dart ไม่มีคีย์เวิร์ด private, public สำหรับกำหนด visibility ให้กับฟีลด์/เมธอดเหมือนอย่างภาษา Java แต่เราสามารถทำให้ฟีลด์/เมธอดเป็น private ได้โดยใส่ underscore (_) นำหน้าชื่อฟีลด์/เมธอด ดังตัวอย่าง

class Point {
  double _x = 0;
  double _y = 0;

  void _move(double dx, double dy) {
    _x += dx;
    _y += dy;
  }
}

ฟีลด์ _x และ _y และเมธอด _move ในตัวอย่างนี้คือ private members อย่างไรก็ตาม private members ใน Dart ไม่ใช่ private ระดับคลาส แต่เป็น private ระดับไลบรารี คือจะสามารถเข้าถึงได้จากภายในไลบรารีเดียวกันเท่านั้น

NOTE

ไฟล์​ .dart แต่ละไฟล์ก็คือไลบรารีดังที่เคยอธิบายแล้ว แต่เราสามารถแยกโค้ดของไลบรารีหนึ่งออกเป็นหลายๆไฟล์ได้เช่นกัน ซึ่งจะยังไม่อธิบายตอนนี้

สำหรับฟีลด์/เมธอดที่ไม่มี underscore นำหน้าชื่อจะถือเป็น public และสามารถเข้าถึงได้จากทุกที่ในโปรแกรม

Getter และ Setter

Getter และ setter คือเมธอดพิเศษที่ใช้ในการอ่านค่าและกำหนดค่าของฟีลด์ ซึ่งการเรียกใช้งาน getter/setter จากภายนอกคลาสจะมีรูปแบบเหมือนกับการอ่านค่าและกำหนดค่าของฟีลด์ตามปกติ คือไม่ได้มีรูปแบบเป็นการเรียกเมธอด

การสร้าง getter และ setter ให้ใช้คีย์เวิร์ด get และ set ดังตัวอย่าง

class Rectangle {
  double _width;
  double _height;

  Rectangle(this._width, this._height);

  // Getter สำหรับอ่านค่า _width
  double get width => _width;

  // Setter สำหรับกำหนดค่า _width
  set width(double value) {
    if (value > 0) {
      _width = value;
    } else {
      // โยนข้อผิดพลาดเมื่อค่าที่ส่งเข้ามาเป็นค่าลบหรือ 0
      throw ArgumentError('Width must be greater than 0');
    }
  }

  // Getter สำหรับอ่านค่า _height
  double get height => _height;

  // Setter สำหรับกำหนดค่า _height
  set height(double value) {
    if (value > 0) {
      _height = value;
    } else {
      // โยนข้อผิดพลาดเมื่อค่าที่ส่งเข้ามาเป็นค่าลบหรือ 0
      throw ArgumentError('Height must be greater than 0');
    }
  }

  // Getter สำหรับหาพื้นที่ของสี่เหลี่ยม
  double get area => _width * _height;
}

ตัวอย่างนี้คือคลาส Rectangle ที่เก็บข้อมูลความกว้างและความสูงของสี่เหลี่ยม, มี getter และ setter สำหรับอ่าน/กำหนดค่าของฟีลด์ _width และ _height ที่เป็น private fields และมี getter สำหรับหาพื้นที่ของสี่เหลี่ยม โดยคำนวณจากความกว้างและความสูง

TIP

Getter ทั้งสามในตัวอย่างนี้ เขียนโดยใช้ arrow syntax แต่หากเขียนในรูปแบบปกติ (block body) จะเป็นดังนี้

double get width {
  return _width;
}

double get height {
  return _height;
}

double get area {
  return _width * _height;
}

ตัวอย่างการใช้งานคลาส Rectangle

void main() {
  var rectangle = Rectangle(5, 10);

  // อ่านค่า width, height และ area โดยใช้ getter
  print('Width: ${rectangle.width}');
  print('Height: ${rectangle.height}');
  print('Area: ${rectangle.area}');

  // เปลี่ยนค่า width และ height โดยใช้ setter
  rectangle.width = 8;
  rectangle.height = 12;

  // อ่านค่า width, height และ area โดยใช้ getter
  print('Width: ${rectangle.width}');
  print('Height: ${rectangle.height}');
  print('Area: ${rectangle.area}');
}

สังเกตว่าการเรียกใช้ getter และ setter จะเหมือนกับการอ่านค่าและกำหนดค่าตัวแปร แทนที่จะเป็นรูปแบบการเรียกฟังก์ชัน

ปรับปรุงคลาส Game และฟังก์ชัน playGame()

  1. เพิ่มเมธอด guess(), private field ชื่อ _guessCount และ getter ชื่อ guessCount ในคลาส Game ตามโค้ดต่อไปนี้
import 'dart:math';

class Game {
  static const defaultMaxRandom = 100;
  final int maxRandom;
  final int answer;
  var _guessCount = 0;

  // constructor
  Game({this.maxRandom = defaultMaxRandom})
      : answer = Random().nextInt(maxRandom) + 1 {
    print('The answer is $answer.');
  }

  int get guessCount => _guessCount;

  /* เมธอดที่ใช้ตรวจสอบเลขที่ทาย
     โดยคืนค่า -1 ถ้าหากเลขที่ทายน้อยกว่าคำตอบ,
     คืนค่า 1 ถ้าหากเลขที่ทายมากกว่าคำตอบ
     และคืนค่า 0 ถ้าหากเลขที่ทายถูกต้อง */
  int guess(int guessedNumber) {
    _guessCount++;
    if (guessedNumber < answer) {
      return -1;
    } else if (guessedNumber > answer) {
      return 1;
    } else {
      return 0;
    }
  }
}
  1. ปรับปรุงโค้ดในไฟล์ lib/guess_the_number.dart เป็นดังนี้
import 'dart:io';
// import 'dart:math';

import 'game.dart';

void playGame() {
  int? max;
  do {
    stdout.write('\nEnter a maximum number to random: ');
    var input = stdin.readLineSync();
    max = int.tryParse(input!);
  } while (max == null);

  // var answer = Random().nextInt(max) + 1;
  // var guessCount = 0;
  var isCorrect = false;
  var game = Game(maxRandom: max);

  print('');
  print('╔════════════════════════════════════════');
  print('║            GUESS THE NUMBER            ');
  print('╟────────────────────────────────────────');

  do {
    // stdout.write('║ Guess the number between 1 and $max: ');
    stdout.write('║ Guess the number between 1 and ${game.maxRandom}: ');
    var input = stdin.readLineSync();
    var guessedNumber = int.tryParse(input!);
    if (guessedNumber == null) {
      continue;
    }

    // guessCount++;
    var result = game.guess(guessedNumber);
    // if (guessedNumber < answer) {
    if (result == -1) {
      print('║ Too low! Try again.');
      print('╟────────────────────────────────────────');
      // } else if (guessedNumber > answer) {
    } else if (result == 1) {
      print('║ Too high! Try again.');
      print('╟────────────────────────────────────────');
    } else {
      isCorrect = true;
      // print('║ Bravo! The number is $answer.');
      // print('║ You got it in $guessCount guesses!');
      print('║ Bravo! The number is ${game.answer}.');
      print('║ You got it in ${game.guessCount} guesses!');
      print('╟────────────────────────────────────────');
    }
  } while (!isCorrect);

  print('║                 THE END                ');
  print('╚════════════════════════════════════════');
}

NOTE

Note: บรรทัดที่เป็น comment ในไฟล์ lib/guess_the_number.dart คือโค้ดเดิมที่ถูกลบออกไป

จากโค้ดจะสังเกตได้ว่า

  • ตัวแปร answer และ guessCount ได้ถูกย้ายจากฟังก์ชัน playGame() ไปเป็น read-only properties ของคลาส Game โดย answer เป็นฟีลด์แบบ final ส่วน guessCount เป็น getter สำหรับให้ภายนอกคลาสอ่านค่าจาก private field ชื่อ _guessCount ไปใช้งาน แต่ไม่มี setter สำหรับกำหนดค่า วิธีนี้ช่วยให้มั่นใจได้ว่าค่าทั้งสองจะไม่ถูกแก้ไขจากภายนอกคลาส Game แน่นอน

  • Logic ที่ใช้ตรวจสอบเลขที่ทาย ได้ถูกย้ายไปอยู่ในเมธอด guess() ของคลาส Game ทำให้ภายนอกคลาสไม่ต้องรู้ logic นี้ โค้ดภายนอกคลาสเพียงแค่เรียกเมธอด guess() โดยส่งเลขที่ทายเข้าไป เมธอด guess() ก็จะคืนค่ากลับมาเป็น -1, 1 หรือ 0 เมื่อเลขที่ทายน้อยไป, มากไป และถูกต้อง ตามลำดับ นอกจากนี้เมธอด guess() ยังเพิ่มค่า guessCount ให้อัตโนมัติด้วยทุกครั้งที่เมธอดถูกเรียกใช้

การสร้างคลาส Game นี้ จึงช่วยให้เราเรียกใช้งานโค้ดที่เป็น engine ของเกมทายเลข (การสุ่มคำตอบ, การตรวจสอบเลขที่ทาย, การนับจำนวนครั้งที่ทาย) ได้อย่างเป็นระบบมากขึ้น และยังช่วยให้เราสามารถนำ engine นี้ไป re-use ในแอพอื่นๆได้ง่ายขึ้นด้วย ดังที่ผู้อ่านจะได้เห็นในบทถัดไป ซึ่งเราจะสร้างเกมทายเลขในรูปแบบ Flutter application ที่มี UI (การรับ input จากผู้ใช้ และการแสดงผล) แตกต่างจาก console application ในบทนี้โดยสิ้นเชิง แต่แอพทั้งสองใช้ engine ของเกมทายเลขอันเดียวกัน นั่นก็คือคลาส Game ที่สร้างขึ้นในบทนี้

สร้าง Enumerated Type สำหรับค่าที่ส่งคืนจากเมธอด guess()

ตอนนี้เมธอด guess() ในคลาส Game คืนค่า -1, 1 หรือ 0 เพื่อบอกว่าเลขที่ทายน้อยกว่า, มากกว่า หรือเท่ากับคำตอบ แต่ค่าเหล่านี้ไม่สื่อความหมาย ทำให้โค้ดอ่านยาก

เพื่อแก้ปัญหานี้ เราจะสร้าง enumerated type เพื่อกำหนดเป็น type ของค่าที่ส่งคืนออกไปจากเมธอด guess()

  1. เพิ่มโค้ดต่อไปนี้เข้าไปในไฟล์ game.dart โดยใส่ไว้ก่อนหรือหลังคลาส Game ก็ได้ แต่ห้ามอยู่ภายในคลาส Game
enum GuessResult {
  tooLow,
  tooHigh,
  correct,
}

คือการสร้าง enumerated type (หรือเรียกสั้นๆว่า enum) ชื่อ GuessResult ที่มีค่าที่แตกต่างกันไปได้ 3 ค่า คือ tooLow, tooHigh และ correct

  1. ปรับปรุงเมธอด guess() ในคลาส Game ให้ส่งคืนค่าเป็น type GuessResult
GuessResult guess(int guessedNumber) {
  _guessCount++;
  if (guessedNumber < answer) {
    return GuessResult.tooLow;
  } else if (guessedNumber > answer) {
    return GuessResult.tooHigh;
  } else {
    return GuessResult.correct;
  }
}
  1. ปรับปรุงโค้ดในไฟล์ lib/guess_the_number.dart ตรงส่วนที่ใช้ if-else ในการตรวจสอบค่าที่ส่งกลับมาจากเมธอด guess() โดยแทนที่ตัวเลข -1 และ 1 ด้วย GuessResult.tooLow และ GuessResult.tooHigh ตามลำดับ
/* ตัวแปร result จะถูก infer เป็น type GuessResult
   เนื่องจากตอนนี้ return type ของ guess() คือ GuessResult */
var result = game.guess(guessedNumber);
if (result == GuessResult.tooLow) {
  print('║ Too low! Try again.');
  print('╟────────────────────────────────────────');
} else if (result == GuessResult.tooHigh) {
  print('║ Too high! Try again.');
  print('╟────────────────────────────────────────');
} else {
  isCorrect = true;
  print('║ Bravo! The number is ${game.answer}.');
  print('║ You got it in ${game.guessCount} guesses!');
  print('╟────────────────────────────────────────');
}

และหากเปลี่ยนจาก if-else มาเป็น switch-case ก็จะยิ่งทำให้โค้ดอ่านง่ายขึ้น

var result = game.guess(guessedNumber);
switch (result) {
  case GuessResult.tooLow:
    print('║ Too low! Try again.');
    print('╟────────────────────────────────────────');
  case GuessResult.tooHigh:
    print('║ Too high! Try again.');
    print('╟────────────────────────────────────────');
  case GuessResult.correct:
    isCorrect = true;
    print('║ Bravo! The number is ${game.answer}.');
    print('║ You got it in ${game.guessCount} guesses!');
    print('╟────────────────────────────────────────');
}

สิ่งที่ switch-case ของ Dart แตกต่างจากภาษาอื่น เช่น C#, Java, JavaScript ก็คือ หลังจากจบการทำงานในแต่ละ case แล้ว มันจะข้ามไปที่ตอนจบของ switch ทันทีโดยไม่ทำ case อื่นๆที่อยู่ถัดไป เราจึงไม่จำเป็นต้องใส่คำสั่ง break ปิดท้ายในแต่ละ case เอง ในขณะที่ภาษาอื่นเราต้องใส่ break เอง ถ้าไม่ต้องการให้ fall through ไปยัง case ถัดไปด้วย

NOTE

ยกเว้น case ว่าง (case ที่ไม่มี body) ภาษา Dart จะ fall through ไปยัง case ถัดไปให้ และหากต้องการให้ case หนึ่งไปทำงานต่อใน case อื่นด้วย ให้ใช้ continue ตามด้วย label ของ case ที่ต้องการให้ทำงานต่อ ดังตัวอย่าง

switch (command) {
  case 'OPEN':
    executeOpen();
    continue newCase; // ทำงานต่อที่ label newCase

  case 'DENIED': // fall through ไปยัง case CLOSED
  case 'CLOSED':
    executeClosed(); // ทำงานทั้ง case DENIED และ CLOSED

  newCase:
  case 'PENDING':
    executeNowClosed(); // ทำงานทั้ง case OPEN and PENDING
}
  1. รันแอพแล้วลองเล่นเกมดู การทำงานทั้งหมดจะยังคงเหมือนเดิม

เพิ่มการทำงานของเกมให้เล่นได้หลายรอบ

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

  1. ปรับปรุงโค้ดในไฟล์ bin/guess_the_number.dart ดังนี้
import 'dart:io';

import 'package:guess_the_number/guess_the_number.dart';

void main(List<String> arguments) {
  bool playAgain;
  do {
    playGame();
    String? input;
    do {
      stdout.write('Play again? (y/n): ');
      input = stdin.readLineSync();
    } while (input?.toLowerCase() != 'y' && input?.toLowerCase() != 'n');

    playAgain = input?.toLowerCase() == 'y';
  } while (playAgain);
}

เราครอบการเรียกฟังก์ชัน playGame() ด้วย do-while loop (ชั้นนอก) ซึ่งจะทำงานอย่างน้อย 1 รอบ และเมื่อ playGame() จบการทำงาน (ผู้ใช้เล่นเกมจบ) ในแต่ละรอบ แอพจะถามว่าต้องการเล่นต่อหรือไม่ แล้วรอรับ input จากผู้ใช้ทางคีย์บอร์ด

การแสดงข้อความถามและรอรับ input นี้ถูกครอบด้วย do-while loop (ชั้นใน) ซึ่งจะออกจาก loop นี้ต่อเมื่อผู้ใช้ป้อน 'y', 'Y', 'n' หรือ 'N' แต่หากเป็นอย่างอื่นนอกเหนือจากนี้ก็จะถามและให้ตอบใหม่ไปเรื่อยๆ

สำหรับ do-while loop ชั้นนอก จะทำซ้ำเมื่อผู้ใช้ตอบ 'y' หรือ 'Y' (ซึ่งจะกลับไปเรียก playGame() อีกครั้ง) และจะออกจาก loop เมื่อผู้ใช้ตอบ 'n' หรือ 'N' ซึ่งแอพจะจบการทำงาน

TIP

เครื่องหมาย ?. คือหนึ่งใน null-aware operators ของภาษา Dart โดย ?. ใช้ในการอ่านค่าพร็อพเพอร์ตีหรือเรียกเมธอดของออบเจค แต่ถ้าหากตัวแปรออบเจคเป็น null โอเปอเรเตอร์นี้จะให้ค่า null แทนที่จะเกิด run-time error ดังตัวอย่าง

var username = person?.name;
  • ถ้าหาก person ไม่เป็น null ตัวแปร username จะเป็นค่า person.name
  • ถ้าหาก person เป็น null ตัวแปร username จะเป็น null
  1. รันแอพแล้วทดสอบการทำงาน
$ dart run
Building package executable...
Built guess_the_number:guess_the_number.

Enter a maximum number to random: 10
The answer is 6.

╔════════════════════════════════════════
║            GUESS THE NUMBER
╟────────────────────────────────────────
║ Guess the number between 1 and 10: 5
║ Too low! Try again.
╟────────────────────────────────────────
║ Guess the number between 1 and 10: 8
║ Too high! Try again.
╟────────────────────────────────────────
║ Guess the number between 1 and 10: 6
║ Bravo! The number is 6.
║ You got it in 3 guesses!
╟────────────────────────────────────────
║                 THE END
╚════════════════════════════════════════
Play again? (y/n): a
Play again? (y/n): yes
Play again? (y/n): y

Enter a maximum number to random: 10
The answer is 9.

╔════════════════════════════════════════
║            GUESS THE NUMBER
╟────────────────────────────────────────
║ Guess the number between 1 and 10: 9
║ Bravo! The number is 9.
║ You got it in 1 guesses!
╟────────────────────────────────────────
║                 THE END
╚════════════════════════════════════════
Play again? (y/n): n

เก็บประวัติการเล่นเกม

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

การเก็บข้อมูลนี้เป็นเพียงการเก็บลงในตัวแปร หรือก็คือหน่วยความจำ RAM ของเครื่องที่รันแอพ ซึ่งข้อมูลจะหายไปเมื่อแอพจบการทำงาน ส่วนวิธีการเก็บข้อมูลในระยะยาว (data persistence) จะอธิบายในบทต่อๆไป

โค้ดที่จะเขียนในหัวข้อนี้ ประกอบด้วย

  • เพิ่มฟีลด์ที่เป็น list (ตัวแปรชนิด List<Game>) ในคลาส Game สำหรับเก็บออบเจค Game ที่ถูกสร้างขึ้นในฟังก์ชัน playGame() ในแต่ละรอบ รวมทั้งเพิ่มเมธอด addToHistory() ที่ใช้เพิ่มออบเจค Game ลงใน list นี้ และเมธอด getHistory() ที่ใช้ return list นี้ออกไปจากคลาส Game

  • เรียกเมธอด addToHistory() ในตอนท้ายของฟังก์ชัน playGame() (หลังจากผู้ใช้ทายเลขถูก) เพื่อเพิ่มออบเจค Game ของการเล่นเกมรอบปัจจุบันลงใน list

  • เรียกเมธอด getHistory() ในตอนท้ายของฟังก์ชัน main() (หลังจากผู้ใช้ตอบว่าไม่ต้องการเล่นเกมต่อ) เพื่อแสดงประวัติการเล่นเกมทั้งหมด

  1. ที่ไฟล์ game.dart ให้เพิ่มโค้ดในคลาส Game ดังนี้
static final _history = <Game>[];

void addToHistory() {
  _history.add(this);
}

static List<Game> getHistory() {
  return _history;
}


String toString() {
  return 'The number is $answer. You got it in $guessCount guesses.';
}

ตัวแปร _history เป็น private field ของคลาส มันจะถูก infer เป็น type List<Game> ตาม type ของค่า <Game>[] ที่กำหนดเป็นค่าเริ่มต้น ซึ่งค่าเริ่มต้นนี้คือ list ว่าง

TIP

ถ้าหากประกาศตัวแปร _history โดยระบุ type annotation จะเขียนได้ว่า

static final List<Game> _history = [];

ตัวแปร _history ถูกระบุเป็น static ด้วย ทำให้มันเป็นตัวแปรของคลาส ไม่ใช่ของออบเจค และจะถูกแชร์ระหว่างออบเจคทุกตัวของคลาส Game (ตัวแปรนี้ไม่ได้มีในแต่ละออบเจคแบบของใครของมัน)

เมธอด addToHistory() เป็น instance method ที่ใช้เพิ่มออบเจค Game ลงใน _history เมธอดนี้จะต้องเรียกจากตัวแปรที่เป็นออบเจค Game ซึ่งจะทำให้ออบเจค Game นั้นๆถูกเพิ่มลงใน _history

เมธอด getHistory() เป็น static method ที่ใช้ return ค่าของ _history ออกไป เมธอดนี้จะเรียกจากคลาส Game โดยไม่ต้องสร้างออบเจคของคลาสก่อน

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

  1. ที่ไฟล์ lib/guess_the_number.dart ให้เพิ่มโค้ดต่อไปนี้ท้ายฟังก์ชัน playGame() (ก่อนปิดวงเล็บ } ของฟังก์ชัน)
game.addToHistory();

ตัวแปร game คือตัวแปรที่เราสร้างออบเจค Game มาเก็บไว้ในตอนต้นของฟังก์ชัน playGame() เราเรียกเมธอด addToHistory() ของมันเพื่อเพิ่มออบเจค Game ของการเล่นเกมรอบปัจจุบันลงใน _history ในคลาส Game

  1. ที่ไฟล์ bin/guess_the_number.dart ให้เพิ่มฟังก์ชัน printSummary() ดังนี้
void printSummary() {
  print('');
  print('Summary:');
  print('');

  var history = Game.getHistory();
  var i = 0;
  for (var game in history) {
    i++;
    print('🚀 Game #$i - $game');
  }
}

และเพิ่มโค้ดต่อไปนี้ท้ายฟังก์ชัน main() (ก่อนปิดวงเล็บ } ของฟังก์ชัน)

printSummary();

ฟังก์ชัน printSummary() จะอ่าน List<Game> จาก Game.getHistory() แล้วแสดงข้อมูลของออบเจค Game ทั้งหมดที่อยู่ใน list นี้ออกมาโดยใช้ for-in loop ซึ่งในแต่ละรอบของ loop, ตัวแปร game จะอ้างอิงไปยังออบเจค Game แต่ละตัวใน list

TIP

เราสามารถเขียน for loop ที่ทำงานเทียบเท่ากับ for-in loop ได้ดังนี้

for (var i = 0; i < history.length; i++) {
  var game = history[i];
  print('🚀 Game #${i + 1} - $game');
}

การแทรกตัวแปร game ซึ่งเป็นออบเจค Game ลงใน string literal ที่ print ออกมา จะทำให้ออบเจค Game ถูกแปลงเป็นสตริง โดย Dart จะเรียกเมธอด toString() ของออบเจค Game และค่าสตริงที่ toString() return กลับมาจะถูกแทรกลงไปตรงตำแหน่งของตัวแปรนั้นใน string literal

  1. รันแอพแล้วทดสอบการทำงาน
$ dart run
Building package executable...
Built guess_the_number:guess_the_number.

Enter a maximum number to random: 10
The answer is 4.

╔════════════════════════════════════════
║            GUESS THE NUMBER
╟────────────────────────────────────────
║ Guess the number between 1 and 10: 2
║ Too low! Try again.
╟────────────────────────────────────────
║ Guess the number between 1 and 10: 4
║ Bravo! The number is 4.
║ You got it in 2 guesses!
╟────────────────────────────────────────
║                 THE END
╚════════════════════════════════════════
Play again? (y/n): y

Enter a maximum number to random: 10
The answer is 8.

╔════════════════════════════════════════
║            GUESS THE NUMBER
╟────────────────────────────────────────
║ Guess the number between 1 and 10: 8
║ Bravo! The number is 8.
║ You got it in 1 guesses!
╟────────────────────────────────────────
║                 THE END
╚════════════════════════════════════════
Play again? (y/n): n

Summary:

🚀 Game #1 - The number is 4. You got it in 2 guesses.
🚀 Game #2 - The number is 8. You got it in 1 guesses.

สรุป

ในบทนี้คุณได้เรียนรู้พื้นฐานของภาษา Dart โดยผ่านการสร้างเกมทายเลขในรูปแบบ console application ถึงแม้จะเป็นแอพที่ไม่น่าสนใจ แต่หลักการต่างๆของ Dart ที่ใช้สร้างแอพตัวนี้มีความสำคัญมาก และจะใช้ต่อยอดไปยังการเขียน Flutter application ในบทต่อๆไป

เนื้อหาที่คุณได้เรียนรู้ในบทนี้ ประกอบด้วย

  • การประกาศตัวแปร ทั้งแบบระบุ type และแบบใช้ var ในการ infer type
  • การแทรกค่าตัวแปรและ expression ลงในค่าสตริง
  • การใช้ final และ const ในการประกาศตัวแปรที่ไม่สามารถเปลี่ยนค่าได้
  • วิธีต่างๆในการกำหนดค่าเริ่มต้นให้กับฟีลด์แบบ final ของคลาส
  • การใช้ do-while loop และ for-in loop
  • การใช้ if-else และ switch-case
  • การสร้างและใช้งาน enumerated type
  • การสร้างคลาส, การสร้างฟีลด์และเมธอดของคลาส, การใช้ getter และ setter
  • Positional parameter และ named parameter ของฟังก์ชัน/เมธอด
  • การสร้าง list และการเพิ่มข้อมูลลงใน list