التحقّق من ترميز UTF-8
درس 5 من 11
5التحقّق من ترميز UTF-8
أول لقاء جدّي لك مع البتّات: كيف يشفّر UTF-8 حرفًا في بايت إلى أربعة، وكيف تتحقّق منه بآلة حالات صغيرة.
ماذا تشاهد في الرسم: م تصير البايتين 11011001 و10000101: البادئة 110 تعلن بداية حرف من بايتين، و10 تعلن بايت تكملة.
خارطة هذا الجزء
المفاهيم بالترتيب، اضغط أيًّا منها للقفز إليه:
طريقان إلى نفس الفكرة
الحروف الإنجليزية تكفيها علبة صغيرة واحدة، أما الحرف العربي أو الرمز التعبيري فيحتاج علبتين أو ثلاثًا أو أربعًا. أول علبة في المجموعة تحمل ملصقًا يقول كم علبة تتبعها، وكل علبة تابعة لها ملصق خاص. التحقق يعني أن تتأكد أن الملصقات صادقة.
UTF-8 يشفّر نقطة الرمز في 1 إلى 4 بايتات: البايت الأول يبدأ بـ0 أو 110 أو 1110 أو 11110، وكل بايت تابع يبدأ بـ10. تتحقق بآلة حالات تقرأ أعلى البتات بالإزاحة والقناع مثل byte >> 6 == 0b10.
المتطلبات قبل البداية
النظام الثنائي
البايت ثمانية بتّات، وقيمته من 0 إلى 255. format(x, "08b") يعطيك التمثيل الثنائي ويعين كثيرًا أثناء التصحيح.
الإزاحة >>
byte >> 7 يحذف آخر 7 بتّات ويبقي البت الأول. هذه هي أداتك لفحص بدايات البايت.
القناع &
num & 0xFF يُبقي أقل 8 بتّات ويرمي الباقي. النص يقول صريحًا إننا نتعامل مع أقل 8 بتّات فقط.
آلة الحالات
فكرة بسيطة وقوية: متغيّر واحد يحفظ «أين أنا؟»، وهنا هو عدد بايتات المتابعة المتوقّعة. لا تحتاج أكثر.
المفاهيم الأساسية
المطلوب: دالة تأخذ قائمة أعداد صحيحة، كل عدد يمثّل بايتًا، وترجع True إذا كانت القائمة ترميز UTF-8 صالحًا.
1لماذا UTF-8 أصلًا؟
يونيكود يعطي رقمًا لكل حرف في كل لغة، أكثر من مليون رقم محتمل. لو خزّنا كل حرف في 4 بايت لصار ملف إنجليزي كامل أربعة أضعاف حجمه بلا داعٍ. فجاء UTF-8 بحل أنيق: ترميز متغيّر الطول، الحروف الشائعة (ASCII) في بايت واحد، والأقل شيوعًا في اثنين أو ثلاثة أو أربعة.
لكن إذا كان الطول متغيّرًا، كيف يعرف القارئ أين ينتهي الحرف ويبدأ الذي بعده؟ الجواب هو البتّات الأولى من كل بايت، وهي ما سنتحقّق منه.
2جدول الأنماط، قلب المسألة
| طول الحرف | نمط البايت الأول | بايتات المتابعة | المدى |
|---|---|---|---|
| 1 بايت | 0xxxxxxx | لا شيء | 0، 127 (ASCII) |
| 2 بايت | 110xxxxx | 10xxxxxx ×1 | 192، 223 |
| 3 بايت | 1110xxxx | 10xxxxxx ×2 | 224، 239 |
| 4 بايت | 11110xxx | 10xxxxxx ×3 | 240، 247 |
| ، (غير صالح) | 10xxxxxx | متابعة بلا بداية | 128، 191 |
| ، (غير صالح) | 11111xxx | لا يوجد حرف بخمسة بايت | 248، 255 |
لاحظ المنطق: عدد الواحدات في بداية البايت الأول = طول الحرف، ويفصلها صفر. وكل بايت متابعة يبدأ بـ 10 دائمًا، فلا يمكن أن يُخطئ القارئ ويحسب بايت متابعة بدايةَ حرف.
3من النمط إلى الكود
لفحص «هل تبدأ بـ 110؟» نزيح البايت 5 خانات لليمين فيبقى أول 3 بتّات، ونقارنها بـ 0b110:
byte = 0b11000011 # 195 byte >> 5 = 0b110 # 6 -> yes, a 2-byte lead byte >> 6 = 0b11 # 3 -> not 0b10, so not a continuation byte >> 7 = 0b1 # 1 -> not 0, so not plain ASCII
وكل الفحوص الأربعة تتبع نفس المبدأ:
| السؤال | الفحص |
|---|---|
| حرف بايت واحد؟ | byte >> 7 == 0 |
| بداية حرف من بايتين؟ | byte >> 5 == 0b110 |
| بداية حرف من ثلاثة؟ | byte >> 4 == 0b1110 |
| بداية حرف من أربعة؟ | byte >> 3 == 0b11110 |
| بايت متابعة؟ | byte >> 6 == 0b10 |
byte >> 7 == 0 أولًا. لو بدأنا بـ byte >> 5 == 0b110 لنجح البايت 0b11000011 في الفحص خطأً، لأن أول 3 بتّات منه 110 فعلًا. القاعدة: افحص الأنماط من الأقصر إلى الأطول، لأن النمط الأقصر هو الأكثر تحديدًا.4القناع & 0xFF وفخ العدد 256
النص يقول: «كل عدد يمثّل بايتًا واحدًا، فتحتاج التعامل مع أقل 8 بتّات فقط». ولذلك num & 0xFF. تأمّل حالة الاختبار الثالثة [229, 65, 127, 256]:
num | num & 0xFF | ثنائي | التفسير |
|---|---|---|---|
229 | 229 | 11100101 | بداية حرف من 3 بايت → نتوقّع 2 متابعة |
65 | 65 | 01000001 | لكن هذا 0xxxxxxx، ليس متابعة ← False |
127 | 127 | 01111111 | لا نصل له أصلًا |
256 | 0 | 00000000 | لاحظ: 256 يصير صفرًا بعد القناع |
فالجواب False بسبب البايت الثاني، لا بسبب 256. لكن لو كان المدخل [256] لكان True صالحًا تمامًا، وهذه النقطة التي تخلط على كثيرين.
5الفحص الأخير: التسلسل المقطوع
return continuation == 0 في النهاية ليس تجميلًا. لو كان المدخل [0xC3] فهذا بداية حرف من بايتين، لكن البيانات انتهت قبل أن تأتي المتابعة. الحلقة تنتهي بسلام وcontinuation يبقى 1، والتسلسل مقطوع، فالجواب False.
return True في آخر الدالة. هذا يقبل كل تسلسل مقطوع: [0xC3] و[0xF0, 0x9F] وأي بداية حرف بلا متابعتها.كيف يعمل؟
ثلاثة أشكال: آلة الحالات كلها، ثم قرار البايت الواحد، ثم تفكيك حرف حقيقي بايتًا بايتًا.
شكل 12، آلة الحالات: حالة واحدة «أنتظر بداية» وثلاث حالات انتظار. انتهاء البيانات في حالة انتظار يعني تسلسلًا مقطوعًا، فالجواب خطأ.
شكل 13، شجرة قرار البايت الواحد: نفحص من الأقصر إلى الأطول، وآخر فرعين هما نمطا الخطأ.
شكل 14، حرف حقيقي: الوجه المبتسم 😀 في أربعة بايتات، بداية ثم ثلاث متابعات، وحمولتها مجتمعة تعطي رقم يونيكود U+1F600.
الحل خطوة بخطوة
نكتب الحل، نشغّله على حالات الاختبار، ثم على حروف حقيقية من بايثون نفسه. كل مخرج تحت حقيقي.
الخطوة 1، الحل
#!/usr/bin/python3
"""0-validate_utf8"""
def validUTF8(data):
"""Return True if data is a valid UTF-8 encoding."""
continuation = 0
for num in data:
byte = num & 0xFF
if continuation:
if byte >> 6 != 0b10:
return False
continuation -= 1
continue
if byte >> 7 == 0:
continuation = 0
elif byte >> 5 == 0b110:
continuation = 1
elif byte >> 4 == 0b1110:
continuation = 2
elif byte >> 3 == 0b11110:
continuation = 3
else:
return False
return continuation == 0الخطوة 2، حالات الاختبار
$ cat 0-main.py
#!/usr/bin/python3
validUTF8 = __import__('0-validate_utf8').validUTF8
data = [65]
print(validUTF8(data))
data = [80, 121, 116, 104, 111, 110, 32, 105, 115, 32, 99, 111, 111, 108, 33]
print(validUTF8(data))
data = [229, 65, 127, 256]
print(validUTF8(data))
$ ./0-main.py
True
True
Falseالخطوة 3، حروف حقيقية من بايثون
>>> for ch in ["A", "é", "€", "😀", "م"]:
... b = list(ch.encode("utf-8"))
... print(repr(ch), b, [format(x, "08b") for x in b], validUTF8(b))
'A' [65] ['01000001'] True
'é' [195, 169] ['11000011', '10101001'] True
'€' [226, 130, 172] ['11100010', '10000010', '10101100'] True
'😀' [240, 159, 152, 128] ['11110000', '10011111', '10011000', '10000000'] True
'م' [217, 133] ['11011001', '10000101'] Trueهذا أفضل اختبار ممكن: ch.encode("utf-8") تنتج ترميزًا صالحًا بالتعريف، فأي False هنا يعني خطأً في حلك. ولاحظ الحرف العربي م: بايتان، وبدايته 11011001 وهي فعلًا 110xxxxx ✓
الخطوة 4، الحالات غير الصالحة
>>> validUTF8([0xC3]) # a 2-byte lead with nothing after it False >>> validUTF8([0x80]) # a continuation byte with no lead False >>> validUTF8([0xF8, 0x80, 0x80, 0x80]) # 11111000 — no such lead pattern False >>> validUTF8([229, 65, 127, 256]) # 229 leads 3 bytes, 65 is not a continuation False
الخطوة 5، امشِ على 😀 بايتًا بايتًا
تتبّع أربعة بايتات، ثم اكسرها قصدًا
المدخل: [240, 159, 152, 128] وهي بايتات الوجه المبتسم 😀.
البداية: continuation = 0.
البايت 240 = 11110000. continuation = 0 فليس متابعة متوقّعة.
240 >> 7 = 1، فليس ASCII. 240 >> 5 = 0b111، فليس بداية بايتين. 240 >> 4 = 0b1111، فليس ثلاثة. 240 >> 3 = 0b11110 ✓ ← continuation = 3.
البايت 159 = 10011111. continuation = 3 فنتوقّع متابعة.
159 >> 6 = 0b10 ✓ متابعة صالحة ← continuation = 2.
البايت 152 = 10011000. 152 >> 6 = 0b10 ✓ ← continuation = 1.
البايت 128 = 10000000. 128 >> 6 = 0b10 ✓ ← continuation = 0.
انتهت البيانات وcontinuation == 0، فالجواب True ✓
الآن اكسرها: احذف البايت الأخير فيصير [240, 159, 152].
كل شيء يمشي كما فوق، لكن الحلقة تنتهي وcontinuation = 1. فالسطر الأخير return continuation == 0 يرجع False، هذا هو السطر الذي يلتقط التسلسل المقطوع.
U+D800 إلى U+DFFF) وما فوق U+10FFFF. المشروع لا يطلب هذا، لكن معرفته تفرّق بين «صالح شكليًا» و«صالح فعليًا»، وهذه الفروق هي مصدر ثغرات أمنية حقيقية في محلّلات النصوص.اعتقاد شائع × الحقيقة
| الاعتقاد | الحقيقة |
|---|---|
| «البايت الذي قيمته أكبر من 255 يجعل المدخل غير صالح.» | لا. & 0xFF يُبقي أقل 8 بتّات، فـ 256 يصير 0 وهو حرف ASCII صالح. |
| «أفحص بداية الحرف من الأطول إلى الأقصر.» | عكس الصحيح. byte >> 5 == 0b110 ينجح خطأً على بايت من أربعة لأن أول 3 بتّاته 111. ابدأ بالأقصر. |
«return True في النهاية كافٍ.» | يقبل كل تسلسل مقطوع. يجب return continuation == 0 لكي ترفض بداية حرف بلا متابعتها. |
«بايت المتابعة 10xxxxxx يصلح كبداية.» | ما يصلح أبدًا. وجود 10xxxxxx بلا بداية قبله هو خطأ فوري، وهذا ما يجعل UTF-8 قابلًا للمزامنة الذاتية. |
| «يوجد حرف UTF-8 من خمسة أو ستة بايت.» | كان في المسوّدات القديمة، لكن المعيار الحالي يحدّه بأربعة. فنمط 11111xxx غير صالح دائمًا. |
| «إذا نجح الفحص الشكلي فالنص صالح.» | شكليًا نعم، لكن الترميز الزائد والنطاقات المحجوزة تمرّ من هذا الفحص وتُرفض في التحقّق الكامل. |
أين نستخدمه فعليًا؟
كل نص على الإنترنت
أكثر من 98% من صفحات الويب بـ UTF-8. المتصفّح يفعل هذا الفحص بالضبط على كل بايت يستقبله، وأنت الآن تعرف كيف.
أمن المدخلات
ترميز غير صالح أو زائد كان ثغرة حقيقية: نص خبيث يمرّ من الفلتر ثم يُفكّك إلى شيء آخر. لذلك ترفض المحلّلات الحديثة البايتات غير الصالحة بدل أن تتجاهلها.
المزامنة الذاتية
لأن بايت المتابعة يبدأ دائمًا بـ 10، تستطيع أن تقف في منتصف ملف كبير وتعرف أين يبدأ الحرف التالي، بلا قراءة الملف من أوله.
آلات الحالات عمومًا
متغيّر واحد يحفظ «أين أنا» هو نفس النمط في محلّلات JSON وبروتوكولات الشبكة وأي قراءة تدريجية لتيّار.
جرّب بنفسك
ماذا يرجع validUTF8([0xE2, 0x82])؟
0xE2 = 11100010، أي بداية حرف من ثلاثة بايتات، فنتوقّع متابعتين. 0x82 = 10000010 وهي متابعة صالحة فينزل العدّاد إلى 1. ثم تنتهي البيانات وcontinuation = 1، فـ return continuation == 0 يرجع False. (هذه أول بايتين من [0xE2, 0x82, 0xAC] = €.)
بطاقات مراجعة سريعة
0xxxxxxx و110xxxxx و1110xxxx و11110xxx، عدد الواحدات = طول الحرف.10xxxxxx دائمًا. ولا يصلح أبدًا كبداية حرف.num & 0xFF؟256 يصير 0 مثلًا.byte >> 7 == 0 أولًا؟return continuation == 0 في آخر الدالة، لا return True.11111xxx غير صالح دائمًا في المعيار الحالي.تحقّق من فهمك
1. لماذا validUTF8([229, 65, 127, 256]) يرجع False وليس True؟
229 = 11100101 وهي بداية حرف من ثلاثة بايتات، فنتوقّع 10xxxxxx بعدها. لكن 65 = 01000001 وهذا نمط ASCII لا متابعة، فنرجع False فورًا عند البايت الثاني. العدد 256 ما له دخل أصلًا، ما نصل له.
2. اشرح لماذا byte >> 6 == 0b10 أصلًا.
الإزاحة ست خانات تحذف أقل 6 بتّات وتبقي أول اثنين. ومقارنتهما بـ 0b10 تفحص بالضبط أن البايت يبدأ بـ 10، وهو نمط المتابعة. نفس الأسلوب في كل الفحوص: أزِح بمقدار ما تريد تجاهله.
3. (اكتشف الخطأ) حلك يرجع True لـ [0x80]). أين الخطأ؟
ما فحصت أن بايت المتابعة يأتي بعد بداية. غالبًا كتبت فحص byte >> 6 == 0b10 داخل الفرع الرئيسي بلا شرط if continuation، فصار البايت 10000000 مقبولًا وحده.
4. هل يمكن أن يكون المدخل صالحًا وطوله ليس من مضاعفات شيء معيّن؟
نعم، والطول لا يخبرك بشي. [65] طوله 1 وصالح، و[195,169] طوله 2 وصالح، و[226,130,172] طوله 3 وصالح. الصلاحية في الأنماط لا في العدد.
5. (قرار) تبني محلّل نصوص يستقبل بيانات من الإنترنت. تكتفي بهذا الفحص؟
لا. هذا فحص شكلي يكفي للتمرين، لكن محلّلًا حقيقيًا يجب أن يرفض أيضًا الترميز الزائد (A مكتوبًا في بايتين) والنطاق المحجوز U+D800–U+DFFF والقيم فوق U+10FFFF. والأسلم ألا تكتب الفحص بنفسك أصلًا: استخدم bytes(data).decode("utf-8") وهو ينفّذ المعيار كاملًا. اكتب الخوارزمية لتفهمها، واستخدم المكتبة لتعتمد عليها.
مصادر هذا الدرس
مرتّبة بالنوع. ابدأ بالتوثيق الرسمي إذا تبي المرجع الدقيق، وبالمقال أو الفيديو إذا تبي الشرح أولًا.
مقالات2
خلّصت الدرس؟
سجّل الدخول ليُحفظ تقدّمك.