ข้ามไปที่เนื้อหา

ทำไมต้อง t-string

สี่วิธีในการใส่ค่าลงในข้อความที่แปลได้ เปรียบเทียบกันด้วยประโยคเดียวกัน ทั้งสี่วิธีตั้งชื่อให้ตัวยึดตำแหน่งของตนและยอมให้นักแปลสลับลำดับได้ สิ่งที่ต่างกันคือจะเกิดอะไรขึ้นเมื่อคำแปลผิด แคตตาล็อกเอื้อมถึงโปรแกรมของคุณได้มากแค่ไหน และการรับมันมาใช้มีต้นทุนเท่าใด

ตารางมาก่อน คุณจึงหาแถวที่คุณสนใจแล้วอ่านเฉพาะหัวข้อที่อยู่เบื้องหลังแถวนั้นได้

สามฝ่ายเกี่ยวข้องกับทุกข้อความที่ถูกแปล

แคตตาล็อก คือไฟล์ของคำแปล — อยู่ในรูป .po ระหว่างที่มนุษย์แก้ไข และถูกคอมไพล์เป็น .mo เพื่อให้แอปพลิเคชันโหลด (บทแนะนำ พาทำทั้งสองขั้นตอน) สามฝ่ายเกี่ยวข้องกับทุกข้อความ: นักพัฒนา เขียนสตริงต้นทาง นักแปล แก้ไขแคตตาล็อก — บ่อยครั้งบนแพลตฟอร์มภายนอกที่อยู่ห่างไกลจากการรีวิวโค้ดใด ๆ — และ แอปพลิเคชัน นำทั้งสองส่วนมาเรนเดอร์ร่วมกันขณะรันไทม์ รูปแบบการจัดรูปแบบแต่ละแบบด้านล่างตอบคำถามเดียวกันด้วยคำตอบที่ต่างกัน: แคตตาล็อกได้สิทธิ์ควบคุมภาษาการจัดรูปแบบมากแค่ไหน? ในตัวอย่าง _ คือชื่อตามธรรมเนียมของฟังก์ชันแปล และ tr คือฟังก์ชันของไลบรารีนี้

เทียบเคียงกัน

เมื่อนักแปลทำผิดพลาด แคตตาล็อกเดินทางผ่านมือหลายคน และสิ่งที่ผิดพลาดในนั้นส่วนใหญ่เกิดขึ้นโดยไม่ตั้งใจ:

%(name)s .format() flufl.i18n $name t"…"
คำแปล ตัด ตัวยึดตำแหน่งทิ้ง — เรนเดอร์อะไร ค่าหายไปเงียบ ๆ ค่าหายไปเงียบ ๆ ค่าหายไปเงียบ ๆ ข้อความต้นทาง พร้อมคำเตือน (โดยค่าเริ่มต้น)
คำแปล เพิ่ม ตัวยึดตำแหน่งที่ไม่รู้จัก — เรนเดอร์อะไร ข้อยกเว้น ข้อยกเว้น ตัวยึดตำแหน่งยังคงมองเห็นเป็นข้อความ ข้อความต้นทาง พร้อมคำเตือน (โดยค่าเริ่มต้น)
คำแปล เปลี่ยนรูปแบบ ของตัวยึดตำแหน่ง — เรนเดอร์อะไร ตามที่แคตตาล็อกสั่ง หรือข้อยกเว้นถ้าตัวอักษรบอกชนิดไม่เข้ากับค่าอีกต่อไป ตามที่แคตตาล็อกสั่ง เขียนไม่ได้ใน $-strings ข้อความต้นทาง พร้อมคำเตือน
ตัวยึดตำแหน่งถูกตรวจสอบตอนเรนเดอร์หรือไม่ ไม่ ไม่ ไม่ ใช่ (ดูด้านล่าง)

