আপনি এই লাইনটা যে পেজে পড়ছেন, সেটা UTF-8 encoded। আপনার টার্মিনাল, আপনার ডাটাবেস, আপনার API-র JSON response, সবখানে একই গল্প। W3Techs-এর হিসাবে পৃথিবীর প্রায় ৯৯% ওয়েবসাইট আজ UTF-8 ব্যবহার করে। অথচ বেশিরভাগ ডেভেলপার জীবনে একবারও ভেবে দেখেননি জিনিসটা ভেতরে কীভাবে কাজ করে।

সত্যি বলতে, না ভাবলেও চলে। যতদিন না কোনো ফাইলে হঠাৎ ক টাইপের ভাঙা অক্ষর দেখা যায়, বা len("হ্যালো") কেন ১৫ রিটার্ন করে সেই প্রশ্নে আটকে যেতে হয়। তখন UTF-8 না বুঝলে debugging অন্ধকারে হাতড়ানোর মতো লাগে। এই লেখায় একদম শুরু থেকে ধরে ধরে দেখব: ASCII কোথায় আটকে গেল, Unicode কী সমাধান দিল, আর UTF-8-এর byte pattern কেন এত সুন্দর একটা ডিজাইন। মাঝখানে একটা interactive visualizer আছে, নিজে টাইপ করে সব হাতেকলমে দেখতে পারবেন।

Text আসলে সংখ্যা

কম্পিউটার অক্ষর চেনে না, শুধু সংখ্যা চেনে। তাই "HELLO" স্টোর করতে হলে প্রতিটা অক্ষরকে আগে একটা সংখ্যায় ম্যাপ করতে হয়, তারপর সেই সংখ্যা binary হয়ে মেমোরিতে বসে।

ASCII অনুযায়ী ম্যাপিংটা এরকম:

H = 72
E = 69
L = 76
L = 76
O = 79

কোন অক্ষর কোন সংখ্যা পাবে, এই ম্যাপিংয়ের নামই encoding। পুরো ব্যাপারটা এটুকুই। জটিলতা শুরু হয় তখন, যখন প্রশ্ন ওঠে: কার অক্ষর ম্যাপ হবে?

ASCII: প্রথম সফল টেক্সট encoding

১৯৬৩ সালে আসে ASCII (American Standard Code for Information Interchange)। এটা ৭-bit fixed-width encoding, মানে প্রতিটা অক্ষর ঠিক ৭ bit জায়গা নেয়। ৭ bit-এ সম্ভাব্য কম্বিনেশন 2⁷ = ১২৮টা।

এই ১২৮টা ঘরে ASCII খুব গুছিয়ে বসিয়েছিল ইংরেজি ছোট-বড় হাতের অক্ষর, সংখ্যা, punctuation আর কিছু control character (newline, tab, bell)। আমেরিকান টেলিপ্রিন্টার আর কম্পিউটারের জন্য যথেষ্টর চেয়ে বেশি।

সমস্যাটা নামেই লেখা আছে: American Standard। বাংলা, হিন্দি, আরবি তো দূরের কথা, ইউরোপের accent-ওয়ালা é বা ñ-এরও জায়গা হয়নি। আর চাইনিজ-জাপানিজের হাজার হাজার অক্ষরের জন্য ১২৮টা ঘর? প্রশ্নই ওঠে না।

Unicode: পৃথিবীর সমস্ত ভাষার এক তালিকা

১৯৮০-র দশকের শেষে কম্পিউটার সারা দুনিয়ায় ছড়িয়ে পড়ছে, আর প্রতিটা দেশ নিজের মতো encoding বানাচ্ছে। এক encoding-এ লেখা ফাইল আরেক encoding-এর সফটওয়্যারে খুললেই অক্ষর ভেঙে যায়। এই বিশৃঙ্খলা থেকে বাঁচতে দরকার ছিল একটাই global তালিকা, যেখানে পৃথিবীর প্রতিটা অক্ষরের জন্য একটা unique নম্বর বরাদ্দ থাকবে।

সেই তালিকার নাম Unicode। প্রতিটা অক্ষরের নম্বরটাকে বলে code point, লেখা হয় U+ দিয়ে:

  • U+0041 = A
  • U+0985 = অ
  • U+1F600 = 😀

খেয়াল করুন, Unicode শুধু বলে দেয় কোন অক্ষরের নম্বর কত। সেই নম্বরটা মেমোরিতে বা ফাইলে কোন byte আকারে বসবে, সেটা Unicode-এর কাজ না। ওই কাজটা করে UTF (Unicode Transformation Format), আর এখানেই আসল ইঞ্জিনিয়ারিং।

