Я говорю прежде всего о тех, кто, по выражению Кевина Бурильона, "лишен гена эмпатии". Нельзя создавать хорошие API или языки, не представив себя в шкуре рядового программиста, который пользуется ими. Однако есть люди, создающие хорошие API и языки. И есть знатоки технической стороны проектирования языка, которые говорят: "Это сделает все несовместимым с LALR(l), надо сделать по-другому". Это крайне полезное знание. Но оно не заменяет гена эмпатии - такой знаток может создать кошмарный язык, не пригодный для использования.
Есть и другие - способные выжать из языка все, что возможно, ради большей эффективности. Надо найти им нужное применение - они будут счастливы и принесут пользу вашей компании. Вообще, необходимо знать сильные места ваших разработчиков и пользоваться этим. Это я так оправдываюсь за свое плохое знание инструментов. Слабое оправдание, понятно.
Сейбел: Поговорим об отладке. Можете ли вы назвать худшую ошибку из тех, что вам встречались?
Блох: Мне сразу приходит в голову один кошмарный и в то же время любопытный случай. Это было в начале 1990-х, я тогда работал в питт-сбургской компании Transarc. Мне пришлось заниматься реализацией транзакционной разделяемой памяти при очень плотном графике. Проектирование и реализацию я закончил в срок и даже успел написать несколько библиотечных компонентов. Но я нервничал из-за того, что произвел много нового кода в спешке.
Для тестирования кода я написал чудовищного "убийцу". Он запускал множество транзакций, каждая из которых содержала рекурсивно вложенные транзакции - вплоть до определенной глубины вложения. Каждая из вложенных транзакций могла блокировать и читать некоторые элементы разделяемого массива в восходящем порядке и что-то прибавлять к каждому из них, сохраняя инвариант, так что сумма всех элементов массива равнялась нулю. Каждая субтранзакция либо фиксировалась, либо прерывалась - соотношение случаев было 90:10, как-то так. Множество потоков запускали эти транзакции параллельно и воздействовали на массив в течение долгого времени. Поскольку я тестировал разделяемую память, то запускал несколько многопоточных "убийц", каждый в своем собственном процессе.
При разумном уровне многопоточности "убийца" работал вполне надежно. Но когда этот уровень повысился, я обнаружил, что иногда - именно иногда - "убийца" не проходил проверку внутренней целостности. Я не понимал, что делается, и, естественно, думал, что это моя ошибка - ведь я написал столько нового кода.
С неделю я потратил на модульные тесты для каждого компонента - все было в порядке. Потом я написал программу проверки целостности для каждой внутренней структуры данных и мог делать проверку после каждого изменения - пока не случалось, что элемент не проходил проверку. Наконец я уловил непрохождение проверки на низком уровне - такое было не каждый раз, но теперь я мог проанализировать происходящее. И пришел к неизбежному выводу: мои блокировки не работали. У меня были параллельные последовательности операций типа "прочесть-изменить-записать", так что две транзакции блокировали, читали и записывали одно и то же значение. И последняя запись затирала первую.
Я написал собственный диспетчер блокировок, поэтому стал подозревать его. Но ведь он без проблем прошел модульные тесты! Наконец я определил, что виноват был не он, а реализация мьютексов в нижележащем слое. Тогда операционные системы еще не поддерживали многопоточность, и пакет для ее поддержки нам пришлось писать самим. Вышло так, что разработчик, отвечавший за код мьютексов, случайно перепутал метки подпрограмм "заблокировать" и "попробовать заблокировать" в ассемблерной реализации потоков в Solaris. Так что каждый раз, когда вы думали, что вызываете безусловную блокировку, на самом деле она только пыталась произойти, и наоборот. И когда случался конфликт - в то время редкость, - второй поток оказывался в критической секции, как если бы в первом потоке не было блокировки. Самое забавное, что вся компания на несколько недель оказалась без мьютексов, и никто не заметил.
В своей превосходной статье "Engineering a Sort Function" (Разработка функции Sort) Бентли и Макилрой цитируют чудесное высказывание Кнута насчет приведения себя в самое поганое настроение, на которое только вы способны. Как раз это я и сделал для той серии тестов. Но это сделало ошибку крайне трудно обнаружимой. Прежде всего, из-за многопоточности каждый случай оказывался почти невоспроизводимым. Далее, оказались ложными мои представления не о чем-нибудь, а о ядре системы. Обычно начинающие программисты легко приходят к выводу, что язык или система не в порядке. Но тут базовая конструкция, на которую я опирался, - мьютекс - действительно оказалась сломанной.
Сейбел: Итак, ошибка содержалась не в вашем коде, но вы тем временем написали столь подробные тесты для кода, что ошибку волей-неволей пришлось искать вне его. Как по-вашему, мог ли - или должен ли был - автор мьютексов написать тесты для нахождения этой ошибки, которые избавили бы вас от полутора недель отладки?
Блох: Мне кажется, хорошая автоматическая программа проверки мьютексов спасла бы меня от мучений, но не забудем, что это было в начале 1990-х. Мне и в голову не приходило винить разработчика за то, что он не создал достаточно хороших модульных тестов. Даже сегодня писать модульные тесты для многопоточных программ - подлинное искусство.
Сейбел: Мы говорили о пошаговом прохождении кода. А какими средствами отладки вы пользуетесь сейчас?
Блох: Наверное, я кажусь неандертальцем, но важнейшие инструменты для меня, как и раньше, - мои глаза и мозг. Я распечатываю все необходимые фрагменты кода и очень внимательно их изучаю.
Отладчики - хорошее средство, и порой мне хочется пользоваться оператором print, но вместо этого я прибегаю к точке останова. Время от времени я применяю отладчики, но и без них чувствую себя вполне уверенно. Имея возможность использовать операторы print и внимательно читать код, я вполне могу находить ошибки.
Я уже говорил, что пользуюсь операторами утверждения для проверки сохранности сложных инвариантов. Если инварианты ломаются, я хочу знать, когда это случилось, какие действия привели к этому.
Кстати, я вспомнил еще одну труднонаходимую ошибку. Правда, не могу сказать точно, было это в Transarc или на последнем курсе Университета Карнеги-Меллона, когда я работал над системой распределенных транзакций Camelot. He я нашел эту ошибку, но сам случай меня глубоко поразил.
У нас был трассировочный пакет, позволявший коду выводить отладочную информацию. Каждое отслеженное событие снабжалось меткой с указанием идентификатора потока, где оно произошло. Иногда идентификаторы оказывались неверными, и мы не понимали, почему. Наконец, мы решили, что с этой ошибкой можно еще пожить сколько-то времени, - она казалась безобидной.
Но выяснилось, что ошибка не в трассировочном пакете - все было гораздо серьезнее. Чтобы найти идентификатор потока, трассировочный пакет вызывал код из потоковой библиотеки. А тот делал штуку, очень в то время распространенную: смотрел старшие биты адреса стековой переменной. То есть он брал значение указателя стековой переменной и сдвигал его вправо на фиксированное число позиций, получая таким образом идентификатор потока. Дело в том, что у каждого потока был стек определенного размера, который выражался заранее известной степенью двойки.
Выглядит логично, так? Но, к сожалению, те, кто создавал объекты в стеке, делали их слишком большими по тогдашним меркам. Массив из 100 элементов, по 4 Кбайт каждый, - всего 400 Кбайт в стеке одного потока. Получался перескок через красную зону стека в стек соседнего потока. И мы получали неверный идентификатор потока. Хуже того: когда поток обращался к локальным для потока переменным, он считывал переменные другого потока, поскольку его идентификатор использовался как ключ для доступа к этим переменным.
Итак, то, что мы приняли за безобидный недочет трассировочного пакета, оказалось признаком действительно серьезной ошибки. Событие приписывалось потоку 43 вместо потока 42, так как один поток невольно подменял собой другой, и это могло иметь катастрофические последствия.
Вот почему нам нужны языки с хорошими параметрами безопасности. Лучше обойтись без таких случаев. Недавно у меня был разговор в одном университете: там хотели обучать программистов сначала языкам Си и C++, а потом Java, так как они хотели, чтобы программисты овладели системой "на всю глубину". Меня спросили, что я думаю об этом.
Думаю, посыл здесь правильный, но выводы ошибочные. Да, студентам нужно изучать низкоуровневые языки, и даже язык ассемблера, и даже устройство чипов. Правда, чипы сейчас превратились в невероятно сложных чудовищ и теряют в производительности именно из-за своей сложности. Но знание того, что происходит на низших уровнях системы, сильно облегчает высокоуровневое программирование.
И я считаю, что все это важно изучать. Но это не значит, что надо начинать с такого низкоуровневого языка, как Си! Зачем студентам, только-только приступающим к программированию, сталкиваться с переполнением буфера, ручным выделением памяти и тому подобным?
Мы с Джеймсом Гослингом однажды обсуждали появление Java, и он сказал: "Время от времени нужно нажимать кнопку перезагрузки. Это едва ли не самое прекрасное, что может случиться". Обычно вам приходится поддерживать совместимость со старыми программами, но иногда - нет, и это здорово. Но к сожалению, как это случилось с Java, проходит десятилетие - и ваша система сама становится проблемой для других.
Сейбел: Значит ли это, что язык Java уже немного устарел и что он быстро усложняется, но при этом совершенствуется куда медленнее?
Блох: Очень непростой вопрос. Например, Java 5 вышел намного более сложным, чем мы хотели. Я даже не представлял, насколько обобщенные типы и особенно символы подстановки усложнят язык. Надо отдать должное Грэму Гамильтону - он понял все это в свое время, а я нет.
Интересно, что он годами боролся за невключение обобщенных типов в язык. Но понятие вариативности - которая и лежит в основе символов подстановки - вошло в моду в то время, когда мы старались не снабжать Java обобщенными типами. Если бы они появились раньше и без всякой вариативности, мы бы теперь имели более простой и легкий в работе язык.
При всем том от символов подстановки есть реальная польза. Есть глубокая несовместимость между методом выделения подтипов и обобщенными типами, и символы подстановки позволяют во многом ее нивелировать. Но это достигается ценой переусложнения. Некоторые считают наилучшим решением вариативность на стороне объявления, в противоположность таковой на стороне использования, но я не сильно уверен.
Нельзя твердо судить о чем-то, если это не было использовано многими программистами в реальной рабочей обстановке. Есть языки, хорошо работающие в своей узкой области, и некоторые говорят о них: "Отличный язык, жаль, что им пользуется так мало народа". Иногда, однако, для этого есть веские причины. Надеюсь, какой-нибудь язык, где используется вариативность при объявлении, к примеру Scala или С# 4.0, ответит на этот вопрос раз и навсегда.
Сейбел: Что же дало импульс к появлению обобщенных типов?
Блох: Как часто бывает с идеями, которые на практике оказываются хуже, чем в теории, мы верили собственным заявлениям для прессы. Я представлял себе это так: почти все коллекции у нас однородны - список строк, хеш строк на целые числа и так далее. Но по умолчанию они создаются разнородными - все это коллекции объектов, которые надо приводить к нужным типам при выборке, - абсурд! Не лучше ли указать системе, что вот это, например, хеш строк на целые числа? Пусть она сделает приведение типов за меня, а во время компиляции укажет мне, если я допущу ошибку. Больше ошибок будет отслежено, система будет иметь больше высокоуровневой информации, а это хорошо.
Обобщенные типы, как и многое из того, что мы добавили в Java 5, казались мне средством автоматизации того, что раньше делалось вручную: пусть этим займется язык! Кое-где я попал в точку: цикл f or-each - отличная штука. Он скрывает от вас сложное устройство итератора или индексных переменных. Код становится короче, но площадь концептуальной поверхности при этом не увеличивается. Даже скорее уменьшается: мы ввели ложный полиморфизм массивов и других коллекций, и можно выполнять итерацию над ArrayList или над массивом, совершенно не интересуясь, над чем именно она выполняется.
Но главная причина того, почему эта идея не сработала для обобщенных типов, - они стали крупным прибавлением к системе типизации, и без того сложной. С системами типизации нужно обращаться осторожно, поскольку это может повлечь далеко идущие и непредсказуемые последствия для языка.
А урок таков: если вы совершенствуете зрелый язык, нужно больше чем когда-либо задумываться над балансом возможностей и сложности. Сложность во многих разделах языка растет квадратично: прибавив всего одно свойство, вы получаете куда более сложную структуру. Если язык близок к тому, чтобы превысить уровень понимания программистов, усложнять дальше просто нельзя - все пойдет прахом.
Если же все-таки усложнять, исчезнет язык или нет? Нет, не исчезнет. Мне кажется, C++ давно превысил этот уровень, а сколько народу им пользуется! Но тем самым вы побуждаете людей разбивать его на части. И почти в каждой известной мне лавочке, где используют C++, говорят: "Да, мы используем C++, но не применяем ни множественное наследование, ни перегрузку операторов". Есть свойства, которые вы не используете, потому что код тогда получается слишком сложным. Думаю, не стоит и пытаться. Каждый программист должен иметь возможность читать код любого из своих коллег, а в нашем случае эту возможность легко утратить.
Сейбел: Не кажется ли вам, что Java без обобщенных типов был бы сегодня лучше?
Блох: Не знаю. Обобщенные типы по-прежнему мне нравятся - они находят за меня ошибки в моем коде. Эти средства помогают найти мне вещи, которые обычно включаются в комментарии, и перенести их в код, где компилятор может обеспечить их корректность. С другой стороны, когда я вижу сообщения об ошибке, связанные с параметризованными типами, а потом нахожу сделанные для этих типов обобщенные объявления, вроде моего Enum - class Enum<E extends Enum<E>>", то понимаю, что обобщенные типы не были достаточно хорошо проработаны, чтобы их включить.
Программист должен или быть оптимистом, или застрелиться. И мы говорим: "Конечно, мы это умеем. Мы знаем все об обобщенных типах еще с тех пор, как познакомились с языком CLU. Это технология 25-летней давности. То же самое сегодня можно слышать про замыкания, правда, о них говорят, что им уже 50 лет. "Это легко и не усложняет язык".
Черт возьми, конечно усложняет! Но, думаю, обобщенные типы послужили для нас хорошим уроком. Нельзя добавлять что-то к языку, пока не поймешь, как поведет себя концептуальная поверхность, пока не будет веских доводов в пользу того, что программисты смогут эффективно пользоваться новым свойством и оно облегчит им жизнь.
Если бы мы знали, как простые люди отреагируют на обобщенные типы, то, конечно, придумали бы что-нибудь другое. Значит ли это, что эти средства вообще не надо было изобретать? Наверное, все-таки не значит. Думаю, они полезны. Главный аргумент в их пользу - раз большинство коллекций однородны, а не разнородны, работать с однородными коллекциями должно быть легче. Кроме того, приведение типов вообще не очень хорошая штука. Оно не всегда срабатывает и не делает вашу программу красивой. Поэтому, полагаю, должна быть возможность задавать тип коллекции, и он должен проверяться автоматически. Но нужны ли для этого страдания из-за переусложненности средств? Нет. Видимо, нам все же стоило сделать их попроще.
Сейбел: Скажите, а пользователи требовали обобщенных типов? Кто-нибудь жаловался, что их отсутствие мешает писать программы?
Блох: Ну, что касается разработчиков, ответ, увы, отрицательный. Пожалуй, виноват здесь я - эта штука казалась мне красивой, и я думал, что стою на правильном пути.
Но при разработке программ мы часто чуем какие-то вещи нутром. Кто-нибудь просил меня о fоreach? Опять же нет. Но я знал, что стою на правильном пути, и это оказалось так - многие пользуются этим. Но большой грех для разработчика - создавать программы, которые просто отлично смотрятся, хорошо сделаны и так далее. Если вы не решаете реальные проблемы реальных пользователей - в нашем случае Java-программистов, - то не надо ничего добавлять.
Есть чудесное выступление Гослинга "The Feel of Java" (Почувствовать Java); в нем он говорит, что нужно трижды ощутить необходимость чего-то, прежде чем внедрять это. Нельзя добавлять программу только из-за ее красоты.
Но люди все равно добавляют. Что делают разработчики? Пишут код. И, работая над библиотекой или языком, они хотят добавить туда что-то свое. Некий внутренний голос должен подсказывать, какое сочетание свойств будет работать хорошо, что нужно добавлять, а чего не нужно. Ведь чаще всего вы можете добавить к языку больше, чем должны. Это означает не то, что ваши программы плохи, а лишь то, что надо правильно выбирать, не валя все в кучу.
Сейбел: Я читал книги "Java Puzzlers" и "Java Concurrency in Practice". Меня удивило, что в языке, который изначально был очень простым, столько секретов.
Блох: Секреты есть, но это неизбежно, они есть во всех языках. Можно было бы написать книгу "Си: головоломки".
Сейбел: Ну, этот язык - сплошная трудность.
Блох: Да, тут понадобилась бы целая книжная полка. В Java такие случаи особенно нужно отмечать, ведь его считают простым языком. В каждом языке свои проблемы, в Java их не так много, и они по большей части довольно забавны и интересны.
Сейбел: Говоря о программировании, есть ли что-то, чему научила вас работа над Java и обдумывание его структуры?
Блох: Очень многому. Об одном я упоминал в своем посте "Nearly All Binary Searches and Mergesorts Are Broken" (Почти все двоичные поиски и сортировки слиянием сломаны): невероятно трудно правильно написать даже небольшую программу. Мы обманываем сами себя, считая, что наши программы более-менее свободны от ошибок. Это не так. Большей частью наши программы не содержат ошибок лишь настолько, чтобы справляться с возложенной на них задачей.
Я усвоил, что учитывая, насколько трудно писать корректные программы, надо принимать помощь, откуда только возможно. Все, что удаляет потенциальные ошибки, - хорошо. Вот почему я убежденный сторонник статической типизации и статического анализа - они позволяют устранить ошибки определенного типа. Все, что облегчает программисту его задачу, - нужно и полезно.
Я укрепился в своем мнении насчет того, что нужна качественная документация API. Javadoc во многом способствовал успеху платформы, хотя не все это замечают. Качественная документация API всегда была частью Java-культуры, как я считаю, потому что Javadoc присутствовал с самого начала.