Introduzione
Uno
dei cavalli di battaglia di Java, dalla sua concezione quattro anni fa,
è sempre stata la portabilità delle applicazioni su un numero
enorme di piattaforme, in primis Windows NT, Mac e vari dialetti di Unix.
Questa promessa, lo si sa, è stata quasi sempre mantenuta, anche
se la situazione varia molto a seconda delle singole implementazioni, poiché
la Sun stessa supporta direttamente soltanto quella per Solaris e per Win32.
Il resto è lasciato alla responsabilità ed alla prontezza
delle singole case madri che autonomamente sviluppano la versione di Java
ognuna per le proprie piattaforme.
Tale
caratteristica di Java ha però costretto coloro che hanno preso
in considerazione tale aspetto, a dotarlo di un insieme di funzionalità
a livello lievemente astratto, che quindi rappresentasse una sorta di minimo
comune multiplo tra le possibilità dei sistemi più diffusi.
Quindi, se da un lato in Java la manipolazione dei file e directory (sia
come contenitori di dati, sia come entità sulla memoria di massa)
è molto agevole, da un altro non è possibile sviluppare applicazioni
che visualizzino finestre su monitor multiplo, come avviene già
in Unix e Macintosh. Questo esempio non è casuale: a partire dalla
versione di Java2 SDK 1.3 (“Kestrel”), la Sun ha annunciato il supporto
di Java per il display multiplo; ciò è stato possibile da
quando anche la Microsoft ha incluso il supporto per questa funzionalità
nei propri sistemi operativi, a partire da Windows 98 e Windows 2000, e
quindi il concetto di minimo comune multiplo poteva comprendere anche tale
caratteristica, ormai comune (seppure con qualche differenza) a tutte le
piattaforme interessate da Java.
Il
rilascio della versione 1.3 del JDK per Solaris e Windows è prevista
per la primavera: speriamo che le promesse siano mantenute e che le altre
aziende sappiano stare al passo con la sua evoluzione.
Che cosa è
JNI e quando serve?
In
generale, Java fornisce allo sviluppatore tutto quello di cui ha bisogno
nei vari settori, e in alcuni di questi (programmazione distribuita, applicazioni
per Internet, etc.) permette di ottenere ottimi risultati in tempo significativamente
inferiore ad altri linguaggi. Tuttavia, esistono casi in cui è indispensabile
che un’applicazione Java interagisca a basso livello con il sistema in
cui viene eseguita, cioè con la sua parte nativa. Per questo l’ambiente
di sviluppo di Java (cioè il JDK in una sua qualunque versione)
contiene la libreria JNI, o Java Native Interface. Si tratta di una collezione
di librerie che permettono l’interazione e lo scambio di dati tra una qualunque
macchina virtuale Java (JVM) e il sistema in cui essa “vive”. Tipicamente,
l’interazione con le parti a basso livello del sistema operativo è
realizzata con linguaggi la consentano, e tra questi primeggiano sicuramente
il C e il C++.
L’interazione
a basso livello con il sistema, come regola, dovrebbe essere evitata, soprattutto
nello sviluppo di nuove applicazioni, che possono essere disegnate da zero
con certi criteri progettuali che tengano conto di tali problematiche;
tuttavia, esistono casi essa può rendersi estremamente utile.
Fra
i più comuni, possiamo annoverare:
-
Interfacciamento
di nuove applicazioni Java con software già esistente e non convertibile
per le più disparate ragioni, quali fattibilità, costi, vincoli
di carattere tecnico (es. sistemi di acquisizione dati sul campo);
-
Aggiunta
ai propri programmi di funzionalità che non sono messe a disposizione
da Java (vedi, tuttavia, il caso del monitor multiplo citato nell’introduzione);
-
Necessità
di disporre di particolari performance in alcune funzionalità che
Java, malgrado i recenti ed importanti progressi (il più eclatante
dei quali è HotSpot), non è in grado di fornire.
La principale
conseguenza dell’uso di JNI in un’applicazione è che questa, ovviamente,
non può più fregiarsi del titolo “100% Pure Java”. Allora
viene spontanea una domanda: JNI è sconsigliabile oppure no?
Anche
se non abbiamo la pretesa di dare una risposta definitiva, risposta che
probabilmente non esiste, cerchiamo di approfondire il problema anche alla
luce di quelle che sono le linee guida tracciate dalla Sun.
Per
comprendere meglio la situazione, ci si riferisca alla Figura 1. In essa
è rappresentato uno schema “a blocchi” sulla base della visibilità
che ciascuna parte del software possiede in relazione al resto del sistema.
|
Figura
1 Interazione di applicazioni Java con l'ambiente in cui operano
Per
semplicità, abbiamo limitato la visibilità di Java soltanto
alle librerie di base (es. package java.lang, java.io, AWT, etc.) lasciando
da parte le funzionalità di più alto livello (es. Swing)
le quali nella figura dovrebbero collocarsi in un livello intermedio tra
gli ultimi più in alto; tutto ciò complicherebbe la spiegazione
senza peraltro apportare informazione indispensabile per la comprensione
di questi concetti. E’ chiaro che un’applicazione grafica potrebbe usare
sia AWT sia Swing, e utilizzare in vario modo le librerie di base.
Nella
nostra figura, le frecce bidirezionali piene rappresentano le normali interazioni
di visibilità che si realizzano fra i diversi strati di un sistema
software; quelle chiare, invece, rappresentano le interazioni che si potrebbero
realizzare tra un’applicazione scritta in Java nei confronti delle parti
di sistema più a basso livello o di software già esistente
in casi come quelli elencati poco sopra. Tali interazioni sono possibili
proprio grazie all’utilizzo della libreria JNI.
Quindi,
in certi casi di tali interazioni non si può fare a meno, e quindi
si mina la portabilità delle applicazioni. Ci sono due possibilità,
a seconda dell’obiettivo che ci si prefigga:
-
La nostra
nuova applicazione è stata scritta in Java per sfruttare le sue
caratteristiche particolari e il suo particolare approccio ai concetti
di OOP/OOD, ma deve funzionare solo su una certa piattaforma;
-
La nostra
nuova applicazione è stata scritta in Java non solo per quanto detto
sopra, ma deve anche funzionare su piattaforme diverse e ovviamente fornire
le stesse funzionalità.
Nel
primo caso tutto il nostro discorso non pone problemi. Nel secondo, ovviamente,
l’unico modo per assicurare la portabilità della nostra applicazione
è quello di sviluppare la parte di software (in particolare modo,
libreria) che interagisce a basso livello per tutti i sistemi operativi
su cui intendiamo eseguire il nostro applicativo. In questo modo, anche
se non potremo più affermare di aver scritto un’applicazione solo
in Java, di esso potremmo comunque sfruttare tutti i vantaggi, e la portabilità
è salva. Sembrerà un paradosso, ma d’altra parte Java stesso
è fatto in questa maniera: si pensi ancora una volta ad AWT, che
sotto Windows usa il suo SDK, sotto Unix si basa su Motif e Xt, e molti
altri esempi potrebbero essere portati. La conclusione è, quindi,
che JNI vada usata cum grano salis ma è lecito sfruttarne le potenzialità
quando occorre, non senza prima aver tentato di ottenere gli stessi risultati
in Java puro.
Struttura di JNI:
vista d'insieme delle funzionalità
Come
si è visto nel paragrafo precedente, JNI è un po’ il tramite
tra i due “mondi” del codice nativo e di una qualunque JVM. In Figura 2
è messo in risalto questo aspetto, che in qualche modo riempie le
frecce bidirezionali vuote presenti in Figura 1.
|
Figura
2 Dettaglio del rapporto tra Java e il mondo nativo attraverso JNI
La
relazione tra i due mondi è in realtà in qualche modo speculare.
Infatti, abbiamo detto che è possibile chiamare e interagire con
la parte a basso livello implementando in codice nativo metodi di classi
Java per le ragioni di cui al §1, ed è su questa parte che
si concentrerà essenzialmente la trattazione. In realtà,
è possibile anche fare esattamente il cammino inverso: creare una
JVM e invocarne i metodi di classi di sistema o anche definite dall’utente.
In
Figura 3 è riportata una breve panoramica (estratta dal JNI tutorial)
di come è possibile chiamare da Java software scritto in C/C++.
|
Figura
3 Interazione con il C/C++ dalla parte di Java.
I metodi
forniti da JNI consentono una grande flessibilità, e di essi riportiamo
le caratteristiche più importanti nell’elenco che segue. Per una
descrizione dettagliata si rimanda alla JNI Specification. E’ possibile
dunque:
-
Avere
completo accesso alle classi Java, sia di sistema, sia definite dall’utente,
esaminandone gli attributi, chiamandone i metodi, etc;
-
Definire
e dichiarare intere classi con tutte le loro caratteristiche, in maniera
analoga a quanto è possibile in Java puro con la Reflection API;
-
Creare
nuovi oggetti di cui è stata già data completa definizione
in Java attraverso uno dei costruttori;
-
Creare
array e stringhe di tipi primitivi o di qualsiasi altro oggetto;
-
Creare
e sollevare eccezioni.
Java
e il C/C++ sono linguaggi, come si sa, che hanno una matrice in comune.
Siccome in Java il trattamento dei tipi base (interi, booleani, numeri
in virgola mobile, etc.) non è altrettanto flessibile che in C/C++,
esiste una corrispondenza standard di tali tipi nel passaggio fra i due
ambienti. Ad esempio, gli interi in Java sono soltanto con segno, e di
questo bisogna tenere conto (ad es. un numero intero a 32 bit in C/C++
senza segno maggiore di 231-1 (2147483647), se passato ad un programma
Java, dev’essere contenuto in un intero lungo a 64 bit.
In
Figura 4 e Figura 5 sono riportate rispettivamente le mappature tra i tipi
primitivi e quelli complessi tra Java e C/C++ [v. JNI Spec.]
|
Figura
4 Tipi primitivi tra Java e C/C++
|
Figura
5 Equivalente dei tipi complessi di Java nel mondo del codice
nativo.
Nel
caso dei tipi rappresentati in Figura 5 se una procedura Java ad esempio
ha come parametro una String, nella corrispondente implementazione nativa
quel parametro sarà dichiarato di tipo jstring e così via;
non scendiamo più oltre nel dettaglio, poiché gli esempi
che seguiranno serviranno a chiarire le idee più di qualunque discorso
astratto.
Un’altra
cosa importante le cosiddette type signatures (“firme”), riportate in Tabella
1 che indicano con un codice convenzionale il tipo di parametri e il tipo
di ritorno di una qualunque funzione nella sua controparte Java. Inoltre,
queste firme servono alle istruzioni in C/C++ per attribuire alla controparte
nativa di un oggetto Java il suo tipo corretto, ed inoltre di esplorare
le classi definite in Java in modo analogo a quanto avviene grazie al package
java.lang.reflection. In aggiunta a questo, l’implementazione nativa di
metodi in Java segue una particolare nomenclatura, che vedremo più
avanti.
TIPO JAVA
|
“FIRMA”
|
void
|
V
|
boolean
|
Z
|
byte
|
B
|
char
|
C
|
short
|
S
|
int
|
I
|
long
|
J
|
float
|
F
|
double
|
D
|
class
|
Lclass;
|
[type
|
type[]
|
Tipo di ritorno di un metodo
|
(lista argomenti)tipo di ritorno
|
Tabella
1. "Firme" dei tipi di Java.
Come
esempio, un esempio supponiamo di avere due metodi di una classe così
definiti:
1.
void pippo(int x, String s, double y);
2.
byte[] topolino(com.foo.bar.Minni x, boolean b)
Le
due firme diventano rispettivamente:
1.
(Iljava/lang/String;D)V
2.
(Lcom/foo/bar/Minni;Z)[B
il
tipo di uscita di una funzione va in fondo a tutto, mentre le firme dei
parametri vanno inserite tra parentesi e in sequenza senza alcun separatore
(es. spazio) interposto.
Un primo semplice
esempio
Nelle
sezioni precedenti, molto discorsive e forse un po’ tediose, ci siamo limitati
a descrivere gli aspetti principali e simbologia, che risultano fondamentali
per capire la filosofia che sta dietro a tutto questo discorso, nonché
per rendere immediatamente fruibili gli esempi. In questo capitolo presentiamo,
passo per passo, la nostra prima implementazione di un semplicissimo metodo
Java in codice nativo, in modo tale che possa essere ripetuto da chiunque
volesse.
Siccome
in questo esempio e in tutti gli altri che verranno presentati si è
scelto di usare il C++ come codice per l’implementazione nativa, si ritiene
per scontata da parte del lettore almeno dei rudimenti essenziali. Gli
strumenti indispensabili per mettere in pratica gli esempi sono:
-
Un
compilatore C++, meglio se come ambiente integrato. Sotto PC andranno bene
il Visual C++ dalla versione 4.0 in poi; sotto Unix ci si può accontentare
di un editor di testi (Xemacs, Nedit, o se non c’è altro, il caro
VI) e di un compilatore a riga di comando (es. g++, cxx, e similari) e
di un Makefile. Si richiede la capacità di generare, sotto ambedue
le piattaforme, librerie dinamiche (DLL in Windows e .so in Unix);
-
Una versione
del JDK completa e installata correttamente (classpath configurato ed eseguibili
presenti nel percorso di sistema). Per semplicità, ci si riferirà
alla versione 1.1.x, con cui forse la maggior parte ha familiarità,
ma dovrebbe funzionare tutto anche con Java 2;
-
Una qualunque
shell sotto Unix e il prompt di MS-DOS (o qualche shell più evoluta
come 4DOS oppure 4NT).
Il
programma in Java
Ovviamente
la prima cosa di cui disporre è un piccolo programmino in Java,
che scriveremo e salveremo con un editor. Sia Hello.java il seguente:
package
it.provaJNI;
public
class Hello{
public native void sayHello(String person);
public static void main(String[] args){
Hello hello = new Hello();
if (args.length > 1)
for (int i=0; i<=args.length-1; i++)
hello.sayHello(args[i]);
}
static{
System.loadLibrary(“it_provaJNI_Hello”);
}
}
Da
notare che il metodo sayHello(...) è soltanto definito, ma non è
composto da una singola riga di codice in Java. E’ però preceduto
dalla parola chiave native che ne specifica appunto le caratteristiche.
Altra cosa da notare è il blocco static, che prima dell’esecuzione
del programma carica la libreria dinamica (it_provaJNI_Hello.dll oppure
libit_provaJNI_Hello.so) che fra poco scriveremo. Compiliamolo quindi con
javac -g it\provaJNI\Hello.java. Si presti attenzione alle directory, poiché
Java su questo è molto fiscale: se il package è quello del
sorgente, bisogna mettere il sorgente in un albero del tipo <radice>\it\provaJNI\,
ove la radice di riferimento può essere scelta a piacere.
Lo
“scheletro” dell’implementazione in C++
Dopo
aver scritto e compilato il programma in Java, non si può ancora
lanciarlo. Se infatti lo facessimo, l’interprete si lamenterebbe dando
un’eccezione di UnsatisfiedLinkException, non avendo trovato la libreria
dinamica che si aspetterebbe.
Ora
dobbiamo creare l’implementazione del nostro metodo in C++. Per fare questo,
dobbiamo in qualche modo generare il prototipo della sua controparte in
C++, seguendo la nomenclatura descritta al paragrafo 3. Siccome sarebbe
impensabile ricavarla a mano, ci viene in aiuto un tool di Java, detto
javah. Eseguiamo dunque il comando:
javah
-jni it.provaJNI.Hello
che produce
il seguente file di intestazione, dal nome it_provaJNI_Hello.h:
/*
DO NOT EDIT THIS FILE - it is machine generated */
#include
<jni.h>
/*
Header for class it_provaJNI_Hello */
#ifndef
_Included_it_provaJNI_Hello
#define
_Included_it_provaJNI_Hello
#ifdef
__cplusplus
extern
"C" {
#endif
/*
*
Class: it_provaJNI_Hello
*
Method: sayHello
*
Signature: (Ljava/lang/String;)V
*/
JNIEXPORT
void JNICALL Java_it_provaJNI_Hello_sayHello
(JNIEnv *, jobject, jstring);
#ifdef
__cplusplus
}
#endif
#endif
Questo
file è molto interessante. Osserviamo i seguenti fatti:
-
Un’ultima
regola di nomenclatura che avevamo lasciato da parte ci mostra la nomenclatura
con cui un metodo nativo è mappato in java:
<Tipo
di ritorno> Java_<nome package>_<Classe>_<Metodo>
in
pratica, al prefisso Java è attaccato il nome completamente esplicitato,
solo che al posto dei punti vengono messi dei “_”.
-
JNIEXPORT
e JNICALL sono due macro dipendenti dal sistema operativo definite in jni.h;
non ci soffermiamo su di loro. Il primo parametro è il puntatore
all’”ambiente” che ci fornirà tutti i necessari metodi per manipolazione
di oggetti (elencati al §3), il primo è il puntatore alla classe
stessa (cioè “this”), e l’ultimo parametro è la stringa “mappata”
(a java.lang.String corrisponde jstring) secondo lo schema di Figura 5.
Quindi, la lista dei parametri sarà sempre del tipo (puntatore all’ambiente,
puntatore this, <lista dei parametri del metodo java con i tipi mappati>)
.
-
Ultima
osservazione, la firma (“Signature”) del metodo scritta nei commenti segue
esattamente le convenzioni esposte in Tabella 1.
L’implementazione
del sorgente in C++, compilazione ed esecuzione
Dopo
aver generato il file di intestazione, procediamo all’implementazione della
parte in C++. Si noti che il file .h può essere anche rinominato
a nostro piacimento (lo stesso invece, non vale ovviamente per le procedure
al suo interno). Il consiglio è, tuttavia, di mantenere e attribuirlo
anche al file .cpp . Nel nostro caso avremo quindi il sorgente nel file
it_provaJNI_Hello.cpp il cui sorgente è come segue:
#include
<iostream.h>
#include
“it_provaJNI_Hello.h”
//
Native implementation for the method sayHello
//
of the class “Hello”
//
Emmanuele Sordini 1999
JNIEXPORT
void JNICALL Java_it_provaJNI_Hello_sayHello
(JNIEnv* env, jobject this, jstring person)
{
const char* helloString =
env->GetStringUTFChars(person, NULL);
cout << “Hello, “<< helloString << “!\n”;
env->ReleaseStringUTFChars(helloString);
}
Sebbene
semplice, il corpo del sorgente è interessante. Intanto, il nome
(ma non il tipo!) attribuito ai parametri della funzione è assolutamente
arbitrario, e non deve per forza coincidere con il sorgente in Java; tuttavia,
si tratta di una buona prassi, esattamente come il nome stesso del file
in C++.
Volendo
stampare una stringa sulla console con le procedure standard del C++ oppure
anche quelle del C (printf(…)), non è possibile passare loro direttamente
una stringa così come è stata ricevuta dalla JVM, poiché
il piantamento del programma è garantito. Infatti, la stringa in
Java è in formato Unicode a 16 bit, mentre noi abbiamo bisogno di
uno strumento per trasformarla in un array di caratteri utilizzabili con
in C++. Per fare ciò, si usa la procedura GetStringUTFChars(…) che
esegue tale trasformazione in una stringa di char (terminata dal carattere
nullo) in formato UTF (Unicode Text Format) a 8 bit, che praticamente coincide
con il familiare ASCII. Il secondo parametro è un puntatore ad un
flag di tipo logico il quale, se non impostato a NULL ma associato ad una
variabile, riporta se durante la creazione della stringa ne sia stata effettuata
o meno una copia.
Il
metodo nativo è chiamato dalla JVM, il cui garbage collector (GC)
non è abilitato a deallocare la stringa di caratteri che abbiamo
creato. La sua liberazione è quindi nostro compito tramite la procedura
ReleaseStringUTFChars(…). Non compiere quest’operazione in caso creassimo
un oggetto Java all’interno del nostro codice nativo e ne passassimo la
reference come parametro di uscita dal metodo. In questo modo, il suo controllo
ritornerebbe pienamente sotto la JVM e noi non dovremmo liberarlo.
Ora
creare la libreria dinamica è molto semplice. Prendiamo come esempio
il compilatore in linea di comando del MS VC++ 6.0 cl.exe (in questo specifico
caso nella versione 6.0). Supponendo di avere tutti i percorsi e le variabili
di ambiente impostati in modo correttamo (per il MSVC eseguendo il file
batch vcvars32.bat), ci posizioniamo nella directory ove si trova il sorgente
in C++ e il suo file di intestazione e il comando:
cl
it_provaJNI_Hello.cpp -I\Java\jdk118\include\win32
-I\Java\jdk118\include
/LD
sostituendo
i percorsi con quelli caratteristici della macchina su cui si stava lavorando;
il flag /LD indica al linker di generare una libreria dinamica. Se tutto
è andato bene, otterremo numerosi file, tra cui anche it_provaJNI_Hello.dll.
Ora
è sufficiente copiarla nella directory ove lanciamo l’interprete
Java, oppure fare in modo che essa si trovi nel PATH di sistema (variabile
di ambiente!).
Per
fare una prova, battiamo
java
it.provaJNI.Hello Paperino Pippo Topolino
e
se tutto è stato eseguito correttamente, si ottiene
Hello,
Paperino!
Hello,
Pippo!
Hello,
Topolino!
Un
avvertimento: l’errore più frequente (come già accennato
prima) in cui si possa incorrere in fase di esecuzione è un’eccezione
del tipo
java.lang.UnsatisfiedLinkException:
no it_provaJNI_Hello in java.library.path
(seguita
dalla stampa dello stack delle chiamate), causata dall’inteprete Java che
non riesce a trovare la DLL. Verificarne la giusta collocazione secondo
quanto detto sopra e riprovare.
Conclusione e riferimenti
In
questa prima puntata sono stati esaminati gli aspetti e le funzionalità
principali di più importanti di JNI, con particolare riguardo alla
sua opportunità di utilizzo in diverse situazioni e nell’integrazione
di nuove applicazioni in Java con software esistente. Si spera che il primo
e semplice esempio presentato al §4 sia sufficiente per introdurre
il lettore all’uso di JNI; seguirà prossimamente la discussione
di altri casi, alcuni reali frutto di esperienze lavorative, altri costruiti
su misura per scendere più in dettaglio negli aspetti di JNI.
La
bibliografia e i riferimenti riguardo a JNI non è, purtroppo, così
vasta come quella disponibile sulle JFC e su altri argomenti: come al solito,
Internet recita la parte del leone in quanto a numero di fonti, anche se
spesso è necessario discernere la qualità. Tra i riferimenti
ritenuti più importanti riportiamo:
[1]
Javasoft, The JNI Interface Specification: documento ufficiale disponibile
dal sito di Javasoft http://www.javasoft.com.
[2]
Javasoft, The Java Tutorial (JNI Trail), disponibile in linea o scaricabile
dal sito di Javasoft http://www.javasoft.com.
[3]
Sheng Liang, The JavaTM Native Interface: Programmer's Guide and Specification,
ed.Addison Wesley Longman, Inc. June 1999.
|