Operatori Bitwise in Java

Concetti Chiave
  • Gli operatori bitwise in Java permettono di manipolare i singoli bit di un valore intero, offrendo operazioni come AND, OR, XOR e NOT.
  • Gli operatori di scorrimento a sinistra e a destra consentono di spostare i bit di un valore, con effetti diversi a seconda del tipo di scorrimento (con o senza segno).

Gli Operatori Bitwise

Java definisce diversi operatori bitwise (operatori bit a bit) che possono essere applicati ai tipi interi: long, int, short, char e byte. Questi operatori agiscono sui singoli bit dei loro operandi. Sono riepilogati nella tabella seguente:

Operatore Risultato
~ NOT unario bitwise
& AND bitwise
| OR bitwise
^ OR esclusivo bitwise
>> Scorrimento a destra
>>> Scorrimento a destra con riempimento di zeri
<< Scorrimento a sinistra
&= Assegnazione AND bitwise
|= Assegnazione OR bitwise
^= Assegnazione OR esclusivo bitwise
>>= Assegnazione scorrimento a destra
>>>= Assegnazione scorrimento a destra con riempimento di zeri
<<= Assegnazione scorrimento a sinistra
Tabella 1: Operatori bit a bit in Java

Siccome gli operatori bitwise manipolano i bit all'interno di un intero, è importante comprenderne gli effetti su un valore.

In particolare, è utile sapere in che modo Java memorizza i valori interi e come rappresenta i numeri negativi. Prima di procedere, conviene esaminare brevemente questi due argomenti.

Tutti i tipi interi sono rappresentati da numeri binari di larghezze di bit variabili. Per esempio, il valore byte per 42 in binario è 00101010, dove ogni posizione rappresenta una potenza di due, iniziando con 2^0 nel bit più a destra. La posizione immediatamente a sinistra corrisponde a 2^1, ossia 2, e così via verso sinistra con 2^2 (4), quindi 8, 16, 32 e così via. Pertanto 42 ha bit a 1 nelle posizioni 1, 3 e 5 (contando da 0 a destra); di conseguenza 42 è la somma di 2^1 + 2^3 + 2^5, vale a dire 2 + 8 + 32.

Tutti i tipi interi (tranne char) sono numeri con segno; possono quindi rappresentare valori negativi oltre ai positivi. Java usa una codifica nota come complemento a due, secondo la quale i numeri negativi sono rappresentati invertendo (cambiando gli 1 in 0 e viceversa) tutti i bit di un valore, quindi aggiungendo 1 al risultato. Per esempio, –42 si rappresenta invertendo tutti i bit di 42, ossia 00101010, ottenendo 11010101, quindi aggiungendo 1, che produce 11010110, cioè –42. Per decodificare un numero negativo, si invertono prima tutti i bit e poi si aggiunge 1. Per esempio, –42 ( 11010110 ) invertito produce 00101001, ossia 41; aggiungendo 1 si ottiene 42.

La ragione per cui Java (e la maggior parte dei linguaggi) utilizza il complemento a due è evidente se si considera il problema dell'attraversamento dello zero. Supponendo un valore byte, lo zero è rappresentato da 00000000. Nel complemento a uno, invertendo semplicemente tutti i bit si ottiene 11111111, cioè lo zero negativo, invalido nell'aritmetica intera. Il complemento a due risolve il problema: dopo l'inversione si aggiunge 1, ottenendo 100000000. Il bit 1 che trabocca a sinistra viene scartato, con il risultato desiderato che –0 equivale a 0 e 11111111 è la codifica di –1. Sebbene l'esempio usi un byte, lo stesso principio si applica a tutti i tipi interi di Java.

Poiché Java memorizza i numeri negativi in complemento a due — e poiché tutti gli interi sono con segno — l'uso degli operatori bitwise può produrre risultati inattesi. Per esempio, attivare il bit di ordine superiore farà interpretare il valore risultante come negativo, indipendentemente dall'intenzione. Occorre ricordare che il bit di ordine superiore determina il segno di un intero, a prescindere da come venga impostato.

Gli Operatori Logici Bitwise

Gli operatori logici bitwise sono &, |, ^ e ~. La tabella seguente mostra il risultato di ciascuna operazione. Gli operatori bitwise vengono applicati a ogni singolo bit di ciascun operando.

