วันนี้จะมาเล่าเรื่องของคลาสชุด "วันและเวลา" บน Java ให้ฟังนิดหน่อยครับ

วันเก่า ๆ ...

ตั้งแต่ Java 1.0 เรามีคลาสที่ดูแลเรื่องของ "วันและเวลา" อยู่หนึ่งคลาส ชื่อว่า ... Date ซึ่ง จากมุมมองของนักพัฒนาหลาย ๆ คน คลาสนี้เป็นคลาสที่เรียกได้ว่าออกแบบมาได้แย่ที่สุดของ Java เลยทีเดียว ด้วยเหตุผลที่ว่า มันผูกวันที่และเวลาเอาไว้ในคลาสเดียว

หลังจากนั้นไม่นาน ใน Java 1.1 ทาง Sun ก็มีการเพิ่มคลาสที่เกี่ยวข้องกับเวลาเข้ามาอีกหนึ่งคลาส มีชื่อว่า Calendar ด้วยเหตุผลด้านการจัดการ Timezone การรองรับ Internationalization และตัว year parameter ใน method ต่าง ๆ ของ Date (ที่จะถูก + 1900 เข้าไปอัตโนมัติ) แต่ปัญหาที่สำคัญที่สุดอย่างการเป็น สองคลาสในคลาสเดียว ก็ยังคงอยู่

สองคนในร่างเดียว ?

ทั้ง Date และ Caleandar นั้นเป็นคลาสที่จริง ๆ แล้วเป็น wrapper ครอบเวลาของเครื่องคอมพิวเตอร์ที่มันทำงานอยู่ โดยตัวข้อมูลข้างในนั้นจริง ๆ เป็นตัวเลขที่นับจำนวนมิลลิวินาทีนับตั้งแต่เที่ยงคืนของวันที่ 1 มกราคม 1970 ตัว method ที่เอาไว้หาว่า ตอนนี้วันที่เท่าไหร่ เดือนอะไร ปีอะไร กี่โมง กี่นาที กี่วินาที นั้นจะคำนวนว่าไอ้เจ้าค่าที่เก็บไว้นั้นมันผ่านมาจากเวลาข้างต้นมาเท่าไหร่ แล้วคืนค่ากลับออกมา

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

แต่ในมุมมองของผู้ใช้คลาส พอเราเห็นชื่อคลาส เราก็จะนึกว่า เอ๊ะนี่มันคือ "วันที่" นะ (เพราะปฎิทินมันไม่มีเวลาบอก) ดังนั้นเราก็จะคาดหวังว่า ถ้าเรารันโปรแกรมข้างล่าง ในวันที่ 29 มกราคม 2016 แล้วมันควรจะคืนค่า true บอกว่าเป็นวันนี้แหละ

public bool IsToday2016Jan29() {
  Calendar c = new GregorianCalendar(2016, Calendar.JANUARY, 29);
  Calendar today = Calendar.getInstance();
  return today.equals(c);
}

method นี้จะมีโอกาส return true ได้แค่ 1 วินาทีเท่านั้น (ไม่ใช่ทั้งวัน) และเอาเข้าจริง ๆ ต่อให้เราเช็คแต่ละฟิล์ดว่าเป็นวันเดียวกันหรือไม่ ก็มีโอกาสจะไม่ตรงกันได้อีก ขึ้นอยู่กับ timezone ของตัวแปรทั้งสองตัว ...

เดือน ...

อันนี้เป็นบั๊กที่คนเขียน Java แทบทุกคนโดนมาแล้วกับตัว ... ลองดูโค๊ดข้างล่างนี้นะครับ ...

// create a calendar with date = January 30, 2015
Calendar c = new GregorianCalendar(2015, 1, 30);

มีใครบอกได้ไหมว่าทำไมมันถึงพังตรงนี้ ? คำตอบคือ ถ้าเราไปดูใน Java Doc เราจะพบว่า เดือนมกราคมมีค่าเป็น 0 และไอ้ค่าเดือน 1 นั่นน่ะครับมันคือ February ... ซึ่งไม่มีวันที่ 30

นี่ยังโชคดี แค่รันแล้วเจอ exception ทุกรอบ ถ้าเกิดว่าเป็นวันอื่น ๆ เราก็จะไปรู้ตัวอีกทีตอนผลลัพท์ผิด นั่นน่ากลัวกว่าเยอะครับ

