ข้ามไปยังเนื้อหา

fmt, strings และ strconv

Package fmt คือจุดเริ่มต้นของ output เกือบทั้งหมดใน Go function สามตัวครอบคลุมกรณีใช้งานส่วนใหญ่:

  • fmt.Println(args...) — พิมพ์แต่ละ argument คั่นด้วย space ตามด้วย newline
  • fmt.Printf(format, args...) — พิมพ์โดยใช้ format string พร้อม verb
  • fmt.Sprintf(format, args...) — คืน string ที่ format แล้วแทนที่จะพิมพ์
Verbความหมาย
%vการแสดงผล default
%+vstruct พร้อมชื่อ field
%#vการแสดงผลแบบ Go-syntax
%Ttype ของค่า
%dจำนวนเต็มฐาน 10
%sstring (ไม่มีเครื่องหมายคำพูด)
%qstring ที่มีเครื่องหมายคำพูดคู่พร้อม Go escaping
%fทศนิยม
type Person struct { Name string; Age int }
p := Person{"Alice", 30}
fmt.Printf("%v\n", p) // {Alice 30}
fmt.Printf("%+v\n", p) // {Name:Alice Age:30}
fmt.Printf("%#v\n", p) // main.Person{Name:"Alice", Age:30}
fmt.Printf("%T\n", p) // main.Person

เมื่อค่าหนึ่ง implement fmt.Stringer (String() string) หรือ error %v (รวมถึง %s, Println และทุก verb ที่ fallback ไปที่การแสดงผล default) จะเรียก method นั้นแทนที่จะพิมพ์ field ของ struct สะดวกดี — จนกว่า method String() เองจะ format receiver ด้วย %v:

type Point struct{ X, Y int }
func (p Point) String() string {
return fmt.Sprintf("%v", p) // BUG: %v on a Stringer calls String() again
}

go vet จับรูปแบบนี้ได้ตอน compile — พบบ่อยพอในโค้ดที่ AI เขียนจนมี analyzer ของตัวเองโดยเฉพาะ:

main.go:8:22: fmt.Sprintf format %v with arg p causes recursive (command-line-arguments.Point).String method call

วิธีแก้คือ format field ข้างในแทน หรือแปลงเป็น type ที่ไม่มี method นี้: fmt.Sprintf("(%d, %d)", p.X, p.Y) หรือ fmt.Sprintf("%v", struct{ X, Y int }(p))

ทุก verb รับ width และ สำหรับ float รับ precision ด้วย เขียนไว้ก่อนตัวอักษร verb:

fmt.Printf("%08b\n", 5) // 00000101 — zero-padded to 8 digits
fmt.Printf("[%6.2f]\n", 3.14159) // [ 3.14] — width 6, 2 decimal places
fmt.Printf("%x\n", 255) // ff — lowercase hex

width ที่เป็นค่าลบจะจัดชิดซ้ายแทนชิดขวา (%-8s); syntax ของ width แบบเดียวกันใช้ได้กับ %d, %s, %q และ verb อื่นๆ ทั้งหมด

%w ไม่ใช่ format verb ทั่วไป — มันคือสัญญาณเฉพาะสำหรับ fmt.Errorf บอกให้ wrap error ที่ให้มา เพื่อให้ errors.Is/errors.As/errors.Unwrap มองทะลุเข้าไปได้ทีหลัง function อื่นของ fmt ทุกตัวถือว่า %w เป็น error และ go vet จะไม่ยอมให้ compile ผ่าน:

baseErr := errors.New("disk full")
wrapped := fmt.Errorf("save failed: %w", baseErr) // OK — Errorf understands %w
notWrapped := fmt.Sprintf("save failed: %w", baseErr) // go vet error
main.go:17:42: fmt.Sprintf does not support error-wrapping directive %w