A B A | B A & B A ^ B ~A
0 0 0 0 0 1
1 0 1 0 1 0
0 1 1 0 1 1
1 1 1 1 0 0
Tabella 2: Operazioni logiche bitwise

Il Bitwise NOT

Chiamato anche complemento bitwise, l'operatore NOT unario ~ inverte tutti i bit dell'operando. Per esempio, il numero 42, il cui pattern di bit è

00101010

diventa

11010101

dopo l'applicazione dell'operatore NOT.

Il Bitwise AND

L'operatore AND & produce un bit 1 se entrambi gli operandi hanno bit 1; in tutti gli altri casi produce 0. Esempio:

  00101010    42
& 00001111    15
----------    --
  00001010    10

Il Bitwise OR

L'operatore OR | combina i bit in modo che, se uno dei due è 1, il bit risultante sia 1:

  00101010   42
| 00001111   15
----------   --
  00101111   47

Il Bitwise XOR

L'operatore XOR ^ combina i bit in modo che, se esattamente uno degli operandi è 1, il risultato sia 1; altrimenti il risultato è 0. L'esempio seguente mostra l'effetto di ^ e mette in evidenza un attributo utile dell'operazione: il pattern dei bit di 42 viene invertito ovunque il secondo operando possieda un bit 1, mentre rimane invariato dove il secondo operando ha bit 0.

  00101010   42
^ 00001111   15
----------   --
  00100101   37

Uso degli Operatori Logici Bitwise

// Dimostrazione degli operatori logici bitwise.
class LogicaBit {
    public static void main(String[] args) {
        String[] binario = {
            "0000", "0001", "0010", "0011", "0100", "0101", "0110", "0111",
            "1000", "1001", "1010", "1011", "1100", "1101", "1110", "1111"
        };

        int a = 3; // 0 + 2 + 1 ovvero 0011 in binario
        int b = 6; // 4 + 2 + 0 ovvero 0110 in binario

        int c = a | b;
        int d = a & b;
        int e = a ^ b;
        int f = (~a & b) | (a & ~b);
        int g = ~a & 0x0f;

        System.out.println("        a = " + binario[a]);
        System.out.println("        b = " + binario[b]);
        System.out.println("      a|b = " + binario[c]);
        System.out.println("      a&b = " + binario[d]);
        System.out.println("      a^b = " + binario[e]);
        System.out.println("~a&b|a&~b = " + binario[f]);
        System.out.println("       ~a = " + binario[g]);
    }
}

In questo esempio, a e b presentano tutti e quattro i casi possibili per due cifre binarie (0-0, 0-1, 1-0, 1-1). Le operazioni | e & sui singoli bit sono evidenziate nei risultati c e d. I valori assegnati a e e f coincidono, mostrando il comportamento di ^. L'array di stringhe binario contiene la rappresentazione binaria in formato leggibile dei numeri da 0 a 15; l'array viene indicizzato per visualizzare la rappresentazione binaria di ciascun risultato. Il valore di ~a è AND-ato con 0x0f (0000 1111 in binario) per ridurlo a un valore inferiore a 16, così da poterlo stampare tramite l'array binario. Di seguito l'output prodotto dal programma:

        a = 0011
        b = 0110
      a|b = 0111
      a&b = 0010
      a^b = 0101
~a&b|a&~b = 0101
       ~a = 1100

Lo Scorrimento a Sinistra

L'operatore di scorrimento a sinistra, <<, sposta tutti i bit di un valore verso sinistra di un numero specificato di volte. La forma generale è:

valore << num

Qui num indica il numero di posizioni di scorrimento verso sinistra da applicare a valore. In altre parole, << sposta tutti i bit del valore specificato verso sinistra del numero di posizioni indicato da num. A ogni scorrimento, il bit più significativo viene espulso (e perso) e sulla destra viene introdotto uno zero. Ciò significa che, quando il valore dell'operando è di tipo int, i bit vengono persi non appena oltrepassano la posizione 31; se l'operando è di tipo long, i bit vengono persi dopo la posizione 63.