নতুন সমস্যা: পুরোনো সফটওয়্যার ASCII ধরে বসে আছে

সহজ সমাধান তো হাতের কাছেই ছিল: প্রতিটা code point-কে ৪ byte-এ স্টোর করো (UTF-32), সব অক্ষর ধরবে। কিন্তু দুটো বড় ঝামেলা।

প্রথমত, অপচয়। সাধারণ ইংরেজি টেক্সটে প্রতিটা অক্ষর ১ byte-এর জায়গায় ৪ byte খাবে, ফাইল সাইজ চারগুণ।

দ্বিতীয়ত, এবং এটাই মারাত্মক: backward compatibility। ততদিনে দুনিয়ার ইমেল সিস্টেম, টার্মিনাল, ফাইল ফরম্যাট, C-এর string function, সবকিছু ধরে নিয়েছে text মানে ASCII, আর 00000000 byte মানে string-এর শেষ। UTF-32-এ 'A' হয় 00 00 00 41, মানে তিনটা null byte। পুরোনো সফটওয়্যার এই ফাইল দেখামাত্র string শেষ ধরে নেবে। রাতারাতি সব সফটওয়্যার বদলানো সম্ভব ছিল না।

দরকার ছিল এমন এক encoding যা ASCII ফাইলকে একদম আগের মতো রাখবে, আবার Unicode-এর পুরো range-ও encode করতে পারবে, আর জায়গাও কম খাবে।

UTF-8: variable-width সমাধান

UTF-8-এর মূল আইডিয়া একটাই: সব অক্ষরকে সমান জায়গা দেওয়ার দরকার নেই। যে অক্ষর ছোট নম্বরের, সে কম byte নেবে; বড় নম্বরের অক্ষর বেশি byte নেবে। একে বলে variable-width encoding, প্রতি character ১ থেকে ৪ byte।

Code point rangeকারাByte
U+0000 – U+007FASCII (A, z, 7, !)১
U+0080 – U+07FFLatin accent (é, ñ), গ্রিক, আরবি২
U+0800 – U+FFFFবাংলা, হিন্দি, চাইনিজ, জাপানিজ৩
U+10000 – U+10FFFFEmoji, বিরল script৪

সবচেয়ে বুদ্ধিদীপ্ত অংশটা প্রথম সারিতে। ASCII-র ১২৮টা character UTF-8-এও ঠিক ১ byte, এবং byte-টা হুবহু ASCII-র মতোই। ফলে পৃথিবীর প্রতিটা পুরোনো ASCII ফাইল কোনো পরিবর্তন ছাড়াই valid UTF-8। পুরোনো সফটওয়্যার কিছু টেরই পেল না। এই একটা সিদ্ধান্তই UTF-8-কে জিতিয়ে দিয়েছে।

Byte pattern: UTF-8-এর ব্যাকরণ

এখন আসল প্রশ্ন: একটা byte দেখে কীভাবে বোঝা যায় সেটা ১-byte character, নাকি ৩-byte character-এর মাঝের অংশ? UTF-8 এটা সমাধান করে byte-এর শুরুর bit দিয়ে। প্রতিটা byte নিজেই ঘোষণা করে সে কে:

  • 0xxxxxxx শুরুতে 0 মানে ASCII, একাই এক character
  • 110xxxxx শুরুতে 110 মানে ২-byte character-এর প্রথম byte
  • 1110xxxx শুরুতে 1110 মানে ৩-byte character-এর প্রথম byte
  • 11110xxx শুরুতে 11110 মানে ৪-byte character-এর প্রথম byte
  • 10xxxxxx শুরুতে 10 মানে continuation byte, কোনো একটা character-এর লেজ

আর x চিহ্নিত ঘরগুলোতে বসে আসল code point-এর bit। তিনটা উদাহরণ দেখা যাক।

'A' (U+0041), ১ byte। Code point 65, binary-তে 1000001। ৭ bit-এর ঘরে সরাসরি বসে যায়:

01000001

'é' (U+00E9), ২ byte। Code point binary 11101001 (৮ bit), এক byte-এ ধরে না। তাই ১১টা x ঘরওয়ালা ২-byte pattern-এ ভাগ হয়ে বসে:

110xxxxx 10xxxxxx   pattern
11000011 10101001   é  (hex: C3 A9)

'ক' (U+0995), ৩ byte। বাংলার প্রায় সব অক্ষর এই দলে। Code point binary ১৬ bit-এ 0000100110010101, তিন byte-এ ভাগ হয়:

1110xxxx 10xxxxxx 10xxxxxx   pattern
11100000 10100110 10010101   ক  (hex: E0 A6 95)

