الفصل 23

قراءة الملفات وكتابتها

قراءة الملفات وكتابتها باستخدام with open، الفروق بين أوضاع r و w و a و x، السطر الجديد في نهاية كل سطر، لماذا نكتب encoding دائماً، واستخدام pathlib للمهام السريعة.

35 دقيقةPython 3.12
  1. 1المشكلة
  2. 2الفهم
  3. 3أمثلة محلولة
  4. 4التوقع
  5. 5التطبيق
  6. 6التحدي

المشكلة التي نقوم بحلها

كل ما بنيناه على مدار الفصول السابقة كان يختفي تماماً بمجرد انتهاء تشغيل البرنامج.

python
basket = []
basket.append("pen")
print(basket)
text
['pen']

شغّل البرنامج مرة أخرى وستجد السلة فارغة من جديد. القوائم، والقواميس، والدوال — كل شيء يعيش في الذاكرة العشوائية (RAM)، وحياة الذاكرة تنتهي تماماً بانتهاء عمل البرنامج.

كان الفصل السابق يدور حول مشاركة الكود البرمجي بين البرامج المختلفة. أما هذا الفصل فيدور حول البيانات — كيف يترك تشغيلٌ ما نتاجه للتشغيل التالي، أو لشخص آخر تماماً.

python
with open("notes.txt", "w", encoding="utf-8") as fh:
    fh.write("first line\n")
    fh.write("second line\n")

with open("notes.txt", "r", encoding="utf-8") as fh:
    print(fh.read(), end="")
text
first line
second line

انتهى البرنامج من العمل، وما زال ملف notes.txt موجوداً ومحفوظاً على القرص.

في نهاية هذا الدرس ستكون قادراً على

  • قراءة الملفات وكتابتها باستخدام with open(...) وشرح سبب تفضيل with
  • التمييز بدقة بين أوضاع الفتح "r" و "w" و "a" و "x" — وخاصة ما يمحوه الوضع "w"
  • قراءة الملف سطراً بسطر ومعالجة رمز السطر الجديد \n في نهاية كل سطر
  • شرح سبب كتابة encoding="utf-8" دائماً وبلا استثناء
  • استخدام pathlib.Path لإنجاز المهام البسيطة والسريعة
  • قراءة وفهم أخطاء FileNotFoundError و UnicodeDecodeError

المتطلبات السابقة: الوحدات النمطية والاستيراد.


لماذا نستخدم with؟

فتح أي ملف هو بمثابة استعارة مورد من نظام التشغيل، وأي استعارة يجب أن تُرد. إذا لم يُغلق الملف، فقد لا تصل البيانات التي كتبتها إلى القرص مطلقاً، كما أن تراكم الملفات المفتوحة دون إغلاق قد يؤدي إلى توقف البرنامج بأكمله عن العمل.

كتلة with تتكفل بإعادة هذا المورد تلقائياً — حيث يُغلق الملف فور انتهاء الكتلة، حتى لو حدث استثناء أو خطأ في داخلها.

python
with open("notes.txt", "r", encoding="utf-8") as fh:
    text = fh.read()

print(fh.closed)
print(fh.read())
text
True
ValueError: I/O operation on closed file.

المتغير fh يظل موجوداً بعد انتهاء الكتلة — فنطاق المتغيرات يعمل تماماً كما تعلمنا سابقاً — ولكن الملف نفسه يصبح مغلقاً. وفي هذه الدورة التدريبية، تُكتب دالة open() دائماً بصحبة with.

قراءة الملفات

لقراءة محتوى الملف كاملاً دفعة واحدة، نستخدم التابع .read():

python
with open("notes.txt", "r", encoding="utf-8") as fh:
    text = fh.read()

print(repr(text))
print(len(text))
text
'first line\nsecond line\n'
23

استخدمنا repr هنا عمداً، لأن print العادية تخفي فواصل الأسطر. الملف النصي هو مجرد نص واحد طويل، وتستقر فواصل الأسطر بداخله على هيئة رمز \n — وتذكر هذه الحقيقة هو نصف فهم هذا الفصل.

قراءة الملف سطراً بسطر

استخدام .read() ليس خياراً مناسباً للملفات الضخمة، لأنه يحمّل الملف بأكمله دفعة واحدة في الذاكرة. بدلاً من ذلك، فإن التكرار عبر كائن الملف نفسه يجعل بايثون يقرأ سطراً واحداً في كل دورة:

python
with open("orders.txt", "r", encoding="utf-8") as fh:
    for line in fh:
        print(repr(line))
text
'pen,15.0,3\n'
'bag,850.0,1\n'
'ink,120.0,2\n'

كل سطر ينتهي بالرمز \n. وهذا هو مصدر الالتباس الأكثر شيوعاً على الإطلاق — فالأمر int(line) لن ينجح، والمقارنة line == "pen" لن تتحقق، والسبب خفي عن العين للوهلة الأولى.

لذا فإن الخطوة الأولى في الغالبية العظمى من الحالات هي استخدام .strip():

