Люди имеют твердое мнение о том, как разработать хороший API. Следовательно, в сети много страниц и книг, объясняющих, как это сделать. Эта статья будет посвящена конкретному аспекту хороших API: регулярности. Регулярность — это то, что происходит, когда вы следуете « Принципу наименьшего удивления ». Этот принцип действует независимо от того, какой личный вкус и стиль вы хотели бы использовать в своем API, в противном случае. Таким образом, это одна из самых важных функций хорошего API. При разработке «обычного» API необходимо учитывать следующее.
Правило № 1: Установите сильные условия
Если ваш API будет расти, вы будете снова и снова использовать одни и те же термины.
Например, некоторые действия будут иметь несколько разновидностей, что приведет к различным классам / типам / методам, которые отличаются лишь незначительным поведением. Тот факт, что они похожи, должен отражаться в их именах. Имена должны использовать сильные термины. Взять, к примеру, JDBC. Независимо от того, как вы выполняете Statement , вы всегда будете использовать термин execute
для этого. Например, вы будете вызывать любой из этих методов:
Аналогичным образом, вы всегда будете использовать термин « close
ресурсы» независимо от того, какой ресурс вы выпускаете. Например, вы позвоните:
Фактически, close
— это настолько сильный и устоявшийся термин в JDK, что он привел к интерфейсам java.io.Closeable
(начиная с Java 1.5) и java.lang.AutoCloseable
(начиная с Java 1.7), которые обычно заключить договор об освобождении ресурсов.
Нарушение правила: наблюдаемое
Это правило нарушается пару раз в JDK. Например, в классе java.util.Observable
. В то время как другие «коллекционные» типы установили условия
-
size()
-
remove()
-
removeAll()
… Этот класс объявляет
В этом контексте нет веских оснований для использования других терминов. То же самое относится и к Observer.update()
, который действительно должен называться notify()
, иным образом установленный термин в API JDK.
Нарушение правила: весна. Большинство из этого
Весна действительно стала популярной в те дни, когда J2EE был странным, медленным и громоздким. Подумайте об EJB 2.0 … Там могут быть похожие мнения о Spring, которые не по теме для этого поста. Вот как Spring нарушает это конкретное правило. Пара случайных примеров, в которых Spring не может установить строгие термины и вместо этого использует длинные конкатенации бессмысленных, неконкретных слов:
-
AbstractBeanFactoryBasedTargetSourceCreator
-
AbstractInterceptorDrivenBeanDefinitionDecorator
-
AbstractRefreshablePortletApplicationContext
-
AspectJAdviceParameterNameDiscoverer
-
BeanFactoryTransactionAttributeSourceAdvisor
-
ClassPathScanningCandidateComponentProvider
… это может продолжаться бесконечно, мое любимое существо …
- J2eeBasedPreAuthenticatedWebAuthenticationDetailsSource . Обратите внимание, я писал о краткости раньше …
Помимо «ощущения» как ужасного API (для меня), вот еще несколько объективный анализ:
- В чем разница между
Creator
иFactory
- В чем разница между
Source
иProvider
? - В чем разница между
Advisor
иProvider
? - В чем заключается тонкая разница между
Discoverer
иProvider
? -
AspectJAdvice
лиAdvisor
сAspectJAdvice
? - Это
ScanningCandidate
илиCandidateComponent
? - Что такое
TargetSource
? И как бы он отличался отSourceTarget
если бы неSourceSource
или мой любимый:SourceSourceTargetProviderSource
?
Гари Флеминг прокомментировал мой предыдущий пост в блоге о забавных именах Spring:
Я готов поспорить, что имя класса, сгенерированного цепочкой Маркова (на основе Spring Security), будет неотличимо от реального.
Вернуться к большей серьезности …
Правило № 2: применяйте симметрию к комбинациям терминов
Как только вы установили сильные условия, вы начнете их комбинировать. Когда вы посмотрите на API-интерфейсы Collection JDK, вы заметите тот факт, что они симметричны в том смысле, что они установили термины add()
, remove()
, contains()
и all
, прежде чем объединить их симметрично:
-
add(E)
-
addAll(Collection<? extends E>)
-
remove(Object)
-
removeAll(Collection<?>)
-
contains(Object)
-
containsAll(Collection<?>)
Теперь тип Collection
является хорошим примером, где может быть приемлемо исключение из этого правила, когда метод «не тянет свой вес» . Это, вероятно, относится к retainAll(Collection<?>)
, retainAll(Collection<?>)
не имеет эквивалентного метода retain(E)
. Впрочем, это может быть и регулярным нарушением этого правила.
Нарушение правила: карта
Это правило постоянно нарушается, в основном из-за того, что некоторые методы не увеличивают свой вес (что в конечном итоге является вопросом вкуса). С методами защиты Java 8 больше не будет оправданий для того, чтобы не добавлять реализации по умолчанию для полезных служебных методов, которые должны были быть в некоторых типах. Например: Map
. Это нарушает это правило пару раз:
- Он имеет
keySet()
а такжеcontainsKey(Object)
keySet()
containsKey(Object)
- Имеет
values()
а такжеcontainsValue(Object)
- Он имеет
entrySet()
но неcontainsEntry(K, V)
entrySet()
containsEntry(K, V)
Также обратите внимание, что нет смысла использовать термин Set
в именах методов. Подпись метода уже указывает, что результат имеет тип Set
. Было бы более согласованным и симметричным, если бы эти методы были названы keys()
, values()
, entries()
. (В дополнение к этому, Sets
и Lists
— это еще одна тема, о которой я скоро напишу в блоге, так как я думаю, что эти типы также не имеют собственного веса)
В то же время интерфейс Map
нарушает это правило, предоставляя
-
put(K, V)
а такжеputAll(Map)
-
remove(Object)
, но неremoveAll(Collection<?>)
Кроме того, нет необходимости устанавливать термин clear()
вместо повторного использования removeAll()
без аргументов. Это относится ко всем членам Collection API. Фактически метод clear()
также нарушает правило # 1. Это не сразу очевидно, если clear
remove
что-то немного отличное от remove
при удалении элементов коллекции.
Правило № 3: добавьте удобства за счет перегрузки
В большинстве случаев есть только одна веская причина, по которой вы хотите перегружать метод: удобство. Часто вы хотите делать одно и то же в разных контекстах, но создание этого очень специфического типа аргумента метода обременительно. Поэтому для удобства вы предлагаете своим пользователям API другой вариант того же метода с установленным «дружественным» типом аргумента. Это можно наблюдать снова в типе Collection
. У нас есть:
-
toArray()
, который является удобной перегрузкой… -
toArray(T[])
Другой пример — служебный класс Arrays
. У нас есть:
-
copyOf(T[], int)
, который является несовместимой перегрузкой… -
copyOf(boolean[], int)
и of… -
copyOf(int[], int)
- … и все остальные
Перегрузка в основном используется по двум причинам:
- Предоставление поведения аргумента «по умолчанию», как в
Collection.toArray()
- Поддержка нескольких несовместимых, но «похожих» наборов аргументов, как в
Arrays.copyOf()
Другие языки включили эти понятия в свой языковой синтаксис. Многие языки (например, PL / SQL) формально поддерживают именованные аргументы по умолчанию. Некоторые языки (например, JavaScript) даже не заботятся о том, сколько на самом деле аргументов. И еще один новый язык JVM под названием Ceylon избавился от перегрузки , объединив поддержку именованных аргументов по умолчанию с типами объединения. Так как Ceylon является языком статической типизации, это, вероятно, самый мощный подход для повышения удобства вашего API.
Нарушение правила: TreeSet
Трудно найти хороший пример случая, когда это правило нарушается в JDK. Но есть одно: TreeSet
и TreeMap
. Их конструкторы перегружены несколько раз. Давайте посмотрим на эти два конструктора:
Последний «ловко» добавляет некоторое удобство к первому в том, что он извлекает хорошо известный Comparator
из аргумента SortedSet
для сохранения порядка. Это поведение сильно отличается от совместимого (!) Первого конструктора, который не выполняет проверку instanceof
коллекции аргументов. Т.е. эти два вызова конструктора приводят к разному поведению:
1
2
3
4
5
6
7
|
SortedSet<Object> original = // [...] // Preserves ordering: new TreeSet<Object>(original); // Resets ordering: new TreeSet<Object>((Collection<Object>) original); |
Эти конструкторы нарушают правило в том, что они производят совершенно другое поведение. Они не просто удобство.
Правило № 4: последовательный порядок аргументов
Убедитесь, что вы последовательно упорядочиваете аргументы своих методов. Это очевидная вещь для перегруженных методов, так как вы можете сразу увидеть, как лучше всегда ставить массив первым, а потом int в предыдущем примере из служебного класса Arrays
:
-
copyOf(T[], int)
, который является несовместимой перегрузкой… -
copyOf(boolean[], int)
-
copyOf(int[], int)
- … и все остальные
Но вы быстро заметите, что все методы в этом классе будут использовать массив в первую очередь. Несколько примеров:
-
binarySearch(Object[], Object)
-
copyOfRange(T[], int, int)
-
fill(Object[], Object)
-
sort(T[], Comparator<? super T>)
Нарушение правила: массивы
Этот же класс также «тонко» нарушает это правило, поскольку он помещает необязательные аргументы между другими аргументами при перегрузке методов. Например, он объявляет
Когда последний должен был быть fill(Object[], Object, int, int)
. Это «тонкое» нарушение правила, так как вы также можете утверждать, что те методы в Arrays
которые ограничивают массив аргументов диапазоном, всегда помещают массив и аргумент диапазона вместе. Таким образом, метод fill()
снова будет следовать правилу, так как он обеспечивает тот же порядок аргументов, что и copyOfRange()
, например:
Вы никогда не сможете избежать этой проблемы, если вы сильно перегружаете свой API. К сожалению, Java не поддерживает именованные параметры, что помогает формально различать аргументы в большом списке аргументов, поскольку иногда нельзя избежать больших списков аргументов.
Нарушение правила: строка
Другой случай нарушения правил — это класс String
:
Проблемы здесь:
- Трудно сразу понять разницу между этими двумя методами, так как необязательный
boolean
аргумент вставляется в начало списка аргументов. - Трудно сразу понять назначение каждого аргумента int, так как в одном методе много аргументов
Правило № 5: Установите типы возвращаемых значений
Это может быть немного спорным, так как люди могут иметь разные взгляды на эту тему. Однако, независимо от вашего мнения, вы должны создавать последовательный, регулярный API, когда дело доходит до определения типов возвращаемых значений. Пример набора правил (с которым вы можете не согласиться):
- Методы, возвращающие один объект, должны возвращать
null
если объект не был найден - Методы, возвращающие несколько объектов, должны возвращать пустой
List
,Set
,Map
, массив и т. Д., Если объект не был найден (никогда неnull
) - Методы должны генерировать исключения только в случае… ну, исключения
При таком наборе правил не рекомендуется использовать 1-2 метода, которые:
- …
ObjectNotFoundExceptions
когда объект не найден - … Вернуть пустое значение вместо пустых
Lists
Нарушение правила: Файл
Файл является примером класса JDK, который нарушает многие правила. Среди них правило регулярных типов возврата. Его Javadoc File.list()
читает:
Массив строк с именами файлов и каталогов в каталоге, обозначенном этим абстрактным путем. Массив будет пустым, если каталог пуст. Возвращает ноль, если это абстрактное имя пути не обозначает каталог или если произошла ошибка ввода-вывода.
Итак, правильный способ перебора имен файлов (если вы занимаетесь защитным программированием):
1
2
3
4
5
6
7
8
|
String[] files = file.list(); // You should never forget this null check! if (files != null ) { for (String file : files) { // Do things with your file } } |
Конечно, мы могли бы поспорить, что экспертная группа по Java 5 могла бы хорошо с нами поработать, чтобы проверить их реализацию цикла foreach. Аналогично отсутствующей проверке нуля при переключении перечисления (что должно привести к значению по default:
case). В этом случае они, вероятно, предпочли подход «провалиться раньше».
Дело в том, что File
уже имеет достаточные средства проверки, действительно ли file
является каталогом ( File.isDirectory()
). И это должно IOException
если что-то пошло не так, вместо того, чтобы возвращать null
. Это очень сильное нарушение этого правила, вызывающее много боли на сайте вызова… Следовательно:
НИКОГДА не возвращайте ноль при возврате массивов или коллекций!
Нарушение правила: JPA
Примером того, как JPA нарушает это правило, является способ извлечения сущностей из EntityManager
или из Query
:
- Методы
EntityManager.find()
возвращаютnull
если ни одна сущность не может быть найдена -
Query.getSingleResult()
NoResultException
если ни одна сущность не может быть найдена
Поскольку NoResultException
является RuntimeException
этот недостаток сильно нарушает Принцип наименьшего удивления , так как вы можете не знать об этой разнице до времени выполнения!
Если вы настаиваете на исключении NoResultExceptions, сделайте их проверенными исключениями, поскольку клиентский код ДОЛЖЕН их обработать
Заключение и дальнейшее чтение
… вернее, дальнейшее наблюдение. Взгляните на презентацию Джоша Блоха по дизайну API. Он согласен с большинством моих требований, около 0:30:30.
Другой полезный пример такой веб-страницы — «Контрольный список разработки Java API» от The Amiable API : http://theamiableapi.com/2012/01/16/java-api-design-checklist/