пятница, 3 января 2014 г.

Контрактное программирование под Android

О пользе и преимуществах методики контрактного программирования (Design by Contracts, DBC) написано много. Вот несколько хороших статей: Основная идея методики: улучшить надежность программного обеспечения за счет определения контрактов между взаимодействующими программными компонентами. Для метода задается контракт, который описывает:
  • пред-условия, которым должен соответствовать вызывающий код;
  • пост-условия, которые гарантируются методом.
Некоторые преимущества, которые обеспечивает DBC:
  • Повышение надежности программы за счет систематической и гарантированной проверки входных данных.
  • Исключение лишних проверок в коде. Корректность данных проверяется в методе, а не в вызывающем коде.
  • Автоматическое документирование кода, создание спецификации API программного модуля.
  • Косвенно: повышение эффективности статического анализа кода.
Вариантов реализации DBC для Java достаточно много. Обзорные статьи на эту тему: Однако, Java - это Java, а Android - это Android. Как всегда, под Android есть своя специфика. В данной статье я хочу рассмотреть практические подходы для реализации технологии DBC при программировании на Android.

Assert, Exception и Guava

Язык Java не поддерживает концепцию Design by Contracts напрямую и, вероятно, не будет поддерживать в обозримом будущем. Все что доступно разработчикам - Programming With Assertions. Т.е. assert-ы и исключения. Пример:
  public BigInteger modInverse(BigInteger m) {
  //проверка пред-условий. В случае ошибки - exception
    if (m == null) {
      throw new ArithmeticException("Value is null"); 
    }
    if (m.signum <= 0) {
      throw new ArithmeticException("Modulus not positive: " + m);
    }
    ... 
  //проверка пост-условий 
    assert this.multiply(result).mod(m).equals(ONE) : this;
    return result;
  }
Пред-условия предлагается проверять всегда и в случае ошибки генерировать exception. Пост-условия - проверять через assert, т.е. только в отладочном коде. При необходимости, включать проверку assert в релизе.

Несмотря на то, что такой подход вполне рабочий, он не лишен ряда недостатков:

  • код метода загромождается;
  • условия контракта "размазаны" в теле метода;
  • поздняя проверка контракта (во время выполнения, а не в процессе программирования/компиляции);
  • "неотключаемость" проверок и т.д.
Есть так же проблема, специфичная для Android: стандартные assert по умолчанию отключены на Android-девайсах и эмуляторе. Их нужно не забывать включать, например командой adb shell setprop debug.assert 1(assert будет работать до перезагрузки девайса). Вместо стандартных assert можно использовать JUnit Assert и исключать их из релиза с помощью ProGuard. Update: для проверки состояния стандартных контролов Android хорошо подойдет библиотека FEST Android.

Частично указанные выше проблемы можно решить с помощью набора библиотек Guava. В состав Guava входит библиотека Preconditions, предоставляющая набор функций для явной и компактной записи предусловий. Пример выше можно переписать с помощью Guava.Preconditions следующим образом:

  public BigInteger modInverse(BigInteger m) {
  //проверка пред-условий. В случае ошибки - exception
    com.google.common.base.Preconditions.checkNotNull(m, "Value is null");
    com.google.common.base.Preconditions.checkArgument(m.signum <= 0, "Modulus not positive: %d", m);
    ... 
  //проверка пост-условий 
    assert this.multiply(result).mod(m).equals(ONE) : this;
    return result;
  }

Null анализ. JSR 305, JSR 308 и статический анализ кода

В Java 5 был добавлен механизм аннотаций - появилась возможность снабжать код Java метаданными. Аннотации находят много полезных применений, в том числе, Android-специфичных.

Одно из типичных применений аннотаций - помощь в null-анализе. С помощью аннотаций @Nullable и @NotNull разработчик явно указывает, какие параметры могут принимать значения null, какие не могут, какие методы могут возвращать null, какие нет. По сути, это частный случай "контрактов" концепции Design by Contracts. Подобные "контракты" позволяют отловить распространенные ошибки, связанные с null, в частности:

  • Null Pointer Access/Dereference: попытка обращения к объекту, в то время как он равен null; null pointer exception в результате.
  • Redundant Check For Null: лишняя проверка на null там, где точно известно, что значение не может быть равно null.
Типичный пример:
   public @NonNull String test(@Nullable String b) {
      return b; //Ошибка! функция не должна возвращать null, тогда как b может быть равна null
   }
   public void makeTest() {
      String result = test(null); 
      if (result != null) { ... }  //!Ошибка: test не может возвращать null, проверка на null не нужна
   }
В приведенном коде две ошибки. Первая: согласно аннотации @NonNull, функция test не может возвращать null, но в данной она null может вернуть. Вторая ошибка: поскольку test не может возвращать null, то проверка результатов работы функции на null - лишняя. Статические анализаторы кода легко ловят подобные ошибки на стадии компиляции кода.

