28/04/2020

Web (3): Daft Jong — простая игра на JS/WebGL

В этой заметке я представляю простейшую версию (обкаточную реализацию) игры типа «пасьянс», а именно — трёхмерную разновидность известного MahJong'а (его пасьянсной вариации). Хотя, скорее, у меня смесь головоломки и пасьянса.

Технологии надо описывать, а в игру — играть. Поиграть вы можете, пройдя по ссылке внизу. А здесь я вкратце опишу какие технологии я применял и какие решения принимал. Игра написана на WebGL, с использованием Three.js. Цель игры — разобрать куб, попарно убирая одинаковые фишки (кубики).

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

Отрисовка фишек
В этой игре 35, или уже более, разных «правил». Узоры на фишках я решил отрисовать алгоритмом. Во-первых, потому что рисование такого количества картинок руками у меня заняло бы больше времени (а это не входило в мои планы), а написав алгоритмы отрисовки нескольких примитивных элементов, основанных на линиях и кругах, я смог их комбинировать и получить большое количество «правил». И, на данный момент, ещё не все комбинации употреблены. Во-вторых, генерация узоров на стороне клиента даёт ещё три бонуса. Первый серьёзный — возможность изменения картинок на лету. Пока реализовано только изменение цветов. Но можно реализовать случайную генерацию узоров, что не позволит игрокам «привыкнуть» к «правилам». Второй бонус заключается в возможности контролировать качество графики на стороне клиента, играя размером текстуры и соотношением логического пикселя экрана к аппаратному — DPR. Эту возможность, я может быть использую в реализации алгоритма адаптивного подбора нагрузки на CPU/GPU. Третий бонус, по началу не столь важный — трафик. Но если «правил» — разных цветов и комбинаций, будет больше, то исключение ожидания загрузки картинок с сервера будет более желательно, а рисовать руками придётся ещё больше и скучнее.

Выбор объектов
Так как в игре нужно разбирать головоломку, выбирая попарно фишки, технически нам нужен механизм выбора объектов на экране. Два подхода к решению этой задачи я описывал в статье Web (1): RayCasting vs GPU Color Picking (на примере Three.js). Для этого приложения я выбрал метод трассировки лучом (RayCasting) потому что объектов на сцене немного, а прозрачность текстур не используется.

NB
У меня игра лучше всего работает на телефонах — хоть и греет устройство и батарею расходует, но играется идеально гладко. На всех моих компьютерах — Linux Netbook, MacBook и даже большой Linux на Core i7 c nVidia GPU, работает гораздо хуже — компьютеры шумят, греются, а игра сильно тормозит. А в Safari на MacOS вообще едва дышит, так как аппаратная OpenGL не используется, якобы для экономии батареи.

P.S.
В игре так же реализована несложная система авторизации, её я здесь не описываю. Про это будет отдельная статья.

Ссылка на игру: Daft Jong

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


07/04/2020

Размышления (1): о C, C++, связи между ними, почему ООП переоценен и последствия этого

Если вы являетесь фанатом какого-либо из указанных в заголовке языков, лучше пропустите эту статью, потому что здесь я буду критически и аргументировано высказываться (в основном в сторону ООП, C++ и Страуструпа).

Для начала предлагаю рассмотреть, чем отличаются подходы процедурного программирования и объектно-ориентированного. В парадигме процедурного программирования на первое место ставится функционал, а в объектно-ориентированном — информация, свойства описываемого объекта из предметной области. Информация (описания объектов, параметры, переменные) в процедурном языке вторичны и описываются отдельно от функций (как правило, в виде т.н. «структур») и передаются в функции параметрами. В объектах, наоборот — информация (свойства) объекта объединяются в описании класса, а его функционал описывается и реализуется внутри класса. Поэтому в процедурных языках основной единицей является модуль с реализованным функционалом, который, в свою очередь, состоит из процедур. В объектно-ориентированных языках основной единицей является класс, который состоит из его свойств и, включённых в него функций. То есть, в процедурном языке функция работает с переданными ей данными, в объектно-ориентированном — класс содержит в себе все свои свойства и встроенные функции, постоянно имеющие доступ к этим свойствам.
На самом деле всё ООП построено на процедурах, которым, при вызове, неявно передаётся ссылка на структуру класса. Иной реализации быть не может, поэтому объектно-ориентированные языки являются лишь «парадигмой», идеей или подходом, а не технологией (в смысле системном).

Теперь рассмотрим области применения обоих подходов. Изначально разработка всего ПО была системного уровня — управление устройством приёма перфокарт, жёстким диском, простейшим принтером, примитивные базы данных, несложные файловые системы. Всё это писалось на ассемблере — императивном языке. Поэтому всё началось именно с парадигмы программирования, максимально приближённого к технике. Затем языки программирования развились до таких как C, Pascal, BASIC — процедурные языки. Количество обрабатываемых одной программой объектов схожих типов возрастало. Появлялись стандарты интерфейсов — для жёстких дисков, принтеров, дисководов. Да и самих устройств одного типа к компьютеру стали подключать больше. (Обратите внимание на то, что в описании выше я умышленно перечислил все объекты, с которыми работали разработчики в единственном числе, а здесь — во множественном). Появилась многозадачность, и что не маловажно — начали появляться графические интерфейсы — сущности, состоящие из множества объектов с повторяющимися характеристиками (свойствами) со схожим поведением и с некоторыми разнящимися свойствами (например, название и размеры кнопки или окна).
На самом деле интерфейсы были не только графическими, например, был Borland TurboVision — библиотека для создания программ с пользовательским интерфейсом на псевдографике (TUI — Text User Interface). Реализована эта библиотека была на Borland Pascal (Pascal c ООП) и Borland C++.

