ทำไม Go ถึง compile ไว — เล่าจากห้องเซิร์ฟเวอร์ Google ที่ร้อนระอุ
ปี 2007 — ห้องเซิร์ฟเวอร์ของ Google ร้อนจนแทบละลาย
ไม่ใช่เพราะอากาศ — แต่เพราะ คอมไพเลอร์ C++
นักพัฒนาที่ Google สามคน — Robert Griesemer, Rob Pike, และ Ken Thompson — กำลังรอโค้ด compile
Ken Thompson คือใคร? เขาคือคนที่สร้าง Unix, สร้าง ภาษา B (บรรพบุรุษของ C), ร่วมคิดค้น UTF-8, และได้รับ Turing Award — รางวัลโนเบลของวงการคอมพิวเตอร์
สามคนนี้... นั่งรอโค้ด compile เป็นสิบนาที
Pike เล่าในภายหลังว่า: "เราเบื่อที่จะรอ — ก็เลยสร้างภาษาใหม่"
เรื่องเล่าก่อน Go — ทำไม C++ ที่ Google ถึง compile ช้า
ลองนึกภาพ: Google มีโค้ด C++ มหาศาล — หนึ่งไฟล์ #include อีกไฟล์ — #include ซ้อน #include — กลายเป็น chain ยาวเป็นกิโลเมตร
// main.cc
#include "server.h"
// server.h
#include "handler.h"
// handler.h
#include "request.h"
// request.h
#include "database.h"
// database.h — #include อีก 20 ไฟล์...
เปลี่ยนโค้ด 1 บรรทัดใน database.h → ทุกไฟล์ที่ #include chain มาถึงต้อง compile ใหม่หมด → เป็นชั่วโมง
และอย่าลืม — C++ มี template, มี preprocessor, มี header files, มี forward declaration, มี ODR (One Definition Rule) — ทุกอย่างทำให้ compiler ต้องทำงานหนักขึ้น
สามคนนี้ตัดสินใจว่า: "ภาษาใหม่ต้อง compile ไว — มาก"
ข้อที่ 1: ไม่มี Header Files — อ่านตรง ๆ
Go ไม่มี .h ไฟล์ — compiler อ่าน .go โดยตรง:
// Go — import อย่างเดียว ไม่มี include
import (
"net/http"
"encoding/json"
)
compiler รู้ว่า package net/http export อะไรบ้าง — อ่านครั้งเดียว — จบ
C++ ต้องทำอะไร? compiler อ่าน header.h → preprocessor ไล่ #include → ต่อ chain → อ่านซ้ำ 20 รอบ — source file เดียวอาจใหญ่เป็นแสนบรรทัดก่อนถึง compiler จริง
Go: 1 ไฟล์ = 1 ไฟล์ — compiler รู้ทันทีว่าต้องอ่านอะไร
ข้อที่ 2: Dependency Graph — ห้ามวกกลับมาที่เดิม
Go ออกแบบ dependency เป็น DAG (Directed Acyclic Graph) — ห้าม circular import
// ✅ ถูก — dependency เป็น tree
package A → import B
package B → import C
// ❌ ผิด — circular — compile ไม่ผ่าน
package A → import B
package B → import A // compile error!
ทำไมถึงเร็ว? ถ้า dependency เป็น DAG — compiler สามารถ:
- เรียงลำดับการ compile ได้ — compile C ก่อน, แล้ว B, แล้ว A
- ทำ parallel ได้ — compile C, B, A พร้อมกันถ้าไม่ขึ้นต่อกัน
- รู้ว่าไฟล์ไหนต้อง compile ใหม่ — เปลี่ยน C อย่างเดียว → A, B ไม่ต้อง compile ใหม่ถ้า interface ไม่เปลี่ยน
Go ใช้ DAG ตั้งแต่ดีไซน์ — ไม่ใช่ afterthought
ข้อที่ 3: Import ที่ไม่ได้ใช้ = Error
import "fmt"
import "os" // ❌ compile error: imported and not used
func main() {
fmt.Println("hello")
}
ฟังดูน่ารำคาญ — แต่มันมีเหตุผล: compiler ไม่ต้องเสียเวลาอ่าน package ที่คุณ import มาแต่เปล่าประโยชน์
ใน C++ — คุณ #include <vector> แล้วไม่ได้ใช้ — compiler ยังต้องอ่านทั้งไฟล์ — สิ้นเปลือง
Go: goimports จัดการให้ — import เอง, ลบเอง — คุณไม่ต้องคิด
ข้อที่ 4: Object Files อัจฉริยะ — metadata อยู่ส่วนหัว
เวลา Go compile package — มันสร้าง .a (object file) ที่ สมบูรณ์ในตัวเอง — และสิ่งที่สำคัญคือการจัดเรียงข้อมูล
math.a:
┌─────────────────────────────┐
│ METADATA (ส่วนหัว) │
│ - รายชื่อ symbols ที่ export │
│ - รายชื่อ dependencies │
│ - type information (สำหรับ │
│ reflection) │
├─────────────────────────────┤
│ MACHINE CODE (ส่วนท้าย) │
│ - โค้ดจริงของ package math │
└─────────────────────────────┘
ประเด็นสำคัญ: Metadata อยู่ที่ ส่วนหัวของไฟล์ — เวลา package B import math — compiler อ่านแค่ metadata (ไม่กี่ KB) — ไม่ต้อง parse source code ทั้งหมด (หลายพันบรรทัด) — ไม่ต้อง re-type-check
เปรียบเทียบกับ C++:
// C++ — ทุกครั้งที่ compile main.cc
#include "math.h" // ← ต้องเปิด math.h → parse → type-check → expand
// math.h อาจ include math_impl.h → parse → expand
// math_impl.h อาจ include vector → parse → expand
// ทั้งหมดนี้เกิดขึ้นซ้ำในทุก .cc ที่ใช้ math.h
Go: อ่าน metadata ครั้งเดียวต่อ package — C++: parse headers ซ้ำในทุก translation unit
💡 ลองนึกภาพมี 100 ไฟล์ import
net/http— C++ parse header ซ้ำ 100 รอบ — Go อ่าน metadata ของnet/http.a100 รอบ แต่การอ่าน metadata (ไม่กี่ KB) เร็วกว่า parse source (หลายพันบรรทัด) หลายเท่า
ข้อที่ 5: Plan 9 Assembly — "ภาษาเดียว ทุก CPU"
Go ใช้ Plan 9 assembly — assembly language ที่ออกแบบโดยทีมเดียวกับ Unix — Ken Thompson เป็นหนึ่งในนั้น
// Plan 9 assembly ของ Go
TEXT ·Add(SB), NOSPLIT, $0-16
MOVQ a+0(FP), AX
ADDQ b+8(FP), AX
MOVQ AX, ret+16(FP)
RET
แล้วมันต่างจากภาษาอื่นยังไง?
C/C++: compiler ใช้ assembly ของ CPU นั้นโดยตรง
// x86 assembly → C compiler สร้าง
add eax, ebx ← ใช้ได้บน x86 เท่านั้น
// ARM assembly → คนละโลก
add r0, r0, r1 ← ใช้ได้บน ARM เท่านั้น
การจะ cross-compile C — คุณต้องมี cross-compiler toolchain:
- Linux → ARM: ต้องติดตั้ง
arm-linux-gnueabihf-gcc - macOS → Windows: ต้องติดตั้ง
x86_64-w64-mingw32-gcc - แต่ละคู่ platform = toolchain ใหม่ — ติดตั้งครั้งละเป็นชั่วโมง
Rust: ใช้ LLVM เป็น backend — LLVM มี IR (Intermediate Representation) ของตัวเอง — ข้อดีคือ LLVM optimize เก่งมาก — แต่ LLVM เองเป็นโค้ด C++ มหาศาล — หนัก ช้า
Go เลือกทางสายกลาง: เขียน compiler backend เองทั้งหมด — ไม่ใช้ LLVM, ไม่ใช้ GCC, ไม่ใช้ toolchain ภายนอกเลยแม้แต่นิดเดียว
Mapping — แผนที่
Go เขียนโค้ด → คอมไพล์เป็น Plan 9 assembly (IR กลาง) → แล้วคอมไพล์ต่อเป็น machine code โดยตรง:
write.go (Go source)
│
▼
[Go compiler frontend] → parse, type-check
│
▼
Plan 9 assembly (IR กลาง — architecture-independent)
│
├──→ [x86-64 backend] → MOVQ, ADDQ, RET (Intel/AMD)
├──→ [ARM64 backend] → MOV, ADD, RET (Apple Silicon, Pi)
├──→ [ARM backend] → MOV, ADD, RET (Pi 32-bit)
├──→ [RISC-V backend] → ... (SiFive, etc.)
└──→ [MIPS, s390x, ...]
ทุก backend อยู่ใน Go compiler เรียบร้อย — ไม่ต้องติดตั้งอะไรเพิ่ม
เปรียบเทียบ 3 ภาษา — ใครต้อง setup อะไรบ้าง
| Go | Rust | C/C++ | |
|---|---|---|---|
| Cross-compile ไป ARM | GOARCH=arm go build |
rustup target add + linker |
apt install cross-gcc |
| Backend | เขียนเอง (Go) | LLVM (C++) | GCC/LLVM |
| ขนาด toolchain | ~100MB | ~2GB+ | ~500MB-2GB |
| เวลา setup | 0 (ติด Go แล้ว cross-compile ได้เลย) | 5-10 นาที (add target) | 1-3 ชม (หา toolchain, build) |
ตัวอย่างจริง — ผมเคย cross-compile Go ให้ Raspberry Pi:
# บน Mac — สร้าง binary สำหรับ Raspberry Pi
GOOS=linux GOARCH=arm64 go build -o myapp-pi .
# copy ไป Pi
scp myapp-pi pi@raspberrypi:~
# รัน — ใช้ได้ทันที
ssh pi@raspberrypi ./myapp-pi
3 บรรทัด — ไม่ต้องหา ARM cross-compiler — ไม่ต้องติดตั้ง sysroot — เพราะ Go compiler มี ARM backend อยู่แล้ว — มันรู้ว่า ARM ใช้ instruction อะไร, register ชื่ออะไร, calling convention เป็นยังไง — ทุกอย่าง built-in
นี่คือเหตุผลที่ Go เป็นราชาแห่ง cross-compilation — ไม่ใช่เพราะมันมีฟีเจอร์วิเศษ — แต่เพราะมันไม่พึ่งพาใครเลย
ข้อที่ 6: Static Linking — ไม่ต้องพึ่งระบบ
Go compile เป็น static binary — รวมทุกอย่างไว้ในไฟล์เดียว:
$ ls -lh myapp
-rwxr-xr-x 8.2M myapp # ← 8MB — ทั้ง app ในไฟล์เดียว
$ ldd myapp
not a dynamic executable # ← ไม่พึ่งพา .so ใด ๆ
C/C++ ต้องทำอะไร? compile เป็น binary → ต้องมี libc.so, libstdc++.so, libpthread.so ฯลฯ — เปลี่ยน distro → พัง
Go: binary เดียว — copy ไปเครื่องไหนก็รันได้ — compile บน Mac, deploy บน Linux, รันบน Raspberry Pi — ง่ายมาก
ข้อที่ 7: Compiler เขียนด้วย Go — เร็วขึ้นเรื่อย ๆ
Go compiler ยุคแรกเขียนด้วย C — แต่ตั้งแต่ Go 1.5 (2015) — compiler เขียนด้วย Go เอง
นี่คือ "bootstrapping" — Go compile ตัวเอง — และทุกครั้งที่ Go เวอร์ชันใหม่เร็วขึ้น — compiler ก็เร็วขึ้นตาม
Go 1.0 (2012): compiler เขียนด้วย C
Go 1.5 (2015): compiler เขียนด้วย Go — ช้าลงเล็กน้อย
Go 1.7 (2016): compiler เร็วขึ้น 20-30% ด้วย SSA backend
Go 1.9 (2017): compiler เร็วขึ้นอีก
...
Go 1.22 (2024): compiler แทบจะ instantaneous สำหรับโปรเจกต์ขนาดกลาง
ข้อที่ 8: Grammar Simplicity — อ่านโค้ดรอบเดียวจบ
Go แตกต่างจาก C++ ตรงที่ ไวยากรณ์ถูกออกแบบมาเพื่อ compiler — ไม่ใช่เพื่อโปรแกรมเมอร์ที่ชอบความซับซ้อน
Hand-Written Recursive Descent — parser ที่มนุษย์เขียนเอง
Go ใช้ hand-written recursive descent parser — คือ parser ที่เขียนด้วยมือ ไม่ได้ generate จาก grammar (ต่างจาก yacc/bison ที่ใช้ใน C compiler หลายตัว)
การเขียน parser ด้วยมือมีข้อดี: compiler ควบคุมทุกอย่างได้ — ไม่มี overhead จาก table-driven parsing — และ error message อ่านรู้เรื่องเพราะมนุษย์เขียนเอง
📜 เกร็ดประวัติศาสตร์: Go 1.0-1.4 ใช้ yacc (ซึ่งใช้ LALR(1)) สำหรับบางส่วนของ parser — แต่ตั้งแต่ Go 1.5 ที่ compiler ถูก rewrite ด้วย Go ทั้งหมด — parser เปลี่ยนเป็น hand-written recursive descent เต็มตัว
แต่ไม่ว่าจะใช้ yacc หรือ recursive descent — หัวใจสำคัญคือ Go grammar ถูกออกแบบให้ ง่ายพอที่ parser แบบไหนก็ทำงานได้ใน single pass
// ทุกประโยคใน Go — compiler รู้ทันทีว่าคืออะไร
func Add(a, b int) int { // ← function declaration — ไม่ใช่อย่างอื่น
return a + b
}
x := Add(1, 2) // ← short variable declaration — ชัดเจน
var y int = Add(3, 4) // ← variable declaration — var นำหน้า
compiler ไม่ต้องถามว่า "นี่คือ function call หรือ variable declaration?" — เพราะ syntax บังคับให้เดาไม่ได้
C++: Most Vexing Parse — compiler เองก็สับสน
// C++ — Most Vexing Parse: compiler ไม่รู้ว่านี่คืออะไร
Widget w(); // ❓ function declaration (คืนค่า Widget) — หรือ variable declaration?
// คำตอบ: มันคือ function declaration! (Most Vexing Parse)
Widget w{}; // ✅ variable declaration — ใช้ {} แทน
นี่คือตัวอย่างของ context-sensitive grammar ใน C++ — compiler ต้องดูบริบทว่ามี type ชื่อ Widget ไหม — มี constructor แบบไหน — มันคือ function หรือ variable — ทั้งหมดนี้ใช้เวลา CPU มหาศาล
ตารางเทียบ — อะไรทำให้ Go ง่าย C++ ยาก
| Go | C++ | |
|---|---|---|
| Grammar complexity | ออกแบบให้ unambiguous — parser เลือกใช้ได้ (yacc หรือ recursive descent) | Context-sensitive — parser ต้องเดาจากบริบท |
| Parsing | Single-pass — รอบเดียวจบ | Multiple-pass + template instantiation |
| Preprocessor | ❌ ไม่มี | ✅ #include, #define, #ifdef
|
| Forward declaration | ❌ ไม่ต้อง — order อิสระ | ✅ ต้อง — class A;
|
| Template | ❌ (generics 1.18+ เป็นคนละเรื่อง) | ✅ template metaprogramming |
| Compile-time computation | ❌ | ✅ constexpr, SFINAE |
ทำไม Go ถึงไม่มี preprocessor?
Preprocessor ใน C/C++ ทำให้ compiler ต้องทำงาน 2 รอบ:
source.c → [preprocessor: expand #include, #define] → expanded.c → [compiler]
ทุกไฟล์ .c ผ่าน preprocessor → ขยายเป็นไฟล์ใหญ่ยักษ์ → ส่งให้ compiler จริง — นี่คือ 2x งาน
Go: source code → compiler โดยตรง — ไม่มีขั้นกลาง — 1 pass, 1 job
ผลลัพธ์ในโลกจริง
Kubernetes (Go): ~3 ล้านบรรทัด — compile ทั้งโปรเจกต์ใน ~2 นาที
Chromium (C++): ~35 ล้านบรรทัด — compile ใช้เวลา 30-60 นาที (แม้ใช้ distributed build!)
สัดส่วน: Kubernetes มีโค้ด 1/10 ของ Chromium — แต่ compile เร็วกว่า 15-30 เท่า — ไม่ใช่แค่เพราะขนาดเล็กกว่า — แต่เพราะ Go compiler ถูกออกแบบมาให้ "เร็ว" ตั้งแต่แรก
📊 สรุป — อะไรทำให้ Go compile ไว
| ปัจจัย | ทำอะไร | ทำไมถึงเร็ว |
|---|---|---|
| ไม่มี Header Files | อ่าน .go ตรง |
compiler ไม่ต้องไล่ #include chain |
| DAG Dependency | ห้าม circular import | เรียงลำดับ compile ได้, parallel ได้ |
| Unused Import = Error | compiler ไม่โหลด package ที่ไม่ใช้ | ลดงาน compiler |
| Object Files อัจฉริยะ |
.a มีข้อมูลครบ |
ไม่ต้องเปิด source หา dependency |
| Plan 9 Assembly | IR กลาง | cross-compile ได้ทันที โดยไม่ต้อง toolchain ใหม่ |
| Static Linking | binary ในไฟล์เดียว | deploy ง่าย ไม่พึ่งระบบ |
| Compiler เขียนด้วย Go | bootstrapping | compiler เร็วขึ้นทุกเวอร์ชัน |
| Simple Grammar | hand-written parser, ไม่มี preprocessor, single-pass | ไม่ต้อง parse ซ้ำ, ไม่คลุมเครือ |
🎯 ทำไมเรื่องนี้ถึงสำคัญ
ไม่ใช่แค่ "compile ไว = สะดวก" — แต่มันเปลี่ยน วิธีคิด
C++: compile → รอ 5 นาที → เช็คมือถือ → ลืมว่าทำอะไรอยู่
Go: compile → 2 วิ → test → แก้ → compile → test → flow
Fast feedback loop คือ superpower — คุณทดลองได้เร็ว, กล้า refactor, ไม่กลัว compile error — เพราะมันใช้เวลาแค่ 2 วิ
นี่คือสิ่งที่ Ken Thompson, Rob Pike, และ Robert Griesemer ตั้งใจ — ไม่ใช่แค่ "สร้างภาษาใหม่" — แต่คือการสร้าง ประสบการณ์ ใหม่ในการเขียนโปรแกรม
📚 อ่านต่อ:
- Go at Google: Language Design in the Service of Software Engineering — Rob Pike (2012)
- The Go Programming Language — Donovan & Kernighan
- Go FAQ: Why does Go compile so quickly?
Top comments (0)