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

ภาษาที่ใช้เขียนโค้ดใน 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
สร้างโปรเจค
- ให้สร้าง Dart project ใหม่ชื่อ guess_the_number โดยพิมพ์คำสั่งที่ command line ดังนี้
dart create guess_the_number
คำสั่งนี้จะสร้างโปรเจค ซึ่งก็คือโฟลเดอร์ชื่อ guess_the_number ขึ้นมาภายในโฟลเดอร์ปัจจุบัน
- ทำการ 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()
นี้

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

ไฟล์ pubspec.yaml
ข้อมูลในไฟล์นี้คือ metadata ของแอพ เช่น
- ชื่อแพคเกจของแอพ
- คำอธิบายเกี่ยวกับแอพ
- เวอร์ชันของแอพ
- เวอร์ชันของภาษา Dart ที่แอพของเราต้องการ
- ชื่อและเวอร์ชันของแพคเกจ (Dart package) ที่มีการใช้งานในแอพของเรา

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

จะมีข้อความบอกว่ากำลัง 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 แล้วลองป้อนค่าต่างๆเข้าไปดู

คุณจะพบว่าถ้าป้อนค่าที่ไม่ใช่รูปแบบของเลขจำนวนเต็ม แอพจะไม่รับค่านั้น และจะขอให้ป้อนใหม่ อันเนื่องมาจากการทำงานของ 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 แล้วลองเล่นเกมดู

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
ที่เป็น typedouble
ทั้งคู่ (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 แทน
Game
ปรับปรุง Constructor ของคลาส ให้ปรับปรุง 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
ถูกประกาศเป็น typeint
และมีค่าดีฟอลต์เป็น 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
Game
ให้ใช้ Named Parameter
ปรับปรุง Constructor ของคลาส ให้ปรับปรุงคลาส 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()
ปรับปรุงคลาส - เพิ่มเมธอด
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;
}
}
}
- ปรับปรุงโค้ดในไฟล์
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
ที่สร้างขึ้นในบทนี้
guess()
สร้าง Enumerated Type สำหรับค่าที่ส่งคืนจากเมธอด ตอนนี้เมธอด guess()
ในคลาส Game
คืนค่า -1, 1 หรือ 0 เพื่อบอกว่าเลขที่ทายน้อยกว่า, มากกว่า หรือเท่ากับคำตอบ แต่ค่าเหล่านี้ไม่สื่อความหมาย ทำให้โค้ดอ่านยาก
เพื่อแก้ปัญหานี้ เราจะสร้าง enumerated type เพื่อกำหนดเป็น type ของค่าที่ส่งคืนออกไปจากเมธอด guess()
- เพิ่มโค้ดต่อไปนี้เข้าไปในไฟล์ game.dart โดยใส่ไว้ก่อนหรือหลังคลาส
Game
ก็ได้ แต่ห้ามอยู่ภายในคลาสGame
enum GuessResult {
tooLow,
tooHigh,
correct,
}
คือการสร้าง enumerated type (หรือเรียกสั้นๆว่า enum) ชื่อ GuessResult
ที่มีค่าที่แตกต่างกันไปได้ 3 ค่า คือ tooLow
, tooHigh
และ correct
- ปรับปรุงเมธอด
guess()
ในคลาสGame
ให้ส่งคืนค่าเป็น typeGuessResult
GuessResult guess(int guessedNumber) {
_guessCount++;
if (guessedNumber < answer) {
return GuessResult.tooLow;
} else if (guessedNumber > answer) {
return GuessResult.tooHigh;
} else {
return GuessResult.correct;
}
}
- ปรับปรุงโค้ดในไฟล์ 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
}
- รันแอพแล้วลองเล่นเกมดู การทำงานทั้งหมดจะยังคงเหมือนเดิม
เพิ่มการทำงานของเกมให้เล่นได้หลายรอบ
เราจะปรับปรุงการทำงานของแอพให้เล่นเกมได้หลายรอบ โดยเมื่อจบเกมจะแสดงข้อความถามผู้ใช้ว่าต้องการเล่นเกมต่อหรือไม่
- ปรับปรุงโค้ดในไฟล์ 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
- รันแอพแล้วทดสอบการทำงาน
$ 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()
(หลังจากผู้ใช้ตอบว่าไม่ต้องการเล่นเกมต่อ) เพื่อแสดงประวัติการเล่นเกมทั้งหมด
- ที่ไฟล์ 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
นั้นๆ ได้แก่ เลขที่เป็นคำตอบ และจำนวนครั้งที่ทาย
- ที่ไฟล์ lib/guess_the_number.dart ให้เพิ่มโค้ดต่อไปนี้ท้ายฟังก์ชัน
playGame()
(ก่อนปิดวงเล็บ}
ของฟังก์ชัน)
game.addToHistory();
ตัวแปร game
คือตัวแปรที่เราสร้างออบเจค Game
มาเก็บไว้ในตอนต้นของฟังก์ชัน playGame()
เราเรียกเมธอด addToHistory()
ของมันเพื่อเพิ่มออบเจค Game
ของการเล่นเกมรอบปัจจุบันลงใน _history
ในคลาส Game
- ที่ไฟล์ 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
- รันแอพแล้วทดสอบการทำงาน
$ 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