Статьи

Типы сильнее тестов

TDD заменяет проверку типа … так же, как крепкий напиток заменяет печали. — Брент Йорги

Если у вас есть формальное доказательство свойства кода, который вы пишете, и оно не может быть закодировано в типах, где это подходящее место для него? — Даниэль Спивак

Зависимость одного класса от другого должна зависеть от наименьшего возможного интерфейса — Роберт Мартин

У программистов всегда были долгие дискуссии о том, как сильная система типов может помочь в написании хороших программ. Они часто приводят к сравнению динамически и статически типизированных языков. Некоторые даже считают, что в динамически типизированных языках нет системы типов.

На самом деле, мы должны говорить не о статически и динамически типизированных языках, а о проверке типов во время компиляции и во время выполнения. На самом деле, программисты предпочитают использовать проверку типа во время выполнения, чтобы называть свой язык «динамически типизированным», потому что «динамический» считается качеством, а «статический», как правило, нет. Адепты проверки типов во время компиляции предпочитают говорить о слабо типизированных по сравнению со строго типизированными языками, поскольку очевидно, что лучше быть сильным, чем слабым.

В конце концов, часто остается один вопрос: куда вписываются тесты?

Когда проверять типы

Проверка типов только во время выполнения дает вам больше свободы. Тем не менее, это в основном свобода возиться с типами. Если вы сделаете ошибку типа, компилятор не скажет вам, и ошибка появится только во время выполнения … или нет. Есть две основные причины, по которым ошибки не отображаются:

  • Языки с проверкой типов во время выполнения обычно предлагают некоторые неявные функции преобразования типов. Если вы не выбрали правильный тип, язык попытается преобразовать ваш тип в подходящий. Однако у вас нет оснований полагать, что это будет правильно, если вы не сделали это нарочно.

  • Если он не может преобразовать ваш тип в любой подходящий, он выдаст ошибку. Последствия этой ошибки могут быть не очень известны. Если вам повезет, программа вылетит с сообщением, сообщившим, в чем заключалась ошибка. Если это не так, это может привести к сбою потока и запуску приложения в неопределенном состоянии. Кроме того, если ошибки не возникает, вы можете подумать, что программа правильная. Может быть ошибка в еще не выполненной части программы. И эта ошибка может ожить еще долго после запуска программы.

По этим причинам статически проверенная система типов поможет лучше написать правильные программы. Но возникает другой вопрос: насколько сильной должна быть система типов? Какой уровень ограничений он должен накладывать на ваше программирование?

Видеть проверку типов как ограничение — ошибка. Сильный тип проверки в основном помогает.

Однако система типов Java слабая. Что это обозначает? Это означает, что некоторые части «поведения» программы абстрагированы в типах. Но вы можете создавать свои собственные типы или использовать библиотеки, предоставляющие новые типы.

Уточнение поведения с помощью тестов.

Могут ли типы указывать «поведение»?

Это интересный вопрос, но он поднимает новые вопросы: что такое поведение? И являются ли программы средством определения поведения компьютера?

Ответ «да» на последний вопрос не дает никакой информации о том, какие программы вообще, а только о типе программирования, которое вы делаете. Написание программ, определяющих поведение, называется «императивным программированием». Написание программ, которые не являются чем-то другим.

Функциональное программирование является «не обязательным», что означает, что вы не пишете программу, определяющую поведение компьютера. Вы пишете алгебраическое выражение, которое при оценке дает ожидаемый результат. Но вы не описываете, как программа будет себя вести для этого. Это не должно удивлять даже для императивных программистов, потому что они используют это каждый день. Рассмотрим следующий пример:

void displayMessage(String message) {} System.out.println(message); } 

Когда вы пишете это, вы указываете поведение? Вы, конечно, не указываете, как должен вести себя компьютер, чтобы сообщение выводилось на консоль. Вы просто просите эффект, который будет применен к значению. Вы даже не выбираете это значение. Определение поведения описывает способ, которым вы получите результат, и это делается путем составления инструкций с использованием управляющих структур. Но функциональное программирование не использует ни структуры управления, ни инструкции.