go vet (เป็นส่วนหนึ่งของการเช็ก default ของ go build มาตั้งแต่ Go 1.10 สำหรับ analyzer บางตัว และรันเสมอโดย go test) ปฏิเสธตั้งแต่ตอน compile-check ความผิดพลาดนี้จึงแทบไม่ถึง production — แต่ก็ควรรู้จักไว้เวลาอ่าน diff เพราะโค้ดที่ AI เขียนบางครั้ง copy format string ที่มี %w ไปใส่ใน Sprintf หรือ Println ตามความเคยชิน

Package strings มี function สำหรับค้นหา, แยก, รวม และแปลง string Go string เป็น immutable UTF-8 byte sequence; function เหล่านี้คืน string ใหม่แทนที่จะแก้ไขของเดิม

s := "hello, world"
strings.Contains(s, "world") // true
strings.HasPrefix(s, "hello") // true
strings.Split(s, ", ") // ["hello" "world"]
strings.Join([]string{"a","b"}, "-") // "a-b"
strings.ReplaceAll(s, "l", "L") // "heLLo, worLd"
strings.ToUpper(s) // "HELLO, WORLD"
strings.TrimSpace(" hi ") // "hi"

Go string เป็น byte sequence แบบ immutable ที่มักเก็บ UTF-8 text len(s) นับเป็น byte และ s[i] index เป็น byte เดียว — ไม่ใช่ตัวอักษร ในทางกลับกัน for range บน string จะ decode UTF-8 และให้ rune (Unicode code point, int32) ที่แต่ละตำแหน่ง พร้อม byte offset ที่มันเริ่มต้น:

s := "café" // é is 2 bytes in UTF-8
fmt.Println(len(s), utf8.RuneCountInString(s)) // 5 4 — byte count vs rune count differ
for i, r := range s {
fmt.Printf("byte %d: rune %q\n", i, r)
}
fmt.Println(s[3]) // 195 — a raw UTF-8 continuation byte, not a printable rune

การ index s[i] ด้วย offset ทางคณิตศาสตร์ (แทนที่จะวนด้วย range หรือแปลงเป็น []rune) ปลอดภัยเฉพาะ string ที่เป็น ASCII ล้วนเท่านั้น input ที่มีตัวอักษรนอก ASCII จะถูก slice เข้าไปกลางตัวอักษร multi-byte เงียบๆ

strings.TrimPrefix(s, prefix) ลบ prefix เฉพาะเมื่อ s ขึ้นต้นด้วย string นั้นเป๊ะๆ ครั้งเดียว strings.TrimLeft(s, cutset) ทำงานต่างไปเลย: มันถือว่า cutset เป็น set ของ byte แต่ละตัว และตัด byte ที่นำหน้าทุกตัวที่ปรากฏอยู่ใน set นั้น — ไม่ใช่ prefix string:

strings.TrimPrefix("aardvark", "ar") // "aardvark" — does not start with "ar"
strings.TrimLeft("aardvark", "ar") // "dvark" — strips every leading 'a' or 'r'

โค้ดที่ AI เขียนที่ใช้ TrimLeft โดยคาดหวังพฤติกรรมแบบ prefix-string จะได้ผลลัพธ์ที่ดูสมเหตุสมผลแต่ผิด กับ input ใดๆ ที่ตัวอักษรใน cutset ซ้ำกันหรือเรียงลำดับต่างไป — ใช้ TrimPrefix (หรือ CutPrefix) เมื่อต้องการ “ลบ prefix นี้เป๊ะๆ”

เมื่อสร้าง string จากหลายชิ้นใน loop ให้ใช้ strings.Builder แทน += operator += จะ allocate string ใหม่ทุกรอบ; Builder เขียนลง buffer ที่ขยายได้และ allocate string สุดท้ายแค่ครั้งเดียว

var b strings.Builder
b.Grow(64) // pre-size the buffer when you know roughly how much you'll write
for i := 0; i < 5; i++ {
fmt.Fprintf(&b, "item%d ", i)
}
result := strings.TrimSpace(b.String()) // "item0 item1 item2 item3 item4"