Первоначально появилось великое множество вариантов Null-аннотаций. Естественно, это оказалось не удобно. Поэтому была предпринята попытка выработать стандартный набор "аннотаций, способных помочь статическим анализаторам в поиске багов" - см. JSR-305: Annotations for Software Defect Detection. Помимо null-аннотаций, набор включал и другие полезные аннотации - для работы с регулярными выражениями, многопоточностью, аннотации для борьбы с утечкой ресурсов и т.д. (полный список). В настоящий момент, этот JSR-документ не развивается и помечен как Dormant (бездействующий, пассивный). Тем не менее, предложенный в JSR-305 аннотаций утвердился в качестве стандарта де-факто и, по крайней мере в части null-аннотаций, его поддерживают многие инструменты статического анализа (проблемы с именованием все равно остались: например, в IntelliJ используется директива @NotNull вместо @NonNull из JSR-305).

Замечу, что помимо JSR-305, есть JSR-303: Bean Validation, описывающий набор аннотаций javax.validation.constraints.*. Он уже реализован в Java EE 6. Однако, эти аннотации предназначены для проверки кода во время исполнения, а не для статического анализа.

В Java 8 планируют существенно расширить возможности аннотаций, см. JSR-308: Annotations on Java Types. Например, можно будет написать ArrayList<@Nonnull String>. По идее, возможности nullness-анализа на стадии компиляции должны существенно возрасти.

Конечно, расстановка аннотаций в коде требует дополнительной работы со стороны программиста. Некоторые разработчики считают, что такая работа может не оправдать себя - аннотаций не достаточно, чтобы выловить многие null-ошибки, см. JSR 305: a silver bullet or not a bullet at all? Тем не менее, несомненно, что аннотации позволяют более точно описать намерения программиста и помочь статическим анализаторам. А количество дополнительной работы можно и нужно минимизировать. Разработчики библиотеки Google Guice предлагают следующий подход:

  • запретить null по умолчанию;
  • все nullable-значения явно помечать @Nullable.
При таком подходе аннотацию явного использования @NotNull не требуется, достаточно использовать только @Nullable.

Стандарт JSR-305 не описывает аннотацию "non null by default". Однако, такая аннотация есть в Eclipse: с помощью org.eclipse.jdt.annotation.NonNullByDefault можно пометить пакет, после чего весь код в пакете будет считаться not-null по умолчанию.

Разработчики IntelliJ пошли несколько иным путм. IDE автоматически определяет параметры, которые используются без проверки на null и далее в процессе анализа считает, что это @NotNull-параметры.

Инструменты для анализ кода

Теперь поговорим о средствах анализа кода. Статических анализаторов кода для Android достаточно много, но не все из них содержат средства nullness-анализа. Вот некоторые, которые точно содержат: Протестируем работу бесплатного инструментария на тестовом примере.

Тестовый пример

 public static class TestClass {
  @NonNull String _NonNull /*= "test1"*/; //Error 1: _NonNull is not initialized in the code below
  @Nullable String _Nullable;
  
  @Nullable private String test_nullable(@NonNull String paramNonNull, @Nullable String paramNullable) {
   Log.i(LOG_TAG, _NonNull.toString()); //null pointer dereference because of Error 1
   Log.i(LOG_TAG, _Nullable.toString()); //Error 2: null pointer dereference
   Log.i(LOG_TAG, paramNonNull.toString()); 
   Log.i(LOG_TAG, paramNullable.toString()); //Error 3: null pointer dereference
   
   _NonNull = null; //Error 4: store of null value into nonnull-variable
   double d = java.lang.Math.random();
   if (d > 0.99f) {
    return null;
   }
   if (d > 0.9f ) {
    _Nullable = "test2";
    _NonNull = "test3";
   }
   
   if (_Nullable != null) { //no error 1: _Nullable can be null here
    String m = _Nullable.toString(); //no error 2: because _Nullable is not null here
    Log.i(LOG_TAG, m);
    Log.i(LOG_TAG, _Nullable.toString()); //no error 3: because _Nullable is not null here 
   }
   
   if (_NonNull != null) { //Error 5: redundant nullcheck 
    Log.i(LOG_TAG, _NonNull.toString());
   }
   
   if (_NonNull.length() > 2) { //Error 6: possible null pointer dereference
    return _NonNull;
   } else {
    return _Nullable;
   }
  }
  
  @NonNull private String test_nonnull(@NonNull String paramNonNull, @Nullable String paramNullable) {
   return paramNullable; //Error 7: paramNullable can be null, but function must returns not-null
  }

  public void makeTests() {
   String t1 = test_nullable(null, "test4"); //Error 8: first param must be not null
   if (t1 != null) { //no error 4: t1 can be null  
    Log.i(LOG_TAG, t1.toString()); //no error 5:  t1 is not null here  
   }
   Log.i(LOG_TAG, t1.toString()); //Error 9:  possible null pointer dereference

   String t2 = test_nonnull("test5", null);
   if (t2 != null) { //Error 10:  redundant nullcheck
    Log.i(LOG_TAG, t2.toString()); 
   }
   Log.i(LOG_TAG, t2.toString()); //no error 6: t2 is not null here
  }     
 }