С конца семидесятых и все восьмидесятые года Бьёрн Страуструп трудился над своим детищем — языком ООП (в том числе и) высокого уровня, в последствии получившим название C++. Долгое время язык назывался «C with Classes» («C с классами») и автор не предполагал выпускать его как публичный продукт. Когда язык C++ начал окончательно формироваться, Страуструп принял решение не отходить от Plain C, и построил C++ на основе последней.
Некоторое время меня раздражало, что человек сделал язык, значительно отличающийся от одного из уважаемых мной, схожим с ним и даже назвал его похоже («++» означает «шаг вперёд по сравнению с C»). Что, на мой взгляд вносило путаницу (которая, кстати, никуда и не делась). Я даже думал (по аналогии с тем, как, по одной из версий, сделали с JavaScript — использовали Java в названии, чтобы воспользоваться славой молодой и быстро набирающей тогда популярность Java), что Страуструп решил воспользоваться хорошим и сильным имиджем Plain C для того чтобы популяризировать свой язык. Ведь C++ — не первый, и на тот момент, не единственный ООП-язык.

Но теперь, рассмотрев историю, я понял (или выдвинул свою теорию), что C++ стал представляться надстройкой над Plain С, вместо того чтобы выбрать другое название и синтаксис, не для использования славы последней. C++ сохранил совместимость с Plain С для того чтобы хорошо работать с ней в паре. И дело не только в том, что мы можем линковать объектные файлы с обоих языков и легко импортировать функции и даже классы (это можно делать с любыми языками, из которых можно получить бинарно-совместимые объектные файлы). Дело в том, что мы можем использовать исходный код на Plain C в проектах на C++. То есть Plain C и C++ считаются родственниками (во всяком случае в POSIX) вполне оправдано. Огромная кодовая база, наработанная всем сообществом POSIX-разработчиков, после выхода C++, при необходимости (при пересмотре подхода от процедурного к ООП) могла быть использована. Я понял, что это была не попытка использовать славу Plain C, а обеспечение возможности сохранить огромное количество наработок. Это следствие того, что управление жёстким диском и принтером, перешло в управление жёсткими дисками и принтерами, то есть логичное отражение ситуации в мире компьютерной техники на средства разработки.

Выше я писал о путанице, которая «никуда не делась». Путаница заключается в том, что многие рассматривают C++ не как ООП-надстройку над Plain C, а как «улучшенную» Plain C. Автоматически подразумевая, что Plain C не полноценна и рассматривая её, как «язык предыдущей версии». Отсюда происходят много попыток впихивать C++ везде, где только получается. А получается далеко не всегда хорошо (см. ниже). Это имеет последствия и на рынке труда — часто разработчикам на C++ предлагают ощутимо большие зарплаты, чем на Plain-C-вакансиях. А это, в свою очередь, приводит к тому, что новички (студенты и все, кто хотят начать карьеру разработчика) выбирают для изучения C++ (и ООП, соответственно). Становится больше C++-разработчиков, что приводит к ещё большему увеличению соответствующих вакансий. Так этот порочный круг замыкается и превращается в «эпидемию ООП». Отчасти, более высока ставка для ООП-разработчиков оправдана тем, что ООП-языки изначально несколько сложнее изучить, особенно, если к C++ добавить, часто требуемые (и, кстати, так же часто применяемые без надобности) STL, boost. В последние годы к требованиям на C++ ещё добавляется Qt. А Plain C, вроде кажется проще, даже книга от её авторов такая маленькая — за что здесь платить? Разработчику на Plain C нужно платить не за знание «высоких материй» (за которые платят ООП-разработчикам), а за знание и навыки работы с системами — железо (процессоры, контроллеры, шины передачи данных, стандарты, аппаратные протоколы, сети), инструменты (анализаторы, осциллограф, генератор, тестер, иногда даже паяльник), системные API (POSIX, kernel space) и, что самое важное, за понимание того, как всё это связано. Но, как правило, даже когда в компании ищут системного разработчика с пониманием того, что он должен знать, это часто не учитывается при расчёте оклада.

Эпидемия ООП или примеры абсурда.
Разработчикам аппаратуры на SoC нужно подключить к железке экран. В беседе команда, состоящая из ООП-разработчиков, мыслит следующим образом: «Экран это тип устройства вывода, а устройство вывода это класс устройства. Сделаем класс экран, унаследованный от класса устройство вывода, который в свою очередь унаследован от класса устройство». Сделали один класс, от него унаследовали другой и так ещё несколько раз, а потом реализовали Singleton последнего (в реальной жизни экран, в виде семисегментного индикатора, на передней панели маршрутизатора подключен один). Все вышестоящие классы оказались невостребованными.
Ещё один пример. В Java (считается самым чистым ООП-языком на данный момент) всё является классом. Для того, чтобы впихнуть ООП-парадигму в реальную жизнь, пришлось придумать статическую функцию main () в открытом (public) главном классе, имя которого должно совпадать с именем файла. Статическая функция в Java — это функция, которую можно вызывать без создания экземпляра класса. Мне нравится Java, но костыль, который её разработчикам пришлось применить, говорит о том, насколько переоценен ООП и насколько он далёк от вычислительной техники.

На системном уровне и на железе (на встраиваемой технике) важно не отвлекаться на ООП-парадигму и не «витать в облаках», а работать как можно ближе к системе или к железу. А ООП-языки (даже C++) сильно «оборачивают» POSIX и железо. Знание POSIX в современном мире подобно воздуху — если ты понял POSIX, ты понял жизнь (во всяком случае её ВТ-составляющую). Пониманию и изучению POSIX'а (и вычислительной техники как таковой) помогает Plain C, Assembler, так как они «прозрачны» для вычислительной техники. В этом и есть реальная проблема, вытекающая из «путаницы», что я описываю. Переоценка ООП-подхода приводит к тому, что люди учатся ИТ, игнорируя вычислительную технику (ВТ), которая является основой современной техники. И последствием этого становится появление целого пласта профессионалов, вовсе не понимающих ВТ. А людей, понимающих ВТ, становится всё меньше. И в этом виноват не Б.Страуструп, не Э.Шмидт и не Б.Эккель, а весь рынок.