แคตตาล็อกมีอำนาจแค่ไหน คำแปลคือข้อมูลจากภายนอกรีโพซิทอรีของคุณ และแต่ละรูปแบบยื่นอำนาจให้มันไม่เท่ากัน:

%(name)s .format() flufl.i18n $name t"…"
ค่ามาจากไหน แมปที่ระบุอย่างชัดแจ้ง อาร์กิวเมนต์ที่ระบุอย่างชัดแจ้ง ตัวแปร local และ global ของผู้เรียก บวก extras ที่เป็นทางเลือก ค่าที่ถูกจับไว้ภายใน t-string
แคตตาล็อกเปลี่ยนวิธีจัดรูปแบบค่าได้หรือไม่ ได้ ได้ ไม่ได้ ไม่ได้
แคตตาล็อกล้วงเข้าไปในอ็อบเจกต์ (เข้าถึงแอตทริบิวต์) ได้หรือไม่ ไม่ได้ ได้ ได้ ด้วยชื่อแบบมีจุด ไม่ได้
"ภาษาปัจจุบัน" อยู่ที่ไหน แล้วแต่แอปพลิเคชันจะเก็บไว้ตรงไหน แล้วแต่แอปพลิเคชันจะเก็บไว้ตรงไหน สแตกของรหัสภาษาบนอ็อบเจกต์แอปพลิเคชันที่ใช้ร่วมกัน ContextVar แยกตามแต่ละ task หรือแต่ละคำขอ

การผสานเข้าระบบมีต้นทุนเท่าใด ทุกอย่างข้างต้นได้มาฟรีหากเครื่องมือเข้ากันได้ ส่วนนี้คือจุดที่มันอาจเข้ากันไม่ได้:

%(name)s .format() flufl.i18n $name t"…"
Python ขั้นต่ำ เวอร์ชันใดก็ได้ เวอร์ชันใดก็ได้ 3.10 3.14
ความสุกงอม ไลบรารีมาตรฐาน ไลบรารีมาตรฐาน รุ่นเสถียรที่ออกแล้ว อัลฟา
ใช้แคตตาล็อก PO/MO ธรรมดาหรือไม่ ใช่ ใช่ ใช่ ใช่
ต้องใช้ตัวสกัดซอร์สแบบกำหนดเองหรือไม่ ไม่ ไม่ ไม่ ใช่ ในปัจจุบัน
Babel อนุมานแฟล็ก PO ตัวใด เพื่อให้เครื่องมือที่มีอยู่ตรวจสอบได้ python-format python-brace-format ไม่มี python-brace-format

ว่าด้วยการตรวจสอบตอนเรนเดอร์: ข้อความเอกพจน์ถูกตรวจสอบว่าตัวยึดตำแหน่งตรงกันพอดี ข้อความพหูพจน์ก็ถูกตรวจสอบเช่นกัน เทียบกับ กฎยูเนียน/อินเตอร์เซกชัน ที่อนุญาตให้รูปพหูพจน์ของภาษาปลายทางแตกต่างจากของภาษาต้นทางได้ ส่วนการตรวจสอบรายรูปที่เข้มงวดกว่าจะทำงานตอนคอมไพล์แคตตาล็อก (การสกัดข้อความ)

แถวเรื่องแฟล็กรูปแบบเป็นเรื่องของการตรวจสอบที่รู้จักตัวยึดตำแหน่ง ไม่ใช่ความเข้ากันได้ของแคตตาล็อก "ไม่มี" หมายความว่าเครื่องมือ gettext มาตรฐานยังคงอ่านและคอมไพล์ข้อความได้ แต่ msgfmt --check-format ไม่มีไวยากรณ์ตัวยึดตำแหน่งแบบ $ ให้นำมาใช้ตรวจ

ความเข้ากันได้และความสุกงอม

สองแถวแรกของตารางสุดท้ายคือแถวที่ตัดสินว่าจะรับมาใช้หรือไม่ จึงควรพูดออกมาตรง ๆ มากกว่าจะเป็นแค่ช่องในตาราง