python
with open("orders.txt", "r", encoding="utf-8") as fh:
    for line in fh:
        name, price, qty = line.strip().split(",")
        print(f"{name:<5} {float(price) * int(qty):>8.2f}")
text
pen      45.00
bag     850.00
ink     240.00

استخدام .strip() ثم .split(",") ثم float() و int() — هذا التسلسل سيتكرر معك مراراً وتكراراً عند قراءة الملفات. كل ما يخرج من الملف هو نص (String) وليس أرقاماً، والتحويل الذي تعلمناه في الفصل السابع مطلوب هنا أيضاً.

إذا كنت ترغب في الحصول على جميع الأسطر دفعة واحدة في قائمة، فهناك طريقتان:

python
with open("orders.txt", "r", encoding="utf-8") as fh:
    lines = fh.read().splitlines()

print(lines)
text
['pen,15.0,3', 'bag,850.0,1', 'ink,120.0,2']

التابع .splitlines() يحذف نهايات الأسطر تلقائياً. هناك أيضاً التابع .readlines()، ولكنه يُبقي على رموز \n في نهاية كل سطر — وهو الفخ الذي يتعثر فيه الكثيرون.

الكتابة وحرف واحد بالغ الخطورة

python
with open("notes.txt", "w", encoding="utf-8") as fh:
    fh.write("new\n")

إذا كان الملف يحتوي سابقاً على old content، فالنتيجة الآن هي:

text
new

الوضع "w" يفرغ الملف ويمحو محتواه تماماً — وذلك في لحظة الفتح ذاتها، وقبل كتابة أي شيء فيه. ولا يوجد أي تحذير ولا سبيل للتراجع.

للحفاظ على المحتوى الحالي والإضافة إلى نهايته، استخدم الوضع "a" (Append):

python
with open("notes.txt", "a", encoding="utf-8") as fh:
    fh.write("new\n")
text
old content
new

أما إذا أردت رفض الكتابة تماماً في حال كان الملف موجوداً بالفعل، فاستخدم الوضع "x" (Exclusive creation):

text
FileExistsError: [Errno 17] File exists: 'notes.txt'

الأوضاع الأربعة في سطر واحد: "r" للقراءة فقط، و "w" لمحو القديم والكتابة من جديد، و "a" للإضافة في النهاية، و "x" لإنشاء ملف جديد حصراً.

تريث قليلاً قبل كتابة الحرف "w". لقد فقد الكثير من المبرمجين أعمالاً حقيقية بسبب هذا الحرف الواحد. إذا كان الملف الذي تفتحه لم ينشئه برنامجك بنفسه، ففكر جيداً قبل استخدام "w".

أنت المسؤول عن إضافة \n

دالة print تنزل سطراً جديداً تلقائياً نيابة عنك. أما دالة write فلا تفعل ذلك.

python
rows = ["pen", "bag"]

with open("out.txt", "w", encoding="utf-8") as fh:
    for row in rows:
        fh.write(row)
text
'penbag'

لذا فإن أنظف طريقة لكتابة قائمة من الأسطر هي:

python
rows = ["pen", "bag"]

with open("out.txt", "w", encoding="utf-8") as fh:
    fh.write("\n".join(rows) + "\n")
text
'pen\nbag\n'

تضع "\n".join(rows) فواصل أسطر بين العناصر، وتنهي الإضافة + "\n" الملف بسطر جديد — وهو العرف الاصطلاحي القياسي للملفات النصية وما تتوقعه العديد من الأدوات.

encoding="utf-8" — دائماً وبلا استثناء

بدون تحديد encoding، يعتمد بايثون على الترميز الافتراضي لنظام التشغيل، وهو ما يختلف من جهاز لآخر. والنتيجة هي برنامج يعمل بسلاسة على جهازك ويتعطل تماماً على جهاز شخص آخر.

القراءة بالترميز الخاطئ تؤدي إلى:

text
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 3: invalid start byte

لذا اكتب encoding="utf-8" في كل مرة تكتب فيها open() — سواء للقراءة أو للكتابة على حد سواء. الاستثناء الوحيد هو الوضع الثنائي ("rb" و "wb")، حيث لا يطرح السؤال أصلاً لعدم وجود ترميز نصي.

pathlib — مسار مختصر للمهام البسيطة

بالنسبة للملفات الصغيرة، قد تبدو كتابة كتلة with بأكملها عبئاً طويلاً.

python
from pathlib import Path

path = Path("orders.txt")
print(path.exists())
print(len(path.read_text(encoding="utf-8").splitlines()))

out = Path("report.txt")
out.write_text("done\n", encoding="utf-8")
print(out.read_text(encoding="utf-8"), end="")
text
True
3
done

التابعان read_text و write_text يتكفلان بفتح الملف وإغلاقه داخلياً، فلا حاجة لكتابة with. ويوفر كائن Path ميزات أخرى متعددة — مثل .exists() و .name و .suffix و .parent — وتُدمج مسارات المجلدات باستخدام علامة /، كما في: Path("data") / "orders.txt".