Builder ไม่ปลอดภัยที่จะ copy เมื่อมันมีข้อมูลอยู่แล้ว — เหมือน bytes.Buffer แต่ strings.Builder บังคับใช้กฎนี้อย่างจริงจัง การ copy Builder ที่ไม่ใช่ zero value โดยค่าแล้วเขียนลงใน copy จะ panic ตอน runtime ด้วย strings: illegal use of non-zero Builder copied by value เพราะ copy นั้นมี pointer ย้อนกลับไปยัง buffer เดิมที่ runtime ตรวจพบว่าย้ายที่แล้ว

helper สามตระกูลที่มาในรุ่นล่าสุดและแทนที่การคำนวณ index จำนวนมาก:

// Go 1.18: split on the FIRST separator
key, val, ok := strings.Cut("user=alice", "=") // "user", "alice", true
// Go 1.27: split on the LAST separator — replaces strings.LastIndex dances
dir, file, ok := strings.CutLast("a/b/c.txt", "/") // "a/b", "c.txt", true
// Go 1.24: lazy iterators — no intermediate []string allocation
text := "a=1\nb=2\n"
for line := range strings.Lines(text) { fmt.Print(line) } // keeps the "\n"
for part := range strings.SplitSeq("a,b,c", ",") { fmt.Println(part) }
for word := range strings.FieldsSeq(" a b ") { fmt.Println(word) }

Lines, SplitSeq และ FieldsSeq คืน iter.Seq[string]; ใช้เมื่อต้องการวนแค่รอบเดียว และเก็บ Split/Fields ไว้เมื่อต้องการ slice ที่ index เข้าไปได้ bytes มีชุดเดียวกัน (bytes.CutLast และเพื่อนๆ)

อีกสองอย่างเล็กๆ ที่ควรรู้: package uuid ใหม่ (Go 1.27) สร้างและ parse UUID โดยไม่ต้องใช้ third-party module และ log/slog.NewMultiHandler (Go 1.26) กระจาย logger ตัวเดียวไปหลาย handler

strconv แปลงระหว่าง string กับ numeric type function ที่พบบ่อยที่สุด:

n, err := strconv.Atoi("42") // string → int
s := strconv.Itoa(42) // int → string
f, err := strconv.ParseFloat("3.14", 64) // string → float64
b, err := strconv.ParseBool("true") // string → bool

Atoi และ ParseFloat คืน (value, error) เพราะ input อาจไม่ใช่ตัวเลขที่ถูกต้อง ต้องเช็ก error ก่อนใช้ value เสมอ

ParseInt(s, base, bitSize) รองรับฐานอื่นๆ (strconv.ParseInt("2A", 16, 64) → 42) และ strconv.Quote สร้าง string แบบ Go-syntax ที่มี escape ให้เห็นชัด — มีประโยชน์เวลา log ค่าที่อาจมี control character

strconv.Itoa(n) และ fmt.Sprint(n) ให้ string เดียวกันสำหรับ int แต่ต้นทุนไม่เท่ากัน fmt.Sprint box argument เป็น any แล้วใช้ reflection เพื่อหาว่ามันเป็นจำนวนเต็มก่อนจะ format; strconv.Itoa แปลงตรงๆ โดยไม่มี boxing หรือ reflection วัดผลบนเครื่องนี้:

BenchmarkItoa-11 92186020 12.60 ns/op 8 B/op 1 allocs/op
BenchmarkSprint-11 35726424 34.59 ns/op 8 B/op 1 allocs/op

จำนวน alloc เท่ากัน แต่ fmt.Sprint เสีย CPU time ต่อการเรียกมากกว่าราว 2.7 เท่า — reflection ไม่ฟรีแม้จะไม่ได้ alloc เพิ่ม ใช้ strconv ใน hot path ที่รู้ concrete type แน่นอน; ใช้ fmt.Sprintf เมื่อต้อง interpolate หลายค่าเข้า template