Вполне допускаю, что данный пример охватывает не все возможные типы ошибок. Но типичные ошибки в нем точно присутствуют.

Результаты тестирования

IssueEclipse 4.1 (Indigo)Eclipse 4.2 (Juno)Eclipse 4.3 (Kepler)FindBugs 2.0.3Checker Framework 1.7.1Android Studio 0.4
Error 1 FAILED ok ok ok ok FAILED
Error 2 FAILED ok ok ok ok ok
Error 3 FAILED ok ok FAILED ok ok
Error 4 FAILED ok ok ok ok ok
Error 5 FAILED FAILED ok FAILED ok ok
Error 6 FAILED FAILED FAILED ok FAILED FAILED
Error 7 FAILED ok ok ok ok ok
Error 8 ok ok ok FAILED ok ok
Error 9 FAILED ok ok FAILED ok ok
Error 10 FAILED ok ok FAILED FAILED ok
No Error 1 ok ok ok ok ok ok
No Error 2 ok ok ok ok ok ok
No Error 3 ok FAILED FAILED ok FAILED ok
No Error 4 ok ok ok ok ok ok
No Error 5 ok ok ok ok ok ok
No Error 6 FAILED ok ok ok ok ok

Компилятор IntelliJ / Android Studio как всегда на высоте. Последняя версия Eclipse 4.3 (Kepler) не отстает, в ней nullness-анализ сильно допилили. Правда, есть пара особенностей:

  • По умолчанию null-analysis с учетом аннотаций отключен, его нужно включить в настройках проекта: Java Compiler/Errors Warnings/Null analysis/Enable annotation-based null analysis
  • Из-за ложных срабатываний (например, см. "no error 3" в тестовом примере) приходится выставлять уровень предупреждений для всех типов ошибок в Warning.
FindBugs работает отлично и находит некоторые ошибки, которые пропускают другие анализаторы. Текущая версия Checker Framework довольно сильно глючит - зависает в процессе анализа на проектах, использующих сторонние библиотеки, не работает под 64-битным eclipse... Но если не зависнет, то может принести много пользы на практике.

Тяжелая артиллерия: библиотеки, реализующие Design By Contracts в Java