โค๊ดที่ถูกต้องสำหรับโจทย์ด้านบน คือ

Calendar c = new GregorianCalendar(2015, Calendar.January, 30);

สำหรับเราคนไทย เราใช้เดือนเป็นชื่อ ไม่ว่าทั้งภาษาไทยและภาษาอังกฤษ คนฝรั่งส่วนใหญ่ก็ยังพอจะโชคดี เพราะใช้ชื่อเดือนเหมือนกัน แต่ความซวยมันไปตกที่คนญี่ปุ่นครับ เพราะว่าในภาษาญี่ปุ่น มันไม่มีชื่อเดือนครับ พวกเขาจะเรียก เดือนหนึ่ง (一月 ishigatsu) เดือนสอง (二月nigatsu) เดือนสาม (三月 sangatsu) อะไรแบบนี้ ถ้าจำไม่ผิด ภาษาเกาหลีและจีนก็เป็นแบบนี้เช่นกัน

อีกอย่างคือนี่คือการอ้างอิงปฎิทิน Gregorian ที่มี 12 เดือน แต่ ไม่ใช่ทุกปฎิทินจะมี 12 เดือน (ปฎิทินแบบฮิบบรูวจะมี 13 เดือนในบางปี ส่วนแบบเขมรนั้นบางปีจะมีเดือน 8 สองเดือน!) ดังนั้นนี่เป็นอีกจุดที่เป็นปัญหาในการออกแบบคลาสชุดนี้

ดังนั้น เพื่อให้ระบบเดือนครอบคลุมหลายๆ ภาษา และหลายๆ ปฎิทิน การเรียกเดือนนั้นจะเรียกเป็นตัวเลข นับตั้งแต่ 1 จนถึง 12 หรือ 13 แล้วแต่ปฎิทินที่ใช้ครับ ทั้งนี้การเรียกเดือนเป็นตัวเลขนั้นก็สอดคล้องกับมาตรฐาน ISO8601 ด้วยครับ แต่มาตรฐานนี้เขาใช้ Gregorian เป็นฐานน่ะนะ

interface สุดพิศดาร

คลาส Date มีลักษณะพิเศษคือมันเป็น Immutable Class ก็คือ เมื่อสร้างเสร็จแล้วใครจะไปเปลี่ยนอะไรมันไม่ได้ เมื่อสร้างเสร็จแล้ว ถ้าเกิดจำเป็นจะต้องเปลี่ยนวันที่ใหม่ ทางเดียวที่ทำได้คือ ... สร้าง object ใหม่ครับ

สำหรับ Calendar นั้นดีกว่าตรงที่มันยังพอจะเป็น Mutable Class ที่เรายังพอเปลี่ยนค่าข้างในได้บ้าง แต่ด้วยความที่มันเป็นปฎิทิน มันถูกออกแบบมาให้รองรับกับปฎิทินหลาย ๆ แบบ ดังนั้นแทนที่มันจะมี getDate(), getYear() หรืออะไรแบบนี้ พี่ท่านดันมาพร้อมกับ method เดียว นั่นคือ get(int field) เป็นอะไรที่น่าหงุดหงิดมาก เพราะเวลาเราเขียนเราก็จะคิดว่าเฮ้ยมันน่าจะมีเมธอดนี้นะ ปัญหาอีกอีกอย่างคือถ้าใส่ตัว input ผิดโปรแกรมก็พัง ... แถม input ดันเป็น int ธรรมดาอีก (ต้องเข้าใจว่ายุคนั้นเรายังไม่มี enum นะครับ)

คือพูดกันตรง ๆ เลยว่า Calendar เนี่ยเป็นอะไรที่น่ารำคาญมากเวลาใช้ ...

ปีใหม่ใหม่

สำหรับท่านที่อ่านถึงตรงนี้แล้ว ขออนุญาตพักเบรค ขอเสนอเพลงที่ว่า "ปีใหม่ใหม่" จากคุณโรส สิรินทิพย์นะครับ

ชีวิตยังมีความหวังในปีใหม่ใหม่ ...