%-format และ .format() ถูกฝังมาในตัว Python และไม่ต้องพึ่ง dependency ใดเลย flufl.i18n เป็นแพ็กเกจที่เติบโตเต็มที่ ออกรุ่นแล้วและถูกใช้งานจริง ทำงานบน Python 3.10 ขึ้นไป ส่วน gettext-tstrings ยังเป็นรุ่น อัลฟา และต้องใช้ Python 3.14 ขึ้นไป เพราะ t-string เป็นไวยากรณ์ใหม่ใน 3.14 — ไม่มี back-port และไม่มีทางมีได้ ข้อกำหนด ของมันคือส่วนที่นิ่งแล้ว ส่วน API ฝั่ง Python ยังอาจขยับได้ก่อนถึง 1.0

สิ่งที่ไม่มีวิธีไหนต้องแลกเลยคือความเข้ากันได้ของแคตตาล็อก ทั้งสี่วิธีผลิตไฟล์ POT/PO/MO ธรรมดาที่โปรแกรมแก้ไข PO แพลตฟอร์มการแปล และเครื่องมือ GNU gettext ทุกตัวอ่านได้อยู่แล้ว ตัวเลือกด้านล่างจึงย้อนกลับได้ในแบบที่การเปลี่ยนรูปแบบแคตตาล็อกทำไม่ได้ การย้ายระบบ ว่าด้วยการย้ายโปรเจกต์ที่มีอยู่แล้ว

หัวข้อด้านล่างแสดงสิ่งที่ต้องแลกของแต่ละวิธีโดยละเอียด ทีละวิธี

%-format

_("Hello %(name)s") % {"name": name}

สิ่งที่อาจผิดพลาด: ตัวยึดตำแหน่งที่เสียหายกลายเป็นข้อยกเว้นขณะรันไทม์ เว้นแต่การตรวจสอบแคตตาล็อกจะจับมันได้ก่อน

สตริงในแคตตาล็อกพกไวยากรณ์ printf ติดตัวไปด้วย รวมถึงตัวอักษรบอกชนิดที่ต่อท้าย — ตัว s ใน %(name)s — ซึ่งมองข้ามได้ง่ายและถูกทำให้เสียหายได้ง่าย:

>>> "Hello %(name)" % {"name": "Ada"}  # the trailing "s" was deleted
Traceback (most recent call last):
  ...
ValueError: incomplete format

การแก้ไขเพียงหนึ่งตัวอักษรในโปรแกรมแก้ไข PO กลายเป็นข้อยกเว้นขณะรันไทม์ เว้นแต่การตรวจสอบแคตตาล็อกจะจับได้ก่อน msgfmt --check-format ของ GNU จับข้อผิดพลาดนี้ได้ก็จริง แต่เฉพาะกับข้อความที่ติดแฟล็ก python-format เท่านั้น และเฉพาะเมื่อแคตตาล็อกผ่าน msgfmt จริง ๆ ระหว่างทางไปสู่แอปพลิเคชันของคุณ

str.format

_("Hello {name}").format(name=name)

วิธีนี้ตัดตัวอักษรบอกชนิดที่ต่อท้ายออก โดยยังคงตัวยึดตำแหน่งแบบมีชื่อที่สลับลำดับได้อย่างอิสระ สิ่งที่อาจผิดพลาดย้ายไปอยู่อีกฝั่งของการแลกเปลี่ยน: คำแปลได้อำนาจเหนืออ็อบเจกต์ของคุณ

str.format เป็นภาษานิพจน์ขนาดเล็ก และการเรียกมันบนสตริงหนึ่ง หมายถึงการยื่นสิทธิ์ให้สตริงนั้นใช้ภาษานี้ได้:

>>> "{name.__class__.__mro__}".format(name="Ada")
"(<class 'str'>, <class 'object'>)"
>>> settings.api_key = "sk-live-…"
>>> "{conf.api_key}".format(conf=settings)
'sk-live-…'

