- คำตอบ Java
humanReadableByteCount ที่เขียนในปี 2010 ถูกยืนยันในงานวิจัยปี 2018 ว่าเป็น โค้ดตัวอย่างจาก Stack Overflow ที่ถูกคัดลอกมากที่สุด แต่ให้ผลลัพธ์ผิดที่ค่าเขตแดนของการฟอร์แมตขนาดไบต์
- โค้ดนี้อาศัยข้อเท็จจริงที่ว่าคำนำหน้าอย่าง
kB, MB, GB เป็นกำลังของ 1000 หรือ 1024 และเลือกหน่วยด้วย การคำนวณลอการิทึม แทนการวนลูป
- บั๊กหลักคือปัญหา ค่าเขตแดนจากการปัดเศษ ที่ทำให้
999,999 bytes ถูกแสดงเป็น "1000.0 kB" ในโหมด SI ทั้งที่ตามสเปก ถ้าช่วงตัวเลขต้องอยู่ระหว่าง 1 ถึง 999.9 ผลลัพธ์ที่ถูกต้องคือ "1.0 MB"
- สำหรับค่าที่ใหญ่กว่านั้น ยังมี ข้อจำกัดด้านความแม่นยำของเลขทศนิยมลอยตัว ของ
double เข้ามาซ้อน ทำให้ค่าอินพุต 999,949,999,999,999,999 ออกมาเป็น 1000.0 PB และการแก้ต้องอาศัยการคำนวณค่าขีดแบ่ง การลดสเกล การแก้แพตเทิร์นบิต และ strictfp
- โค้ดเวอร์ชันสุดท้ายรองรับค่าลบและ
Long.MIN_VALUE ได้ แต่สูญเสียความกระชับแบบเดิมไป และการคัดลอกโค้ดจาก Stack Overflow ควรมาพร้อมกับ การทดสอบ edge case และการระบุที่มา
การทำให้เรียบง่ายที่คำตอบปี 2010 ตั้งใจไว้
- ปัญหาคือการฟอร์แมตจำนวนไบต์ให้เป็นสตริงที่มนุษย์อ่านง่าย
- ตัวอย่าง: แสดง
123,456,789 bytes เป็น "123.5 MB"
- สเปกโดยนัยคือส่วนตัวเลขของสตริงผลลัพธ์ต้องอยู่ ระหว่าง 1 ถึง 999.9 และมี suffix ของขนาดที่เหมาะสมต่อท้าย
- คำตอบก่อนหน้านี้ใช้แนวทางแบบ วนลูป โดยไล่
EB, PB, TB, GB, MB, kB, B จากหน่วยใหญ่ไปเล็ก แล้วเลือกหน่วยแรกที่มีค่าน้อยกว่าจำนวนไบต์
- คำตอบใหม่ใช้
Math.log และ Math.pow เพื่อลดการวนลูปและการแตกแขนง
- ในโหมด SI หน่วยคือ
1000
- ในการเขียนแบบไบนารี หน่วยคือ
1024
- แปลงค่า
exp = log(bytes) / log(unit) เป็นจำนวนเต็มเพื่อใช้เป็นดัชนีของคำนำหน้า
- คำนำหน้าใช้
"kMGTPE" สำหรับ SI และ "KMGTPE" สำหรับไบนารี โดยไบนารีจะเติม "i" ต่อท้าย
สภาพการคัดลอกและกรณีของ OpenJDK
- งานวิจัยของ Sebastian Baltes Usage and Attribution of Stack Overflow Code Snippets in GitHub Projects วิเคราะห์ว่าโค้ดตัวอย่างจาก Stack Overflow ถูกนำไปใช้ในโปรเจ็กต์ GitHub อย่างไร และมีการระบุที่มาหรือไม่
- วิธีวิเคราะห์คือดึงโค้ดตัวอย่างจาก Stack Overflow data dump แล้วนำไปเทียบกับโค้ดใน public GitHub repository
- คำถามหลักคือมีการระบุที่มาตาม สัญญาอนุญาต CC BY-SA 3.0 ของ Stack Overflow หรือไม่
- ผลคือผู้ใช้ส่วนใหญ่ไม่ได้ใส่การระบุที่มาที่เหมาะสม
- คำตอบ ID 3758880 อยู่บนสุดของตารางในงานวิจัย และตอนนั้นมียอดดูหลายแสนครั้งกับอัปโหวตมากกว่า 1,000 ครั้ง
- เมื่อค้นหา
humanReadableByteCount บน GitHub จะพบการใช้งานหลายพันกรณี และใน local repository สามารถตรวจสอบได้ด้วยคำสั่งต่อไปนี้
git grep humanReadableByteCount
- ยังพบโค้ดที่ตรงกันใน repository ของ OpenJDK ด้วย
- โค้ดนั้นไม่มีการระบุที่มา และไลเซนส์ของ OpenJDK ก็ไม่เข้ากันกับ CC BY-SA 3.0
- Sebastian Baltes ถามใน mailing list สำหรับนักพัฒนา OpenJDK ว่าโค้ดนี้ถูกคัดลอกจาก Stack Overflow ไปยัง OpenJDK หรือในทางกลับกัน
- ผู้เขียนคำตอบยังไม่ได้เข้าทำงานที่ Oracle ก่อน commit นั้นจะถูกรวมเข้าไป และก็ไม่ได้มีส่วนร่วมกับแพตช์นั้น
- หลังจากนั้นมีการเปิดอีชู และโค้ดถูกลบออก
บั๊กแรก: ค่าเขตแดนที่มี 999 ต่อเนื่องกัน
- ปัญหาที่ดูน่าสงสัยบนผิวเผินไม่ใช่ต้นเหตุจริง
- ค่าสูงสุดของ
long คือ 2^63 - 1 หรือประมาณ 9.2 × 10^18 จึงไม่ทะลุไปเกินหน่วยหลัง EB
- กรณี
bytes < unit ถูกจัดการโดย if แรกอยู่แล้ว ดังนั้น exp จึงไม่กลายเป็น 0 จนทำให้ charAt(exp - 1) ล้มเหลว
- ปัญหาจริงคือ ค่าเขตแดนจากการปัดเศษ
- อินพุต
999,999 bytes กลายเป็น "1000.0 kB" ในโหมด SI
- ตามสเปกที่กำหนดให้ส่วนตัวเลขต้องอยู่ระหว่าง 1 ถึง 999.9 ผลลัพธ์ที่ถูกต้องคือ
"1.0 MB"
- ณ เวลาที่เขียน บรรดาคำตอบที่โพสต์ไว้ทั้งหมด 22 คำตอบ รวมถึงคำตอบที่ใช้ Apache Commons และไลบรารี Android ต่างก็มีบั๊กนี้หรือรูปแบบใกล้เคียง
- หัวใจของการแก้คือ ค่าขีดแบ่ง ที่ใช้ตัดสินว่าจะเลื่อนเลขชี้กำลัง
exp ไปหน่วยถัดไปเมื่อใด
- จุดที่เปลี่ยนจาก
k เป็น M คือเมื่อค่ากลายเป็น 1 MB ที่ใกล้กว่า 999.9 k นั่นคือ 999,950
- จุดที่เปลี่ยนจาก
M เป็น G คือ 999,950,000
- ในโหมดไบนารี ค่าขีดแบ่งไม่เป็นจำนวนเต็ม จึงต้องใช้
ceil
if (bytes >= Math.ceil(Math.pow(unit, exp) * (unit - 0.05)))
exp++;
บั๊กที่สอง: ขีดจำกัดความแม่นยำของ double
- แม้ใช้การแก้ข้างต้น อินพุต
999,949,999,999,999,999 ก็ยังถูกแสดงเป็น 1000.0 PB ทั้งที่ผลลัพธ์ที่ถูกต้องคือ 999.9 PB
- สาเหตุไม่ใช่ตัวสูตรคณิตศาสตร์เอง แต่เป็น ข้อจำกัดความแม่นยำของ
double
- ในการแทนค่าแบบ IEEE 754 ค่าทศนิยมลอยตัวที่อยู่ใกล้ 0 จะถี่ แต่เมื่อค่าใหญ่ขึ้นจะห่างมาก
- สำหรับ
double ที่ใหญ่มาก แม้ลบ Long.MAX_VALUE ออกไป ค่าก็อาจไม่เปลี่ยน
double a = Double.MAX_VALUE;
double b = a - Long.MAX_VALUE;
System.err.println(a == b); // prints true
- การคำนวณที่มีปัญหาเกิดขึ้นสองจุด
- การหารที่ทำในอาร์กิวเมนต์ของ
String.format
- การคำนวณค่าขีดแบ่งเพื่อพิจารณาว่าจะเพิ่ม
exp หรือไม่
- ปัญหาแรกแก้โดยลดค่า
bytes ระหว่างทางให้อยู่ในช่วงที่มีความแม่นยำดีกว่า แล้วปรับ exp ตาม
- แนวคิดคือสุดท้ายผลลัพธ์ก็ต้องถูกปัดเศษอยู่แล้ว จึงยอมทิ้งหลักที่ต่ำกว่านี้ได้
if (exp > 4) {
bytes /= unit;
exp--;
}
- ส่วนปัญหาที่สอง บิตล่างมีความสำคัญ
999,949,99…9 และ 999,950,00…0 ต้องถูกจัดเข้าคนละเลขชี้กำลัง
- ค่าขีดแบ่งที่เป็นไปได้มี 12 ค่าเมื่อรวม SI และไบนารี และมีเพียงค่าเดียวที่ให้ผลลัพธ์ผิด
- ผลลัพธ์ที่ผิดสามารถระบุได้จากแพตเทิร์นบิตที่ลงท้ายด้วย
D00 และจึงแก้เฉพาะกรณีนั้น
- เนื่องจากพึ่งพาแพตเทิร์นบิตของผลลัพธ์ floating-point แบบเฉพาะ จึงต้องใส่
strictfp
อินพุตค่าลบและโค้ดสุดท้าย
- เนื่องจาก Java ไม่มี
long แบบ unsigned จึงมีการเพิ่มการรองรับจำนวนไบต์ที่เป็นลบด้วย
- ก่อนหน้านี้ อินพุต
-10,000 จะถูกแสดงเป็น -10000 B
- มีการเพิ่ม
absBytes เพื่อให้การคำนวณที่เกี่ยวกับ exp อิงจากค่าสัมบูรณ์
Long.MIN_VALUE ต้องมีการจัดการเป็นพิเศษ
- เพราะ
-Long.MIN_VALUE == Long.MIN_VALUE
- ดังนั้นถ้า
bytes == Long.MIN_VALUE จะใช้ Long.MAX_VALUE และในกรณีอื่นจะใช้ Math.abs(bytes)
- เวอร์ชันสุดท้ายมีทั้ง
strictfp, การแก้ค่าขีดแบ่ง, การรองรับ Long.MIN_VALUE และการลดสเกลเมื่อเลขชี้กำลังใหญ่
- โค้ดที่เดิมตั้งใจจะหลีกเลี่ยงการวนลูปและการแตกแขนงมากเกินไป กลับกลายเป็น อ่านยากกว่าเวอร์ชันแรก หลังจากขัดเกลาทุก corner case
- หากต้องการโค้ดเวอร์ชันล่าสุดที่พร้อมใช้งานจริง สามารถดูบทความแยก Formatting byte size to human readable format
บทเรียนที่เหลือในงานจริง
- โค้ดตัวอย่างจาก Stack Overflow ต่อให้มีอัปโหวตหลายพันครั้งก็ อาจมีบั๊กได้
- โค้ดที่คัดลอกมาควรได้รับการทดสอบ edge case โดยเฉพาะ
- การคำนวณเลขทศนิยมลอยตัวจัดการได้ยากเมื่อเจอค่าเขตแดนและจำนวนที่ใหญ่มาก
- เมื่อต้องคัดลอกโค้ด ควรมี การระบุที่มา อย่างเหมาะสม ไม่เช่นนั้นอาจกลายเป็นปัญหาจริงได้
1 ความคิดเห็น
ความคิดเห็นบน Hacker News
น่าสนใจที่คำตอบที่ใช้ค่าฮาร์ดโค้ดกับคำสั่ง
if(หรือwhile) ล้วนทำการเปรียบเทียบสูงสุด 5 ครั้งถ้าหน่วยมีแค่ B, KiB, MiB, GiB, TiB, EiB ก็แก้ได้ด้วยคำสั่ง
ifสูงสุด 3 ตัวเช่นกัน เพราะถ้าตรวจว่าเป็น GiB ขึ้นไป ก็จะรู้ได้ว่าไม่ใช่ B/KiB/MiB ดังนั้น binary search ชนะต่อให้ขยายไปถึง ZiB และ YiB การเปรียบเทียบสูงสุด 3 ครั้งก็ยังพอ ส่วนวิธีฮาร์ดโค้ดจะไปได้ถึงสูงสุด 7 ครั้ง ถ้าให้เขียนเอง ผมคงไม่ใช้
log/pow/เลขทศนิยมลอยตัว เพราะเสี่ยงพลาดมากเกินไป และน่าจะฮาร์ดโค้ดคำสั่งifแต่ทำเป็น binary searchโค้ดแบบนี้เท่ากับทำงานมากมายเพื่อให้ได้โค้ดที่ช้ากว่า ซับซ้อนกว่า และทดสอบกับรีวิวได้ยากกว่า
(2019) การอภิปรายในอดีต:
https://news.ycombinator.com/item?id=21693431
https://news.ycombinator.com/item?id=21698619
https://news.ycombinator.com/item?id=27533684
The most copied StackOverflow snippet of all time is flawed (2019) - https://news.ycombinator.com/item?id=27533684 - มิถุนายน 2021, ความคิดเห็น 334 รายการ
The most copied StackOverflow snippet of all time is flawed - https://news.ycombinator.com/item?id=21698619 - ธันวาคม 2019, ความคิดเห็น 88 รายการ
The most copied StackOverflow snippet of all time is flawed - https://news.ycombinator.com/item?id=21693431 - ธันวาคม 2019, ความคิดเห็น 3 รายการ
ไม่เข้าใจเลย ถ้ามี suffix 7 แบบ ก็เลือกอันที่ถูกด้วย binary search ได้ และใช้การเปรียบเทียบ 3 ครั้ง หรือถ้าทำแบบง่าย ๆ ก็แค่เปรียบเทียบ 6 ครั้ง
ไม่รู้ว่าการใช้
log()สองครั้ง,pow()หนึ่งครั้ง และceil()ดีกว่าวิธีง่าย ๆ ตรงไหน บั๊กที่อธิบายตรงนี้เองเป็นตัวอย่างที่สมบูรณ์แบบของการพยายามฉลาดเกินไปถึงอย่างนั้นก็ยังดีกว่าตัวอย่างโค้ดแรกในบทความต้นทางเล็กน้อย เพราะคำนึงถึงบั๊กการปัดเศษ
อีกอย่าง การเปรียบเทียบ 6 ครั้งจะเกิดเฉพาะตอนเป็นค่าสูงสุดเท่านั้น และในการใช้งานจริงก็ดูไม่น่าจะเป็นไปได้มากนัก ถ้าค่าส่วนใหญ่อยู่ในช่วง B หรือ KB วิธีเชิงเส้นอาจดีกว่า
แม้จะเป็นการโปรโมตอย่างหน้าด้าน ๆ แต่ถ้าอยากฟอร์แมตขนาดให้อยู่ในรูปแบบที่มนุษย์อ่านง่ายได้อย่างรวดเร็วและถูกต้อง แทนที่จะคัดลอกจาก S/O ก็ใช้ไลบรารีโอเพนซอร์ส PrettySize ของเราได้ มีสำหรับ Rust [0] และ .NET [1] และยังช่วยให้การดำเนินการเชิงตรรกะแบบ type-safe กับขนาดไฟล์ทำได้อย่างปลอดภัยและง่ายดาย
ชิ้นส่วนจาก S/O มี 4 บรรทัดก็จริง แต่ไลบรารีเหล่านี้ครอบคลุมกว่ามาก และมีทั้งเทสต์ ตัวเลือกฟอร์แมตเอาต์พุต การแปลงขนาด ฯลฯ
[0]: https://github.com/neosmart/prettysize-rs
[1]: https://github.com/neosmart/PrettySize.net
สงสัยล้วน ๆ ว่า มีนักพัฒนาจำนวนไม่น้อยจริงไหมที่คัดลอกโค้ดที่ไม่น่าเชื่อถือจาก StackOverflow แล้วเอาไปวางในแอปพลิเคชันเลย?
สมมติฐานที่ว่าผู้คนแค่คัดลอกจาก StackOverflow นั้นเป็นเรื่องที่รู้กันดี แต่ก่อนจะได้เห็นใครทำจริง ๆ ผมคิดว่ามันเป็นแค่มุกตลกมากกว่า ผมเองก็ใช้ StackOverflow เป็นจุดเริ่มต้นเวลาต้องแก้ปัญหาในพื้นที่ที่ไม่คุ้นเคย แต่ไม่เคยคัดลอกโค้ดไปตรง ๆ
โดยปกติแล้วโค้ดชิ้นเล็ก ๆ มักไม่ได้ทำแค่สิ่งที่ผมต้องการแบบพอดีเป๊ะ จึงต้องไปดู API แล้วสร้างวิธีแก้ของตัวเองจากแนวทางที่อธิบายไว้ โดยเฉพาะใน Python StackOverflow มักช่วยชี้ทางไปยัง niche API ที่มีประโยชน์อยู่บ่อย ๆ
พูดตามตัวอักษรคือ Google → คลิกลิงก์ Stack Overflow แรกที่เห็น → คัดลอก/วาง code block แรกที่เห็น และบางครั้งแม้แต่ภาษาก็ยังไม่ตรงกัน ระหว่าง pair programming ถึงขั้นต้องแย่งอุปกรณ์ป้อนข้อมูลออกจากมือจริง ๆ พอบอกว่ามันผิด เขาก็กำลังวางโค้ดชิ้นที่สองในหน้าเว็บนั้นแล้วก่อนจะพูดจบเสียอีก และเร็วแบบแปลก ๆ ด้วย
แม้จะเป็นกรณีสุดโต่ง แต่ก็มีนักพัฒนาจำนวนมากที่คิดแบบ “ต้องการโค้ด; Stack Overflow มีโค้ด; จบ!” โดยไม่คิดเลยว่านั่นเป็นวิธีแก้ที่เหมาะสมหรือไม่
ยังไงเราก็หยิบโค้ดไลบรารีที่คนแปลกหน้าเขียนมาใช้กับส่วน งานระบบท่อประปา ที่เราไม่ได้ใส่ใจมากนักอยู่ตลอด ถ้าอยากขุดลงไปทำความเข้าใจ ก็มักจะเขียนเอง แต่ถ้าส่วนนี้แค่อยากให้ “มันทำงานได้” แล้วเดินโปรเจกต์ต่อ ก็จะกลายเป็นการพัฒนาที่ขับเคลื่อนด้วยข้อผิดพลาดจากคอมไพเลอร์
ชื่อตัวแปรก็เปลี่ยนด้วย เพราะมี
foo,bar,bazเยอะเกินไปจนมักอ่านยากสำหรับคน พอเจอปัญหาเดิมอีกครั้ง ก็จำได้ง่ายกว่าด้วยว่าตัวเองทำอะไรไป เมื่อเทียบกับการคัดลอกแบบหลับหูหลับตาต้องการ
log 2แล้วทำไมถึงใช้ ลอการิทึมแบบ floating-point ก็ไม่รู้ถ้าผมไม่ได้พลาดอะไรไป นิพจน์ข้างล่างนี้ให้ค่า
floor(log2(value))ได้อย่างถูกต้องสำหรับจำนวนบวกที่น้อยกว่า 2^63 ไบต์ และเร็วกว่าเยอะ:Long.bitCount( (Long.highestOneBit(value) << 1) - 1) - 1พอเห็นโค้ดชิ้นนั้น ก็เห็นการคำนวณ
logแบบ floating-point กับการหารบนจำนวนเต็มทันที เลยตัดทิ้งในหัวทันทีว่าเป็นโค้ดที่พยายามฉลาดเกินไปจนโดยเนื้อแท้แล้วมีแนวโน้มเกิดบั๊กง่ายสายโซ่ของความรู้ ลงลึกไปจนสุดจริง ๆ แสดงให้เห็นว่าแม้ความรู้เล็กน้อยมาก ๆ พอเอาออกมาแล้ว จะยัดกลับเข้าไปใหม่ได้ยากแค่ไหน
ในสถานการณ์ที่ Stack Exchange กำลังสูญเสียผู้มีส่วนร่วมที่แอคทีฟอย่างรวดเร็ว ผมสงสัยว่าต้องใช้อะไรบ้างเพื่อแก้ไข คำตอบแบบมือปืนไว ที่ภายหลังพบว่าผิดทาง และก็สงสัยด้วยว่า เมื่อคำตอบที่ “ผิดนิด ๆ” แบบนี้แข็งตัวอยู่ในประวัติการค้นหา และมากขึ้นเรื่อย ๆ ในประวัติศาสตร์ของ LLM มันจะหมายความอย่างไรต่อความรู้ร่วมของเรา
ทำให้นึกถึงตอนฝึกทหารขั้นพื้นฐาน ครูฝึกมักตั้งใจมอบภารกิจที่ไม่มีใครทำเป็นให้ทหารใหม่ โดยไม่ให้คำแนะนำ แล้วก็เดินจากไป
จากนั้นจะมีใครสักคนเริ่มทำด้วยวิธีที่ผิดเสมอ และคนที่เหลือทั้งหมดก็ทำตามคนนั้น
ใน การคาดการณ์เศรษฐกิจ สาธารณะก็เกิดเรื่องคล้ายกัน คนที่ทายผิดอยู่คนเดียวขณะที่คนอื่นทายถูก จะถูกปฏิบัติอย่างรุนแรงกว่าคนที่ผิดไปพร้อมกันทุกคนมาก
ผมไม่จำเป็นต้องมองข้อผิดพลาดแบบ floating-point ในอัลกอริทึมแบบนี้ว่าเป็น “ข้อบกพร่อง” เสมอไป ถ้าโค้ดนิยามวิธีแก้ที่ถูกต้องทั้งในเชิงตรรกะและคณิตศาสตร์ ผมก็ถือว่าตัวมันเอง “ถูก”
การจัดการ ข้อผิดพลาดแบบ floating-point เป็นงานอีกระดับหนึ่งที่อยู่สูงขึ้นไป และทำเฉพาะเมื่อมันสำคัญจริง ๆ ผมสามารถจินตนาการถึงภาษาโปรแกรมในอนาคตที่สมบูรณ์แบบ ซึ่งไม่มีข้อผิดพลาดแบบ floating-point อยู่เลยจนไม่ต้องพิจารณา และอัลกอริทึมของผม 99% ก็เหมือนกับมุ่งเป้าไปที่ภาษานั้น