ที่ผมจะบอกคือใน Java 8 มีการเพิ่ม Date Time API ตัวใหม่ในแพคเกจที่ชื่อว่า java.time ซึ่งเป็นผลจาก JSR-310 ซึ่ง กว่าจะได้ใช้กันก็ต้องรอกันเป็นสิบปี (ตัว JSR ตัวนี้เริ่มต้นตั้งแต่ปี 2007 ครับ น่าจะประมาณยุค Java 6)

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

  • LocalDate เป็นคลาสที่เก็บวันที่โดยไม่มีเวลามาเกี่ยวข้อง คือเก็บแค่ วัน เดือน ปี แค่นั้น ไม่มีเวลา ไม่มีไทม์โซน และอื่น ๆ
  • LocalTime เป็นคลาสที่มีแต่เวลา ไม่มีวันที่ มีแค่ ชั่วโมง นาที วินาที แค่นั้น ไม่มีไทม์โซนด้วยครับ
  • LocalDateTime ก็ตามชื่อ คือมีแค่ วัน เดือน ปี และ ชั่วโมง นาที วินาที ไม่มีไทม์โซน
  • ZonedDateTime อันนี้จะมีไทม์โซนเข้ามาเกี่ยวแล้วครับ
  • OffsetDateTime คล้าย ๆ ข้างบน แต่จะเป็นการระบุ offset แทนที่จะระบุ timezone คือ มีหลาย timezone ที่มี offset เท่ากันครับ ถ้าเกิดว่าตัวชื่อ timezone ไม่สำคัญ เราก็สามารถใช้ OffsetDateTime ได้

นอกจากนี้ java.time ยังมีคลาสที่ใช้ในการคำนวนหาระยะเวลานับจากจุดหนึ่งไปยังอีกจุดหนึ่งของแกนเวลา (เท่ห์ไหม ?) อีกด้วย

  • Period เป็นระยะเวลานับเป็นวัน สามารถคำนวนหาเป็นเดือน หรือปีได้ด้วย
  • Duration เป็นระยะเวลานับเป็นวินาที สามารถเอาไปคำนวนเป็นระยะอื่น ๆ ได้เช่นกัน

การคำนวนเวลาทำได้ง่ายขึ้นมากครับ เพราะว่าเราไม่จำเป็นต้องเอาไอ้คลาสสองคลาสมาทำเป็นทุกสิ่งทุกอย่างแล้ว โค๊ดก็เขียนง่ายและอ่านง่ายขึ้น อย่างไอ้ฟังก์ชันที่หาว่าวันที่ที่ใส่เข้ามาเป็นวันนี้หรือเปล่า เราสามารถยุบเหลือ date.equals(LocalDate.now()) ได้เลย

ส่วนเรื่องเดือนก็แก้แล้วเหมือนกัน คลาสชุดนี้ใช้เดือน 1-12 ครับ อย่าง January 30, 2015 ก็เขียนว่า

LocalDate date = LocalDate.of(2015, 1, 30) 

ไม่มีการหลงละว่า เดือน 1 คือเดือนอะไรกันแน่

ความเหนือชั้นอีกอย่งของ java.time คือคลาสในชุดนี้มีชื่อที่สือความหมายว่าตัวมันคืออะไร ในขณะที่ java.util.Date และ java.util.Calendar นั้นมีชื่อที่ไม่ได้สื่อถึงการออกแบบและวิธีใช้งานอย่างชัดเจน ทำให้คนใช้กันแบบผิด ๆ

สำหรับคนที่ 'ปีใหม่ใหม่' ยังมาไม่ถึง