ทีนี้ลองแทนที่สตริงตามตัวอักษรเหล่านั้นด้วยสิ่งที่ _() คืนค่ามา หากคำแปลของ Hello {name} กลับมาเป็น {conf.api_key} การเรนเดอร์มันจะพิมพ์ API key ของคุณออกมา — แคตตาล็อกเป็นผู้ตัดสินว่าอะไรถูกอ่าน ไม่ใช่โค้ดของคุณ แคตตาล็อกไม่ใช่โค้ด แต่มันเดินทางเหมือนข้อมูล: ออกไปยังแพลตฟอร์มแปลภาษา ผ่านมือหลายคน กลับมาเป็น .po ถูกคอมไพล์เป็น .mo และบางครั้งก็ถูก vendor มาจากภายนอกโปรเจกต์ของคุณทั้งหมด .format() มอบสิทธิ์เข้าถึงแอตทริบิวต์ของอ็อบเจกต์ที่คุณส่งเข้าไปให้แก่ทุกขั้นตอนของการเดินทางนั้น

$-strings และ flufl.i18n

from flufl.i18n import initialize

_ = initialize("example")

name = "Ada"
print(_("Hello $name"))  # Hello Ada — the value came from the caller's locals

string.Template ในไลบรารีมาตรฐานเป็นผู้จัดหาภาษาแทรกค่าแบบ $name แต่ตัวมันเองไม่ใช่ API สำหรับการแปล flufl.i18n ผสานรูปแบบนี้เข้ากับการค้นหาแคตตาล็อกของ gettext สังเกตว่าค่าไม่เคยถูกส่งเข้าไปเลย: flufl.i18n สร้างเนมสเปซสำหรับการแทนค่าจาก globals และ locals ของผู้เรียก — ตัวแปรใดก็ตามที่มีอยู่ ณ จุดเรียกใช้จะพร้อมให้ข้อความใช้งาน แมป extras ที่เป็นทางเลือกจะมีลำดับความสำคัญเหนือทั้งสองอย่าง ไวยากรณ์ฝั่งนักแปลของมันไม่มีตัวอักษรบอกชนิดต่อท้ายหรือตัวระบุรูปแบบ และตัวยึดตำแหน่งยังคงสลับลำดับได้อย่างอิสระ

การแทนค่าที่ไม่พร้อมใช้งานจะไม่ raise หากมี name = "Ada" แต่ไม่มี nombre ในเนมสเปซของผู้เรียก คำแปลในแคตตาล็อกที่เป็น Hello $nombre จะเรนเดอร์เป็น Hello $nombre: ตัวยึดตำแหน่งที่แก้ค่าไม่ได้ยังคงมองเห็นได้ พฤติกรรมที่มีบันทึกไว้ นี้รักษาส่วนที่เหลือของข้อความที่แปลแล้วไว้แทนที่จะทำให้การเรียกล้มเหลว ส่วนข้อยกเว้นที่เกิดขึ้นระหว่างการแก้ค่าแอตทริบิวต์หรือการแปลงค่ายังคงแพร่ต่อไปได้

flufl.i18n มีความสามารถมากกว่า string.Template เปล่า ๆ ในแง่หนึ่งที่เกี่ยวข้องกับเรื่องนี้ Template แบบกำหนดเอง ของมันรับตัวยึดตำแหน่งแบบมีจุด เช่น $settings.api_key และ translator ของมันจะแก้ค่าเส้นทางเหล่านั้นเทียบกับค่าของผู้เรียก ตัวยึดตำแหน่งที่ถูกแปลจึงอาจอ้างชื่อ local หรือ global ใดก็ได้ของผู้เรียกที่มีอยู่ และด้วยไวยากรณ์แบบมีจุด ยังไล่เข้าไปในแอตทริบิวต์ของมันได้ด้วย นั่นสะดวกเมื่อข้อความต้องการแอตทริบิวต์ แต่ในขณะเดียวกันก็ทำให้เฟรมของผู้เรียกกลายเป็นส่วนหนึ่งของเนมสเปซการแทนค่าของแคตตาล็อก การเปรียบเทียบในหน้านี้อธิบาย flufl.i18n 6.0.0 ไม่ใช่ทุกการใช้งานที่เป็นไปได้ของ string.Template

