
ล้างรหัสงานด้วย Python removeprefix และ removesuffix ให้ข้อมูลไม่แหว่ง
งานนำเข้ายอดขายมักต้องตัด INV- ออกจากรหัสคำสั่งซื้อ หรือตัด .csv ก่อนนำชื่อไฟล์ไปทำรายงาน ถ้าหยิบ lstrip('INV-') มาใช้ ข้อมูลบางแถวอาจดูถูกต้อง แต่บางแถวกลับเสียตัวอักษรที่เป็นส่วนหนึ่งของรหัสไปด้วย ผมจะพาใช้ removeprefix() และ removesuffix() ให้ตัดเฉพาะข้อความที่ตกลงกันไว้ พร้อมเทสต์ดักเคสที่ข้อมูลดูปกติแต่ผลลัพธ์ผิดครับ
WHY: ตัดตรงจุด ช่วยรักษาคีย์ที่ใช้จับคู่ข้อมูล
- รหัสที่ถูกตัดเกินอาจจับคู่ใบสั่งซื้อกับรายการชำระเงินไม่เจอ ต่อให้ยอดบาทถูกต้อง งานกระทบยอดก็ยังสะดุด
- ชื่อเมธอดบอกเจตนาได้ทันที คนดูแลสคริปต์ต่อไม่ต้องเดาว่าเลขในคำสั่งตัดสตริงหมายถึงอะไร
- ข้อมูลที่ไม่มีข้อความนำหน้าหรือต่อท้ายตามที่ระบุจะคงค่าเดิม จึงรองรับข้อมูลปนหลายรูปแบบได้ แต่ต้องตรวจรูปแบบแยกหากระบบบังคับให้มี
เมธอดคือฟังก์ชันที่เรียกผ่านวัตถุ เช่น order_id.removeprefix('INV-') ข้อเสนอมาตรฐาน PEP 616 ระบุฟีเจอร์นี้สำหรับ Python 3.9 และเพิ่มให้ทั้ง str, bytes และ bytearray บทความนี้ใช้ Python 3.14 โดยไม่ต้องติดตั้งแพ็กเกจเพิ่ม
ทำงานยังไง
Prefix คือข้อความนำหน้า ส่วน suffix คือข้อความต่อท้าย removeprefix('INV-') ตรวจว่าต้นข้อความตรงกับ INV- ทั้งชุดหรือไม่ ถ้าตรงจึงตัดหนึ่งชุด ส่วน removesuffix('.csv') ทำแบบเดียวกันที่ท้ายข้อความ ถ้าไม่ตรงจะคืนข้อความเดิม และการส่งข้อความว่าง '' จะไม่ทำให้เนื้อหาหาย ตามนิยามใน PEP 616
นึกถึงป้ายราคาแปะหน้ากล่อง เราอยากลอกป้ายที่เขียนว่า INV- ออกทั้งแผ่น แต่ lstrip('INV-') เหมือนสั่งให้แกะตัว I, N, V และ - ที่เจอจากขอบซ้ายไปเรื่อย ๆ โดยไม่สนลำดับ จนเจอตัวที่อยู่นอกชุด ส่วน rstrip('.csv') ใช้หลักเดียวกันจากขวา นี่คือความต่างระหว่างข้อความหนึ่งชุดกับ character set หรือชุดตัวอักษร ซึ่งเป็นต้นเหตุที่ PEP 616 อธิบายไว้โดยตรง
ดังนั้น INV-2026-0001 อาจทำให้เราเผลอมั่นใจ เพราะทั้งสองวิธีให้ผลเหมือนกัน แต่ INV-VIP-0002 จะเผยบั๊กทันทีเมื่อส่วน VI ของ VIP ถูกกินไปด้วย เทสต์จึงต้องมีข้อมูลที่เนื้อรหัสเริ่มด้วยตัวอักษรจากชุดนั้นด้วยครับ
โค้ดตัวอย่างพร้อมเทสต์
บันทึกทั้งบล็อกเป็น clean_ids.py แล้วรัน python3 -I -B clean_ids.py ตัวอย่างนี้ใช้ unittest ซึ่งเป็นเครื่องมือทดสอบในไลบรารีมาตรฐาน การตรวจแต่ละข้อจะเทียบค่าจริงกับค่าที่เราคาดหวัง ข้อมูลชื่อไทย ยอดบาท และเบอร์โทรด้านล่างเป็นข้อมูลสมมติสำหรับทดสอบ
import unittest
def clean_order_id(value):
return value.removeprefix("INV-")
def report_name(filename):
return filename.removesuffix(".csv")
def local_phone(value):
# กติกาของชุดข้อมูลนี้: +66 ตามด้วยเลขที่ตัด 0 หน้าออกแล้ว
if value.startswith("+66"):
return "0" + value.removeprefix("+66")
return value
def clean_row(row):
return {
**row,
"order_id": clean_order_id(row["order_id"]),
"report": report_name(row["report"]),
"phone": local_phone(row["phone"]),
}
class CleaningTests(unittest.TestCase):
def test_thai_order(self):
original = {
"customer": "มะลิ",
"date": "2026-10-19",
"amount_baht": 1290,
"order_id": "INV-2026-0001",
"report": "ยอดขาย-2026-10-19.csv",
"phone": "+66812345678",
}
cleaned = clean_row(original)
self.assertEqual(cleaned["order_id"], "2026-0001")
self.assertEqual(cleaned["report"], "ยอดขาย-2026-10-19")
self.assertEqual(cleaned["phone"], "0812345678")
self.assertEqual(cleaned["customer"], "มะลิ")
self.assertEqual(cleaned["amount_baht"], 1290)
self.assertEqual(original["order_id"], "INV-2026-0001")
def test_lstrip_bug(self):
value = "INV-VIP-0002"
self.assertEqual(value.lstrip("INV-"), "P-0002")
self.assertEqual(clean_order_id(value), "VIP-0002")
def test_rstrip_bug(self):
value = "metrics.csv"
self.assertEqual(value.rstrip(".csv"), "metri")
self.assertEqual(report_name(value), "metrics")
def test_no_match_and_exact_case(self):
self.assertEqual(clean_order_id("ORD-0003"), "ORD-0003")
self.assertEqual(clean_order_id("inv-0003"), "inv-0003")
self.assertEqual(report_name("ยอดขาย.CSV"), "ยอดขาย.CSV")
self.assertEqual(report_name("ยอดขาย.csv.bak"), "ยอดขาย.csv.bak")
self.assertEqual(local_phone("0812345678"), "0812345678")
self.assertEqual(local_phone("+66123+66"), "0123+66")
def test_one_copy_and_empty_affix(self):
self.assertEqual(clean_order_id("INV-INV-0004"), "INV-0004")
self.assertEqual(report_name("ยอดขาย.csv.csv"), "ยอดขาย.csv")
self.assertEqual("INV-0004".removeprefix(""), "INV-0004")
self.assertEqual("ยอดขาย.csv".removesuffix(""), "ยอดขาย.csv")
self.assertEqual(clean_order_id("INV-"), "")
self.assertEqual(clean_order_id(""), "")
def test_binary_data(self):
self.assertEqual(b"INV-0005".removeprefix(b"INV-"), b"0005")
self.assertEqual(b"metrics.csv".removesuffix(b".csv"), b"metrics")
original = bytearray(b"INV-0006")
self.assertEqual(original.removeprefix(b"INV-"), bytearray(b"0006"))
self.assertEqual(original, bytearray(b"INV-0006"))
if __name__ == "__main__": unittest.main()
ผมรันบล็อกนี้ด้วย Python 3.14.7 ผ่านคำสั่ง python3 -I -B clean_ids.py แล้ว เทสต์ทั้ง 6 ข้อผ่านครับ
เทสต์ test_lstrip_bug และ test_rstrip_bug ตั้งใจยืนยันผลลัพธ์ที่ผิดจากวิธีเดิมด้วย เพื่อให้เห็นความต่างชัด ๆ ส่วน test_no_match_and_exact_case ยืนยันว่าตัวพิมพ์ต้องตรง และข้อความที่อยู่กลางสตริงจะไม่ถูกตัด การไม่มี prefix จึงไม่ใช่ข้อผิดพลาดอัตโนมัติ หากรับเฉพาะใบสั่งซื้อแบบ INV- ต้องตรวจและแยกรายการผิดรูปแบบก่อนเรียกฟังก์ชัน
ฝั่งเบอร์โทร การเติม 0 เป็นกติกาของตัวอย่าง ไม่ใช่ความสามารถของ removeprefix() ตัวฟังก์ชันนี้ไม่ได้ตรวจว่าเป็นเบอร์ไทยที่ใช้ได้จริง เคส +66123+66 จงใจแสดงว่าตัดเฉพาะหัว และยังคงข้อความส่วนท้ายไว้ งานจริงควรกำหนดรูปแบบที่รับได้ก่อนแปลง
สำหรับข้อมูลไบนารีหรือข้อมูลที่เก็บเป็นไบต์ ใช้ b'INV-' คู่กับ bytes ได้ตามเทสต์ PEP 616 ยังระบุว่าเมธอดของ bytes และ bytearray รับอาร์กิวเมนต์แบบ bytes-like หรือวัตถุที่ให้ข้อมูลเป็นไบต์ได้ อย่าสับสนกับการถอดรหัสไบต์เป็นข้อความไทย เพราะเป็นอีกขั้นตอนหนึ่งของงาน
จุดที่คนพลาดบ่อย
- ใช้ strip เพื่อตัดคำทั้งคำ —
lstripและrstripเหมาะกับการตัดสมาชิกจากชุดตัวอักษรที่ขอบ ตามความต่างใน PEP 616 ถ้าต้องลบคำตรงตัว ให้เปลี่ยนเป็นremoveprefixหรือremovesuffixและมีเทสต์อย่างVIP-0002ที่ช่วยเปิดโปงการตัดเกิน - คิดว่าตัดได้ทุกตำแหน่งหรือทุกชุด — เมธอดนี้ตัดอย่างมากหนึ่งชุดที่ขอบที่ระบุ ถ้าเจอ
INV-INV-0004อย่ารีบวนลูปลบทิ้งจนหมด ควรตัดสินก่อนว่าข้อมูลซ้ำเป็นสิ่งที่ต้องแก้หรือควรส่งตรวจ - เรียกแล้วไม่เก็บผลลัพธ์ — การเรียกเมธอดคืนค่าที่ตัดแล้ว ให้กำหนดกลับด้วย
order_id = order_id.removeprefix('INV-')ตัวอย่างเทสต์ยังตรวจว่ารหัสในแถวต้นฉบับอยู่ครบ เพื่อให้ตามรอยข้อมูลก่อนแปลงได้ - มองว่าตัดสำเร็จเท่ากับข้อมูลถูกต้อง — หลังตัด
INV-อาจเหลือสตริงว่างได้ และ.CSVไม่ตรงกับ.csvให้ตรวจค่าที่จำเป็น รวมถึงกำหนดนโยบายตัวพิมพ์ใหญ่เล็กให้ชัด อย่าลดตัวพิมพ์ทั้งชื่อไฟล์ถ้ายังต้องรักษาชื่อเดิม - เขียน slicing เองแล้วพลาดค่าว่าง — slicing คือการเลือกช่วงข้อความด้วยดัชนี กรณีตัดท้ายด้วย
[:-len(suffix)]จะมีปัญหาเมื่อ suffix ว่าง เพราะ[:-0]ให้ข้อความว่าง PEP 616 ระบุการป้องกันเคสนี้ไว้แล้ว ใช้เมธอดสำเร็จรูปจะลดภาระจุดนี้
ใช้ตรงไหนในงานจริง
- เตรียมรหัสคำสั่งซื้อจากไฟล์ส่งออกก่อนจับคู่กับระบบบัญชี โดยเก็บรหัสดิบไว้สำหรับตรวจย้อนกลับ
- สร้างชื่อรายงานจากไฟล์
.csvโดยรักษาคำท้ายชื่ออย่างmetricsซึ่งมีตัวอักษรในชุดc,s,vปนอยู่ - แปลงเบอร์โทรที่ผ่านการตรวจรูปแบบแล้ว จากกติกา
+66เป็นรูปแบบภายในระบบที่ขึ้นต้นด้วย0 - ตัดหัวข้อมูลจากอุปกรณ์หรือไฟล์ไบนารีด้วยเวอร์ชัน
bytesโดยไม่ต้องแปลงเป็นstrเพื่อตัดหัวอย่างเดียว
เทียบกับทางเลือกอื่น
ตารางนี้เทียบเรื่องเจตนาและความเสี่ยงในการเขียน ไม่ได้อ้างผลวัดความเร็ว รายละเอียดเมธอดของชนิดข้อมูลพื้นฐานอ่านต่อได้ที่ Python Built-in Types และเหตุผลที่เพิ่มวิธีตัดตรงขอบอยู่ใน PEP 616
| วิธี | ดีตรงไหน | ระวังอะไร |
|---|---|---|
removeprefix / removesuffix | บอกชัดว่าตัดข้อความตรงขอบ | ต้องใช้ Python 3.9+ และตัดเพียงหนึ่งชุด |
lstrip / rstrip | เหมาะเมื่ออยากตัดชุดตัวอักษรจากขอบ | อาร์กิวเมนต์ไม่ได้หมายถึงคำหนึ่งคำ |
startswith / endswith แล้ว slicing | แยกเงื่อนไขตรวจพบกับการตัดได้ | ต้องจัดการความยาวและ suffix ว่างเอง |
| Regular expression หรือรูปแบบค้นหาข้อความ | เหมาะเมื่อกติกามีรูปแบบซับซ้อน | ต้องผูกตำแหน่งหัวท้ายและจัดการอักขระพิเศษให้ถูก |
Recap
- ใช้
removeprefixตัดข้อความนำหน้าที่ตรงทั้งชุด - ใช้
removesuffixตัดข้อความต่อท้ายที่ตรงทั้งชุด - จำว่า
lstripและrstripรับชุดตัวอักษร ไม่ใช่ prefix หรือ suffix - เก็บค่าที่คืนกลับ และรักษาข้อมูลต้นฉบับเมื่อจำเป็นต้องตรวจย้อนหลัง
- เทสต์กรณีไม่ตรง ตัวพิมพ์ต่าง ค่าว่าง และข้อความซ้ำสองชุด
- ตรวจความถูกต้องของรหัสและเบอร์โทรแยกจากการตัดข้อความ
- ใช้ Python 3.9 ขึ้นไป และเลือก
strหรือbytesให้ตรงชนิดข้อมูล