สำหรับคนที่ยังคงติดอยู่กับวังวนของ Java 7 นะครับ อันที่จริงก่อน JSR-310 จะถูกสร้างขึ้น ก็มี library ตัวหนึ่งที่ชื่อว่า Joda-Time ที่ออกแบบมาเพื่อสู้รบกับความห่วยของ Date และ Calendar (อันที่จริงจะโทษสองคลาสนี้ก็ไม่ถูกนัก แค่ว่าชื่อมันไม่สื่อถึงสิ่งที่มันเป็นเท่านั้นเอง) Joda-Time ได้รับความนิยมสูงมาก ใน mvn central repository นั้นมีบางเวอร์ชันที่ถูกนำไปใช้งานมากกว่า 450 artifact (ก็ประมาณว่าซอฟต์แวร์ + เวอร์ชันน่ะครับ

Joda-Time มีลักษณะใกล้เคียงกับ Java 8 Date/Time API มากทีเดียว ชื่อคลาสก็ใกล้เคียงกัน คอนเซพท์ก็เหมือนกัน ดังนั้นเรียนรู้ไปไม่เสียหายครับ

ทั้งนี้ผู้พัฒนา Joda-Time แนะนำว่า ถ้าเป็นไปได้ควร migrate โค๊ดไปใช้ Java 8 Date/Time ไปเลยดีกว่าถ้าทำได้ (เข้าใจว่าคงไม่อยากดูแลแล้ว เพราะมีตัวที่ทำงานได้ใกล้เคียงกันมาในตัว standard library เรียบร้อยแล้ว)

ก็ลองศึกษากันดูนะครับ

อันนี้เป็นวิธีที่ผมใช้ นอกเหนือจากพวก style guide ที่ให้มาตามภาษาต่าง ๆ เอามาแชร์ให้ฟังกันเผื่อว่าน่าสนใจนะครับ

ชื่อ Method ควรสื่อถึงการกระทำ (action)

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

โดยทั่ว ๆ ไปเราก็จะเขียนชื่อ method เป็น verb + object ตามไวยากรณ์ภาษาอังกฤษ อย่างเช่น

  • println()
  • getCount()
  • calculate()

ตัว verb อาจจะเป็นเอกพจน์ หรือพหูพจน์ก็ได้ แต่ตาม style guide ส่วนใหญ่มักจะระบุให้เขียนเป็นเอกพจน์ ทั้ง ๆ ที่มันมักใช้กับตัวแปรที่เป็นเอกพจน์ก็ตาม (เข้าใจว่าเป็นการประหยัดหน่วยความจำครับ ได้ 1byte :)) อันนี้เราก็เขียนตามไกด์เลย ไม่ต้องกังวลอะไร

get กับ set ใช้กับ getter และ setter ตามลำดับ

อันนี้ไม่ได้เกี่ยวกับการ์ตูนหุ่นยนต์ หรือวอลเล่ย์บอลแต่อย่างใด getter กับ setter เป็น method ที่ทำหน้าที่เป็นตัวดึงข้อมูลออกจากวัตถุ กับ กำหนดข้อมูลเข้าไปในวัตถุ (ตามลำดับ) อย่างเช่น ...

class Student {
  public Student(String name) { setName(name); }
  private String name;
  public String getName(){ return name; }
  public String setName(String name) { this.name = name;}
}

ตามความหมายแล้ว getter เป็นการดึงข้อมูลออกมาจากวัตถุ ก็คือสิ่งที่ตัววัตถุนั้นเป็นเจ้าของอยู่ แต่บางครั้งเราจะเจอ method แบบนี้

class StudentUtil {
  public static Student getNewStudent(String name) { 
    return new Student(name); 
  }
}

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

เขียนชื่อ method ตาม design pattern

design pattern เป็นรูปแบบการออกแบบคลาสที่เป็นทำกันบ่อย ๆ เป็นรูปแบบซ้ำ ๆ ซึ่งมีโปรแกรมเมอร์ 4 ท่านสังเกตและนำเอารูปแบบที่ซ้ำ ๆ กันมารวบรวมออกมาเป็นหนังสือที่ชื่อว่า Design Patterns โดยกำกับชื่อเอาไว้ว่ารูปแบบไหนมีชื่อเป็นอย่างไร

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

ผมจะยกอีกสักตัวอย่าง คือ Builder ซึ่งเป็นคลาสที่ทำหน้าที่สร้างวัตถุใหม่เช่นกัน แต่ว่าวิธีการสร้างจะซับซ้อนกว่า อย่างเช่น class Uri.Builder ใน Android เนี่ยจะใช้ในการสร้าง uri ซึ่งเจ้าคลาสนี้จะมี method จำนวนหนึ่งในการตั้งค่าพารามิเตอร์ของวัตถุที่มันกำลังสร้างอยู่ และมี method ชื่อ build() ในการเอา parameter ทั้งหมดที่มันรับมาก่อนหน้านี้ไปสร้างเป็นวัตถุใหม่ (ต่างกับ create() ที่มักจะใช้ parameter ของ method ในการสร้างวัตถุ)

ผมยกตัวอย่างการใช้คลาสนี้จาก StackOverflow นะครับ แต่จะดัดแปลงให้ดูเข้าใจง่ายก่อน (วิธีใช้ที่เหมาะสมจริง ๆ อยู่ในลิงค์ ดูเองนะครับ)