Итог таков:
  1. Везде, где можно обойтись без ООП, нужно обходиться без ООП. Таких областей, возможно меньше, чем подходящих под применение ООП, но они есть. Моё мнение — это весь системный уровень, вся встраиваемая техника. Многие приложения так же могут быть успешно реализованы без ООП.
  2. ООП нужно применять только хорошо подумав, и решив подходит ли ситуация под основной критерий для внедрения ООП — много объектов с похожим поведением и с необходимостью настраивать небольшую часть свойств и достаточно сложные связи между сущностями предметной области. И если у вас Web-сервер, поддерживающий много соединений, это ещё не обязательно повод для ООП.
Всё это конечно полезно для тех, кто способен (с точки зрения опыта и техники) и может (с точки зрения организационной) принимать такие решения. Тем, кто либо в силу опыта, либо в силу организационных моментов не может принимать такие решения, пока остаётся учиться и присматриваться.

Да будет всему своё место, но к ВТ, в любом случае, следует относиться с надлежащим уважением.

P.S.
Здесь так же хочу добавить пояснение. Не всем и не всегда понятно что такое ANSI C и Plain C и когда стоит употреблять тот или иной термин. ANSI C — это стандарт языка, Plain C — это термин, обозначающий концепцию, парадигму, процедурного подхода. Когда упоминают ANSI C, имеется в виду контекст стандарта (как правило его ограничения), когда говорят Plain C, акцент делается именно на парадигме программирования (процедурном) — как противопоставление C++. Любая ANSI C есть Plain C, но не любая Plain C есть ANSI C.
До 1988 года Plain C-код на разных компиляторах мог не собраться. Выражение ANSI C было популярным после выхода стандарта, в 1988 году, когда компиляторы стали «держаться ближе» к этому стандарту. Тогда было актуально, например, на собеседовании говорить, что вы работаете на ANSI C. Сейчас большинство компиляторов близки к стандарту, поэтому в основном, в речи уместно использовать термин Plain C (или просто «Си»). Так как во-первых, скорее всего, вы имеете в виду именно парадигму программирования, во-вторых, вы вряд ли знаете весь стандарт чтобы похвастаться знанием ANSI C (даже GCC реализует ANSI C с некоторым дополнениями).

06/04/2020

Web (2): Watcher.js — компонент для просмотра log-файлов

Задача
Иногда, чаще при разработке встраиваемых систем, возникает необходимость просматривать журнальный файл (лог) какого-либо приложения или демона, работающего на целевой системе или на «железке». Каждый раз ходить на устройство по SSH/Telnet/UART'у бывает не очень удобно. Особенно, когда система уже работает и имеются средства удалённой загрузки, допустим, конфигураций, прошивок или самого ПО, на устройство через систему управления (которая так же может быть Web-приложением).
Можно было бы просто запрашивать интересующий файл через Web-сервер. Но тут есть два нюанса. Первое — Web-сервер, в целях безопасности, не отдаст клиенту файл, расположенный вне DocumentRoot, по простому HTTP-запросу. Второе — даже если настроить сервер так, чтобы он отдавал файл (можно разрешить доступ к каталогу с логами или сделать ссылки на интересующие файлы в DocumentRoot), не будет автоматического обновления дописываемых в лог строк — придётся каждый раз обновлять страницу и ждать пока файл загрузится заново и проматывать вниз. Здесь казалось бы, можно просто написать CGI, которая будет делать 
tail -f /path/to/file
но на Web'е это работать не будет, потому что сервер отдаёт ответ от CGI только по завершению процесса, а tail -f не завершится никогда. Страница на стороне клиента зависнет в вечном состоянии загрузки.

Решение
Именно эти две задачи и решает мой компонент (Web-приложение), который я представляю в этой статье — компонент для просмотра текстовых файлов через Web-интерфейс, Watcher.
Watcher позволяет просматривать любые текстовые файлы на файловой системе, к которым у CGI-скриптов, запускаемых Web-сервером, есть доступ на чтение. Я на своей системе смог просмотреть даже /etc/fstab и /proc/cpuinfo. (А вот /var/log/messages без изменения прав доступа не получилось загрузить, он оказался под запретом даже для чтения.) Строки грузятся кусками и один раз, то есть вы можете читать информацию, не дожидаясь её полной прогрузки и без повторных загрузок файла.

Алгоритм, по которому работает Watcher
Сперва запрашивается размер файла, выраженный в количестве строк. На этом же этапе происходит определение ошибки доступа или отсутствия указанного файла. Если обнаружена ошибка, выводится сообщение и дальнейшая работа прекращается. Если ошибки не произошло, Watcher кусками подгружает строки из файла от нулевой до того размера, который был получен в начале. Затем запускается циклический таймер, который опрашивает изменение размера файла (в строках). Если файл увеличился, запрашивается (кусками) новое содержимое и отображается. Данные (количество строк и прогресс) получаемые на первом этапе отображаются в левом поле (Fetch). Данные, подгружаемые в последствии, отображаются в центральном поле (Watch).

Управление Watcher'ом
Имя файла задаётся в параметре адресной строки (?File=). То есть, вы вводите адрес (например, 127.0.0.1/Watcher.html?File=../log) и нажимаете enter. Всё, Watcher начинает работать по загрузке страницы. Если вам удобно чтобы при подгрузке новых строк, компонент автоматически прокручивался на них, есть переключатель Auto Scroll.
Иногда бывает нужно сбросить лог, очистить его содержимое. Для этого есть кнопка Clear Log. Эта функция очищает файл (при условии соответствующих прав доступа) на устройстве и перезапускает Watcher.

