العمليات على مستوى البِت (Bitwise Operations)

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

الأعداد الثنائية (Binary Numbers)

تقوم الحواسيب بتخزين الأعداد الصحيحة في صورة ثنائية — باستخدام 0 و1 فقط. نقول إن البِت مُعيَّن (set) إذا كانت قيمته 1. كل موضع (بِت) يمثّل قوة من قوى العدد 2:

Bit position:  7  6  5  4  3  2  1  0
Value:       128 64 32 16  8  4  2  1

للحصول على قيمة عدد ثنائي، نقوم بجمع قوى العدد 2 في الأماكن التي يكون فيها البِت مساويًا لـ1.

أمثلة

00000110  =  4 + 2       = 6
00001010  =  8 + 2       = 10
00001100  =  8 + 4       = 12
00000001  =  1           = 1
00000000  =  0           = 0

إذًا عندما نجري عمليات على مستوى البِت (&, |, ^، إلخ)، فنحن في الواقع ندمج أو نعدّل هذه البِتّات الفردية.

المتمّم الثنائي (Two’s Complement) والأعداد السالبة

حتى الآن تحدّثنا فقط عن الأعداد غير السالبة. لكن في C++ (وفي معظم المعالجات الحديثة)، تُخزَّن الأعداد الصحيحة ذات الإشارة في صيغة تُسمّى المتمّم الثنائي (two’s complement).

الفكرة:

  • استخدام البِت الأعلى (أكثر بِت أهمية) كبِت إشارة.

لنفرض أن لدينا أعدادًا صحيحة موقّعة مكوّنة من 8 بِتّات:

Bit position:  7  6  5  4   3  2  1  0
Value:       -128 64 32 16  8  4  2  1

مدى الأعداد ذات الإشارة على 8 بِتّات هو:

-128 ... +127

شكل الأعداد الموجبة

الأعداد الموجبة (والصفر) تكون كما هي في النظام الثنائي العادي:

00000101 = 5
00000000 = 0
00001010 = 10

كيف تُخزَّن الأعداد السالبة (متمّم ثنائي)

للحصول على -x في نظام المتمّم الثنائي:

  1. نكتب x بالثنائي.
  2. نقلب كل البِتّات (0 -> 1، 1 -> 0).
  3. نضيف 1.

مثال: تمثيل -5 على 8 بِتّات:

+5        = 00000101
flip      = 11111010
+ 1       = 11111011  = -5

فحص -1:

+1        = 00000001
flip      = 11111110
+ 1       = 11111111  = -1

إذًا:

11111111 = -1
11111011 = -5
10000000 = -128

لماذا هذه الطريقة مفيدة؟

باستخدام المتمّم الثنائي:

  • جمع الأعداد الثنائية العادي يعمل أيضًا مع الأعداد السالبة دون تغيير.
  • لا يوجد سوى تمثيل واحد للصفر.
  • عملية الطرح يمكن تنفيذها كـ a + (-b) فقط.

الإزاحة لليمين مع الأعداد السالبة

بالنسبة للأنواع غير الموقّعة (unsigned)، فإن >> يقوم دائمًا بإزاحة البِتّات لليمين مع إدخال بِتّات 0 من اليسار.

أما للأنواع الموقّعة، فمعظم المترجمات تقوم بإزاحة حسابية لليمين (arithmetic right shift):

  • حيث يتم نسخ بِت الإشارة (أعلى بِت)، وبالتالي تظل الأعداد السالبة سالبة أثناء الإزاحة.

مثال (مع افتراض الإزاحة الحسابية):

x  = -4   -> 11111100 (8-bit view)
x >> 1    -> 11111110 (still negative: -2)

لهذا السبب، عندما نهتم بالبِتّات الخام فقط، يكون من الأكثر أمانًا غالبًا استخدام الأنواع غير الموقّعة مثل uint32_t وunsigned long long، إلخ.

أهم العوامل (operators) على مستوى البِت

كل هذه العوامل تعمل على الأنواع الصحيحة (int, long long، إلخ):

العامل الاسم ما يفعله على مستوى البِتّات
& AND يساوي 1 إذا كان كلا البِتّين 1، وإلا 0
| OR يساوي 1 إذا كان على الأقل أحد البِتّين 1، وإلا 0
^ XOR يساوي 1 إذا كان البِتّان مختلفين، وإلا 0
~ NOT / complement يقلب كل البِتّات (1 → 0، 0 → 1)
<< إزاحة لليسار يزيح البِتّات لليسار (يشبه الضرب بقوى العدد 2)
>> إزاحة لليمين يزيح البِتّات لليمين (يشبه القسمة على قوى العدد 2)

رؤية العمليات على مستوى البِت في صورة ثنائية

لنستخدم أعدادًا صغيرة ونفترض أنها مكوّنة من 8 بِتّات لزيادة الوضوح:

a = 6   -> 00000110
b = 10  -> 00001010

عامل AND &

a & b   -> 00000110
           00001010
           --------
           00000010  = 2

عامل OR |

a | b   -> 00000110
           00001010
           --------
           00001110  = 14

عامل XOR ^

a ^ b   -> 00000110
           00001010
           --------
           00001100  = 12   (bits that are different become 1)

عامل NOT ~

~a -> 11111001 (على 8 بِتّات، لكن في C++ الحقيقية تُطبَّق العملية على عرض النوع الكامل للعدد)

الإزاحات (Shifts)

a << 1  -> 00001100  = 12   (6 * 2)
a << 2  -> 00011000  = 24   (6 * 4)

b >> 1  -> 00000101  = 5    (10 / 2)
b >> 2  -> 00000010  = 2    (10 / 4, integer division)

مثال بسيط في C++

#include <iostream>
using namespace std;

void printBits(int x) {
    for (int i = 7; i >= 0; i--) {
        cout << ((x >> i) & 1);
    }
}

int main() {
    int a = 6;   // 00000110
    int b = 10;  // 00001010

    cout << "a = "; printBits(a); cout << " (" << a << ")\n";
    cout << "b = "; printBits(b); cout << " (" << b << ")\n\n";

    cout << "a & b = "; printBits(a & b); cout << " (" << (a & b) << ")\n";
    cout << "a | b = "; printBits(a | b); cout << " (" << (a | b) << ")\n";
    cout << "a ^ b = "; printBits(a ^ b); cout << " (" << (a ^ b) << ")\n";
    cout << "~a    = "; printBits((unsigned char)~a); cout << " (only low 8 bits)\n";

    cout << "a << 1 = "; printBits(a << 1); cout << " (" << (a << 1) << ")\n";
    cout << "b >> 1 = "; printBits(b >> 1); cout << " (" << (b >> 1) << ")\n";
}

تقوم الدالة printBits بالمرور على جميع مواضع البِتّات i (من 7 إلى 0)، وتنفّذ x >> i، أي تُزِيح قيمة x بمقدار i خانة إلى اليمين. هذا يجعل البِت الموجود في الموضع i ينتقل إلى أقصى اليمين. بعدها نأخذ:

& 1

أي نُجري عملية AND مع 00000001، والتي تؤدي إلى الإبقاء على البِت الأيمن فقط وإلغاء باقي البِتّات. إذًا مجموع العمليتين (x >> i) & 1 هو استخراج البِت الموجود في الموضع i وعرضه على الشاشة.