มันยังตอบคำถามที่อีกสองรูปแบบการจัดรูปแบบโยนให้แอปพลิเคชันรับผิดชอบทั้งหมดด้วย นั่นคือ ภาษาไหนคือภาษาปัจจุบัน และจะเปลี่ยนมันอย่างไร อ็อบเจกต์แอปพลิเคชัน เก็บสแตกของภาษาไว้ โดย _.push(code) กับ _.pop() เป็นตัวเลื่อนมัน with _.using(code): ซ้อนกันได้ และ กลยุทธ์ จะหาแคตตาล็อกให้จากรหัสภาษา แอปพลิเคชันจึงไม่ต้องจัดการอ็อบเจกต์แคตตาล็อกเองเลย เซิร์ฟเวอร์ที่ต้องผลิตข้อความมากกว่าหนึ่งภาษาภายในหน่วยการทำงานเดียว — หน้าเว็บสำหรับผู้อ่าน และการแจ้งเตือนถึงคนที่บัญชีตั้งค่าไว้เป็นอีกภาษาหนึ่ง — คือกรณีที่สิ่งนี้มีอยู่เพื่อรองรับ

สแตกนั้นอยู่บนอ็อบเจกต์แอปพลิเคชันซึ่งทั้งโปรเซสใช้ร่วมกัน คำขอสองรายการที่ทับซ้อนกันจึงใช้สแตกเดียวกัน และบล็อกที่ไม่ได้ซ้อนกันอย่างเคร่งครัดในเชิงเวลาก็จะส่งภาษาผิดให้แก่กัน:

async def greet(code, delay):
    with _.using(code):
        await asyncio.sleep(delay)
        return _("Hello $name")


async def main():
    return await asyncio.gather(greet("fr", 0.01), greet("ja", 0.02))
>>> asyncio.run(main())  # "fr" entered first and left first, so it read "ja" off the top
['こんにちは Ada', 'Bonjour Ada']

ไลบรารีนี้คงความสามารถเดียวกันไว้ — การผูกซ้อนกันและคลายออกในแบบเดียวกัน — แต่เก็บไว้ใน ContextVar แทนสแตกที่ใช้ร่วมกัน การสลับกันข้างต้นจึงแยกกันตามแต่ละ task ดูวิธีเขียนที่เทียบเท่าได้ที่ หลายภาษาพร้อมกัน สิ่งที่ไลบรารีนี้ไม่ได้จัดหาให้คือการค้นหาแคตตาล็อกจากรหัสภาษา: คุณเป็นฝ่ายส่งอ็อบเจกต์การแปลเข้ามา ซึ่งในกรณีทั่วไปก็คือการเรียก gettext.translation() เพียงครั้งเดียว และไลบรารีมาตรฐานจะแคชแคตตาล็อกที่แจงแล้วไว้ให้

t-string

tr(t"Hello {name}")

แคตตาล็อกยังคงเห็น Hello {name} และยังคงเป็นแคตตาล็อก PO/MO ธรรมดา ความแตกต่างอยู่ที่ คำแปลได้รับอนุญาตให้พูดอะไรได้บ้าง และใครเป็นผู้ตรวจสอบ

ไลบรารีนี้ตรวจสอบทุกคำแปลเทียบกับตัวยึดตำแหน่งของข้อความต้นทางก่อนเรนเดอร์ และยอมรับเพียงชื่อเปล่า ๆ เท่านั้น ไม่รับสิ่งอื่นใด เมื่อเทียบกับ t"Hello {name}":

