الإدخال والإخراج وNIO.2

تدفقات البايت مقابل تدفقات المحارف

15 دقيقة الدرس 6 من 13

تدفقات البايت مقابل تدفقات المحارف

تُبنى مكتبة الإدخال والإخراج في Java على تسلسلَين هرميَّين متوازيَين: تدفقات البايت وتدفقات المحارف. اختيار التسلسل الخاطئ هو أحد أبرز أسباب تلف النصوص، وفساد الملفات الثنائية، وأخطاء الترميز التي لا تظهر إلا على أنظمة تشغيل بعينها. يشرح هذا الدرس ما يفعله كل تسلسل، ولماذا يوجد كلاهما، وكيف تختار بينهما.

نظرة سريعة على التسلسلَين الهرميَّين

كل صنف إدخال/إخراج في حزمة java.io ينحدر من أحد أربعة جذور مجردة:

  • InputStream / OutputStream — تسلسل تدفقات البايت.
  • Reader / Writer — تسلسل تدفقات المحارف.

تتعامل تدفقات البايت مع البايتات الخام بحجم 8 بتات — أي البتات الفعلية المخزنة على القرص أو المُرسَلة عبر الشبكة. أما تدفقات المحارف فتُضيف طبقة معالجة Unicode فوقها، وتُترجم تلقائيًا بين البايتات وتمثيل Java الداخلي للمحارف (UTF-16) باستخدام Charset.

تدفقات البايت: InputStream وOutputStream

الدوال المحورية بسيطة جدًا:

// InputStream int read() // اقرأ بايتًا واحدًا (0–255)، أو -1 عند نهاية الملف int read(byte[] buf, int off, int len) void close() // OutputStream void write(int b) // اكتب أدنى 8 بتات من b void write(byte[] buf, int off, int len) void flush() void close()

لأن كل بايت يُعامَل كقيمة غامضة دون أي تفسير ترميزي، فإن تدفقات البايت هي الأداة المناسبة لـ:

  • ملفات الصور والصوت والفيديو.
  • أرشيفات ZIP/JAR وملفات الصنف المُجمَّعة وملفات PDF.
  • مقابس الشبكة (بيانات TCP هي دائمًا بايتات).
  • أي حالة تحتاج فيها إلى تحكم كامل في نمط البتات الخام.

مثال بسيط — نسخ ملف ثنائي بمصفوفة بايتات (التخزين المؤقت مغطى في الدرس السابع؛ هذا المثال يعرض الواجهة البرمجية الخام):

import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; public class ByteCopy { public static void main(String[] args) throws IOException { try (var in = new FileInputStream("photo.jpg"); var out = new FileOutputStream("photo_copy.jpg")) { byte[] buffer = new byte[8192]; int bytesRead; while ((bytesRead = in.read(buffer)) != -1) { out.write(buffer, 0, bytesRead); } } System.out.println("اكتملت النسخة."); } }
لا تستخدم تدفقات البايت قط لقراءة النصوص. تُعيد read() قيمة int تحمل بايتًا خامًا واحدًا. في ملف UTF-8 يحتوي على إشارة اليورو (3 بايتات: 0xE2 0x82 0xAC)، ستحصل على ثلاث قيم صحيحة منفصلة — لا على المحرف. تدفقات المحارف تُعالج التسلسلات متعددة البايتات نيابةً عنك.

تدفقات المحارف: Reader وWriter

يُعكس تسلسل تدفقات المحارف تسلسل البايتات، لكنه يعمل على قيم char في Java (وحدات ترميز UTF-16):

// Reader int read() // اقرأ محرفًا واحدًا (0–65535)، أو -1 عند نهاية الملف int read(char[] cbuf, int off, int len) void close() // Writer void write(int c) // اكتب محرفًا واحدًا void write(char[] cbuf, int off, int len) void write(String s) // دالة مساعدة void flush() void close()

الصنفان الجسريان الأساسيان هما InputStreamReader وOutputStreamWriter. يُغلّفان تدفق بايتات مع Charset، ويُنفذان التحويل بين البايتات والمحارف:

import java.io.*; import java.nio.charset.StandardCharsets; public class CharStreamDemo { public static void main(String[] args) throws IOException { // كتابة نص بترميز UTF-8 try (var writer = new OutputStreamWriter( new FileOutputStream("notes.txt"), StandardCharsets.UTF_8)) { writer.write("مرحبًا بالعالم!\n"); writer.write("السعر: 49.99 دولارًا\n"); } // قراءته مجددًا try (var reader = new InputStreamReader( new FileInputStream("notes.txt"), StandardCharsets.UTF_8)) { char[] buf = new char[256]; int n; while ((n = reader.read(buf)) != -1) { System.out.print(new String(buf, 0, n)); } } } }

من الناحية العملية نادرًا ما تستخدم InputStreamReader وOutputStreamWriter مباشرةً — إذ تُغلّفهما في BufferedReader/BufferedWriter أو تستخدم المصانع الجاهزة في Files (مغطاة لاحقًا). لكن فهم الجسر ضروري لمعرفة أين يحدث تحويل الترميز بالضبط.

حدد مجموعة المحارف دائمًا — ولا تعتمد على الافتراضي المنصّي

إن استدعيت new FileReader("file.txt") دون تحديد مجموعة محارف، يستخدم Java الترميز الافتراضي للمنصة — وهو UTF-8 على Linux/macOS الحديثة لكنه كان تاريخيًا CP1252 على Windows. قد يكون ملف كُتب على جهاز غير قابل للقراءة على جهاز آخر.

FileReader / FileWriter بدون تحديد مجموعة محارف فخ يعتمد على المنصة. مرر دائمًا Charset صريحًا. منذ Java 11 يقبل كلا المُنشئَين وسيطة Charset:
// خاطئ — يستخدم الترميز الافتراضي للـ JVM؛ ينكسر عبر المنصات var reader = new FileReader("data.txt"); // صحيح — UTF-8 الصريح في كل مكان var reader = new FileReader("data.txt", StandardCharsets.UTF_8); var writer = new FileWriter("out.txt", StandardCharsets.UTF_8);

الأصناف الفعلية التي ستستخدمها

تطبيقات تدفقات البايت:

  • FileInputStream / FileOutputStream — قراءة وكتابة الملفات كبايتات خام.
  • ByteArrayInputStream / ByteArrayOutputStream — مخازن بايتات في الذاكرة (مفيدة جدًا في الاختبارات).
  • DataInputStream / DataOutputStream — قراءة وكتابة أنواع Java الأولية (int، double، إلخ) بصيغة ثنائية قابلة للنقل.

تطبيقات تدفقات المحارف:

  • FileReader / FileWriter — أغلفة رفيعة حول InputStreamReader / OutputStreamWriter للملفات.
  • StringReader / StringWriter — مخازن محارف في الذاكرة مدعومة بـ String.
  • PrintWriter — يُغلّف أي Writer ويُضيف println/printf؛ هو المكافئ الإخراجي لـ System.out.

قاعدة الاختيار

اطرح على نفسك سؤالًا واحدًا: هل هذه البيانات نص يستطيع إنسان قراءته في محرر نصوص؟

  • نعم (كود مصدري، CSV، JSON، ملفات السجلات، HTML) ← استخدم تدفق محارف، دائمًا مع Charset صريح.
  • لا (صور، ثنائيات مُجمَّعة، بيانات مشفرة، أرشيفات مضغوطة) ← استخدم تدفق بايتات.
اختصارات NIO.2. في الكود الحديث عادةً ما تلجأ إلى Files.readString(path, charset) أو Files.writeString(path, text, charset) أو Files.newBufferedReader(path, charset) بدلًا من تجميع سلاسل التدفقات يدويًا. هذه الدوال تُعالج التخزين المؤقت والترميز والإغلاق في استدعاء واحد. تحت الغطاء لا تزال تستخدم نفس تسلسل Reader/Writer — ومعرفة الأساسيات يُمكّنك من تشخيص الأخطاء حين تظهر.

أخطاء الترميز في الواقع العملي

هذا مثال مكسور عمدًا يوضح ما يحدث حين تخلط تدفقات البايت والمحارف بتهور:

// خاطئ: قراءة ملف UTF-8 كبايتات خام وتحويلها إلى char try (var in = new FileInputStream("arabic.txt")) { int b; while ((b = in.read()) != -1) { System.out.print((char) b); // يُفسد كل محرف خارج نطاق ASCII } } // صحيح: دع InputStreamReader يتعامل مع الترميز try (var reader = new InputStreamReader( new FileInputStream("arabic.txt"), StandardCharsets.UTF_8)) { int c; while ((c = reader.read()) != -1) { System.out.print((char) c); // إخراج Unicode صحيح } }

الخلاصة

استخدم تدفقات البايت (عائلة InputStream/OutputStream) لأي بيانات ذات طبيعة ثنائية. استخدم تدفقات المحارف (عائلة Reader/Writer) لجميع البيانات النصية، وحدد دائمًا Charset صريحًا — StandardCharsets.UTF_8 هو الافتراضي الصحيح للكود الجديد. الصنفان الجسريان InputStreamReader وOutputStreamWriter هما الموضع الذي يجري فيه تحويل الترميز فعليًا؛ فكل تدفق محارف يقرأ أو يكتب بايتات في نهاية المطاف عبرهما.