Bir harf neden bu kadar sorun çıkarır?
Anadolu Market'in müşteri listesini Excel'den aldın; "Şişli" yerine "ÅžiÅŸli", "İzmir" yerine "İzmir" görüyorsun. Aramada "istanbul" yazan müşteri "İSTANBUL" kaydını bulamıyor. Alfabetik sıralı raporda "Çorum" listenin en sonunda, "Zonguldak"tan sonra çıkıyor. Bunların hepsi aynı kökten gelir: bilgisayar harf bilmez, sayı bilir. Bir harfin hangi sayıyla, o sayının da hangi baytlarla saklandığını anlamayan geliştirici, Türkçe veriyle her gün bu hatalarla boğuşur. Bu ders metinlerin "altını" gösteriyor: Unicode, UTF-8, bytes ve Türkçeye özel tuzaklar. Metin metotlarını (split, strip, replace) metinler dersinde gördün; burada karakterin kendisine iniyoruz.
Bu bozulmayı kendin üret: UTF-8 baytlarını Windows'un Batı Avrupa tablosu (cp1252) ile okuyan program, Türkçe harfi iki ayrı karakter sanır. Aynı yoldan geri dönersen metin düzelir.
bozuk = "Şişli".encode("utf-8").decode("cp1252")
print(bozuk)
print("İzmir".encode("utf-8").decode("cp1252"))
print(bozuk.encode("cp1252").decode("utf-8"))
ASCII, Unicode ve UTF-8
ASCII (1960'lar) 128 karakterlik bir tablodur: İngilizce harfler, rakamlar, noktalama. "A" = 65, "a" = 97. ç, ş, ğ, ı, İ, ö, ü yoktur. Bu yüzden her ülke kendi 256'lık tablosunu yaptı; Türkçe için Windows'ta cp1254, standartlarda ISO-8859-9. Aynı sayı farklı tabloda farklı harf demekti ve karmaşa buradan doğdu.
Unicode bu karmaşayı bitiren evrensel katalogdur: dünyadaki her karaktere (emoji dâhil) tek bir numara verir. Bu numaraya kod noktası (code point) denir ve U+00E7 (ç) biçiminde yazılır. Unicode "hangi numara" sorusunu çözer; "bu numara diske hangi baytlarla yazılır" sorusunu ise bir kodlama (encoding) çözer. Bugünün standardı UTF-8'dir: ASCII karakterlerini 1 bayt, Türkçe harfleri 2 bayt, çoğu Asya karakterini 3, emojileri 4 baytla yazar. ASCII ile geriye dönük uyumludur; web sayfalarının çok büyük bölümü UTF-8'dir.
| Karakter | Kod noktası | ord() | UTF-8 baytları | cp1254 |
|---|---|---|---|---|
| A | U+0041 | 65 | 41 (1 bayt) | 41 |
| ç | U+00E7 | 231 | C3 A7 (2 bayt) | E7 |
| ş | U+015F | 351 | C5 9F (2 bayt) | FE |
| ı | U+0131 | 305 | C4 B1 (2 bayt) | FD |
| İ | U+0130 | 304 | C4 B0 (2 bayt) | DD |
| ₺ | U+20BA | 8378 | E2 82 BA (3 bayt) | yok |
| 😀 | U+1F600 | 128512 | F0 9F 98 80 (4 bayt) | yok |
ord, chr ve kaçış dizileri
Python 3'te str türü bir Unicode kod noktaları dizisidir. ord(karakter) kod noktasını, chr(sayi) karakteri verir. Klavyede olmayan karakterleri metin içinde kaçış dizileriyle (escape sequences) yazarsın.
print(ord("A"), ord("a"), ord("ç"), ord("₺"))
print(chr(65), chr(304), chr(0x20BA))
print(f"U+{ord('ş'):04X}") # kod noktası gösterimi
print("Fiyat: 120\u20ba") # \u + 4 hane
print("\N{LATIN CAPITAL LETTER I WITH DOT ABOVE}zmir") # adıyla
print("Ad\tŞehir\nAyşe\tİzmir") # sekme ve satır sonu
print("C:\\raporlar\\yeni", 'Market\'in', "\"tırnak\"")
print(r"C:\raporlar\yeni") # ham dize: \ olduğu gibi
| Dizi | Anlamı | Dizi | Anlamı |
|---|---|---|---|
\n | Satır sonu (newline) | \\ | Ters eğik çizginin kendisi |
\t | Sekme (tab) | \' \" | Tırnak işareti |
\r | Satır başına dön (Windows satır sonu \r\n) | \xhh | 2 haneli onaltılık kod |
\uhhhh | 4 haneli kod noktası | \U0001F600 | 8 haneli kod noktası |
"C:\yeni\tablo.csv" yazarsan \t sekmeye dönüşür ve dosya bulunamaz (\y ise Python 3.12'de SyntaxWarning verir). Üç çözüm: ham dize r"C:\yeni\tablo.csv", ileri eğik çizgi "C:/yeni/tablo.csv" (Windows bunu da kabul eder) ya da en iyisi pathlib.Path. Ham dize tek ters eğik çizgiyle bitemez: r"C:\" sözdizimi hatasıdır.bytes: metnin disk ve ağdaki hâli
Dosyalar, ağ paketleri ve veritabanı sürücüleri bayt taşır. Python'da bunun türü bytes'tır ve b"..." ile yazılır. Metni bayta çevirmeye encode (kodlama), bayttan metne dönmeye decode (kod çözme) denir. Kural: içeride str, sınırda bytes. Programına giren baytı hemen decode et, çıkarken encode et.
metin = "Şube: İzmir ₺"
veri = metin.encode("utf-8")
print(veri)
print(len(metin), "karakter,", len(veri), "bayt")
print(veri.decode("utf-8") == metin)
eski = metin[:-2].encode("cp1254") # eski Windows Türkçe tablosu
print(eski, len(eski), "bayt")
try:
eski.decode("utf-8")
except UnicodeDecodeError as e:
print("Hata:", e.reason, "| konum:", e.start)
print(eski.decode("cp1254"))
print(eski.decode("utf-8", errors="replace"))
Son satırdaki errors="replace" çözülemeyen baytları � ile değiştirir; "ignore" sessizce atar. İkisi de veri kaybıdır, yalnızca son çare olarak kullan. Asıl çözüm: verinin hangi kodlamayla yazıldığını öğrenip onunla açmak.
Dosyalarda encoding="utf-8"
open() kodlama belirtilmezse işletim sisteminin varsayılanını kullanır; Türkçe Windows'ta bu genellikle cp1254'tür, macOS/Linux'ta UTF-8. Aynı kod iki bilgisayarda farklı davranır. Bu yüzden metin dosyası açarken her zaman encoding="utf-8" yaz. Excel'in "CSV UTF-8" kaydı dosyanın başına görünmez bir BOM işareti koyar; böyle dosyaları encoding="utf-8-sig" ile açarsan ilk sütun adı bozulmaz.
with open("musteriler.csv", "w", encoding="utf-8") as f:
f.write("ad;şehir\nAyşe;İzmir\nÇağrı;Muğla\n")
with open("musteriler.csv", encoding="utf-8") as f:
print(f.read())
with open("musteriler.csv", "rb") as f: # ikili mod: bytes gelir
print(f.read()[:12])
Türkçe I/İ/ı/i: büyük-küçük harf tuzağı
Türkçede dört ayrı i harfi vardır: noktalı i/İ ve noktasız ı/I. Python'un upper() ve lower() metotları Unicode'un dilden bağımsız kurallarını uygular; o kurallarda i'nin büyüğü I'dır. Sonuç:
print("istanbul".upper()) # ISTANBUL (İSTANBUL değil!)
print("IĞDIR".lower()) # iğdir (ığdır değil!)
print("ırmak".upper()) # IRMAK (doğru)
kucuk = "İzmir".lower()
print(kucuk, len(kucuk), len("İzmir")) # i + birleşik nokta: 6 karakter!
print(kucuk == "izmir")
print("Straße".lower(), "Straße".casefold()) # casefold daha agresif
"İ".lower(), i ve ardından görünmez bir birleşik nokta (U+0307) üretir; uzunluk 2 olur ve "izmir" ile eşleşmez. casefold() büyük/küçük harf duyarsız karşılaştırma için tasarlanmıştır (Almanca ß → ss) ama Türkçe I sorununu o da çözmez. Çözüm basit: büyütmeden/küçültmeden önce dört harfi elle eşle.
def tr_upper(metin):
return metin.replace("i", "İ").replace("ı", "I").upper()
def tr_lower(metin):
return metin.replace("İ", "i").replace("I", "ı").lower()
print(tr_upper("istanbul ılgaz")) # İSTANBUL ILGAZ
print(tr_lower("IĞDIR İZMİR")) # ığdır izmir
print(tr_lower("İzmir") == "izmir") # True
TR_BUYUK = str.maketrans("iı", "İI") tablosunu bir kez oluşturup metin.translate(TR_BUYUK).upper() kullanmak, zincirleme replace'ten daha hızlı ve okunurdur.Metin karşılaştırma ve Türkçe sıralama
Python metinleri kod noktası sırasıyla, karakter karakter karşılaştırır (sözlükbilimsel, lexicographic). İlk farklı karakter kazananı belirler; biri diğerinin başıysa kısa olan küçüktür. Bu, PCAP sınavının sevdiği bir konudur ve üç sürpriz içerir: büyük harfler küçüklerden önce gelir ("Z" < "a"), rakamlar metin gibi karşılaştırılır ("10" < "9") ve Türkçe harfler İngilizce alfabenin sonuna düşer (ç = 231, z = 122).
print("Zeytin" < "armut", "10" < "9", "elma" < "elmas")
sehirler = ["Zonguldak", "çorum", "Ankara", "Iğdır", "İzmir", "ağrı", "Bursa", "Şanlıurfa"]
print(sorted(sehirler))
ALFABE = "abcçdefgğhıijklmnoöpqrsştuüvwxyz"
SIRA = {harf: i for i, harf in enumerate(ALFABE)}
def tr_anahtar(kelime):
kucuk = kelime.replace("İ", "i").replace("I", "ı").lower()
return [SIRA.get(h, 100 + ord(h)) for h in kucuk]
print(sorted(sehirler, key=tr_anahtar))
sorted'a verdiğin key fonksiyonu her elemanı karşılaştırılabilir bir değere çevirir; burada her harfi Türk alfabesindeki sırasına. Alfabede olmayan karakterler (boşluk, rakam) 100'den sonraya, kendi kod noktası sırasıyla yerleşir. Kendi bilgisayarında locale.setlocale(locale.LC_COLLATE, "tr_TR.UTF-8") ve key=locale.strxfrm da kullanılabilir, ama yerel ayarın sistemde kurulu olmasına bağlıdır; sunucularda çoğu zaman yoktur.
Normalleştirme: aynı görünen, farklı olan
"ç" harfi Unicode'da iki biçimde yazılabilir: tek kod noktası U+00E7 (birleşik, NFC) ya da c + birleşik çengel U+0327 (ayrışık, NFD). Ekranda aynı görünürler ama == False döner. macOS dosya adları ve bazı kopyala-yapıştır kaynakları NFD üretir. unicodedata.normalize ikisini aynı biçime getirir; aynı araç aksanları silmek (URL kısaltması, "slug") için de kullanılır.
import unicodedata
a = "\u00e7ay" # ç tek kod noktası
b = "c\u0327ay" # c + birleşik çengel
print(a, b, a == b, len(a), len(b))
print(unicodedata.normalize("NFC", a) == unicodedata.normalize("NFC", b))
def aksansiz(metin):
ayrik = unicodedata.normalize("NFD", metin)
sade = "".join(h for h in ayrik if not unicodedata.combining(h))
return sade.replace("ı", "i") # ı ayrışmaz, elle eşle
print(aksansiz("Çağrı Şüküroğlu Gölbaşı"))
print(unicodedata.name("ğ"))
Karakter mi, kod noktası mı, grafem mi?
len() kod noktası sayar. Kullanıcının "bir karakter" gördüğü şeye grafem (grapheme) denir ve birden fazla kod noktasından oluşabilir: ayrışık "ç" 2, bayrak emojisi 2, aile emojisi 7 kod noktasıdır. SMS karakter sayacı ya da isim kısaltma gibi işlerde bu fark hataya yol açar. Grafem kümelemesi için regex paketinin \X deseni ya da grapheme paketi kullanılır (kendi bilgisayarında: pip install regex).
Bayt sınırı olan alanlar
Bazı veritabanı sütunları ve protokoller sınırı karakter değil bayt olarak koyar. "50 karakter" sandığın "ŞŞŞ..." metni UTF-8'de 100 bayttır. Kırparken baytı ortadan bölersen geçersiz UTF-8 üretirsin; doğru yol karakter karakter ilerleyip bayt toplamını izlemek ya da veri[:sinir].decode("utf-8", errors="ignore") ile yarım kalan son karakteri atmaktır.
Varsayılan kodlamanın geleceği
PEP 686 ile Python 3.15'te UTF-8 modu varsayılan oldu; bu sürümden itibaren open() her platformda UTF-8 kullanır. 3.14 ve öncesini destekleyen her kodda encoding="utf-8" yazmak tek güvenli yoldur; açıkça yazmak okuyana da niyetini gösterir. python -X utf8 ya da PYTHONUTF8=1 ortam değişkeni bu modu eski sürümlerde de açar.
Gerçek dilbilimsel sıralama
Kendi anahtar fonksiyonun Türkçe için yeterlidir; çok dilli ürünlerde ise Unicode Collation Algorithm (UCA) gerekir. PyICU paketi bunu sağlar: icu.Collator.createInstance(icu.Locale("tr_TR")). Veritabanı tarafında PostgreSQL'in COLLATE "tr-TR-x-icu" ayarı aynı işi yapar.