toString : เขียนยังไง ?


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

พ่อ : เรามาดูกันก่อนว่าใน javadoc comment ของเมท็อด toString ในคลาส Object เขาเขียนไว้ว่าอย่างไร

Returns a string representation of the object. In general, the toString method returns a string that "textually represents" this object. The result should be a concise but informative representation that is easy for a person to read. It is recommended that all subclasses override this method.

เขาก็ไม่ได้บังคับอะไรมาก แค่บอกว่าให้คืนสตริงที่บรรยายตัวออปเจกต์ อย่างสั้นๆ ได้ใจความ เพื่อให้ "คน" อ่าน โดยเขาแนะนำว่า subclass ทั้งหลายของ Object (ซึ่งก็หมายความว่าทุกๆ คลาสนั่นแหละ) ควร override เมท็อดนี้

เราลองมาดูตัวอย่างที่คนอื่นเขาเขียนกัน
01: public class Sample {
02: public static void main(String[] args) {
03: System.out.println(new Object());
04: System.out.println(new Sample());
05: System.out.println(new java.util.Random());
06: System.out.println(new java.awt.Point(2, 3));
07: System.out.println(new java.awt.Button("OK"));
08: System.out.println(new Thread());
09: System.out.println(new java.awt.geom.Point2D.Double(2,3));
10: System.out.println(new IllegalStateException("test"));
11: System.out.println(new java.io.File("C:/temp/data.txt"));
12: System.out.println(new java.util.Date());
13: System.out.println(new Double(23.4));
14: }
15: }
แปลแล้วสั่งทำงานจะได้ผลดังนี้
java.lang.Object@fabe9
Sample@82c01f
java.util.Random@1d8957f
java.awt.Point[x=2,y=3]
java.awt.Button[button0,0,0,0x0,invalid,label=OK]
Thread[Thread-0,5,main]
Point2D.Double[2.0, 3.0]
java.lang.IllegalStateException: test
C:\temp\data.txt
Thu Jan 02 20:15:44 ICT 2004
23.4


ลองมาวิเคราะห์กันดู จากตัวอย่างจะเห็นผลที่ได้ แบ่งคร่าวๆ ได้สามแบบ
  1. แบบที่ใช้ของคลาส Object  จากตัวอย่างคือสามกรณีแรก  ตัว Object เอง ตัว Sample ที่เขียนให้ดู ที่เห็นชัดๆ ว่าไม่ได้ override เมท็อด toString ของ Object และของคลาส Random ที่เขาเองก็คงไม่เห็นประเด็นอะไรที่ต้องให้ผู้ใช้รู้สภาพภายในของออปเจกต์ของตัว random number generator ก็เลยไม่ได้ override เมท็อด toString เช่นกัน
  2. แบบที่เขียน toString ใหม่  จากตัวอย่างคือห้ากรณีถัดมา  ในรูปแบบที่เริ่มด้วยชื่อคลาส (จะได้รู้ว่าเป็นออปเจกต์ของคลาสใด) ตามด้วยข้อมูลภายใน (ที่เป็น public field ของคลาสอยู่แล้ว) ซึ่งรวมบรรจุในวงเล็บ [ ... ]   ยกเว้นกรณี IllegalStateException ที่แสดงรายละเอียดของ exception หลัง :
  3. แบบที่เขียน toString ใหม่เหมือนกรณีบน แต่ไม่มีการแสดงชื่อคลาสให้เสียเนื้อที่  แสดงข้อมูลให้ตรงประเด็นเลย ไม่ว่าจะเป็นชื่อแฟ้ม กรณีนี้เห็นเลยว่ามีการปรับชื่อแฟ้มที่ป้อนให้ตอนสร้าง ไปเป็นแบบมาตรฐานที่ใช้ของ  OS (ในตัวอย่างนี้ทำงานใน MS Windows) เพื่อเก็บภายในออปเจกต์  (สังเกตที่เครื่องหมาย / กับ \ )

คิด :  พ่ออธิบายมาต้องนาน แล้วต้องเขียนยังไงดี ?

พ่อ :  ก็นี่ไง กำลังจะอธิบายพอดี

กรณีแรกนั้นง่าย เพราะไม่ต้องเขียน  ซึ่งถ้าเราตัดสินใจไม่เขียน toString ก็คงต้องเป็นเพราะมันไม่มีประเด็นที่จะแสดงอะไรให้ผู้ใช้ดูเลย เช่นในกรณีของ java.util.Random  หรือกรณีของคลาส Sample ที่เรามีแค่ main ไว้ทดสอบเล่น  หรือกรณีของพวก utility class ที่มีแต่ static methods เท่านั้น เช่นคลาส Math ซึ่งไม่มีการสร้างออปเจกต์แต่อย่างใด ก็ไม่ต้องไปแสดงอะไรใครเห็นหรอก