Обратимся теперь к библиотекам Java. Варианты реализации подобных библиотек не плохо рассмотрены в диссертации создателя популярной библиотеки Modern Jass слайды (pdf), диссертация (pdf). В целом, реализации DBC для Java отличаются прежде всего способом описания контрактов (аннотации, скриптовый язык в комментариях, отдельные методы и даже классы) и способом проверки соблюдения контракта (пре-процессор, подмена байткода, собственный компилятор, интерпретатор, аспектно-ориентированное программирование и т.д.). Вот, далеко не полный, список библиотек, реализующих концепцию Design by Contracts в Java:
  • Modern Jass. Контракты задаются аннотациями. Плагин для Eclipse есть только под Mac. Последнее обновление: 2007 год.
     public class Bar { 
      @Pre("args.length % 2 == 0")
      public static void main(String[] args){
       System.out.println("Hello, " + args.length);
     }
     }
    
  • Contract4J. Контракты задаются аннотациями Java и записываются на языке AspectJ. Последнее обновление: 2010 год.
    @Contract
    public class SearchEngine {
      ...
      @Pre
      @Post("$return != null && $return.isValid()")
      public PhoneNumber search (String first, String last, 
                                 Address streetAddress) {
        PhoneNumber result = 
          doSearch (first, last, streetAddress);
        return result;
      }
      ...
    }
    
  • jContractor. Контракты записываются в дополнительных методах, имеющих определенные названия. Например, для проверки предусловий и постусловий метода "push" создаются дополнительные методы push_Precondition и push_Precondition, а для проверки инварианта класса метод _Invariant. Последнее обновление библиотеки: 2003 год.
    class Stack {
      public void push (Object o) { ... }
      
      protected boolean push_Precondition (Object o) {
        return o != null;
      }
      protected boolean push_Postcondition (Object o, Void RESULT) {
        return implementation.contains(o) && (size() == OLD.size() + 1);
      }
    }
    
  • C4J. Контракт для класса задается отдельным классом. В процессе работы C4J instrumentor автоматически изменяет код классов, связанных контрактом - если для метода класса задано предусловие, то в начало метода добавляется вызов предусловия; если задано постусловие, то в конце метода производится его вызов и т.д. Библиотека гибко настраивается - проверку контрактов можно отключить целиком или для отдельных методов, в случае нарушения контракта можно генерировать исключения или ограничиться записью в лог файл и т.д. Последнее обновление: 2013 год.
    @ContractReference(TimeOfDayContract.class)
    public class TimeOfDay {
       ... 
       public void setHour(int hour) {
          this.hour = hour;
       }
    }
    
    public class TimeOfDayContract extends TimeOfDay {
    
       ...
    
       @Override public void setHour(int hour) {
          if (preCondition()) {
             assert hour >= HOUR_MIN : "hour >= HOUR_MIN"; // precondition clause 1
             assert hour <= HOUR_MAX : "hour <= HOUR_MAX"; // precondition clause 2
          }
          if (postCondition()) {
             ...
          }
    }
    
  • Cofoja (contracts for java). Библиотека, разрабатываемая сотрудниками Google в рамках ныне отмененного правила использования 20% свободного времени на разработку сторонних проектов. Контракты задаются через аннотации. Плагина для Eclipse нет, но прикрутить библиотеку к Eclipse можно: Contracts for Java (Cofoja) V1.0 in Eclipse (PDF) (у меня к 64-битному Eclipse 4.3 cofoja не прикрутилась, а к 32-битному - без проблем).
        @Requires("n >= 0")
        @Ensures({
          "!result || old (n) % 2 == 1",
          /* For testing purposes: check that old () has the right value. */
          "old (n) == n"
        })
        public static boolean odd(int n) {
          if (n == 0) {
            return false;
          } else {
            return even(n - 1);
        }     
    
  • Java Modeling Language (JML). Контракты описываются на специальном языке в комментариях к методам.
      //@ requires (* x is positive *);
      /*@ ensures (* \result is an approximation to 
        @ the square root of x *)
        @ && \result >= 0;
        @*/
    public static double sqrt(double x) {
    return Math.sqrt(x);
    }
    

А теперь, плохие новости. К сожалению, практически все эти библиотеки не могут быть использованы под Android из-за ограничений Dalvik VM. А именно, отпадают все библиотеки, использующие Java Bytecode Instrumentation - Modern Jass, Contract4J, jContractor, C4J, cofoja... В Android технология Bytecode Instrumentation реализована через Instrumentation classes, на что DBC-библиотеки попросту не рассчитаны. Печально, но мне не удалось найти ни одной библиотеки для реализации DBC в Android-приложении.

Выводы.

Библиотек, позволяющих использовать методологию Design By Contracts в Android приложениях, найти не получилось. Так что полноценной удобной поддержки DBC в Android, похоже, обеспечить пока не удастся. Тем не менее, кое-какие DBC-инструменты для Android-разработчиков все же доступны. Перечислю их еще раз:
  • Библиотека Preconditions из Guava позволяет явно и компактно задавать проверку предусловий.
  • Для проверки постусловий методов можно использовать assert'ы. Поскольку стандартные assert'ы java на девайсах и эмуляторе по умолчанию отключены, их следует не забывать включать. Или же использовать JUnit Assert + FEST Android и исключать их из релиза с помощью ProGuard.
  • Аннотации и @NonNull, @Nullable из JSR-305 позволяют явно указать код, в котором не могут или, наоборот, могут присутствовать null-значения. С этими аннотациями умеют работать многие бесплатные и платные статические анализаторы кода, в частности, FindBugs и Checker Framework.
  • Если вы используете Eclipse, то имеет смысл последовать совету разработчиков библиотеки Google Guice и считать, что по умолчанию никакие параметры и переменные не могут принимать null-значение, а методы - не могут null возвращать. Достигается это за счет использования аннотации org.eclipse.jdt.annotation.NonNullByDefault, с помощью которой следует пометить все пакеты в вашем проекте. В результате, необходимость проставлять @NotNull отпадет, потребуется лишь помечать @Nullable те места в коде, где могут присутствовать null-значения.
  • Следует использовать наиболее свежую версию Eclipse 4.3 (Keppler), т.к. в ней существенно доработаны средства null analyse. По умолчанию они выключены, их следует включить в настройках компилятора Java.

Update:В контексте null-анализа стоит так же упомянуть библиотеку Guava.Optional, с помощью которой можно явно помечать объекты, которые могут принимать null-значения. Полезнейшая штука.

Mindmap для этой статьи

Mindmap, созданный в процессе работы над этой статьей, можно взять здесь, в формате Freemind 1.0 (портативная версия: freemind-bin-1.0.0.zip).

Комментариев нет:

Отправить комментарий