งูการ์ตูนสีเขียวใช้กรรไกรตัดหัวป้ายรหัสจากม้วนกระดาษอย่างระวัง ขณะที่เครื่องทำลายเอกสารที่มีป้ายเตือนข้าง ๆ ฉีกป้ายรหัสจนขาดกระจาย

ล้างรหัสงานด้วย Python removeprefix และ removesuffix ให้ข้อมูลไม่แหว่ง

น้องกายEngineering

งานนำเข้ายอดขายมักต้องตัด 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 หรือวัตถุที่ให้ข้อมูลเป็นไบต์ได้ อย่าสับสนกับการถอดรหัสไบต์เป็นข้อความไทย เพราะเป็นอีกขั้นตอนหนึ่งของงาน

จุดที่คนพลาดบ่อย

  1. ใช้ strip เพื่อตัดคำทั้งคำ — lstrip และ rstrip เหมาะกับการตัดสมาชิกจากชุดตัวอักษรที่ขอบ ตามความต่างใน PEP 616 ถ้าต้องลบคำตรงตัว ให้เปลี่ยนเป็น removeprefix หรือ removesuffix และมีเทสต์อย่าง VIP-0002 ที่ช่วยเปิดโปงการตัดเกิน
  2. คิดว่าตัดได้ทุกตำแหน่งหรือทุกชุด — เมธอดนี้ตัดอย่างมากหนึ่งชุดที่ขอบที่ระบุ ถ้าเจอ INV-INV-0004 อย่ารีบวนลูปลบทิ้งจนหมด ควรตัดสินก่อนว่าข้อมูลซ้ำเป็นสิ่งที่ต้องแก้หรือควรส่งตรวจ
  3. เรียกแล้วไม่เก็บผลลัพธ์ — การเรียกเมธอดคืนค่าที่ตัดแล้ว ให้กำหนดกลับด้วย order_id = order_id.removeprefix('INV-') ตัวอย่างเทสต์ยังตรวจว่ารหัสในแถวต้นฉบับอยู่ครบ เพื่อให้ตามรอยข้อมูลก่อนแปลงได้
  4. มองว่าตัดสำเร็จเท่ากับข้อมูลถูกต้อง — หลังตัด INV- อาจเหลือสตริงว่างได้ และ .CSV ไม่ตรงกับ .csv ให้ตรวจค่าที่จำเป็น รวมถึงกำหนดนโยบายตัวพิมพ์ใหญ่เล็กให้ชัด อย่าลดตัวพิมพ์ทั้งชื่อไฟล์ถ้ายังต้องรักษาชื่อเดิม
  5. เขียน 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 ให้ตรงชนิดข้อมูล