ソースコード解析の実装に関する諸々の事項

権藤研 夏ゼミ 2020/09/24
新山

概要

FGyama は 大規模かつ現実のプロジェクトに適用可能なソースコードレベルの データフロー解析ツールである。

ここでは FGyama を実装するさいに学んだ、 ソースコード解析を適用する際に生じる諸問題を解説する。

  1. 目的
  2. 手順
  3. おまけ - Java型名の内部表現
  4. 結論

1. 目的

始めに「ソースコード解析とは何か」をざっと定義しておく:

注意: ここでは、ソースコード解析 = プログラム解析全体、という理解ではない。 ここで得られる「関係」は、(構文木からの比較的簡単な処理で得られる) 「表層的」なもので、 それ以後の解析 (Dataflow解析、Taint解析、Reachability解析、記号実行など) は 異なる処理である、という立場をとる。 プログラム解析によっては、ソースコードを解析しなくても可能なものが多い。

ソースコード解析 vs バイナリ解析

ソースコード解析バイナリ解析
特徴
  • プログラマの視点に近い。
  • コンパイル時の情報 (識別子、コメント、数式) にアクセス可能。
  • 計算機の視点に近い。
  • 実行時の情報 (メモリ配置、使用レジスタ) にアクセス可能。
長所
  • プログラマの意図をより明確に分析可能。
  • コンパイルできなくても解析可能。
  • 実行環境に依存しない。
  • 分析対象の仕様が単純。
  • 実行時の挙動をより正確に分析可能。
短所
  • 分析対象の仕様が複雑。
  • 実行時の正確な状態はつかみにくい。
  • プログラマの意図はつかみにくい。
  • コンパイルできないと対応不可。
  • 実行環境に依存。

ソースコード解析の前提

多くのソースコード解析で前提となっているのが 「名前 (型名、変数名) が静的に解決可能」という仮定である。 したがって、この前提があてはまらない処理 (eval、reflection) を行っているプログラムは対象にしない。

2. 手順

ソースコード解析のおおまかな手順は以下のとおりである:

  1. 解析の対象となる「プログラム要素」を特定する。
  2. それぞれの解析対象に固有の識別子を付与する。
  3. 構文解析木をもとに、各関係を抽出する。
さらに欲をいえば
  1. 全解析過程にわたって、対象となる各プログラム要素は、ソースコードの位置 (どのファイルの何文字目から何文字目か) と正確に結びついていることが望ましい。

Step 1. および 2. はどんなソースコード解析においても ほぼ共通の処理である。これは、以下の要素に依存する:

  1. 対象のプログラミング言語
  2. interprocedural かどうか

今回は、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 では、厳密に以下のようなステージで解析する。

  1. 型名空間の構築。
  2. 型finderの割り当て。
  3. 変数名空間の構築。
  4. generics の reify化。
  5. メソッドの列挙。

ムカつくことに、 これらの情報はほとんどコンパイラは知っているにもかかわらず、 (たとえば Eclipse JDT などの API を見ても) それらを外部から取り出す方法は存在しない。 さらに Java 言語の使用を独自に拡張し、 本来 type erasure されている generics (総称型) を reify された形で扱いたかったので、結局 自分で実装することにした。

2.1. 型名空間の構築

問題: 以下の 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; }
}

型の名前空間を各パッケージ、クラス、メソッドごとに 階層的に定義することで、各型に一意な名前を与える。

問題: 以下の Java コード中で ??? の部分のフル型名を答えよ。

package ppp.qqq;
ppp
ppp.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 の型システムをややこしくしている機能:

これを解決するために 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 の定義では、パラメータ型 (ここでは ST) への参照が すでにパラメータ列の定義自身で使われることに注意。 そのため、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名) で表現されている。 これらは左から一意に字句解析可能なように作られている:

構文
byteB
charC
shortS
intI
longJ
floatF
doubleD
booleanZ
voidV
配列[
オブジェクトL名前/.../名前;
GenericsL名前/.../名前<12...>;
パラメータ型T名前;
上階型+
メソッド(12...)返り値型

問題: 以下の Java 型名の内部表現を答えよ:

4. 結論

これらのことをぜーんぶ調査・実装するのに 2年かかってしまった。

(Java のような) 大きな言語のソースコード解析は、 よほどのことがない限りやらないほうがよい。


Yusuke Shinyama