int32 เป็น int64 ได้ฟรี แต่ int32 เป็น uint32 เขียนตัวเลขใหม่
บทที่ 4 ถามว่าเกิดอะไรขึ้นเมื่อหมายเลขของ field ถูกเอากลับมาใช้ซ้ำ บทนี้ไม่แตะหมายเลขเลย
หมายเลขเท่าเดิม ชื่อเท่าเดิม สิ่งเดียวที่เปลี่ยนคือชนิดของช่อง quantity
การเปลี่ยนชนิดสี่แบบในบทนี้ผ่าน protoc ทุกแบบ และ parse ผ่านทุกแบบ แต่มีแบบเดียวที่ ผู้อ่านได้ค่าเดิมกลับมาตรงกันทั้งสองภาษา และยังส่ง byte ชุดเดิมต่อออกไปได้ อีกสามแบบเสียอย่างใดอย่างหนึ่งในสองอย่างนั้นไป โดยไม่มีอะไรแดง
orderctl คือเครื่องมือประมวลผลออเดอร์ที่สามคอร์สภาษาในเสานี้สร้างซ้ำทีละภาษา repo ตัวอย่าง
กำลังจัดทำ ระหว่างนี้ทุกตัวเลขในคอร์สนี้รันเองได้ สั่ง
scripts/wire-contract/up.sh เพื่อดึงเครื่องมือที่ตรึง version ไว้ แล้วตามด้วย
python3 scripts/wire-contract/probe.py
int32 เป็น int64 แล้ว byte ไม่ขยับสักตัว
หัวข้อที่มีชื่อว่า “int32 เป็น int64 แล้ว byte ไม่ขยับสักตัว”ขยาย
quantityจาก 32 bit เป็น 64 bit แล้ว Go ยังเขียนออกมา 8 byte ชุดเดิม
ทรงตั้งต้นของคอร์สนี้ประกาศ quantity เป็น int32 ทรงใหม่เปลี่ยนคำเดียว
message Order { string customer = 1; int64 quantity = 2;}Go ใส่ customer เป็น wira และ quantity เป็น 7 เหมือนเดิม แล้วเขียนออกมาได้
8 byte คือ 0a04776972611007 ซึ่งเท่ากับของทรง int32 ทุกตัวอักษร
เหตุผลอยู่ที่ wire type ทั้ง int32 และ int64 ใช้ wire type 0 คือ varint เหมือนกัน
tag จึงเป็น 10 ตัวเดียวกัน และค่า 7 ก็ลงได้ใน byte เดียวทั้งสองชนิด
ฝั่งอ่านตรงกัน ผู้อ่านที่ถือ int64 อ่าน byte ที่ทรง int32 เขียนไว้ ได้ customer เป็น
wira และ quantity เป็น 7 ไม่มี unknown field สักหมายเลข และ serialize กลับได้ byte
ชุดเดิมทุกตัว ทั้งใน Go และใน Java
protobuf.dev ระบุว่า int32 int64 uint32 uint64 และ bool เป็นชนิดที่เข้ากันได้บนสาย
แต่หน้านั้นวางรายการนี้ไว้ในกลุ่มที่มันเรียกว่าปลอดภัยแบบมีเงื่อนไข และเขียนเงื่อนไขต่อท้ายไว้ว่า
ถ้าค่าที่อ่านมาไม่พอดีกับชนิดปลายทาง ผลที่ได้เท่ากับการ cast ค่านั้นเข้าชนิดปลายทาง
ข้อนี้เป็นกฎของ format ไม่ใช่แค่ผลของตัวอย่างนี้ ทรงที่เพิ่งวัดไปคือด้านที่เงื่อนไขไม่ทำงาน
เพราะ 7 พอดีกับทั้ง int32 และ int64 สองหัวข้อถัดไปคือด้านที่เงื่อนไขทำงาน
จำนวนติดลบตัวเดียว กินสิบ byte
หัวข้อที่มีชื่อว่า “จำนวนติดลบตัวเดียว กินสิบ byte”
quantity = -1ทำให้ทั้งใบกลายเป็น 17 byte เพราะ varint ช่องเดียวยาว 10 byte
เปลี่ยนกลับมาเป็นทรงตั้งต้นที่ quantity เป็น int32 แล้วใส่ค่าติดลบแทน 7
Go เขียนออกมาได้ 17 byte
0a047769726110ffffffffffffffffff01แกะทีละกลุ่มแล้วเทียบกับใบ 8 byte ของบทที่ 1 จะเห็นว่าหกตัวแรกกับตัวที่เจ็ดเหมือนกันหมด ที่พองคือกลุ่มสุดท้ายกลุ่มเดียว
| byte | ค่า | ความหมาย |
|---|---|---|
| 1 | 0a | tag ของ field หมายเลข 1 |
| 2 | 04 | ความยาวของชื่อ คือ 4 byte |
| 3–6 | 77 69 72 61 | ตัวอักษร w i r a |
| 7 | 10 | tag ของ field หมายเลข 2 ชนิด varint |
| 8–17 | ff เก้าตัว แล้วปิดท้ายด้วย 01 | ค่า −1 ที่ถูกขยายเป็น 64 bit แล้ว |
การขยายเครื่องหมายsign extensionการขยายจำนวนติดลบให้เต็ม 64 bit ก่อนเข้ารหัสเป็น varint · int32 ที่เป็น −1 จึงกินสิบ byte และทั้งใบเป็นสิบเจ็ด byteคือสิ่งที่ทำให้ค่านี้พอง protobuf เข้ารหัสจำนวนเต็มติดลบ ด้วยการขยายมันให้เต็ม 64 bit ก่อน แล้วค่อยส่งผลลัพธ์นั้นเข้า varint
−1 ที่เต็ม 64 bit คือ bit หนึ่งทั้ง 64 ตัว varint หั่นทีละ 7 bit จึงได้เก้ากลุ่มเต็ม
คือ ff เก้าตัว เหลือ bit สุดท้ายอีกหนึ่งตัวเป็นกลุ่มที่สิบ คือ 01
protobuf.dev ระบุกฎนี้ไว้ตรงๆ ว่าจำนวนติดลบใน int32 และ int64 ยาวสิบ byte เสมอ
เพราะมันถูกมองเป็นจำนวนบวกก้อนใหญ่มาก นี่คือกฎ ไม่ใช่ผลเฉพาะของค่า −1
uint32 อ่าน byte ชุดเดิม แล้วสองภาษาตอบคนละค่า
หัวข้อที่มีชื่อว่า “uint32 อ่าน byte ชุดเดิม แล้วสองภาษาตอบคนละค่า”byte 17 ตัวชุดเดิมไม่ถูกแตะเลย แต่ Go ตอบ
4294967295ส่วน Java ตอบ-1
ทรงถัดไปเปลี่ยน quantity จาก int32 เป็น uint32 แล้วให้ผู้อ่านทรงนี้อ่าน byte
17 ตัวชุดเดิม ฝั่งเขียนไม่ได้เขียนใหม่ และไม่มีใครแปลง byte ระหว่างทาง
ทั้งสอง runtime parse ผ่าน ทั้งคู่ได้ customer เป็น wira และทั้งคู่ไม่มี unknown field
สักหมายเลข ช่องที่ต่างกันมีช่องเดียว
| runtime | ค่าที่ quantity อ่านได้ |
|---|---|
| Go | 4294967295 |
| Java | -1 |
Go มีชนิด uint32 จริง มันจึงพิมพ์ค่าที่ bit ทั้ง 32 ตัวเป็นหนึ่ง ออกมาเป็น 4294967295
ซึ่งคือ 2 ยกกำลัง 32 ลบหนึ่ง
Java ไม่มีชนิด 32 bit ที่ไม่มีเครื่องหมายให้ใช้เลย code generation จึงคืนค่าเป็น int
ธรรมดา bit ทั้ง 32 ตัวตรงกับของ Go ทุกตัว แต่ int ของ Java อ่าน bit บนสุดเป็นเครื่องหมาย
มันจึงพิมพ์ออกมาเป็น -1
นี่ไม่ใช่บั๊กของ runtime ไหนเลย ทั้งคู่ถือ bit ชุดเดียวกัน ต่างกันแค่ตอนแปลง bit เป็นตัวเลขให้คนอ่าน คนที่เขียน test เทียบค่าที่พิมพ์ออกมาข้ามสองภาษา จะเห็นสองภาษานี้ ไม่ตรงกัน ทั้งที่ byte ตรงกัน
serialize กลับ แล้ว byte ไม่เท่าเดิม
หัวข้อที่มีชื่อว่า “serialize กลับ แล้ว byte ไม่เท่าเดิม”ผู้อ่าน
uint32เขียนค่าเดิมออกไปใหม่ได้ 12 byte เพราะ varint หดจาก 10 byte เหลือ 5 byte
จนถึงตรงนี้ยังไม่มีอะไรเสียหาย byte ยังเป็นชุดเดิม เสียหายเกิดตอนผู้อ่านทรง uint32
serialize สิ่งที่มันอ่านมา ส่งต่อออกไป
0a047769726110ffffffff0fbyte ชุดใหม่นี้ยาว 12 byte ไม่ใช่ 17 byte กลุ่มสุดท้ายเหลือ ff สี่ตัวแล้วปิดท้ายด้วย 0f
คือ varint 5 byte แทนที่จะเป็น 10 byte และ Go กับ Java คืน byte ชุดนี้ออกมาเหมือนกันเป๊ะ
เหตุผลคือผู้อ่านทรงนี้ถือค่าเป็นจำนวน 32 bit ที่ไม่มีเครื่องหมาย มันจึงไม่มีอะไรให้ขยาย เป็น 64 bit varint หั่น 32 bit เป็นสี่กลุ่มเต็มกับอีกกลุ่มที่มี 4 bit จึงได้ 5 byte
| ใบ | byte | กลุ่มสุดท้าย |
|---|---|---|
ที่ Go เขียนออกมาจากทรง int32 | 17 | ff เก้าตัว แล้ว 01 |
ที่ผู้อ่านทรง uint32 ส่งต่อ | 12 | ff สี่ตัว แล้ว 0f |
นี่คืองานเดียวของคอร์สนี้ที่ round trip ไม่เท่าเดิมด้วยเรื่องชนิด ค่าที่ผู้อ่านถือไว้ไม่ได้ เปลี่ยน แต่จำนวน byte ที่มันส่งต่อเปลี่ยน ใบที่ออกจากผู้อ่านคนนี้จึงไม่ใช่ใบเดียวกับใบที่ เข้ามา ทั้งที่ไม่มีใครตั้งใจแก้ค่าสักคน
flowchart TD A["ทรง int32 ใส่ค่า -1"] --> B["ขยายเป็น 64 bit ก่อนเข้ารหัส"] B --> C["varint 10 byte · ff เก้าตัวแล้ว 01"] C --> D["ผู้อ่าน uint32 ใน Go ได้ 4294967295"] C --> E["ผู้อ่าน uint32 ใน Java ได้ -1"] D --> F["serialize กลับ · varint 5 byte · ทั้งใบ 12 byte"] E --> F
คำบรรยายภาพ: byte ชุดเดียวเข้าไปในผู้อ่านสองภาษา สองภาษาพิมพ์ค่าออกมาไม่ตรงกัน แต่พอ serialize กลับ ทั้งคู่คืน byte ชุดเดียวกัน ซึ่งสั้นกว่าชุดที่รับเข้ามา
sint32 ใช้ zigzag เลข 7 จึงขึ้นสายเป็น 0e
หัวข้อที่มีชื่อว่า “sint32 ใช้ zigzag เลข 7 จึงขึ้นสายเป็น 0e”ทรง
sint32เขียนเลข 7 ออกมาได้ 8 byte เท่าเดิม แต่ byte สุดท้ายเป็น0eไม่ใช่07
int32 แพงกับจำนวนติดลบ เพราะทุกค่าที่ติดลบยาวสิบ byte ทางแก้ของ protobuf คือชนิด
sint32 และ sint64 ซึ่งใช้การเข้ารหัสคนละแบบ
message Order { string customer = 1; sint32 quantity = 2;}การเข้ารหัสแบบสลับฟันปลาzigzag encodingการเข้ารหัสของ sint32 และ sint64 ที่ย้ายเลขติดลบไปอยู่ใกล้ศูนย์ก่อนเข้า varint · เลข 7 จึงขึ้นสายเป็น 0eย้ายจำนวนติดลบมาอยู่ใกล้ศูนย์ก่อน แล้วค่อยส่ง ผลลัพธ์เข้า varint ค่า 0 ไปเป็น 0 ค่า −1 ไปเป็น 1 ค่า 1 ไปเป็น 2 ค่า −2 ไปเป็น 3 ต่อไป เรื่อยๆ สลับกันไปมา
ผลคือค่าติดลบตัวเล็กใช้ byte น้อยเท่าค่าบวกตัวเล็ก แต่ราคาคือค่าบวกทุกตัวถูกคูณสอง
เลข 7 จึงกลายเป็น 14 ซึ่งเขียนเป็น hex ได้ 0e
Go ใส่ quantity เป็น 7 ในทรงนี้ แล้วเขียนออกมาได้ 8 byte คือ 0a0477697261100e
เท่ากับใบของทรง int32 ทุกตัว ยกเว้น byte สุดท้ายตัวเดียว
| ทรงของผู้เขียน | byte | hex |
|---|---|---|
int32 quantity = 2; ใส่ 7 | 8 | 0a04776972611007 |
sint32 quantity = 2; ใส่ 7 | 8 | 0a0477697261100e |
tag เท่ากัน คือ 10 ทั้งคู่ เพราะ sint32 ก็ใช้ wire type 0 เหมือนกัน ความยาวของใบก็เท่ากัน
ผู้อ่านจึงไม่มีทางรู้จาก byte ว่าตัวเลขที่มาถึงผ่าน zigzag มาแล้วหรือยัง
กับดักเดินสองทาง และทั้งสองทางเงียบ
หัวข้อที่มีชื่อว่า “กับดักเดินสองทาง และทั้งสองทางเงียบ”ผู้อ่าน
sint32อ่าน byte ของint32ได้-4ส่วนผู้อ่านint32อ่าน byte ของsint32ได้14
sint32 ไม่ได้เข้ากันได้กับ int32 บนสาย protobuf.dev จัดให้ sint32 และ sint64
เข้ากันได้กับกันเองเท่านั้น ไม่เข้ากับชนิดจำนวนเต็มตัวอื่น
สิ่งที่วัดได้คือทั้งสองทางไม่มีใครบ่นสักทาง
// ฝั่งเขียนถือทรงนี้message Order { string customer = 1; int32 quantity = 2; }
// ฝั่งอ่านถือทรงนี้ เพราะเพิ่งแก้ให้จำนวนติดลบถูกลงmessage Order { string customer = 1; sint32 quantity = 2; }| ผู้อ่านถือ | อ่าน byte ของ | hex ที่รับเข้ามา | ค่าที่ได้ | unknown | serialize กลับ |
|---|---|---|---|---|---|
sint32 | int32 ใส่ 7 | 0a04776972611007 | -4 | 0 | เท่าเดิม |
int32 | sint32 ใส่ 7 | 0a0477697261100e | 14 | 0 | เท่าเดิม |
ทางแรก byte สุดท้ายคือ 07 ผู้อ่าน sint32 ถอด zigzag กลับ ได้ -4 ทางที่สอง byte สุดท้าย
คือ 0e ผู้อ่าน int32 อ่านตรงๆ ไม่ถอดอะไร ได้ 14
Go และ Java ตอบตรงกันทุกช่องในตารางนี้ ทั้งสองภาษาให้ -4 ในทางแรก และให้ 14 ในทางที่สอง
ข้อนี้จึงเป็นพฤติกรรมของ format ไม่ใช่ของ runtime ใด
ที่อันตรายคือ column ขวาสุด serialize กลับได้ byte ชุดเดิมทุกตัวทั้งสองทาง ค่าผิดจึงเดินทางต่อ ไปได้เรื่อยๆ โดยไม่มีร่องรอยว่าเคยผ่านผู้อ่านที่ถือชนิดคนละตัว
14 เป็นจำนวนจานที่ดูสมเหตุสมผล ส่วน -4 ดูไม่สมเหตุสมผลแต่ก็ไม่ทำให้อะไรพัง
ไม่มี test ฝั่งไหนที่ดูแต่ผลของตัวเองแล้วจะสะดุด นี่คือความหมายของคำว่า wire compatibility
ที่ parse ผ่านไม่ใช่หลักฐาน
สรุปการเปลี่ยนชนิดทั้งสี่แบบ
หัวข้อที่มีชื่อว่า “สรุปการเปลี่ยนชนิดทั้งสี่แบบ”มีแบบเดียวในสี่แบบที่ได้ทั้งค่าเดิมตรงกันสองภาษาและ byte ชุดเดิม อีกสามแบบเสียไปอย่างหนึ่ง
การเปลี่ยนชนิดของ quantity | ค่าที่ผู้อ่านได้ | round trip |
|---|---|---|
int32 เป็น int64 | 7 ซึ่งคือค่าเดิม | เท่าเดิม |
int32 ที่เป็น −1 อ่านด้วย uint32 | Go 4294967295 · Java -1 | ไม่เท่าเดิม |
int32 อ่านด้วย sint32 | -4 | เท่าเดิม |
sint32 อ่านด้วย int32 | 14 | เท่าเดิม |
ทั้งสี่แถวนี้ parse ผ่าน ทั้งสี่แถวไม่มี unknown field สักหมายเลข และสามในสี่แถวส่ง byte ชุดเดิมต่อออกไปได้ ผู้อ่านทั้งสี่ทรงจึงดูปกติจากภายนอกเท่ากันหมด
สิ่งที่แยกแถวแรกออกจากอีกสามแถวไม่ได้อยู่บนสาย และไม่ได้อยู่ในรายการชนิดที่เข้ากันได้ด้วย เพราะแถวที่สองอยู่ในรายการเดียวกับแถวแรก คนที่แก้ schema จึงต้องอ่านทั้งรายการนั้น และเงื่อนไขเรื่อง cast ที่ห้อยท้ายรายการไว้
ราคาของบทนี้
หัวข้อที่มีชื่อว่า “ราคาของบทนี้”ชนิดของช่องไม่ได้เดินทางไปกับ byte แบบเดียวกับที่ชื่อของช่องไม่ได้เดินทางไป
บทที่ 1 บอกว่าชื่อของ field ไม่ได้อยู่บนสาย บทนี้เพิ่มข้อที่สอง คือชนิดของ field ก็ไม่ได้อยู่บนสายเหมือนกัน ที่อยู่บนสายคือหมายเลขกับ wire type
wire type บอกได้แค่ว่าค่านี้เป็น varint หรือเป็นของที่บอกความยาวนำหน้า มันไม่ได้บอกว่า varint ตัวนี้ผ่าน zigzag มาหรือยัง และไม่ได้บอกว่าใครควรอ่านมันเป็นจำนวนที่มีเครื่องหมาย หรือไม่มี
ที่ผ่านมาช่อง quantity ยังเป็นค่าเดี่ยวตลอดทั้งบท เปลี่ยนแต่ชนิดของมัน
บทที่ 6 เปลี่ยนอย่างอื่นแทน คือเปลี่ยนจำนวนค่า
ให้ช่องเดียวถือได้หลายค่า แล้วดูว่าผู้อ่านที่ยังประกาศมันเป็นค่าเดี่ยว ได้ค่าไหนไป
-
Protocol Buffers — Encoding (เข้าถึง 2026-09-04) — ที่มาของกฎว่าจำนวนติดลบยาวสิบ byte เสมอ และของสูตร zigzag
-
Protocol Buffers — Language Guide (proto 3) (เข้าถึง 2026-09-04) — ที่มาของรายการชนิดที่เข้ากันได้บนสาย ของเงื่อนไขเรื่อง cast ที่ห้อยท้ายรายการนั้นไว้ และของข้อที่ว่า
sint32กับsint64เข้ากันได้กับกันเองเท่านั้น -
Protocol Buffers — Java Generated Code (เข้าถึง 2026-09-04) — ที่มาของข้อที่ว่า
uint32ถูก generate ออกมาเป็นintของ Java -
ตัวเลขทุกตัวในบทนี้มาจาก
scripts/wire-contract/probe.pyซึ่งให้ Go เขียน byte แล้วให้ทั้ง Go และ Java อ่านกลับ ต้องสั่งscripts/wire-contract/up.shก่อนหนึ่งครั้ง ซึ่งดึงเครื่องมือสามตัวที่ตรึง version ไว้- protoc 36.1
- protoc-gen-go v1.36.12
- protobuf-java 4.36.1
เช็กความเข้าใจ — บทที่ 5
ข้อ 1 / 3ทรง `int32` ใส่ `quantity = -1` แล้ว Go เขียนออกมาได้ 17 byte ทำไมช่องเดียวถึงกิน 10 byte