package main
import (
"fmt"
"strconv"
"strings"
)
type Person struct {
Name string
Age int
}
func main() {
// fmt verbs
p := Person{Name: "Alice", Age: 30}
fmt.Printf("%v\n", p)
fmt.Printf("%+v\n", p)
fmt.Printf("%#v\n", p)
fmt.Printf("%T\n", p)
fmt.Printf("%d %s %q\n", 42, "hello", "world")
// Sprintf builds a string
s := fmt.Sprintf("Name: %s, Age: %d", p.Name, p.Age)
fmt.Println(s)
// strings package
text := " Go is great "
fmt.Println(strings.TrimSpace(text))
fmt.Println(strings.Contains(text, "great"))
fmt.Println(strings.ToUpper(strings.TrimSpace(text)))
words := strings.Split("one,two,three", ",")
fmt.Println(words)
fmt.Println(strings.Join(words, " | "))
fmt.Println(strings.ReplaceAll("aabbcc", "b", "x"))
// strings.Builder — efficient concatenation
var b strings.Builder
for i := 0; i < 3; i++ {
fmt.Fprintf(&b, "item%d ", i)
}
fmt.Println(strings.TrimSpace(b.String()))
// strconv
n, err := strconv.Atoi("123")
if err == nil {
fmt.Println(n + 1)
}
fmt.Println(strconv.Itoa(456))
f, err := strconv.ParseFloat("3.14", 64)
if err == nil {
fmt.Printf("%.2f\n", f)
}
}
package main
import (
"errors"
"fmt"
"strconv"
"strings"
"testing"
"unicode/utf8"
)
func main() {
// 1. %w is only meaningful in fmt.Errorf — elsewhere it prints a literal error marker
baseErr := errors.New("disk full")
wrapped := fmt.Errorf("save failed: %w", baseErr)
fmt.Println("Errorf %w:", wrapped, "| Unwrap:", errors.Unwrap(wrapped))
// 2. strings are bytes; range walks runes; len() and s[i] are byte-oriented
s := "café" // é is 2 bytes in UTF-8
fmt.Println("len(s):", len(s), "utf8.RuneCountInString(s):", utf8.RuneCountInString(s))
for i, r := range s {
fmt.Printf(" byte offset %d: rune %q (%d)\n", i, r, r)
}
fmt.Println("s[3]:", s[3], "(a raw byte, not a rune)")
// 3. strconv vs fmt.Sprint — allocation count via testing.AllocsPerRun
n := 123456
allocsStrconv := testing.AllocsPerRun(1000, func() { _ = strconv.Itoa(n) })
allocsSprint := testing.AllocsPerRun(1000, func() { _ = fmt.Sprint(n) })
fmt.Printf("allocs/op strconv.Itoa: %.0f fmt.Sprint: %.0f\n", allocsStrconv, allocsSprint)
// strconv.ParseInt and Quote
v, err := strconv.ParseInt("2A", 16, 64)
fmt.Println("ParseInt base16:", v, err)
fmt.Println("Quote:", strconv.Quote("line\tbreak\n"))
// 4. TrimPrefix (exact string) vs TrimLeft (cutset of individual bytes)
fmt.Println("TrimPrefix:", strings.TrimPrefix("aardvark", "ar"))
fmt.Println("TrimLeft cutset trap:", strings.TrimLeft("aardvark", "ar"))
// EqualFold and Fields
fmt.Println("EqualFold:", strings.EqualFold("Go", "GO"))
fmt.Println("Fields:", strings.Fields(" many spaces here "))
// 5. width and padding verbs
fmt.Printf("%%08b: %08b\n", 5)
fmt.Printf("%%6.2f: [%6.2f]\n", 3.14159)
fmt.Printf("%%x: %x\n", 255)
// 6. strings.Builder — Grow then write, then show the copy panic
var b strings.Builder
b.Grow(64)
b.WriteString("hello")
fmt.Println("Builder:", b.String(), "Cap:", b.Cap() >= 64)
func() {
defer func() {
if r := recover(); r != nil {
fmt.Println("copy panic recovered:", r)
}
}()
b2 := b // copying a non-zero Builder by value
b2.WriteString(" world")
}()
}