Ограничения и пояснения
Я долго соображал, что последняя, пустая строка, которую мы видим в текстовых редакторах, на самом деле не существует — символ новой строки добавляется в конце последней строки — той, которая нам кажется предыдущей. То есть, символ новой строки является частью последней строки, а не отдельной строкой. Поэтому, когда вы видите в редакторе или в выводе cat пустую строку снизу — это особенности вывода. Эта строка не выводится в Watcher как отдельная, пустая. Отсюда же следствие — если вы допишете строку в конец файла без символа новой строки, она не подхватится Watcher'ом.
В следствие особенностей алгоритма (работает только со строками и по строкам), который я разработал в этом компоненте есть некоторые ограничения:
  • нельзя удалять строки из файла — Watcher не отреагирует на это, более того — алгоритм вовсе собьётся
  • не имеет смысла изменять строки в файле — однажды загрузив содержимое файла, Watcher не следит за изменениями этого содержимого
  • CGI Watcher'а написан на Python 2.x — на Python 3.x пока не работает
  • у меня в проекте своя структура (расположение файлов) — вам придётся настроить свой httpd так, чтобы работал Python-скрипт из моего каталога (CGI) или переместить его в каталог с вашими CGI
Чтобы воспользоваться:
git clone https://gitlab.com/daftsoft/watcher.js.git



13/03/2020

Великолепная Java (2): сборка проекта — библиотеки, ресурсы

В предыдущей статье (Великолепная Java (1): сборка проекта — компиляция, JAR) мы рассмотрели процесс сборки Java-программ на примере очень просто организованной программы. ООП и принципы архитектуры ПО «SOLID» подразумевают разделение функционала на классы, каждый из которых обеспечивает ограниченный, минимальный с точки зрения архитектуры, функционал. Это подразумевает поставку классов в виде компонентов. Брюс Эккель в своей книге «Философия Java» разделяет разработчиков на «создателей классов» и «программистов-клиентов». Следовательно, в реальной жизни ваши проекты будут организованы несколько (скорее — значительно) сложнее, чем рассмотренный нами ранее пример.
Библиотеки (или компоненты) в Java распространяются в виде тех же .jar-файлов. Да, .jar-файл может содержать не только конечное приложение, но и библиотеки, используемые «программистами-клиентами» (и как мы увидим ниже — любые другие файлы). Библиотека отличается от приложения тем, что не содержит главного класса (того, который мы в прошлой статье указывали при помощи ключа -e). Главный класс отличается от любого класса-компонента тем, что у него есть главная функция — main (). Что, в прочем, не запрещает легко использовать функционал класса, имеющего эту функцию другими классами.
В предыдущей статье мы уже рассматривали преимущества компоновки кода в .jar-файлы, как скомпилированные классы собираются в jar и как запускать .jar-файлы как приложения.
Здесь мы рассмотрим как на Java создавать библиотеки и как создавать приложение с использованием библиотек.
Вы можете спросить — «Зачем мне вообще создавать какие-то компоненты? Я могу просто разнести код на несколько .java-файлов и компилировать их вместе или вовсе реализовать всё в одном файле». У запаковки скомпилированных классов в один компонент (.jar-файл) есть три причины:
Первая — коммерческая: дизассемблирование, хоть и выполнимо, но является задачей непростой, и компании, распространяющие компоненты на коммерческой основе, заинтересованные в сокрытии реализации своих продуктов, не поставляют библиотеки в .java- и .class-файлах. Как мы описывали в предыдущей статье, Java на каждый класс создаёт отдельный .class-файл, и чтобы «красиво» поставлять компонент в виде одного файла, удобно запаковать всю библиотеку (состоящую иногда из сотен .class-файлов) в один .jar-файл. В этой статье мы научимся, в том числе и использовать компоненты, попавшие к нам в руки в виде .jar-файла.
Вторая — архитектурная: компонент представляет (должен быть так спроектирован, чтобы представлять) собой какой-то уровень абстракции. И «программистам-клиентам» удобно использовать функционал этого компонента как отдельный .jar-файл.
Третья — производительность: так как скомпилированный код Java является кроссплатформенным, у разработчиков нет необходимости пересобирать все компоненты при сборке проекта. «Программист-клиент» может использовать скомпилированный однажды компонент в своём проекте где угодно и когда угодно (в отличие от других языков программирования), экономя время на сборку. В крупных проектах эта экономия может быть очень существенной — обратите внимание как, даже такой маленький проект как пример из этой статьи собирается по make clean all.

Начнём. Библиотеки в Java называются package. Идея компоновать классы в библиотеки так же помогает в разрешении конфликтов имён. В разных библиотеках могут находиться классы с одинаковыми именами. Обращаться к ним можно через указание имени библиотеки, в которой он реализован. Это описано в примере кода основного приложения (см. ниже).
Рассмотрим файл с кодом одного из классов, который входит в нашу библиотеку (Hello.java):
package HelloPackage;

public class Hello
{
  public void Print ()
  {
      System.out.println (getClass().getName() + ".Print ()");
  }
}
Ниже представлен пример второго класса из нашей библиотеки (Bye.java):
package HelloPackage;

// Приватный класс библиотеки, в пределах библиотеки он будет 
// доступен, а снаружи её к нему доступа не будет:
class ByeBye
{
  public void Print ()
  {
      System.out.println (getClass().getName() + ".Print ()");
  }
}