คำแปลที่มี ถูกปฏิเสธด้วยข้อความ
{name.__class__.__mro__} placeholder {name.__class__.__mro__} must be a plain name, copied from the source message unchanged
{name!r} placeholder {name} adds formatting; write {name} on its own, because the source message decides how the value is formatted
{0} placeholder {0} must be a plain name, copied from the source message unchanged
{nombre} translation does not match the source placeholders: {name} is missing; {nombre} is not in the source message

ถูกปฏิเสธไม่ได้แปลว่าแครช: โดยค่าเริ่มต้นไลบรารีจะบันทึกคำเตือนแล้วเรนเดอร์ข้อความต้นทาง ดังนั้นแคตตาล็อกที่เสียจะไม่มีวันทำให้แอปพลิเคชันล่ม — สัญญาเดียวกันกับที่ gettext เองรักษาไว้

การจัดรูปแบบยังคงอยู่ที่เดิมที่มันถูกเขียนไว้ นั่นคือในโค้ด:

amount = 1234.5
tr(t"Total: {amount:,.2f}")  # msgid is "Total: {amount}"

:,.2f ไม่มีวันไปถึงแคตตาล็อก ดังนั้นไม่มีคำแปลใดเปลี่ยนแปลงมันได้ และไม่มีนักแปลคนไหนต้องมองมันเลย แต่มันเป็นรูปแบบ ตายตัว ไม่ใช่รูปแบบที่ปรับตามท้องถิ่น — การเลือกจำนวนหลักและตัวคั่นตามแต่ละภาษาเป็น งานของ Babel ก่อนถึงจุดเรียกใช้

ความแตกต่างอีกอย่างคือเครื่องมือ: t-string เป็นไวยากรณ์ใหม่ การสกัดมันออกมาเป็น .pot ในปัจจุบันจึงต้องใช้ตัวสกัดที่รู้จัก t-string เช่นตัวที่แพ็กเกจนี้ จัดเตรียมไว้ให้ Babel

ต้นทุนของข้อจำกัดนี้

นอกเหนือจากข้อกำหนดเรื่องเวอร์ชัน Python แล้ว ราคาของทั้งหมดนี้คือกฎข้อเดียว: การแทรกค่าต้องเป็นชื่อเปล่า ๆ

tr(t"Hello {user.name}")  # raises InvalidTemplateError at the call site
name = user.name  # compute it first
tr(t"Hello {name}")

นั่นเป็นข้อจำกัดจริง ๆ และมันคือข้อจำกัดเดียวกันกับที่ผลิตการรับประกันข้างต้นออกมา เมื่อรวมกับการผูกค่าฝั่งซอร์สและการตรวจสอบตัวยึดตำแหน่งขณะรันไทม์ มันป้องกันไม่ให้สตริงในแคตตาล็อกประเมินนิพจน์ได้ และรักษาให้ชื่อตัวยึดตำแหน่งมีความหมายต่อคนที่ต้องแปลมัน

f-string ใช้แบบนี้ไม่ได้เลย — กว่าไลบรารีใดจะได้เห็นมัน มันก็เป็นสตริงที่เสร็จสมบูรณ์ไปแล้ว การแปลมันจึงหมายถึงการแปลเศษเสี้ยวของประโยค t-string (PEP 750) เก็บข้อความคงที่และค่าแยกจากกัน โดยยังคงไวยากรณ์แบบ f-string และการผูกค่าอย่างชัดแจ้งไว้

Python เดินทางมาถึงจุดนี้ได้อย่างไร — สอง PEP ที่ห่างกันสิบปี และการอภิปรายในไลบรารีมาตรฐานที่ปิดลงโดยไร้คำตอบ — ถูกเล่าไว้พร้อมแหล่งอ้างอิงใน ความเป็นมา