กรณีที่สอง เป็นกรณีที่ใช้กันทั่วไปเพื่อช่วยในการ debug โปรแกรม  ตัวสตริงเริ่มด้วยชื่อคลาสตามด้วยรายละเอียดของข้อมูลภายในออปเจกต์ มาดูตัวอย่างใน java.awt.Point มีรายละเอียดดังนี้
  public String toString() {
return getClass().getName() + "[x=" + x + ",y=" + y + "]";
}
เขียนง่ายๆ คือเรียก getClass( ).getName( ) ได้สตริงชื่อคลาสของออปเจกต์  (getClass  เป็นเมท็อดของคลาส Object ซึ่งเป็นบรรพบุรุษ เพื่อคืนว่าออปเจกต์นี้เป็นคลาสอะไร ส่วน getName ก็คืนสตริงแทนชื่อของคลาสที่ได้จาก getClass)  นำชื่อคลาสมาต่อกับค่า x และ y ของจุด

มาดูอีกตัวอย่าง toString ของ java.lang.Thread มีรายละเอียดดังนี้
  public String toString() {
ThreadGroup group = getThreadGroup();
if (group != null) {
return "Thread[" + getName() + "," + getPriority() + ","
 + group.getName() +
"]";
}
else {
return "Thread[" + getName() + "," + getPriority() + ","
 +
"" + "]";
}
}
เขียนง่ายกว่ากรณีของ Point เสียอีก เขียน "Thread" เป็นชื่อคลาสตรงๆ เลย ไม่ต้องไปใช้ getClass( ).getName( ) ให้ยุ่งยาก
คิด : เอ๊ะ ผมเคยไปดู toString ของหลายๆ คลาส  เห็นเขาใช้ getClass( ).getName( ) กันเยอะ  ทำไมไม่เขียนแบบของ Thread เล่า  ใส่สตริงชื่อคลาสไปตรงๆ ก็สิ้นเรื่อง อย่างของ Point ก็น่าจะเขียน
  public String toString() {
return "Point[x=" + x + ",y=" + y + "]";
}
ง่ายกว่า  พ่อว่าไง ?

พ่อ :  กำลังจะบอกพอดีเลยว่า ที่เขาใช้ getClass().getName() ก็เพราะเขาเขียนไว้เผื่อให้ลูกหลานใช้ด้วย สมมติว่าเราเขียนคลาสใหม่ชื่อ MyThread ดังนี้
  public class MyThread extends Thread {
public static void main(String[] args) {
System.out.println(new MyThread().toString());
}
}
โปรแกรมข้างบนนี้เราสร้าง MyThread extends Thread  การ println ออปเจกต์ของ MyThread ก็จะเรียก toString ของ Thread ซึ่งเป็นคลาสพ่อ ตกทอดมาให้ลูก  เมื่อแปลและสั่งทำงานจะได้

Thread[Thread-0,5,main]

ดูซิว่ามันไม่ถูก  เพราะเราสั่ง toString กับออปเจกต์ของ MyThread แต่สิ่งที่แสดงออกมากลับบอกว่าเป็นของ Thread ที่เป็นเช่นนี้ก็เพราะ toString ของ Thread ดันไปเขียนคำว่า "Thread" ฝังใน toString

แต่ถ้าเราใช้ getClass().getName() แทน ก็จะได้ผลเป็น

MyThread[Thread-0,5,main]

โดยคำว่า MyThread นั้นได้มาจาก getClass().getName() ไปคิดเอาตอน runtime อ่านแล้วสื่อความหมายกว่า

โดยสรุป : ถ้าอยากแสดงชื่อคลาสใน toString  จงใช้ getClass().getName() แทนการเขียนชื่อคลาสตรงๆ ลงไป

คิด : แล้วส่วนที่แสดงข้อมูลภายในออปเจกต์ที่ตามหลังชื่อคลาสล่ะครับ ต้องเขียนอย่างไร ?