Таким образом, на вопрос «Могут ли типы определять поведение», для функционального программиста ответ будет «нет», поскольку поведение не нужно указывать. С другой стороны, для императивного программиста, использующего объектно-ориентированный язык, ответ, очевидно, да, поскольку это то, чем являются объекты (среди прочего): поведение, инкапсулированное в типах. Объекты не просто типы. Но они типы. В Java String , Integer , List являются типами. И эти типы определяют поведение. Не все поведение программы, а ее часть.

Уменьшает ли проверка типов необходимость в тестах?

Давайте рассмотрим пример: скажем, вы хотите объединить два списка строк. Сначала вам нужно проверить аргументы для null а затем каким-то образом собрать списки:

 List<String> concat(List<String> list1, List<String> list2) { if (list1 == null) { return list2; } else if (list2 == null) { return list1; } else { for (int i = 0; i < list2.size(); i++) { list1.add(list2.get(i)); } return list1; } } 

Это то, что можно было бы сделать со старыми языками. Современные языки принесли новые абстракции, позволяющие упростить программу такого рода. Используя цикл «для каждого», мы могли бы написать:

 List<String> concat(List<String> list1, List<String> list2) { if (list1 == null) { return list2; } else if (list2 == null) { return list1; } else { for (String s : list2) { list1.add(s); } return list1; } } 

Java даже предлагает нам лучшую абстракцию:

 List<String> concat(List<String> list1, List<String> list2) { if (list1 == null) { return list2; } else if (list2 == null) { return list1; } else { list1.addAll(list2); return list1; } } 

Или, используя Java 8:

 List<String> concat(List<String> list1, List<String> list2) { if (list1 == null) { return list2; } else if (list2 == null) { return list1; } else { list2.forEach(list1::add); return list1; } } 

Все эти абстракции хороши (особенно addAll ), но они ничего не меняют до уровня тестирования, который нам нужен. Фактически, поскольку мы реализуем null проверку, мы должны проверить, эффективна ли наша реализация null проверки. Таким образом, мы должны проверить нашу программу с каждым аргументом, null (а также с обоими аргументами, null null , конечно).

Можем ли мы использовать систему типов, которая делает ненужными проверки на null ? Да мы можем. Мы могли бы использовать ненулевые типы, такие как предложения Kotlin. В Kotlin тип List<String> не может применяться к null . List<String> является подтипом List<String>? , который обнуляется. Предлагая ненулевую версию, она освобождает нас от проверки на нулевые значения и, следовательно, освобождает нас от проверки этих проверок.

Такой тип с ограничением (не равным нулю) является простейшей формой зависимых типов . Зависимые типы — это типы, которые зависят от значений. Например, для зависимых типов список из 5 элементов типа T может быть другого типа, чем список из 6 элементов типа T. В Википедии есть хорошая статья о зависимых типах .

В других языках необнуляемый List может быть представлен параметризованным типом, таким как Optional<List<String>> . Наиболее заметное различие между этими двумя подходами заключается в том, что Optional<List<String>> не является родительским типом List<String> . Но у них есть несколько сходств, среди которых тот факт, что в отличие от null , они могут представлять отсутствие данных без потери типа. И самое главное, они сочиняют.

Подумайте еще раз о контракте: не должен ли факт, что ваш метод получает аргумент типа List<String> означать, что вы можете безопасно вызывать size() для него (или любого другого метода List )? Если вы не можете доверять типу, вы не можете сделать ничего хорошего без огромного количества тестов. Очевидно, что использование системы типов, которая решает эту проблему, делает ненужным большое количество тестов.

Тестирование против проверки типа

Но у нашей программы есть другая проблема. Вы, вероятно, уже заметили, что программа имела недостатки. В таком случае вы можете подумать, что вам не нужна проверка типов, чтобы сообщить вам. Но почему тогда вы хотите, чтобы тесты сообщали вам, что ваша программа неверна?