Le promozioni di tipo automatiche di Java possono produrre risultati inattesi quando si eseguono scorrimenti su valori byte e short. I valori byte e short vengono promossi a int durante la valutazione di un'espressione e anche il risultato è un int. Di conseguenza, il risultato di uno scorrimento a sinistra su un valore byte o short sarà un int, e i bit spostati non andranno persi finché non oltrepassano la posizione 31. Inoltre, un valore byte o short negativo verrà esteso di segno quando viene promosso a int, quindi i bit più significativi verranno riempiti di 1. Per questo motivo, eseguire uno scorrimento a sinistra su un byte o short implica la necessità di scartare i tre byte più significativi del risultato int. Il metodo più semplice consiste nel fare un cast del risultato di nuovo a byte. Il programma seguente dimostra il concetto:

// Scorrimento a sinistra di un valore byte.
class SpostaByte {
    public static void main(String[] args) {
        byte a = 64, b;
        int i;

        i = a << 2;
        b = (byte) (a << 2);

        System.out.println("Valore originale di a: " + a);
        System.out.println("i e b: " + i + " " + b);
    }
}

L'esecuzione produce:

Valore originale di a: 64
i e b: 256 0

Poiché a viene promosso a int per la valutazione, lo scorrimento di 64 (0100 0000) di due posizioni verso sinistra fa sì che i contenga 256 (1 0000 0000). Il valore di b diventa 0 perché, dopo lo scorrimento, il byte meno significativo è ora zero: il suo unico bit a 1 è stato espulso.

Dato che ogni scorrimento a sinistra raddoppia il valore originale, spesso si utilizza questa operazione come alternativa efficiente alla moltiplicazione per 2. Occorre però fare attenzione: se si fa entrare un 1 nella posizione di bit più significativa (bit 31 o 63), il valore diventa negativo. Il programma seguente illustra il punto:

// Scorrimento a sinistra come metodo rapido per moltiplicare per 2.
class MoltiplicaPerDue {
    public static void main(String[] args) {
        int i;
        int numero = 0xFFFFFFFF;

        for (i = 0; i < 4; i++) {
            numero = numero << 1;
            System.out.println(numero);
        }
    }
}

Output generato:

536870908
1073741816
2147483632
-32

Il valore iniziale è stato scelto con cura affinché, dopo quattro scorrimenti a sinistra, producesse –32. Quando un bit 1 raggiunge il bit 31, il numero viene interpretato come negativo.

Lo Scorrimento a Destra

L'operatore di scorrimento a destra, >>, sposta tutti i bit di un valore verso destra di un numero specificato di volte. La forma generale è:

valore >> num

Qui num specifica il numero di posizioni di scorrimento verso destra da applicare a valore.

Il frammento di codice seguente sposta il valore 32 di due posizioni verso destra, impostando a su 8:

int a = 32;
a = a >> 2; // ora a contiene 8

Quando i bit vengono “espulsi”, essi vanno persi. Per esempio, il codice successivo sposta il valore 35 di due posizioni verso destra, facendo perdere i due bit meno significativi e impostando di nuovo a su 8:

int a = 35;
a = a >> 2; // a contiene 8

La stessa operazione in binario rende più evidente il risultato:

00100011   35
>> 2
00001000    8

Ogni scorrimento verso destra divide il valore per 2 e scarta l'eventuale resto; in alcuni casi ciò offre una divisione intera ad alte prestazioni.

Durante lo scorrimento a destra, i bit più a sinistra esposti dal movimento vengono riempiti con il valore precedentemente contenuto nel bit più significativo: questo processo, chiamato estensione del segno, preserva il segno dei numeri negativi. Per esempio, –8 >> 1 equivale a –4, che in binario è:

11111000   -8
>> 1
11111100   -4

Se si sposta –1 verso destra, il risultato resta sempre –1, perché l'estensione del segno continua a inserire 1 nei bit più significativi.

Talvolta non è desiderabile estendere il segno. Il programma seguente converte un valore byte nella sua rappresentazione esadecimale, mascherando i bit di estensione del segno mediante un AND con 0x0f:

// Mascheramento dell'estensione del segno.
class ByteEsa {
    public static void main(String[] args) {
        char[] esa = {
            '0', '1', '2', '3', '4', '5', '6', '7',
            '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'
        };

        byte b = (byte) 0xf1;

        System.out.println("b = 0x" + esa[(b >> 4) & 0x0f] + esa[b & 0x0f]);
    }
}