Uri.Builder builder = new Uri.Builder();
builder.scheme("https");
builder.authority("www.myawesomesite.com");
builder.appendPath("turtles");
builder.appendPath("types");
builder.appendQueryParameter("type", "1");
builder.appendQueryParameter("sort", "relevance");
builder.fragment("section-name");
Uri uri = builder.build();

โค๊ดชุดนี้สร้าง uri ว่า https://www.myawesomesite.com/turtles/types?type=1&sort=relevance#section-name ครับ อ้อ ... URL เป็น URI ประเภทนึงครับ

method ที่ใช้รับ event (event handler) นำหน้าว่า on

อันนี้แหกกฎข้อบนนิดหน่อย ตรงที่ onXXX ไม่ได้เป็นรูปของ action ครับ (on เป็น ...preposition เป็นคำนำหน้าจังหวะเวลา ประมาณว่า on sunday, on monday หรืออะไรก็ว่าไป ;-)) ตรงนี้เป็นข้อยกเว้น เป็นรูปแบบที่ใช้กันโดยทั่วไปครับ ไปแหกกฎเขาเดี๋ยวชาวบ้านจะอ่านโค๊ดเราไม่รู้เรื่องน่ะ

ถ้าเกิดว่าดีไซน์ของคลาสมีการกระทำบางอย่างที่ บางส่วนถูกกำหนดตายตัว และบางส่วนสามารถแก้ไขได้ในคลาสย่อย ให้แยกเป็นสอง method โดย method ที่แก้ไขได้ให้ขึ้นต้นด้วยคำว่า do

สมมติว่า เรามีคลาสที่เก็บข้อมูลแล้วมีการตรวจสอบข้อมูล (validation) แล้วเราดันจำเป็นต้องเคลียร์เจ้า errorList ทุกครั้งที่มีการ validate เราก็สามารถเขียนแบบ

class abstract Data {
  private List<Error> errorList;
  public final void validate() {
    errorList.clear();
    doValidate(errorList);
  }
  public abstract void doValidate(List<Error> errorList);
}