В любом случае, давайте сделаем это. Возможно, мы захотим протестировать программу с помощью нескольких модульных тестов, таких как:

 @Test public void testAll() { List<String> list1 = new ArrayList<>(Arrays.asList("1", "2", "3")); List<String> list2 = new ArrayList<>(Arrays.asList("4", "5", "6")); List<String> list3 = new ArrayList<>(Arrays.asList("7", "8", "9")); List<String> list4 = new ArrayList<>(Arrays.asList("1", "2", "3", "4", "5", "6")); assertEquals(list4, concat(list1, list2)); assertEquals(list1, concat(list1, null)); assertEquals(list2, concat(null, list2)); assertNull(concat(null, null)); assertEquals( concat(list1, concat(list2, list3)), concat(concat(list1, list2), list3)); } 

Конечно, тесты должны быть выполнены индивидуально, чтобы неудачный тест не останавливал выполнение следующих, а также чтобы убедиться, что они не мешают, но это просто для экономии места. Он работает так же, и все эти тесты проходят. Хорошо? Не так!

Зачем нужно тестирование, если тесты не могут найти ошибку? Конечно, я знаю, что я сделал неправильно в тестируемой программе, поскольку сделал это специально. Но что я сделал не так в тестах?

Это показывает некоторые очень важные факты о тестировании:

  • Тестирование не подтверждает правильность программы. Это даже не доказывает, что это иногда правильно (для аргументов, используемых в тестах). Это только доказывает, что мы не были достаточно умны, чтобы доказать, что это неверно. Здесь нам понадобится мера правильности тестов. Может быть, тесты для тестирования?

  • Чтобы тестирование было эффективным, мы должны больше разбираться в написании тестов, чем в написании программы. В противном случае программа будет чаще всего проходить тесты. Но в целом мы вкладываем в программу как можно больше информации. Так что не так много осталось для испытаний.

  • Верить в то, что большее количество тестов сделает программу лучше, это все равно, что полагать, что взвешивание себя чаще заставит вас похудеть. (Хотя, если шкала находится на десятом этаже, а вы живете на первом, в конечном итоге это может быть не так уж и неправильно. Но это будет только побочным эффектом, поэтому он не будет работать для функциональных программистов.)

Если вы не заметили, почему программа не так, запустите это:

 @Test public void testAll() { List<String> list1 = new ArrayList<>(Arrays.asList("1", "2", "3")); List<String> list2 = new ArrayList<>(Arrays.asList("4", "5", "6")); List<String> list3 = new ArrayList<>(Arrays.asList("7", "8", "9")); assertEquals(concat(list1, concat(list2, list3)), concat(concat(list1, list2), list3)); System.out.println(concat(list1, concat(list2, list3))); System.out.println(concat(concat(list1, list2), list3)); } 

Тесты проходят, но распечатывается:

 [1, 2, 3, 4, 5, 6, 7, 8, 9, 4, 5, 6, 7, 8, 9, 7, 8, 9, 4, 5, 6, 7, 8, 9, 7, 8, 9] [1, 2, 3, 4, 5, 6, 7, 8, 9, 4, 5, 6, 7, 8, 9, 7, 8, 9, 4, 5, 6, 7, 8, 9, 7, 8, 9, 4, 5, 6, 7, 8, 9, 7, 8, 9, 7, 8, 9] 

Конечно, сейчас это очевидно. Но не лучше ли полагаться на систему типов, чтобы предотвратить это? Вместо проверки того, что аргументы не были видоизменены, мы могли бы иметь:

  • сделал защитную копию

  • используемые неизменяемые типы

  • ограничены типы

Может ли компилятор проверить, что мы сделали защитную копию? Возможно нет. И даже с тестами это можно проверить только частично.