Output del programma:

b = 0xf1

Lo Scorrimento a Destra Senza Segno

Come già visto, l'operatore >> riempie automaticamente il bit più significativo con il proprio contenuto precedente a ogni scorrimento, preservando così il segno del valore. Talvolta questo comportamento è indesiderato. Per esempio, quando si spostano valori che non rappresentano numeri (come dati di pixel o grafica), si può preferire che il bit più significativo venga sempre riempito con uno zero, indipendentemente dal valore iniziale. Questo è lo scorrimento senza segno (unsigned shift), ottenuto con l'operatore di scorrimento a destra senza segno di Java, >>>, che inserisce sempre zeri nel bit più significativo.

Il frammento di codice seguente illustra >>>. Qui a è impostato a –1, che corrisponde a tutti i 32 bit a 1 in binario. Il valore viene poi spostato a destra di 24 bit, riempiendo i 24 bit più significativi con zeri ed ignorando la normale estensione del segno. Il risultato è 255.

int a = -1;
a = a >>> 24;

La stessa operazione in binario rende il processo più chiaro:

11111111 11111111 11111111 11111111   -1 in binario come int
>>>24
00000000 00000000 00000000 11111111   255 in binario come int

L'operatore >>> è meno utile di quanto si possa pensare, perché ha senso solo per valori a 32 o 64 bit. I tipi più piccoli vengono promossi automaticamente a int nelle espressioni; di conseguenza l'estensione del segno avviene comunque e lo scorrimento opera su un valore a 32 bit invece che su uno a 8 o 16 bit. Per esempio, ci si potrebbe aspettare che uno scorrimento senza segno su un byte riempia di zeri a partire dal bit 7, ma in realtà viene spostato un valore a 32 bit. Il programma seguente dimostra l'effetto:

// Scorrimento senza segno di un valore byte.
class SpostaByteSenzaSegno {
    static public void main(String[] args) {
        char[] esa = {
            '0', '1', '2', '3', '4', '5', '6', '7',
            '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'
        };

        byte b = (byte) 0xf1;
        byte c = (byte) (b >> 4);
        byte d = (byte) (b >>> 4);
        byte e = (byte) ((b & 0xff) >> 4);

        System.out.println("        b = 0x"
            + esa[(b >> 4) & 0x0f] + esa[b & 0x0f]);
        System.out.println("      b >> 4 = 0x"
            + esa[(c >> 4) & 0x0f] + esa[c & 0x0f]);
        System.out.println("      b >>> 4 = 0x"
            + esa[(d >> 4) & 0x0f] + esa[d & 0x0f]);
        System.out.println("(b & 0xff) >> 4 = 0x"
            + esa[(e >> 4) & 0x0f] + esa[e & 0x0f]);
    }
}

L'output mostra che >>> non produce alcun effetto visibile sui byte:

b = 0xf1
b >> 4 = 0xff
b >>> 4 = 0xff
(b & 0xff) >> 4 = 0x0f

Il valore di c è 0xff a causa della consueta estensione del segno. Anche d è 0xff, benché fosse previsto 0x0f, poiché b è stato promosso a int prima dello scorrimento. Per ottenere 0x0f si maschera prima b con 0xff, si limita cioè il valore a 8 bit, e solo dopo si scorre (e).

Assegnazioni Compatte degli Operatori Bitwise

Tutti gli operatori bitwise binari dispongono di una forma compatta, analoga a quella degli operatori algebrici, che combina l'assegnazione con l'operazione bitwise. Per esempio, le istruzioni seguenti, che spostano a destra di quattro bit il valore contenuto in a, sono equivalenti:

a = a >> 4;
a >>= 4;

Analogamente, le due istruzioni che assegnano ad a l'espressione a OR b sono equivalenti:

a = a | b;
a |= b;

Il programma seguente crea alcune variabili intere e usa le assegnazioni compatte degli operatori bitwise per manipolarle:

class OpBitAssegna {
    public static void main(String[] args) {
        int a = 1;
        int b = 2;
        int c = 3;

        a |= 4;
        b >>= 1;
        c <<= 1;
        a ^= c;

        System.out.println("a = " + a);
        System.out.println("b = " + b);
        System.out.println("c = " + c);
    }
}

Output del programma:

a = 3
b = 1
c = 6