public class Bye
{
  public static void main (String args[]) throws Exception
  {
    // Демонстрация возможности упаковки исполняемых классов
    // в библиотеку, скомпилировав проект, попробуйте выполнить:
    // $JAVA_HOME/bin/java HelloPackage.Bye
    System.out.println ("Bye.main()");
  }
  public void Print ()
  {
      // Здесь класс ByeBye доступен:
      ByeBye BB = new ByeBye ();
      BB.Print();
      System.out.println (getClass().getName() + ".Print ()");
  }
}
Обратите внимание на то, что в разных файлах используется одно название package. Я мог бы объединить реализацию всех трёх классов в одном файле, но специально разнёс их для демонстрации того, что одна библиотека может быть собрана из многих файлов и, соответственно, содержать в себе много классов.
По сути дела библиотека (и её имя) это всего лишь название каталога, в который будут положены .class-файлы. Причём компиляция этих файлов в один проход необязательна — в Makefile я описал три отдельных правила для трёх классов из двух файлов. Java при компиляции любого из них, при необходимости, создаст каталог с названием библиотеки и положит в него, соответствующий .class-файл. Если в одном .java-файле реализованы несколько классов, все соответствующие им .class-файлы будут положены в каталог библиотеки. При компиляции другого файла, в котором указано такое же имя библиотеки, Java добавит новые .class-файлы в существующий каталог, не затрагивая уже находящиеся там файлы.
В коде примера так же описан приватный класс библиотеки (класс ByeBye в файле Bye.java) — это неэкспортируемый из библиотеки класс. Такие классы используются для сокрытия реализации внутреннего функционала библиотеки.
Процесс создания библиотеки заключается в следующем: компилируются все классы (javac с ключом -d — создание библиотеки), затем каталог с классами библиотеки упаковывается в .jar-файл.
Весь этот механизм описан в Makefile, здесь не буду разъяснять все ключи и параметры этого процесса. После сборки библиотеки в .jar-файл мы можем его распространять другим разработчикам единым файлом, без .java- и .class-файлов. Никаких дополнительных файлов (наподобие .h-файлов) не нужно.
Далее, имея .jar-файл библиотеки (либо только что собранный нами, либо полученный от внешнего поставщика), мы собираем основное приложение. Здесь нам нужно создать т.н. манифест — файл с указаниями параметров для Java. Эти параметры будут использоваться при запуске нашего приложения. Сам манифест представлен в проекте. Здесь ограничимся необходимыми нам параметрами. Их два, и они уже известны нам из предыдущей статьи (там мы указывали их в параметрах командной строки):
Main-Class — имя класса, который будет запускаться и имеет функцию main():
Main-Class: HelloWorld
и в Class-Path мы указываем имя .jar-файла нашей библиотеки — Hello.jar (здесь может быть любое количество библиотек, в том числе и полученных из сторонних источников)
Class-Path: Hello.jar
Код класса основного приложения:
import HelloPackage.*;
import java.io.*;

class HelloWorld
{
  public static void main (String args[]) throws Exception
  {
    // Вот так выглядело бы обращение к библиотечному классу
    // без import'а (тоже работает, но писать больше):
    // HelloPackage.Hello lHello = new HelloPackage.Hello ();
    // А так без import'а не сработает:
    Hello lHello = new Hello ();
    Bye lBye = new Bye ();
    
    // Ошибка: ByeBye является приватным классом библиотеки:
    // ByeBye lByeBye = new ByeBye ();
    
    // Получаем доступ к ресурсу - нашему текстовому файлу:
    InputStream lInputStream = 
            HelloWorld.class.getResourceAsStream("Info.txt"); 
    BufferedReader lBufferedReader = 
            new BufferedReader (new InputStreamReader(lInputStream)); 
    String lString = "";
    // Обращаемся к первому классу из нашей библиотеки:
    lHello.Print();
    // Выводим содержимое текстового файла из ресурсов:
    System.out.println ("Strings read from JAR-resource: ");
    while ((lString = lBufferedReader.readLine()) != null) 
      System.out.println(lString); 
    // Обращаемся к другому классу из нашей библиотеки:
    lBye.Print();
  }
}
Использование библиотеки сводится к простому импорту её содержания. В нашем примере мы импортируем все классы (разумеется публично экспортируемые из библиотеки). Выглядит это так:
import HelloPackage.*;

Как я уже писал, мы можем записать в .jar-файл любые файлы. В Java файлы упакованные в .jar-файл называются ресурсами. В примере выше описан механизм доступа к содержимому текстового файла.

Далее компилируем наш главный класс, собираем его .class-файл, файл манифеста, все файлы ресурсов и .jar-файл библиотеки в конечный .jar-файл и имеем приложение, включающее в себя всё необходимое. Как это делается так же описано в Makefile.

Что в этом примере сделано не совсем правильно. В демонстративно-образовательных целях я назвал .jar-файл с библиотекой именем, отличным от имени библиотеки. В целях повышения читаемости и понимания кода, а, следовательно, облегчения его дальнейшей поддержки, так делать не стоит.
Так же в этом примере я разместил код библиотеки и код исполняемого класса в один каталог и свёл всю сборку в один Makefile. С точки зрения архитектуры, это неправильно и так поступать в своей профессиональной деятельности не нужно. Обычно создают отдельный каталог, например lib, в котором лежат все библиотеки. Так как часто «программисты-клиенты» используют библиотеки, разрабатываемые не ими — либо своими коллегами, либо вовсе полученные извне компании, имеет смысл выделить библиотеки в отдельный модуль в системе контроля версий (например, submodule в git).
Если библиотека разрабатывается внутри коллектива, то иногда в принципе можно  отказаться от сборки её в .jar-файл и просто запаковать каталог (в нашем случае, HelloPackage) со всеми скомпилированными классами в .jar-файл с приложением, содержащим основную программу, не забыв указать соответствующий classpath в манифесте приложения.
Теперь по названиям компонентов — в Java принято называть компоненты по шаблону com.companyname.packagename. К слову, com здесь обозначает commercial. В примере я этому не следовал, (не только я) считаю это необязательным. Следовать этому правилу или нет — решать вам.

Чтобы посмотреть весь проект и поиграться скачайте:
git clone https://gitlab.com/daftsoft/javalibstutorial.git

05/03/2020

Великолепная Java (1): сборка проекта — компиляция, JAR

