Java 8 Date & Time API

วันนี้จะมาเล่าเรื่องของคลาสชุด “วันและเวลา” บน 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 เรียบร้อยแล้ว)

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

วิธีการตั้งชื่อ method

อันนี้เป็นวิธีที่ผมใช้ นอกเหนือจากพวก 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 ที่เรียกครับ) เหมือนกับเป็นตัวย้ำนั่นแหละ

อย่าจับ Exception เร็วเกินไป

เล่าเรื่อง 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 ไม่ใช้โค๊ดเรา แล้วก็ไปเขียนฟังก์ชันใหม่แทน กลายเป็นว่าเกิดโค๊ดที่ซ้ำซ้อนโดยไม่จำเป็นครับ