class abstract IntegerData {
  private int value;
  private static final int MAX_VALUE = 100000;
  public void doValidate(List<Error> errorList) {
    if(value > MAX_VALUE ) 
      errorList.add(new MoreThanMaximumWarning(value, MAX_VALUE));
    else if(value < 0) {
      errorList.add(new LessThanZeroError(value);
      return;
    }
  }
}

เจ้า doValidate เนี่ยเป็น method ที่ทำงานในส่วนที่ถูกเปลี่ยนแปลงได้จากคลาสย่อย ที่เราใส่คำว่า do ข้างหน้าเพื่อเป็นการบอกว่าจริง ๆ แล้วมันจะถูกเรียกจาก method ไหน (ซึ่งตามหลักแล้วก็ไม่ควรมีมากกว่า 1 method ที่เรียกครับ) เหมือนกับเป็นตัวย้ำนั่นแหละ

เล่าเรื่อง Java บ้าง นาน ๆ ที (หลัง ๆ ไม่ค่อยได้จับครับ ไม่มีโอกาสเท่าไหร่) วันนี้พอดีไปเปิดโค๊ดคนอื่นดู เป็นคลาสที่ออกแบบไว้เป็น utility class แบบว่าย่ออะไรที่ใช้งานบ่อย ๆ ให้เป็นคลาสเดียวกัน ดูผ่าน ๆ ก็ไม่มีปัญหา แต่ว่าผมก็เจออะไรเกี่ยวกับการจัดการ Exception อยู่

ผมว่าหลาย ๆ คนคงเคยเขียนโค๊ดจับ Exception แบบ

public static Socket connect(String host) {
  Socket s = null;
  try {
    s = new Socket(host, 80);
  } catch (Exception e) {
  }
  return s;
}

แล้วไปลุ้นว่า user จะต้องรู้ว่า เฮ้ย ถ้ามันมีปัญหาอะไรสักอย่างแล้ว เมธอดนี้จะคืนค่าเป็น null นะเออ

นอกจากจะแบบนี้แล้ว บางคนอาจจะใจดีขึ้นมาหน่อยนึง ... เขียนแบบนี้

public static Socket connect(String host) {
  Socket s = null;
  try {
    s = new Socket(host, 80);
  } catch (Exception e) {
    e.printStackTrace();
  }
  return s;
}

เพื่อที่จะให้มันพิมพ์ออกคอนโซลว่ามีปัญหานะเอ้อ

ส่วนตัวผมว่าเขียนแบบนี้ดีกว่าครับ

public static Socket connect(String host) 
    throws UnknownHostException, IOException {
  Socket s = new Socket(host, 80);
  return s;
}

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

อันนี้เหมือนจะเป็นตอนต่อจาก entry ที่เขียนไว้เมื่อเกือบ ๆ 7 ปีที่แล้วครับ

คือโค๊ดอันนั้นเป็นการแสดงว่ามันลูปได้จริง ๆ นะ แต่ปัญหาคือ SDL นั้นไม่มี mixer ในตัวครับ และโปรแกรมนั่นเล่นได้แค่เสียงเดียวเท่านั้น เรียกได้ว่าแทบไม่มีประโยชน์อะไร ... เอาเป็นว่าช่างมันก่อนละกัน วันนี้จะเสนออีกวิธีนึงที่น่าจะใช้ประโยชน์ได้มากกว่า

แต่ก่อนหน้านั้นขออธิบายเรื่องการเล่นไฟล์เสียงแบบ streaming ก่อน

การเล่นเสียงแบบ Streaming

โดยทั่วไปเวลาเราจะเล่นไฟล์เสียง เราจะโหลดข้อมูลทั้งไฟล์ขึ้นไปในบัฟเฟอร์ ในรูปแบบที่ตัว hardware หรือ api สามารถเล่นได้ (มักจะเป็น PCM - Pulse-Code Modulatation) ซึ่งปัญหาคือถ้าไฟล์เสียงเรายาวมาก ๆ มันจะใช้พื้นที่ในหน่วยความจำค่อนมาก โดยทั่วไปก็ราว ๆ 60MB ต่อนาที (คิดที่ PCM 44.1KHz 16bit Stereo ครับ) การโหลดข้อมูลใหญ่ ๆ เข้าในเมโมรีทีเดียวทั้งหมดอาจจะเยอะเกินไป อาจจะทำให้โปรแกรมไม่ทำงานได้

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

พักตรงนี้ก่อน โค๊ดตัวอย่างนี้ใช้ไฟล์จาก Persona 4 OST แต่ผมคงไม่สามารถแปะเพลงนี้ได้ ถ้าสนใจอยากลองฟัง ก็ลองดูคลิปนี้ละกันครับ (คนละเวอร์ชันกันครับ)

ปัญหาเรื่องการเล่นไฟล์เพลงให้ลูปใน SDL_mixer

SDL_Mixer จริง ๆ มีคำสั่งที่ใช้เล่นไฟล์เพลงอยู่ครับ และเราสามารถสั่งให้มันลูปได้ด้วย โค๊ดก็ประมาณนี้

auto music = Mix_LoadMUS("./songs/2.04. Heartbeat, Heartbreak.ogg");
Mix_PlayMusic(music, -1);

ซึ่งสิ่งที่มันทำมันก็เหมือนกับที่ผมเล่าไปข้างบนนั่นแหละ เราเลยไม่ต้องมากังวลเรื่องการเติมบัฟเฟอร์ การสลับบัฟเฟอร์ หรืออะไรทำนองนั้น

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

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

ทางแก้ สำหรับคนที่ยังอยากให้ SDL_mixer

บอกก่อนว่าถ้าไม่อยากใช้ SDL_mixer ก็ทำได้ครับ แต่นั่นคือต้องเขียน mixer เอง เหนื่อยน่าดู อีกวิธีคือการเปลี่ยนไปใช้ API อื่นอย่าง OpenAL เลย (ผมเคยเล่าให้ฟังเมื่อ 7 ปีที่แล้วล่ะมั้ง ลืมละ)

โชคดีอย่าง SDL_mixer มีกลไกที่อนุญาตให้เราเติมบัฟเฟอร์ได้เอง ซึ่งเราจะไปถอดรหัสด้วยวิธีไหนมาจากอะไรก็ได้ แล้วแต่เราเลย ตรงนี้จะเป็นการใช้คำสั่ง Mix_HookMusic() แทนนั่นเอง ปัญหาคือใช้แล้วใช้คำสั่ง Play/Stop ไม่ได้นะครับ เรียกปุ๊บมันเล่นเลย เราต้องเขียนกลไกสั่งให้มันหยุดเอง แต่คำสั่ง Pause/Resume ยังใช้ได้นะ อันนี้ก็ต้องลองกันเอาเองดูนะครับ

ข้างล่างเป็นโค๊ดตัวอย่างครับ

OggVorbis_File vf;
ov_fopen("./songs/2.04. Heartbeat, Heartbreak.ogg", &vf);

Mix_HookMusic([](void *udata, Uint8 *stream, int len) {
  OggVorbis_File *pvf = (OggVorbis_File *) udata;
  const long int start_loop = 447151L;
  const long int end_loop = 3410688L;
  int bitstream;
  int read_total = 0;
  while (true) {
    if (read_total >= len) break;
    auto pos = ov_pcm_tell(pvf);

    if (pos > end_loop)
      ov_pcm_seek(pvf, start_loop);

    int read = ov_read(pvf, (char *) stream + read_total,
                       len - read_total, 0, 2, 1, &bitstream);
    read_total += read;
  }

}, &vf);

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

ผมใช้ตำแหน่งแบบ pcm ซึ่งจะนับจำนวน sample จากจุดเริ่มต้นจนถึงตำแหน่งที่เรากำหนดไว้ ซึ่งจะแม่นยำกว่าการใช้ตำแหน่งแบบเวลา (เช่น วินาที) แต่การจะได้ตำแหน่งนี้มานั้นคงต้องคุยกับ sound design ดี ๆ ล่ะครับ เราสามารถหาตำแหน่งของลูปได้จากโปรแกรมแก้ไขไฟล์เสียง แล้วก็โปรแกรมพวก DAW ครับ (ไฟล์นี้ผมใช้ Sound Forge Audio Studio 10 ที่แถมมากับแลปท็อปผมครับ ... น่าเสียดายที่ Sony ขาย Vaio ไปแล้วจริง ๆ )

2016-01-12 05_13_34-Greenshot

วิธีนี้จะใช้วิธีการเช็คตำแหน่งที่เราอยู่ในปัจจุบันก่อนที่จะสั่ง decode โดยถ้าเราเล่นเกินตำแหน่งลูปไปแล้ว ก็ให้กรอกลับไปยังตำแหน่งเริ่มต้นใหม่ วิธีนี้ยังค่อนข้างหยาบอยู่ เพราะว่าเราเล่น "เกิน" กว่าตำแหน่งลูปก่อนจะวนกลับไป (ตรงนี้จำเป็นเพราะว่าเราถอดรหัสไฟล์ออกมาเป็นข้อมูลก้อนใหญ่ ๆ ก้อนหนึ่งครับ ไม่ใช่ว่าเราโหลดราย sample เลย ดังนั้นมันจะโหลดมาเกินกว่าที่เราตั้งไว้) เป็นสาเหตุว่าทำไมในหลาย ๆ lib ถึงไม่การันตีว่ามันจะลูปที่ตำแหน่งที่เราตั้งไว้ด้วยครับ เพราะมันถอดมาเกินนี่ล่ะ แต่เท่าที่ผมลองฟังดูผมไม่รู้สึกว่ามันมีรอยต่อครับ อาจจะเพราะว่าเลือกจุด loop point ได้ดีด้วย (เป็นจุดที่ amplitude ค่อนข้างต่ำครับ)

อ้อ ถ้าเกิดว่ามันลูปมาไม่เนียน เราสามารถปรับตำแหน่ง loop in/out ได้ครับ โค๊ดตรงนี้ทำงานค่อนข้างเหมือนเดิมในแต่ละครั้ง ถ้าเรามั่วค่าได้ดีรอยต่อมันจะหายไปเอง

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

สุดท้ายคือ วิธีนี้ใช้ custom callback ในการโหลดข้อมูลเข้าบัฟเฟอร์ การที่จะรองรับ file format ต่าง ๆ นั้นเราต้องทำเองหมดเลย (ต่างกับการเล่นไฟล์ด้วยกลไกของ SDL_mixer เอง) ถ้าเราต้องการรองรับหลาย ๆ ฟอร์แมทนี่อาจจะเหนื่อยหน่อยนะครับ :-) ส่วนตัวผมแนะนำว่าให้ใช้แค่ฟอร์แมทเดียวกับทั้งโปรเจคดีกว่า จัดการง่ายกว่าครับ

สุดท้ายทิ้งท้ายด้วยไฟล์โค๊ดตัวอย่าง ลองเข้าไปดูเล่น ๆ นะครับ