Как начинающим изучать новый язык программирования, так и опытным разработчикам иногда нужно разобраться в том как из кода получается «конечный продукт» — программа, которую можно запускать и поставлять пользователям. В этой статье мы рассмотрим все этапы, необходимые для того чтобы из Java-кода получить программу. Будем изучать Java Basic Tools или Java Development Tools, то есть, ручную, консольную сборку проекта. JDK является основным инструментом в работе с Java. Инструменты эти находятся в каталоге $JAVA_HOME/bin (например /opt/JDK-1.8.0_221/bin/).
Напишем простую программу, файл назовём Hello.java:
class HelloWorld
{
  public static void main (String args[])
  {
    System.out.println ("Hello, Java-World!");
  }
}
Для того чтобы скомпилировать Java-программу, нужно выполнить:
$JAVA_HOME/bin/javac ./Hello.java
javac — компилятор Java, транслирует .java-файлы в .class-файлы. В нашем случае, после работы javac в каталоге появится файл HelloWorld.class. Чтобы запустить нашу программу выполняем:
$JAVA_HOME/bin/java HelloWorld
Обратите внимание на три вещи. Первое — запускаем мы именно класс, то есть, нужно указывать не имя .class-файла, а имя класса (то есть имя .class-файла без расширения). Java сама найдёт (или не найдёт) в текущем каталоге файл, содержащий заданный в параметре класс. Поскольку мы указываем имя класса, а не файла, «./» перед ним ставить не нужно — это приведёт к ошибке. Второе — Java чувствительна к регистру в именах классов, файлов, переменных, поэтому все названия должны указываться с учётом регистра. Третье — каждый .class-файл соответствует имени класса, который он содержит. Имя .class-файла не связано с именем .java-файла, в котором он был реализован. Если в одном .java-файле будут реализованы несколько классов, то javac «разберёт» этот файл на соответствующее количество .class-файл'ов. Продемонстрируем это примером. Дополним файл Hello.java другим классом Hello (дополните предыдущий файл этим фрагментом кода):
class Hello
{
  public static void main(String _Args[])
  {
    try
    {
      System.out.println ("Hello, " + _Args[0] + "!");
    } catch (ArrayIndexOutOfBoundsException _E)
    {
      System.out.println ("Please pass parameter to program.");
    }
  }
}
P.S.
В Java список аргументов организован несколько иначе: нулевой элемент — это уже первый параметр, а не имя исполняемого файла.
Теперь, после компиляции (та же команда, что мы использовали в первый раз) мы увидим два .class-файла: Hello.class и HelloWorld.class. Так как оба файла имеют функцию main (), мы можем запустить оба. HelloWorld — как описано выше, и Hello (который принимает параметр командной строки):
$JAVA_HOME/bin/java Hello Daft
Отсюда мы постепенно приходим к необходимости создания какого-то единого файла для поставки пользователям. Мы же не хотим высылать пользователям море непонятных ему .class-файлов и объяснять какой именно является главным и как его запускать, так?
Для этих целей в Java реализованы Java-архивы (.jar-файлы). Чтобы создать .jar-файл и записать в него класс, нужно выполнить команду:
$JAVA_HOME/bin/jar -cvf ./Hello.jar ./Hello.class
Эта команда заархивирует указанный .class-файл в .jar-файл с заданным именем.
Чтобы запустить .jar-файл выполняем следующее:
$JAVA_HOME/bin/java -cp ./Hello.jar Hello
В предыдущей команде мы запаковывали .class-файл и указывали имя файла (с расширением), здесь мы запускаем класс — указываем имя класса (без расширения). Ключ -cp означает classpath — путь, где нужно искать заданный класс. В данной ситуации — это наш .jar-файл.
Как я уже указывал, .jar-файл является архивом и он действительно сжимается при создании, что дополнительно экономит место на носителе или время при передаче его по сети. Конкретно это zip-архив, мы можем посмотреть его содержимое:
unzip -l ./Hello.jar
Увидим:
Archive:  ./Hello.jar
  Length      Date    Time    Name
---------  ---------- -----   ----
        0  03-04-2020 17:50   META-INF/
       69  03-04-2020 17:50   META-INF/MANIFEST.MF
      736  03-04-2020 17:36   Hello.class
---------                     -------
      805                     3 files
В вышеописанную команду запаковки можно добавить не только классы, но и любые другие файлы, которые в последствии можно будет использовать как ресурсы из ваших программ (иконки, картинки, любое иное содержание и даже другие .jar-файлы).
Но пока запуск скомпилированных и даже уже запакованных в один файл классов является всё-таки не самым «дружественным». Как упростить запуск .jar-файла? Для этого предусмотрена возможность указать главный класс, добавив ключ e (что означает указать точку входа в программу, entry point): 
$JAVA_HOME/bin/jar -cvfe ./Hello.jar Hello ./Hello.class
Теперь мы можем запустить .jar-файл самым простым для Java способом:
$JAVA_HOME/bin/java -jar ./Hello.jar
На этом на сегодня всё. В следующих статьях мы рассмотрим сборку более сложных Java-программ (да, ещё есть куда двигаться).

25/02/2020

Многопоточность в POSIX или Multithreading в ANSI C (1): поток и процесс, начало работы с libpthread

В этой статье мы научимся создавать, запускать и останавливать потоки в POSIX-совместимых системах на Plain C.
Сначала вкратце рассмотрим, что такое поток. Воспользуемся последним стандартом POSIX.1-2017, чтобы разобраться в терминологии:
Live Process
An address space with one or more threads executing within that address space, and the required system resources for those threads.
Здесь написано следующее: процесс есть адресное пространство с одним или более потоков выполняющимися в нём и необходимыми системными ресурсами для этих потоков.
Приписка «живой» нас не интересует, в стандарте используется термин Live Process для отличия его от Zombie Process. В этой статье мы будем говорить только о «живых».
Так же в стандарте написано, что многие системные ресурсы определённые стандартом являются общими для всех потоков в процессе. Эти ресурсы включают в себя ID процесса, ID родительского процесса, ID группы процессов, текущую директорию, файловые дескрипторы и пр.
Thread
A single flow of control within a process. Each thread has its own thread ID, scheduling priority and policy, errno value, floating point environment, thread-specific key/value bindings, and the required system resources to support a flow of control. Anything whose address may be determined by a thread, including but not limited to static variables, storage obtained via malloc(), directly addressable storage obtained through implementation-defined functions, and automatic variables, are accessible to all threads in the same process.
Что означает: поток есть одно «течение контроля» (нам, русскоязычным, привычнее термин «поток выполнения»). Каждый поток имеет собственный ID, приоритет планировщика и политики, значение errno и пр.. Всё, чей адрес может быть определён потоком, включая, но не ограничиваясь статическими переменными, динамически выделенная память и др. доступны всем потокам одного процесса.

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