متى تستخدم كل منهما؟ استخدم Path عندما تحتاج قراءة الملف بأكمله دفعة واحدة، واستخدم with open(...) عند القراءة سطراً بسطر. وتمتلك write_text ميزة إضافية: إنها تستبدل الملف افتراضياً وبوضوح، لذا فإن خطأ كتابة "w" دون قصد لن يتكرر هنا.


مثال متكامل

ملف orders.txt:

text
pen,15.0,3
bag,850.0,1
ink,120.0,2

ملف main.py:

python
"""Read an order file, total it, and write a report beside it."""

from pathlib import Path

TAX_RATE = 0.15


def read_order(path):
    """Each line is name,price,quantity. Blank lines are skipped."""
    lines = []
    for raw in path.read_text(encoding="utf-8").splitlines():
        if not raw.strip():
            continue
        name, price, quantity = raw.split(",")
        lines.append((name, float(price), int(quantity)))
    return lines


def report(lines):
    rows = []
    total = 0.0
    for name, price, quantity in lines:
        amount = round(price * quantity * (1 + TAX_RATE), 2)
        total += amount
        rows.append(f"{name:<6} {quantity:>3} {amount:>9.2f}")
    rows.append("-" * 20)
    rows.append(f"{'total':<6} {'':>3} {total:>9.2f}")
    return rows


def main():
    order = read_order(Path("orders.txt"))
    rows = report(order)

    Path("report.txt").write_text("\n".join(rows) + "\n", encoding="utf-8")

    print(Path("report.txt").read_text(encoding="utf-8"), end="")
    print()
    print("lines read:", len(order))


if __name__ == "__main__":
    main()
text
pen      3     51.75
bag      1    977.50
ink      2    276.00
--------------------
total        1305.25

lines read: 3

أربعة أمور جديرة بالانتباه:

ثلاث دوال بثلاث وظائف منفصلة. الدالة read_order تقرأ من القرص، و report تقتصر على الحسابات فقط، و main تكتب النتيجة. لم تُعطَ report أي ملف على الإطلاق — بل أُعطيت قائمة وأرجعت قائمة. لذا فاختبارها لا يتطلب وجود ملف، وفي الفصل الثامن والعشرين سنكون ممتنين تحديداً لهذه الميزة.

تمت معالجة الأسطر الفارغة مسبقاً وبحذر. وجود سطر جديد فارغ في نهاية الملف أمر طبيعي جداً، وبدون معالجته سيفشل raw.split(",") في العثور على ثلاثة عناصر وسيلقي خطأ ValueError. السطر if not raw.strip(): continue يحل المشكلة في سطر واحد.

تحويل float(price) و int(quantity) ليس خياراً بل ضرورة، لأن كل ما يخرج من الملف يصل كنص. إن نسيتهما، فسيقوم price * quantity بتكرار النص بصمت دون أي تنبيه — وهو أسوأ أنواع الأخطاء البرمجية: الخطأ الذي لا يوقف البرنامج.

يُكتب التقرير في ملف منفصل تماماً. لم نفتح orders.txt بوضع "w" إطلاقاً — وظل ملف المدخلات الأصلي سليماً كما هو. هذه عادة برمجية ممتازة: لا تكتب فوق الملف الذي تقرأ منه.


حالات الخطأ الشائعة

FileNotFoundError: [Errno 2] No such file or directory: 'orders.txt' الملف غير موجود، أو أنك تشغل البرنامج من مجلد مختلف. المسار يُحدد بناءً على المجلد الذي شغلت منه الأمر، وليس مجلد السكربت نفسه. اطبع Path("orders.txt").resolve() لتعرف أين يبحث البرنامج بالضبط.

UnicodeDecodeError: 'utf-8' codec can't decode byte ... الملف كُتب بترميز مختلف، أو أنه ليس ملفاً نصياً على الإطلاق. حدد الترميز الصحيح عبر encoding، أو افتحه بوضع القراءة الثنائية "rb" إذا كان ملفاً ثنائياً.

ValueError: I/O operation on closed file. تمت محاولة استخدام كائن الملف بعد انتهاء كتلة with. اقرأ ما تحتاجه من بيانات داخل الكتلة.

ValueError: not enough values to unpack أحد الأسطر لا يحتوي على عدد الفواصل المتوقع — وغالباً ما يكون سطراً فارغاً في النهاية. تجاهل الأسطر الفارغة، وعند الشك اطبع print(repr(raw)).

كتبت الملف لكنه فارغ تماماً تتم قراءة الملف قبل اكتمال كتلة with. تصل عمليات الكتابة إلى القرص فعلياً عند إغلاق الملف.

التصقت جميع الأسطر في سطر واحد طويل الدالة write لا تفصل بين الأسطر تلقائياً. أنت المطالب بإضافة رمز \n.

اختفت كل محتويات الملف القديمة تم فتح الملف باستخدام الوضع "w". استخدم الوضع "r" للقراءة والوضع "a" للإضافة في النهاية.