output จริงจากโปรแกรมนี้บน Go 1.26.5:

Errorf %w: save failed: disk full | Unwrap: disk full
len(s): 5 utf8.RuneCountInString(s): 4
byte offset 0: rune 'c' (99)
byte offset 1: rune 'a' (97)
byte offset 2: rune 'f' (102)
byte offset 3: rune 'é' (233)
s[3]: 195 (a raw byte, not a rune)
allocs/op strconv.Itoa: 1 fmt.Sprint: 1
ParseInt base16: 42 <nil>
Quote: "line\tbreak\n"
TrimPrefix: aardvark
TrimLeft cutset trap: dvark
EqualFold: true
Fields: [many spaces here]
%08b: 00000101
%6.2f: [ 3.14]
%x: ff
Builder: hello Cap: true
copy panic recovered: strings: illegal use of non-zero Builder copied by value

loop range บน "café" เดินผ่าน 4 rune ที่ byte offset 0, 1, 2, 3 — é ที่กว้าง 4 byte ครอบครอง offset 3 กับ 4 นี่คือเหตุผลที่ len(s) เป็น 5 ในขณะที่ utf8.RuneCountInString(s) เป็น 4 และ s[3] ได้ continuation byte เดี่ยวๆ (195) ไม่ใช่ 'é'

flowchart LR
  Value["value passed to %v"] --> Check{"implements Stringer or error?"}
  Check -- "yes" --> Call["call String() / Error()"]
  Check -- "no" --> Reflect["reflect: walk struct fields"]
  Call -- "calls %v on itself" --> Loop["infinite recursion — go vet catches this"]
%v เช็ก Stringer/error ก่อน — String() ที่ format ตัวเองด้วย %v จะ recurse
สิ่งที่ได้ประโยชน์ต้นทุน
fmt.Sprintfformat string ได้ยืดหยุ่นallocate string ใหม่ทุกครั้งที่เรียก
fmt.Fprintf(w, …)เขียนไปยัง io.Writer ใดก็ได้ต้องเช็ก error ที่คืนมา
strings.Builderต่อ string อย่างมีประสิทธิภาพต้องเรียก Reset() ก่อนใช้ซ้ำ
%v / %+v / %#vตรวจสอบค่าใดก็ได้reflect-based — ช้ากว่าการแปลงตรงๆ
  • fmt.Println ใช้ทำ production logging ได้สบายๆ — ใช้ structured logger (log/slog, zap) ใน production แทน; fmt ไม่มี log level, structured field หรือ JSON output
  • การต่อ string ด้วย + เร็วกว่า fmt.Sprintf — ขึ้นกับจำนวนครั้งที่ต่อ; strings.Builder เร็วที่สุดเมื่อต่อหลายชิ้นใน loop
  • %d กับ %v ต่างกันมาก — %d ระบุชัดเจนสำหรับจำนวนเต็ม %v เป็น generic แต่ให้ output เดียวกันสำหรับ int; ต่างกันตอนเป็น struct
  • rune กับ byte คือสิ่งเดียวกัน — byte คือ uint8 (1 byte); rune คือ int32 (1-4 byte สำหรับ Unicode code point)
  • TrimLeft(s, “prefix”) ลบ prefix “prefix” — มันลบ byte ที่นำหน้าที่พบใน cutset ไม่ใช่ prefix string; ใช้ TrimPrefix สำหรับสิ่งนั้น
  • %w ใช้แทน %v สำหรับ error ได้ทุกที่ — %w มีความหมายเฉพาะใน fmt.Errorf เท่านั้น; function อื่นของ fmt ทุกตัวถูก go vet ปฏิเสธ