Я считаю, что разобраться в этом было важно, так как всё (во всяком случае системное) программирование заключается в распределении ресурсов операционной системы и управлении ими, а то, что мы выше почерпнули из стандарта даёт следующие выводы:
  1. Мы можем пользоваться (читать, писать) ресурсами в своих потоках в пределах одного процесса без использования т.н. IPC (методы межпроцессного взаимодействия).
  2. Мы не можем обращаться к ресурсам других процессов без применения IPC. Даже из т.н. основного потока выполнения (main (), например), даже из под root'а. А POSIX хоть и предоставляет инструменты IPC, но это достаточно сложные механизмы.
Теперь рассмотрим технологию создания второго и последующих потоков. На всякий случай, напомню, первый у нас всегда есть — его создала ОС при запуске вашей программы (возможно, о том как это происходит я напишу). Этот поток я называю основным.
В самой по себе ANSI C нет механизмов работы с многопоточностью. Есть различные реализации работы с потоками. Например, специфичная GNU Portable Threads, Protothreads и пр. Первая преследовала цель максимальной портируемости, вторая — легковесности. Мы будем рассматривать самый распространённый (хотя и не самый оптимальный, почему я распишу в последующих статьях) стандарт, т.н. POSIX Threads (pthread). POSIX Threads API реализован в библиотеке libpthread.

Приведём пример простой многопоточной программы:

#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>

/*
 * Здесь мы проверяем, есть ли в системе POSIX Threads
 */
#ifndef _POSIX_THREADS
#error No POSIX Threads!
#endif

/*
 * Функция, код которой будет выполняться в новом потоке.
 * Обратите внимание на то, что мы можем передать сюда
 * указатель на область памяти. Это можно использовать
 * как механизм передачи некоторых начальных данных потоку.
 */
void *ThreadRoutine (void *_Arg)
{
  printf ("String argument passed to thread routine: %s\n", 
          (char *)_Arg);
  while (0)
  {
    sleep (1);
    printf ("Thread is doing infinite work...\n");
  }
  printf ("Thread is about to exit normally...\n");
  
  /*
   * Завершить выполнение потока можно двумя способами: 
   * return и pthread_exit () с возвращаемым нами значением 
   * в параметре. Они эквивалентны за исключением некоторой
   * (незначительной) разницы в быстродействии, что, в свою
   * очередь, связано с функционалом вызова обработчиков 
   * завершения потока. Но мы их здесь не будем рассматривать.
   */
  /* return (void *)(uintptr_t)0xC0FFEE; */
  pthread_exit ((void *)(uintptr_t)0xC0FFEE);
  /*
   * А что будет, если вызвать exit () из функции потока?
   * Приложение завершится, так как exit () это не возврат
   * из функции, а более сложный системный вызов, который
   * "корректно" закрывает приложение вместе со всеми 
   * потоками. Недопонимание этой разницы может возникнуть 
   * из-за того, что мы часто завершаем наше приложение 
   * простым return'ом в main'е. На самом деле, при 
   * "возврате" из main'а компилятор подставляет функционал 
   * "корректного" закрытия программы. Поэтому завершать 
   * работу потока нужно только через return/pthread_exit (). 
   * Вы можете заменить return/pthread_exit () на exit (0) 
   * и посмотреть что произойдёт - вы не увидите вывода 
   * "Thread return".
   */
  /* exit (0); */
}

int main (int _argc, char * _argv[])
{
  /* Идентификатор потока, на Linux'е это целое число, на
   * Mac OS это структура.
   */
  pthread_t thread_id;
  /* Возвращаемое значение функции потока */
  void *ThreadRet = NULL;
  /*
   * Функция pthread_create создаёт поток. Первый параметр - 
   * идентификатор потока (для дальнейших операций с ним). 
   * Второй - аттрибуты, третий - функция потока, четвёртый - 
   * аргумент (мы передадим туда название исполняемого файла).
   */
  pthread_create (&thread_id, NULL, ThreadRoutine, _argv[0]);
  /*
   * Функция pthread_join ожидает выхода из функции потока.
   * Так же здесь мы получаем возвращаемое значение из потока.
   * Перед тем как раскомментировать этот вызов, убедитесь, что
   * цикл while в функции потока выключен (например, поставьте 
   * while (0)). Иначе программа зависнет в ожидании завершения 
   * потока. Ведь pthread_join () ждёт корректного завершения
   * работы потока.
   */
   pthread_join (thread_id, &ThreadRet);
  /*
   * Перед тем как включить этот эксперимент, поставьте в функции
   * потока while (1), чтобы включить его бесконечную работу.
   * Функция pthread_cancel () аварийно завершает поток.
   * Дадим ему 5 секунд "пожить" и завершим.
   */
  /* sleep (5); */
  /* pthread_cancel (thread_id); */
  /* Выводим значение, возвращённое из функции потока */
  printf ("Thread return: %lX\n", (uintptr_t)ThreadRet);
  return EXIT_SUCCESS;
}


NB!
Обратите внимание на то, как при возврате из функции потока, я создаю указатель на некий адрес. Это своеобразный трюк. Чтобы не выделять память для возвращаемого значения, с последующим её освобождением, но вернуть простой int, как результат работы, я вернул указатель на адрес памяти, который придумал из головы. Вы можете возвращать указатели на любые адреса, и анализировать их по возврату из pthread_join (). Но стоит помнить, что обращаться по таким, вымышленным адресам нельзя.