Java предлагает неизменяемые списки, но они громоздки в использовании и не очень эффективны. Плюс они не являются действительно неизменными, но часто довольно неизменными представлениями изменчивых коллекций. И в любом случае, самая большая проблема заключается в том, что Java не различает изменяемые и неизменяемые списки на уровне типов, а только на уровне поведения.

Неизменяемый список Java относится к тому же типу, что и изменяемый. Мы ничего не можем с этим поделать, поэтому мы должны создавать свои собственные типы, что означает выражение этого поведения в системе типов. Хотя это не сложно (а в реальном проекте даже нет необходимости, поскольку мы могли бы использовать существующие реализации, например , PCollections или Javaslang ), существуют другие способы достижения нашей цели.

Поведение List конечно, не ограничивается изменчивостью или неизменностью: многие операции над списками реализуются в терминах управляющих структур, таких как циклы for или while . Некоторые из этих операций были преобразованы в типы, такие как Iterable или Enumeration Мы можем полагаться на такие типы вместо исходного типа List :

 static List<String> concat( Iterable<String> list1, List<String> list2) { if (list1 == null) { return list2; } else if (list2 == null) { // compile error #1 - incompatible types: // Iterable<String> cannot be converted to List<String> return list1; } else { // compile error #2 - invalid method reference // cannot find method add() on Iterable<String> list2.forEach(list1::add); // compile error #3 - incompatible types: // Iterable<String> cannot be converted to List<String> return list1; } } 

Внезапно наша программа больше не компилируется! И это хорошо, потому что у нас есть три ошибки, не только ошибки компиляции, но и концептуальные ошибки! Например, Iterable для чтения, но мы пытаемся добавить элементы.

Здесь мы наблюдаем, что при использовании типа, предоставляющего только минимально необходимый интерфейс, компилятор может помочь нам обнаружить проблему. Поэтому мы изменили наш метод concat на:

 List<String> concat(Iterable<String> list1, List<String> list2) { if (list1 == null) { return list2; } else { ArrayList<String> result = new ArrayList<>(); list1.forEach(result::add); if (list2 == null) { return result; } else { result.addAll(list2); } return result; } } 

И теперь код (несколько более) правильный. Это не значит, что это идеальное решение. Это только означает, что тест не помог нам обнаружить проблему. Система типов сделала.

Сокращение количества возможных реализаций

Мы можем пойти гораздо дальше, но с лучшей системой типов. Обычной практикой является измерение качества тестов путем измерения охвата филиала . Но охват отрасли не значит много. Обычно программисты стараются создать как можно меньше тестов, получая при этом наибольшее покрытие ветвями.

Кроме того, тесты такого рода зависят от реализации, то есть они должны обновляться при изменении реализации. Это громоздко и часто не делается.

Некоторые программисты считают, что тесты не должны зависеть от реализации. Они даже думают, что тесты должны быть написаны до реализации того, что должно быть проверено. Я не мог бы согласиться больше, если бы не небольшое условие: чтобы быть эффективными, эти тесты должны проверять все возможные реализации, так как мы заранее не знаем, какой будет реализация.

Можно даже представить способ проверить надежность набора тестов: программист не должен быть в состоянии написать неправильную реализацию без теста, обнаружившего ее. Написание тестов с учетом этого кажется огромной задачей! (Если вы проводите мутационное тестирование , вы, по крайней мере, знаете, как найти неправильные реализации, которые проходят тесты.)

Хотя не всегда. Еще раз, мы можем сделать так, чтобы система типов помогла нам. В идеале мы хотели бы ограничить типы так, чтобы была только одна возможная реализация. Таким образом, у нас будет только один тест для написания. Является ли это возможным? Ну, иногда это так. Возьмите следующий пример:

 static <A, B, C> Function<A, C> transmogrify(B b, BiFunction<A, B, C> f) { // ... } 

Можете ли вы написать тест для этого метода? Вы можете подумать, что не можете, так как не знаете, что делает метод. Название метода, вероятно, не сильно вам поможет. Так что вы можете посмотреть на реализацию, но, к сожалению, она недоступна. Может быть, это еще не было реализовано, и вы делаете TDD. Что ты можешь сделать?

На самом деле это довольно просто:

 @Test public void testTransmogrify() { Double pound = 0.794546235; BiFunction<Integer, Double, Double> multiply = (a, b) -> a * b; Function<Integer, Double> toPound = transmogrify(pound, multiply); assertEquals(toPound.apply(45), 35.754 , 0.001); } 

Это не магия. На самом деле возможна только одна реализация. Не веришь мне? Читай дальше!

На самом деле это довольно часто встречается в функциональном программировании. Часто у функциональных программистов есть метод для реализации, учитывая его сигнатуру. И часто им просто нужно следовать за типами.

Посмотрим, как это происходит. Для начала мы должны вернуть функцию, чтобы мы могли написать ее в виде лямбды с параметром A a (но нам не нужно указывать тип):

 static <A, B, C> Function<A, C> transmogrify(B b, BiFunction<A, B, C> f) { return a -> } 

Затем мы должны создать C который будет возвращаемым значением функции. Все, что мы имеем в нашем распоряжении, чтобы сделать это, это B и BiFunction , которые создадут C из A и B Не может быть проще! Мы используем A a мы начали лямбда-выражение, и B b данный методу, и применяем BiFunction :

 static <A, B, C> Function<A, C> transmogrify(B b, BiFunction<A, B, C> f) { return a -> f.apply(a, b); } 

Это оно! Теперь вы можете дать более осмысленное имя функции. Он частично применяет второй аргумент BiFunction , создавая Function из одного аргумента. (Точнее, он применяет второй элемент пары, который является уникальным аргументом BiFunction .) Таким образом, мы могли бы назвать его partial2 .

Такая функция полезна, когда вы хотите повторно применить BiFunction с тем же «вторым аргументом». В нашем тесте мы преобразовываем функцию, которая конвертирует валюту в другую по заданной сумме и курсу, в функцию, конвертирующую сумму по определенной ставке. Другими словами, мы создали конвертер долларов в фунты из обычного конвертера валют (поскольку курс конвертации долларов в фунты «встроен» в частично примененную функцию).

Нам действительно нужен тест? С настоящим функциональным языком мы бы не стали. В Java мы можем представлять null значения и исключения с типами, и это именно то, что мы делаем, если хотим функционально программировать на Java.

Увы, мы не всегда можем избежать работы с null и исключениями. Поэтому, если мы не можем увидеть реализацию, нам нужно написать один тест, просто чтобы гарантировать, что метод не вернет null не выдаст исключение. Нам не нужно тестировать больше, чем это — если это работает для одного значения аргумента, это работает для всех. (К сожалению, это не совсем верно, поскольку реализация может проверять некоторые внешние данные и генерировать исключения или возвращать null случайным образом, поэтому это верно только в том случае, если метод является ссылочно прозрачным , что всегда должно быть в функциональном программировании).

В любом случае, если мы написали реализацию, мы увидим, что она не выдает исключение и не возвращает null . Однако этот тест полезен для защиты кода от будущих изменений. Если кто-то изменит реализацию в сторону выдачи исключения или возврата нуля, это, скорее всего, нарушит существующий код, и тест должен показать это как можно раньше. Но учтите, что для устранения обоих условий достаточно одного теста (возвращение пустого значения и исключение).

Определение поведения с типами

Опубликовано Кеном Хокинсом под CC-BY 2.0 / SitePoint изменил фон

Абстрагирование управляющих структур в типах

Тот же принцип применим к более сложным проблемам. Систему типов можно использовать не только для выражения исключений или нулей, но и для любых других условий, таких как кардинальность, «будущее» , необязательность , лень , эффекты, состояние, параллелизм и многое другое. В результате нам не нужна какая-либо структура управления.

Это может показаться невероятным для императивных программистов, но настоящие функциональные языки не имеют управляющих структур. Нет, if ... else , нет циклов for или while , нет switch ... case , нет try ... catch , ничего. Как вы управляете потоком управления тогда? (Готовьтесь, прежде чем читать дальше.) Ответ: вы не делаете! Там нет контроля потока.

Давайте перепишем наш пример concat без какого-либо потока управления. Конечно, Java не предлагает нужные нам типы, поэтому мы собираемся их создать.

Во-первых, давайте посмотрим на алгоритм. У нас есть два списка, и мы хотим объединить их, взяв каждый элемент второго и добавив его к первому. Помните третью цитату в начале статьи?

Зависимость одного класса от другого должна зависеть от наименьшего возможного интерфейса — Роберт Мартин

Система типов будет более полезной, если мы будем использовать минимально необходимую информацию. Первый аргумент метода concat не обязательно должен быть списком. Поскольку мы хотим добавить элементы только к первому аргументу, он должен быть Appendable . (И поскольку нам не нужно никакого другого поведения, оно не обязательно должно быть более специфического типа.) Вот тип Appendable :

 public interface Appendable<T, U> { Appendable<T, U> append(T t); U get(); } 

T — это тип добавляемых элементов, U — коллекция, в которой они содержатся (например, List<T> ). У него есть метод append который возвращает новый Appendable с добавленным данным элементом (сам тип должен быть неизменным), а другой метод get возвращает текущую коллекцию элементов.

Возвращаясь к аргументам, второй также не должен быть списком — он должен быть итеративным. В функциональном программировании мы используем другую концепцию: Foldable .

Чтобы понять Foldable , подумайте о списке символов: мы можем начать с пустой строки и последовательно добавлять каждый символ в строку. Для этого мы используем функцию, принимающую строку и символ и возвращающую строку. Если мы начнем слева и будем использовать строку в качестве первого аргумента, это будет левый сгиб. Если мы начнем справа, используя элемент в качестве первого аргумента функции, это будет правильное сгибание. Таким образом, мы можем определить тип Foldable :

 public interface Foldable<T> extends RightFoldable<T>, LeftFoldable<T> {} public interface LeftFoldable<T> { <U> U foldLeft(U seed, BiFunction<U, T, U> f); } public interface RightFoldable<T> { <U> U foldRight(U seed, BiFunction<T, U, U> f); } 

Конечно, нам нужен тип List реализующий эти интерфейсы. Функциональный будет слишком длинным для этой статьи, но я создал оболочку вокруг Java List которую вы можете попробовать. (Это, конечно, не рабочий код.) Однако сама реализация не имеет отношения к делу — в реальном проекте она будет предоставляться либо языком, либо библиотекой.

Что важно, так это код, который мы должны написать. Вот как должен выглядеть наш метод concat :

 public static <T> List<T> concat(Appendable<T, List<T>> a, LeftFoldable<T> b) { return b.foldLeft(a, Appendable::append).get(); } 

Как бы мы протестировали этот метод? Какое «поведение» достойно испытания? Метод может вернуть null или выдать исключение. Он также может добавлять любое количество копий элементов своего второго аргумента к своему первому аргументу.

Это, кажется, учитывает бесконечное количество реализаций. На самом деле это так, но все неправильные реализации можно устранить одним тестом. Это может быть проверкой результата объединения двух произвольных списков (хотя второй не должен быть пустым), но нет необходимости проверять ожидаемый результат. Достаточно проверить, что длина результата равна сумме длин аргументов.

Все остальные свойства нашего решения выражены в виде типов!

Резюме

В этой статье мы увидели, почему тестирование является очень слабым (но необходимым) инструментом для проверки правильности обязательной программы. Затем мы показали, как функциональное программирование с сильной системой типов значительно снижает потребность в тестировании. В идеальном мире функционального программирования тесты вообще не нужны. Но нет ничего идеального, поэтому нам нужны тесты. Тем не менее, гораздо меньше, чем те, к которым привыкли программисты.

Кстати, функциональное программирование, по тем же причинам, также снижает необходимость ведения журнала и отладки.