FGyama は 大規模かつ現実のプロジェクトに適用可能なソースコードレベルの データフロー解析ツールである。
ここでは FGyama を実装するさいに学んだ、 ソースコード解析を適用する際に生じる諸問題を解説する。
始めに「ソースコード解析とは何か」をざっと定義しておく:
注意: ここでは、ソースコード解析 = プログラム解析全体、という理解ではない。 ここで得られる「関係」は、(構文木からの比較的簡単な処理で得られる) 「表層的」なもので、 それ以後の解析 (Dataflow解析、Taint解析、Reachability解析、記号実行など) は 異なる処理である、という立場をとる。 プログラム解析によっては、ソースコードを解析しなくても可能なものが多い。
ソースコード解析 | バイナリ解析 | |
---|---|---|
特徴 |
|
|
長所 |
|
|
短所 |
|
|
多くのソースコード解析で前提となっているのが 「名前 (型名、変数名) が静的に解決可能」という仮定である。 したがって、この前提があてはまらない処理 (eval、reflection) を行っているプログラムは対象にしない。
ソースコード解析のおおまかな手順は以下のとおりである:
Step 1. および 2. はどんなソースコード解析においても ほぼ共通の処理である。これは、以下の要素に依存する:
今回は、a) プログラミング言語 Java かつ b) interprocedural な例を扱う。 たとえば、以下のようなプログラムが対象となる:
package foo: class A { void f(int x) { ... } void g(int x) { ... } } package bar: class A { void f(int x) { ... } void f(int x, int y) { ... a.f(x); ... } }
たとえばここで、上の例にある a.f(1);
が実行されるとき、
実際に呼ばれるメソッド f
はどれなのかを決定したいとしよう。
そのためには、まず変数 a
の型を知らねばならず、
次にその中のどのメソッドが呼ばれるかを区別する必要がある。
また、x
というローカル変数は上のすべてのメソッドで使われているが、
これらは異なる変数である。このように、プログラム中の各要素に対して
use-def 関係を見いだすには、単なる名前の比較では不可能である。
FGyama では、厳密に以下のようなステージで解析する。
ムカつくことに、 これらの情報はほとんどコンパイラは知っているにもかかわらず、 (たとえば Eclipse JDT などの API を見ても) それらを外部から取り出す方法は存在しない。 さらに Java 言語の使用を独自に拡張し、 本来 type erasure されている generics (総称型) を reify された形で扱いたかったので、結局 自分で実装することにした。
問題: 以下の Java コード中で定義されている型をすべて挙げよ。
package ppp.qqq; class A<T> { class B<S extends A<String>> { ... } int foo(int x) { class C { ... }; C c = new C() { bar() { ... } }; moo(3); moo("zzz"); return baz(3, (z -> z+1)); } int baz(int x, IntFunction f) { return f.apply(x); } static <P> P moo(P p) { return p; } }
T
, S
)。
C
)。
P
)。
型の名前空間を各パッケージ、クラス、メソッドごとに 階層的に定義することで、各型に一意な名前を与える。
問題:
以下の Java コード中で ???
の部分のフル型名を答えよ。
package ppp.qqq;pppppp.qqq class A {ppp.qqq.A class B {}ppp.qqq.A.B ...} int foo(int x) {ppp.qqq.A.foo class C {} ...??? ...};2.2. 型finderの割り当て
Java の型システムをややこしくしている機能:
- 同一パッケージ内にある型名は、そのパッケージのすべてのクラスから見える。
- 各ファイルで import した型名は、そのファイルの中だけで見える。
- あるクラスを継承すると、そのクラス内で定義された inner class が見える。
これを解決するために
finder()
という概念を導入する。 これは「型名のPATH変数」のようなもので、Java コード中の異なる部分は 異なるfinder()
をもつ。まず、相互参照している型はふつうに扱える:
-- A.java -------- // finder(A) = [foo.*] package foo; class A { B b; } -- B.java -------- // finder(B) = [foo.*] package foo; class B { A a; }次に、各 Java ファイルも独自の
finder
をもつとする。 これはimport
された型名を発見できるようにする。-- A.java -------- // finder("A.java") = [animal.mammal.*, java.lang.*] package animal.mammal; import java.lang.*; // finder(Felidae) = [animal.mammal.Felidae] + finder("A.java") class Felidae { // Visible throughout A.java. List a; // Only visible within Felidae (and its subclasses). class Paw { ... } Paw p; }さらに、継承されたクラスは継承元クラスの
finder
も受け継ぐ。-- B.java -------- // finder("B.java") = [animal.mammal.*] package animal.mammal; // finder(Cat) = [animal.mammal.Cat] + finder(Felidae) + finder("B.java") class Cat extends Felidae { class Ear { ... } } // finder(Grumpy) = [animal.mmala.Grumpy] + finder(Cat) + finder("B.java") class Grumpy extends Cat { Paw x; // Visible Ear y; // Visible }2.3. 変数名空間の構築
変数の名前空間も階層的に定義するが、範囲が異なる。 型の空間は1つのメソッドの内部で同一なのに対し、 変数の名前空間はスコープによって異なるからだ。
変数の名前空間 (スコープ) は、ブロックごとに作成される。 これによって異なるブロックは異なる名前空間になり、識別子も異なったものになる。 ちなみに、外側にある変数は内側から見えるようになっている。
問題: 以下の Java コード中で
???
の部分のフル変数名を答えよ。class A {A int a; // A.a}A.f int f(int n) { // A.f.n int x = 0; // ???}A.f.for1 for (int i = 0; i < n; i++) { // A.f.for1.i x += i; }A.f.for2 for (int i = 1; i < n; i++) { // ??? x *= i; }...2.4. generics の reify化
実装にもよるが、ソースコードが含む重要な情報のひとつが変数の型である。
Type Erasureとは
Java では、generics は内部の型に関する情報を持っていない。 つまり消去 (erasure) されている。 したがって、たとえば次のようなメソッドは宣言できない:
void f(List<String> a) { ... } // List<String> = List と同じ。 void f(List<Object> a) { ... } // List<Object> = List と同じ。Reify された型とは
いっぽう、C# などの言語では、generics は内部の型に関する情報を持っている。 これを "reifyされた型" という。FGyama では、なるべく型の情報を区別したいため、 たとえば
List<String>
に対して呼ばれたメソッドと、List<Object>
に対して呼ばれたメソッドを区別したい。 このため、すべての generics は type erasure された型ではなく reify された型として扱うことにした。 つまり、この部分は意図的に Java の仕様から逸脱させている。 FGyama では、generics が具体化されると、それに付随するすべての型が 新たに定義されたとみなす。class C<T extends Object, S extends List<T>> { T x; S y; class E<T> { ... } } C<Object, List<Object>> c1; // 型 C<Object, List<Object>>, C<Object, List<Object>>.E<Object> が新たに定義される。 C<String, List<String>> c2; // 型 C<String, List<String>>, C<String, List<String>>.E<String> が新たに定義される。ただし、Java の generics は依然としてパラメータ型を指定しない使用法も 許しているため、その場合は最小の基底型 (この場合はObject
) を パラメータ型として仮定する。C c3; // C<Object, List<Object>>
Generics の定義内での finder
Generics の定義では、パラメータ型 (ここでは
S
やT
) への参照が すでにパラメータ列の定義自身で使われることに注意。 そのため、generics の定義の内部では新たな finder が定義されるとみなしている。package ppp;ppp class C<T, S>
{ppp.C class T extends Object { ... } // ppp.C.T class S extends List<T> { ... } // ppp.C.S}Generic メソッドにおけるパラメータ型
Java の generic method におけるパラメータは generic class とは異なり、明示的に指定されない。 つまり呼び出し時にパラメータ型を指定する必要がない:
class A { static <P> P moo(P p) { return p; } void foo() { moo(3); // int moo(int p) が呼ばれる。 moo("zzz"); // String moo(String p) が呼ばれる。 } }したがって、ここでは型 P を 「任意の型に対して unification 可能な」 特殊型とみなしている。 ちなみに、FGyama では個々の generic メソッドも reify されるため、上の例では
int moo(int p)
およびString moo(String p)
という 2種類の関数が 内部的に定義される。2.5. メソッドの列挙
すべての型および変数名の識別が終わったら、 ようやくメソッド中の実際のコードを解析することができる。
ここで最後の問題: Java はメソッドの多重定義を許しているため、 「クラス名 + メソッド名」だけではメソッドの一意な識別子にならない。 そのため、メソッドの識別子には次に挙げるような Java 型の内部表現文字列 (JNI名) を使うことにした:
class C { int foo(int x, int y) { ... } // 識別子: foo(II)I void foo(boolean x, String y) { ... } // 識別子: foo(ZLjava/lang/String;)V }2.6. Lambdaの決定
Java の Lambda 式は、基本的に新しい匿名クラスとして扱われる。 ところが Java の Lambda式では、変数の型を明示的に指定しなくてもよい:
class A { void main() { doit(x -> x+1); } ... int doit(IntFunction<int> f) { return f.apply(1); } // intをとる int doit(String x) { ... } }
つまり、各メソッド内のコード解析が一度完了してからでないと Lambda が実際にどのクラスに属する (つまり、Lambda 内の変数がどの型になる) かが 判明しないということである。そのため、FGyama ではメソッド解析のあとに さらに lambda 式のための再帰的な解析ルーチンを含んでいる。
2.7. classファイルの走査
ほとんどの Java のコードでは 標準・外部から提供されたライブラリを参照するのが普通である。 これら外部のコードの型情報はソースコードではなく、 通常はクラスファイルに含まれている。 したがって、以上の処理は実際にはテキストで書かれたソースコードだけでなく、 .jar ファイル内のクラスファイルに対しても実行する必要がある。 (FGyama では Apache BCEL を使用した)。
3. おまけ - Java型名の内部表現
Java のクラスファイル中では、 型名は以下のような内部表現 (JNI名) で表現されている。 これらは左から一意に字句解析可能なように作られている:
型 構文 byte B
char C
short S
int I
long J
float F
double D
boolean Z
void V
配列 [型
オブジェクト L名前/.../名前;
Generics L名前/.../名前<型1型2...>;
パラメータ型 T名前;
上階型 +型
メソッド (型1型2...)返り値型
問題: 以下の Java 型名の内部表現を答えよ:
boolean[]
String[][]
List<String>
int f(long)
Object[] g(int, List<T>)
4. 結論
これらのことをぜーんぶ調査・実装するのに 2年かかってしまった。
(Java のような) 大きな言語のソースコード解析は、 よほどのことがない限りやらないほうがよい。
Yusuke Shinyama