Забрать пример:
https://gitlab.com/daftsoft/pthread1.git

Собирать программы с POSIX Threads нужно с указанием библиотеки pthread:
gcc -std=c89 -pedantic -Wall pthread1.c -lpthread -o pthread1

К слову, в Linux'е не всегда был реализован стандарт POSIX Threads. Как же были реализованы потоки в Linux'е до «принятия» POSIX Threads? В то время (c 2.0 до 2.4 включительно) в Linux'е была своя технология, называлась она LinuxThreads. В общих чертах, она заключалась в копировании кода процесса с предоставлением доступа к памяти родительского процесса. При этом, у потоков были разные идентификаторы процессов (PID) и взаимодействие между ними осуществлялось посредством одного из механизмов IPC — сигналов (USR1 и USR2), что исключало возможность использовать этих сигналов разработчиками. То есть, по современным меркам (или по меркам стандарта), это были не совсем потоки. Всё это несовершенство продолжалось до начала 2000-х годов, когда LinuxThreads была заменена библиотекой потоков POSIX (Native POSIX Thread Library, NPTL), реализующей стандарт POSIX Threads в Linux.
Представленного примера достаточно для того, чтобы начать писать свои первые многопоточные программы.
В следующей статье мы, на примере, рассмотрим как действительно работают «общие ресурсы» в потоках в пределах одного процесса, а так же иные аспекты управления потоками.

19/02/2020

Интересно о размерах (2): модели данных

В продолжение рассуждений-исследований о соотношениях разрядности машины и размерностей типов (Интересно о размерах (1): типы и разрядные сетки машин), рассмотрим, как это выглядит формально (научно), как эти соотношения формализованы в стандартах — внесём окончательную ясность в одном из основополагающих аспектах вычислительной техники.

Соотношение разрядности машины и разрядностей типов данных (на самом деле просто отношения разрядностей типов друг к другу) называется моделью данных. И на самом деле модель данных задаётся компилятором, а не разрядностью машины, ровно, как и не операционной системой. То есть, даже на одной ОС, используя разные компиляторы, можно получить разные соотношения (которые мы косвенно рассмотрели в предыдущей статье на примере программы). Моделей данных существует много, но обычно в распространённых связках разрядность машины/ОС/компилятор используется ограниченное количество.

Принятый (сокращённый) формат названия модели данных состоит из перечисления типов, которые относятся к максимальной разрядности и значения самой разрядности.

Типы это (в порядке возрастания): S  короткое целое число (short), I  целое число (int), L  длинное целое число (long), LL  двойное длинное целое число (long long), P  указатель (void *). Разрядность указывается в битах  32, 64, 128.

char (условно) всегда 8-битный (про историю этого замечательного типа данных я напишу отдельно) и даже не указывается.

Пример, макрос которого мы использовали в предыдущей статье LP64, означает что компилятором задана следующая модель данных — 64-битными являются типы начиная с L (long), а именно — long и pointer. Указание LL излишне, так как он стоит рядом с указателем, и в таком случае считается равным ему. Если бы он был больше чем указатель, в конце было бы дописано LL128 (LP64LL128). Реальный пример ILP32LL64 (int, long и pointer — 32-битные, long long — 64-битный), эта модель использовалась в Win32 и некоторых UNIX'ах в 90-ые года. Можно задаться вопросом — как машина, разрядностью менее разрядности максимального типа вообще может оперировать такими типами? Верно — на операции с этими типами уходит более одной инструкции (такта), что крайне неэффективно с точки зрения вычислительных мощностей. 

Все типы, определённые до типов, указанных в названии модели считаются 32-битными, за исключением типа short — пока он, начиная с PDP-11 является 16-битным всегда. Это подразумевается, хотя возможна и полная запись: I32LP64 (и даже S16L32P64— эта модель данных и принята в стандартах Open System (UNIX является ОС относящейся к этому стандарту). В конце 90-х годов эта модель была согласована как основная для UNIX-систем совместным решением компаний, в том числе DEC, IBM, Sun Microsystems, HP, Intel, NCR.

Есть и отличный от предыдущих пример. В системах UNICOS (UNIX-подобная ОС для линейки суперкомпьютеров Cray) все типы являются 64-битными, и модель данных на этих машинах используется SILP64. И здесь используется символ S, так как даже short здесь 64-битный.

Ну и чтобы наше описание было полным, укажем какая модель данных была в Win16 и Win64. В Win16 использовалась LP32, что означает — 32-битные long и ponter, а int был 16-битным. А в Win64 используется LLP64, то есть, все типы до long long оставлены 32-битными. Сделано это из-за проблем с совместимостью и переписыванием WinAPI при переходе на 64-разрядные системы.

Для большинства UNIX-разработчиков ещё знакомы 32-разрядные системы, там принята модель данных ILP32, что означает что все типы, кроме short, являются 32-битными. И, собственно, единственным отличием 32-разрядных машин (компиляторов) от 64-битных является то, что pointer и long стал 64-битным. Что я и рассмотрел и доказал как один из методов определения разрядности машины в предыдущей статье. Все остальные типы остаются таких же размерностей, что и на 32-битных системах. Сделано это для совместимости кода. Но здесь стоит быть осторожным. В некоторых 32-битных программах типы int, long и pointer используются как синонимы, полагается, что их разрядности равны. Но так как размеры long и указателя в модели данных LP64 стали 64-битными мы должны помнить, что это изменение может вызвать много проблем при переносе кода с 32-разрядной платформы на 64-битную.
Поиск оптимальной модели данных, при каждом переходе на более высокую разрядность системы, является основополагающим моментом в обеспечении качественного процесса разработки ПО. Можно сказать, что модель данных является инструментом разработчика. Так как влиять мы на неё (модель) не можем, наша задача хорошо в них разбираться.

На этом пока всё.