💡 ตัวอย่างจากของจริง

kubectl format table output ด้วย fmt.Fprintf และ text/tabwriter ซึ่งจัด column ให้ตรงกันอัตโนมัติ

Go HTTP server สร้าง error message ด้วย fmt.Sprintf แล้วส่งให้ http.Error ซึ่งรับ plain string

โค้ด fmt/strings/strconv compile ผ่านแทบทุกกรณีไม่ว่าจะถูกหรือผิด ทำให้พลาด bug เล็กๆ ได้ง่าย เช็กสิ่งเหล่านี้:

  • method String() format receiver ด้วย %v (หรือ %s, หรือ fmt.Sprint) แทนที่จะ format field แต่ละตัวหรือไม่ — เป็น recursion ที่ go vet จับได้แต่พลาดตอน review ได้ง่าย?
  • %w โผล่ใน fmt call อื่นที่ไม่ใช่ Errorf หรือไม่? มันเป็น error เงียบๆ ที่ go vet จับได้เช่นกัน แต่เฉพาะเมื่อรัน?
  • โค้ดเรียก strings.TrimLeft/TrimRight โดยคาดหวังการลบแบบ prefix/suffix-string หรือไม่? function พวกนี้รับ cutset ของ byte ไม่ใช่ substring — TrimPrefix/TrimSuffix ต่างหากที่แปลว่า “ลบ string นี้เป๊ะๆ”
  • โค้ด index string ด้วย byte offset (s[i], s[i:j]) เมื่อ input อาจมีตัวอักษรนอก ASCII หรือไม่? อาจ slice เข้ากลาง rune แบบ multi-byte ได้
  • ข้าม strconv ไปใช้ fmt.Sprintf/fmt.Sprint สำหรับแปลงตัวเลขเป็น string ธรรมดาใน hot path หรือไม่ ทั้งที่ต้องจ่ายค่า reflection โดยไม่ได้อะไรเพิ่ม?
  • strings.Builder (หรือ bytes.Buffer) ถูก copy โดยค่าหลังจากเขียนลงไปแล้ว แทนที่จะส่งผ่าน pointer หรือไม่?

🐛 หา bug ในโค้ดนี้

type Point struct{ X, Y int }
func (p Point) String() string {
return fmt.Sprintf("%v", p)
}
func main() {
p := Point{1, 2}
fmt.Println(p)
}
เฉลย

%v บนค่าที่ implement fmt.Stringer จะเรียก String() — แต่ String() ในที่นี้ format p ด้วย %v อีกครั้ง เรียกตัวเองไม่รู้จบ go vet จับรูปแบบนี้ได้ตอน build ก่อนโปรแกรมจะรันด้วยซ้ำ:

main.go:8:22: fmt.Sprintf format %v with arg p causes recursive (command-line-arguments.Point).String method call

แก้ได้โดย format field ข้างในแทนที่จะ format receiver เอง:

func (p Point) String() string {
return fmt.Sprintf("(%d, %d)", p.X, p.Y)
}
Format verb ใดพิมพ์ struct พร้อมชื่อ field?
เกิดอะไรขึ้นเมื่อรันโค้ดนี้?
type P struct{ X int }
func (p P) String() string { return fmt.Sprintf("%v", p) }
fmt.Println(P{1})
โค้ดนี้พิมพ์อะไร และทำไม?
fmt.Println(strings.TrimLeft("aardvark", "ar"))
ทำไม strings.Builder ถึงดีกว่า += สำหรับสร้าง string ใน loop?
strconv.Atoi("42") คืน type อะไร?
fmt.Sprintf("result: %w", err) ผิดตรงไหน?