এজন্যই বাংলা টেক্সটের byte সাইজ character সংখ্যার তিনগুণ হয়। len("হ্যালো") যে ভাষায় byte গোনে সেখানে ১৫ আসে: ৫টা code point, প্রতিটা ৩ byte করে।

নিজে চালিয়ে দেখুন

পড়ে যা বুঝলেন, এবার হাতে ধরে দেখার পালা। নিচের বক্সে যা খুশি লিখুন: ইংরেজি, বাংলা, emoji, সব একসাথে মিশিয়ে। প্রতিটা character-এ click করলে দেখবেন তার code point, প্রতিটা byte-এর binary breakdown, আর কোন bit UTF-8-এর marker (bold) বনাম কোনটা code point-এর নিজের bit।

try:
AU+0041১ byte (ASCII)
code point binary (7 bit): 1000001
byte 1 · lead010000010x41

Bold bit-গুলো UTF-8-এর নিজের marker; বাকি ঘরগুলোতে code point-এর bit বসানো হয়েছে।

3 code point → 8 byte■ ১ byte (ASCII)■ ২ byte■ ৩ byte■ ৪ byte

একটা মজার এক্সপেরিমেন্ট: Aক😀 দিয়ে দেখুন। তিনটা character, অথচ ১ + ৩ + ৪ = ৮ byte। Variable-width মানে কী, এক নজরেই পরিষ্কার।

Self-synchronizing: মাঝখান থেকে পড়লেও পথ হারায় না

Byte pattern-টা আরেকটা সূক্ষ্ম সুবিধা দেয়। ধরুন আপনি একটা বিশাল UTF-8 ফাইলের একদম মাঝখানের কোনো byte-এ এসে পড়লেন, সেটা 10100110। শুরুটা 10, মানে এটা continuation byte। আপনি সাথে সাথে জানেন: এটা কোনো character-এর শুরু না, পেছনে গিয়ে 10 নয় এমন byte খুঁজলেই character-এর শুরু পাওয়া যাবে।

এই property-র নাম self-synchronization। Stream-এর যেকোনো জায়গা থেকে পড়া শুরু করলে সর্বোচ্চ ৩ byte পিছিয়েই sync পাওয়া যায়, পুরো ফাইল প্রথম থেকে পড়তে হয় না। একটা byte নষ্ট হলে ক্ষতি ওই এক character-এই সীমাবদ্ধ থাকে, বাকি টেক্সট ঠিকঠাক পড়া যায়। টেক্সট প্রসেসিং, log parsing, string search, সবখানে এই গ্যারান্টিটা কাজে লাগে।

UTF-8 vs UTF-16 vs UTF-32

Unicode encode করার আরও দুটো উপায় আছে, আর কোথায় কোনটা চলে সেটা জানা থাকলে অনেক কিছুর ব্যাখ্যা মেলে:

Encodingশক্তিদুর্বলতাকোথায় দেখা যায়
UTF-8ASCII compatible, ছোট ফাইলn-তম character পেতে শুরু থেকে হাঁটতে হয়Web, Linux, JSON, Go, Rust
UTF-16BMP-র সব অক্ষর ২ byteASCII-ও ২ byte, surrogate pair-এর জটিলতাWindows API, Java, JavaScript-এর ভেতরে
UTF-32Fixed-width, index করা সহজসব অক্ষর ৪ byte, ভীষণ অপচয়Internal processing-এ কালেভদ্রে

Java বা JavaScript-এ "😀".length কেন ২ দেয়, এই টেবিলেই উত্তর লুকিয়ে আছে: দুটো ভাষাই internally UTF-16 ব্যবহার করে, আর emoji-র মতো ৪-byte character UTF-16-এ দুটো surrogate unit নেয়। এটা নিয়ে আলাদা একটা লেখা লেখার ইচ্ছা আছে।

শেষ কথা

UTF-8 আমার চোখে সফটওয়্যার ইতিহাসের সবচেয়ে সফল migration-গুলোর একটা। কোটি কোটি পুরোনো ফাইল আর সফটওয়্যার না ভেঙে পুরো পৃথিবীকে এক encoding-এ নিয়ে আসা, ব্যাপারটা যত সহজ শোনাচ্ছে, ডিজাইনের দিক থেকে তত সহজ ছিল না। আর পুরো সমাধানটার বীজ একটা ডাইনারের placemat-এ।

উপরের visualizer-টায় নিজের নাম লিখে দেখুন, byte-গুলো চিনতে পারেন কি না। আরও ঘাঁটাঘাঁটি করতে চাইলে Glyphscope ঘুরে আসতে পারেন, ওখানে পুরো টুলটা আছে। আর লেখায় কোনো ভুল চোখে পড়লে জানাবেন, শুধরে নেব।

References