พ่อ : ขอแจงเป็นข้อๆ ดังนี้
  1. เขียนให้สั้นๆ ได้ใจความ อ่านแล้วรู้ว่าข้อมูลแต่ละตัวคืออะไร ดูตัวอย่างไม่ดีของ Thread ข้างบนนี้สิ อ่านแล้วรู้ไหมว่า แต่ละตัวคืออะไร ต้องเดาหรือไม่ก็ไปดู API help ถึงจะรู้  ถ้าไปดูของ Point ดูปั๊ปก็รู้ปุ๊ป ว่าแต่ละตัวคืออะไร
  2. ข้อมูลอะไรที่สำคัญ คิดว่ามีประโยชน์ต่อผู้ใข้ทั่วไป คิดว่าควรให้ ก็ให้  อย่าหวง  อย่าหวงเพราะขี้เกียจเขียน  เพราะถ้าเขียนครึ่งๆ กลางๆ บางตัวให้บางตัวไม่ให้ ผู้ใช้จะอึดอัด เสียชื่อ คุณภาพของคลาสก็ต่ำลง  ขอเน้นนะว่าให้เฉพาะที่คิดว่าสำคัญ มีประโยชน์
  3. ข้อมูลใดที่ให้ผ่าน toString ก็ต้องมั่นใจว่าเป็น public field หรือไม่ก็ต้องมีบริการให้ผ่านเมท็อด (ที่เรียกกันว่า accessor) ด้วย  เช่นกรณีของ Thread เขาให้ thread name, priority และ thread group  ทาง toString เขาก็มีเมท็อด getName, getPriority, และ getThreadGroup  ด้วย   เพราะถ้าเราไม่ให้บริการทางเมท็อด  แล้วผู้ใช้อยากได้ เขาก็อาจใช้ toString แล้วไป "แงะ" จากสตริงที่ได้มา  ซึ่งเป็นวิธีที่ไม่ดี เพราะถ้าเขาแงะแบบนี้ ในอนาคตเราไปแก้ตัว toString ก็ย่อมต้องกระทบถึง "การแงะ" ของเขาแน่ๆ
  4. ถ้าเราไม่ตั้งใจกำหนดรูปแบบของสตริงที่คืนกลับไป  นั่นคือรูปแบบของสตริงอาจเปลี่ยนแปลงได้ในรุ่นถัดๆ ไป  และอยากเตือนให้ผู้ใช้อย่าไป "แงะ" ข้อมูลที่ได้จาก toString ก็ต้องเขียนเจตจำนงนี้ใน javadoc comment  ดูตัวอย่างของ Point
  /**
* Returns a string representation of this point and its location
* in the (<i>x</i>, <i>y</i>) coordinate space. This method is
* intended to be used only for debugging purposes, and the content
* and format of the returned string may vary between implementations.
* The returned string may be empty but may not be <code>null</code>.
*
* @return a string representation of this point
*/

public String toString() {
return getClass().getName() + "[x=" + x + ",y=" + y + "]";
}

ว่าไง พอมั่นใจที่จะเขียน toString ได้หรือยัง ?


คิด :  เดี๋ยวๆ ยังไม่หมด  พ่อยังไม่ได้อธิบาย toString แบบที่สาม อย่างเช่นของ File, Date, และ Double ในตัวอย่างตอนต้นเลย

พ่อ : เกือบลืม กรณีที่สาม มักเป็นการคืนสตริงที่มีรูปแบบแน่ชัด (ระบุชัดเจนใน API  เลยว่าแต่ละตัวแต่ละตำแหน่งคืออะไร) เช่นกรณีของ java.util.Date เขาก็แสดงตามมาตรฐานสากล  ในรูปแบบ dow mon dd hh:mm:ss zzz yyyy  ซึ่งพร้อมใช้ประกอบเป็นผลลัพธ์ของโปรแกรมได้ทันที  จะเขียนแบบนี้ ก็ไม่จำเป็นต้องแสดงชื่อคลาส เพราะต้องการเห็นเนื้อๆ ของออปเจกต์ เพือนำไปใช้เลย ได้ชื่อคลาสมาก็เกะกะ  (ไม่เหมือนกรณีที่สองที่ให้โปรแกรมเมอร์ดู เลยต้องรู้ละเอียดหน่อย)

สิ่งที่ต้องคิดหนักก็คือรูปแบบของผลลัพธ์  ถ้าเรากำหนดรูปแบบแล้ว อีกทั้งไปเขียนใน javadoc comment ที่ปรากฏเป็น semantic contract ไว้ ก็ต้องปฏิบัติตามสัญญาที่เขียนไว้ ในภายภาคหน้า เกิดอยากเปลี่ยนใจ จะต้องถูกกล่าวหาแน่นอนว่าใจโลเล ไม่ทำตามสัญญา   ผู้ใช้ที่เคยใช้คลาสเราโดยพึ่งรูปแบบที่เราสัญญาไว้ ก็ล้มครืนแน่ถ้าเราไปซี้ซั้วเปลี่ยนในอนาคต  ลองอ่านของ java.util.Date เขาเขียนไว้ชัดเจนแค่ไหน

Converts this Date object to a String of the form:
 dow mon dd hh:mm:ss zzz yyyy
where:
dow is the day of the week (Sun, Mon, Tue, Wed, Thu, Fri, Sat).
mon
is the month (Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec).
dd
is the day of the month (01 through 31), as two decimal digits.
hh
is the hour of the day (00 through 23), as two decimal digits.
mm
is the minute within the hour (00 through 59), as two decimal digits.
ss
is the second within the minute (00 through 61, as two decimal digits.
zzz
is the time zone (and may reflect daylight saving time). Standard time zone abbreviations include those recognized by the method parse. If time zone information is not available, then zzz is empty - that is, it consists of no characters at all.
yyyy
is the year, as four decimal digits.

คิด : ครับๆ พอแล้วครับ ผมรู้แล้วครับว่า ต้องจำข้อความ
"the content and format of the returned string may vary between implementations"
เอาไว้เติมใน javadoc comment เป็นการป้องกันตัวเองไม่ให้ถูกว่าในอนาคต กลัวเสียชื่อ

เกร็ดกาแฟ (9 ม.ค. 2547)

Copyright 2004 Somchai Prasitjutrakul