Проект: утилита командной строки
Пора собрать всё вместе и сделать вещь, которой можно пользоваться:
консольную утилиту. Не игрушечный «привет, мир», а программу с ключами,
справкой, понятными ошибками и правильными кодами возврата — такую,
которую не стыдно положить рядом с grep и wc.
Утилита wordfreq строит частотный словарь: какие слова чаще всего
встречаются в тексте. Вот её ответ на тексты всех глав этого курса
(на момент написания — 735 КБ):
$ wordfreq -n 7 курс/*.mdxвсего слов: 61034, различных: 10318 1. в 1480 2. и 1443 3. не 1168 4. mojo 898 5. на 709 6. fragment 508 7. что 488Слово mojo на четвёртом месте, сразу за служебными словами, — курс явно про то,
что обещает. А fragment — это служебная разметка страниц: утилита честно
считает всё, что видит.
Что она должна уметь:
- принимать один или несколько файлов, а вместо имени —
-, чтобы читать стандартный ввод; - понимать ключи
-n N(сколько слов показать) и--min-len K(не считать слова короче K букв), а также-h/--help; - приводить слова к нижнему регистру и отрезать знаки препинания по краям;
- на ошибки в аргументах отвечать сообщением в stderr и кодом 2, на недоступный файл — сообщением, продолжать с остальными и в конце вернуть код 1.
Полный код — в конце главы, а сейчас разберём его по частям.
Аргументы командной строки
Заголовок раздела «Аргументы командной строки»Аргументы отдаёт argv() из std.sys. Нулевой элемент — имя самой
программы, остальные — то, что написал пользователь:
from std.sys import argv
def main(): var args = argv() print("аргументов:", len(args)) for arg in args: print(" -", arg)uv run mojo run args.mojo -n 5 файл.txtаргументов: 4 - args.mojo - -n - 5 - файл.txtПод mojo run нулевым элементом оказывается имя исходника, у собранной
программы — путь, по которому её запустили. Всё, что стоит после имени
файла, mojo run передаёт программе, в том числе --help: компилятор
его не перехватывает.
Элементы argv() — срезы строк (StringSlice), а не String. Сравнивать
их со строками можно сразу; String(...) делает из среза самостоятельную
копию, и в wordfreq мы пишем это явно.
Разбор: ключи с числом
Заголовок раздела «Разбор: ключи с числом»Готового argparse в стандартной библиотеке Mojo нет, и для двух ключей
он не нужен — хватает цикла по аргументам. Настройки собираются в структуру:
@fieldwise_initstruct Options(Movable): var top: Int var min_len: Int var files: List[String]Разбор идёт по индексу, а не циклом for: у ключа -n значение стоит
в следующем аргументе, и его нужно «съесть» вместе с ключом:
def parse_args(mut options: Options) raises: var args = argv() var i = 1 while i < len(args): var arg = String(args[i]) if arg == "-h" or arg == "--help": print(USAGE) exit(0) elif arg == "-n" or arg == "--min-len": if i + 1 >= len(args): raise Error(String("после ", arg, " нужно число")) var value = positive_int(arg, String(args[i + 1])) if arg == "-n": options.top = value else: options.min_len = value i += 1 elif arg.startswith("-") and arg != "-": raise Error(String("неизвестный ключ ", arg)) else: options.files.append(arg) i += 1 if len(options.files) == 0: raise Error("не указано ни одного файла")Одинокий - — не ключ, а имя «файла» для стандартного ввода, поэтому
условие arg != "-".
Проверку числа удобно вынести в отдельную функцию. Int("abc") бросает
ошибку с английским текстом — перехватываем её и бросаем свою, понятную:
def positive_int(flag: String, value: String) raises -> Int: var n: Int try: n = Int(value) except: raise Error(String("после ", flag, " нужно целое число, а не «", value, "»")) if n <= 0: raise Error(String("после ", flag, " нужно число больше нуля")) return nПочему parse_args заполняет структуру, а не возвращает её
Заголовок раздела «Почему parse_args заполняет структуру, а не возвращает её»Естественнее было бы написать var options = parse_args() и поймать
ошибку. Но попробуйте:
def run(): var options: Options try: options = parse_args() except e: print("wordfreq:", e, file=stderr) exit(2) print(options.top)error: use of uninitialized value 'options'
Компилятор не знает, что exit() не возвращается. С его точки зрения
после ветки except выполнение пойдёт дальше — к print(options.top),
а options в этой ветке так и не получила значения.
Создайте значение до try — с настройками по умолчанию — и передайте
в функцию разбора как mut. Так и сделано в wordfreq.
Поэтому main начинается так:
# первые строки функции mainvar options = Options(top=10, min_len=1, files=[])try: parse_args(options)except e: print("wordfreq:", e, file=stderr) print("Подробнее: wordfreq --help", file=stderr) exit(2)Заодно в main не нужен raises: все ошибки обработаны внутри,
и наружу не вылетит ничего, кроме кода возврата.
stdout, stderr и коды возврата
Заголовок раздела «stdout, stderr и коды возврата»Хорошая консольная утилита различает два потока вывода. Результат идёт
в stdout — его перенаправят в файл или передадут следующей программе.
Ошибки и подсказки идут в stderr — их увидит человек, даже если stdout
ушёл в файл. В Mojo для этого у print есть аргумент file:
from std.sys import stderr
print("wordfreq: неизвестный ключ -x", file=stderr)Код возврата задаёт exit(код) из std.sys. Единого стандарта нет,
но договорённость ниже распространённая:
| Код | Когда | Кто ещё так делает |
|---|---|---|
| 0 | всё хорошо (и для --help) | все |
| 1 | работа сделана, но с ошибками: какой-то файл не прочитался | cat, wc |
| 2 | неправильные аргументы, работа не начиналась | argparse в Python, ls, grep |
Проверим на собранной утилите:
$ wordfreq -x text.txt; echo "код $?"wordfreq: неизвестный ключ -xПодробнее: wordfreq --helpкод 2
$ wordfreq -n abc text.txt; echo "код $?"wordfreq: после -n нужно целое число, а не «abc»Подробнее: wordfreq --helpкод 2
$ wordfreq -n 2 text.txt nope.txt /tmp; echo "код $?"wordfreq: nope.txt: Failed to open file 'nope.txt': No such file or directorywordfreq: /tmp: Failed to read from file: Is a directoryвсего слов: 6, различных: 3 1. кот 3 2. и 2код 1В последнем случае утилита не сдалась на первой же ошибке: сообщила
о ней, досчитала остальное и честно вернула 1. Имя файла перед сообщением
main добавляет сама: не во всех ошибках чтения оно есть — «Is a directory»
без него было бы загадкой.
Чтение файлов и стандартного ввода
Заголовок раздела «Чтение файлов и стандартного ввода»Файл читается целиком, как в Python:
def read_all(path: String) raises -> String: var name = "/dev/stdin" if path == "-" else path with open(name, "r") as f: return f.read()Стандартный ввод на Linux и macOS доступен как файл /dev/stdin, поэтому
отдельного кода для него не нужно:
echo "Mojo, mojo и Python" | wordfreq -всего слов: 4, различных: 3 1. mojo 2 2. python 1 3. и 1Если файла нет, open бросает ошибку с текстом
Failed to open file 'nope.txt': No such file or directory, если это
каталог — Failed to read from file: Is a directory, если в файле
не UTF-8 — Cannot construct a String from invalid UTF-8 data.
Все они попадают в main и печатаются с именем файла.
Подсчёт слов
Заголовок раздела «Подсчёт слов»Сердце утилиты — одна короткая функция:
def count_words(text: String, min_len: Int, mut counts: Dict[String, Int]) -> Int: var total = 0 for raw in text.lower().replace("\u00a0", " ").split(): var word = raw.strip(PUNCTUATION) if len(word.codepoints()) < min_len: continue total += 1 var key = String(word) counts[key] = counts.get(key, 0) + 1 return totalПочти всё знакомо по Python: lower() понимает кириллицу, split() без
аргументов режет по пробелам и переводам строк, strip(набор) отрезает
по краям любые символы из набора, Dict доступен без импорта,
а counts.get(key, 0) работает как в Python. Новое — в трёх местах.
Длина строки. len(word) в Mojo 1.1 не компилируется:
error: `len(String/StringSlice)` is not supported because Mojo strings are UTF-8 encoded, so a single length is ambiguous: …
Строки в Mojo хранятся в UTF-8, и «длина» может означать три разных числа: байты, символы Юникода или видимые знаки. Компилятор просит сказать, какое нужно.
len(s.codepoints()) — число символов, как len(s) в Python;
s.byte_length() — число байтов; len(s.graphemes()) — видимые знаки
(подробнее — в главе «Строки»). Для слова «кот»
первые два числа — 3 и 6.
Ключ словаря. raw.strip(...) возвращает срез — ссылку на кусок
большого текста, а не отдельную строку. Словарю нужен ключ, который живёт
сам по себе, поэтому String(word) делает копию.
Неразрывный пробел. split() в Mojo режет только по обычным
ASCII-пробелам, а str.split() в Python — по всем пробелам Юникода.
В русских текстах после типографа между тире и словом часто стоит
неразрывный пробел U+00A0, и без замены "Mojo — язык" дал бы слова
"mojo " (с невидимым пробелом внутри) и "— язык". Поэтому перед
split() мы заменяем его на обычный. Остальные экзотические пробелы
(U+2009, U+3000 и другие) по-прежнему разделителями не считаются.
Сортировка
Заголовок раздела «Сортировка»Нужно упорядочить слова по частоте, а при равной частоте — по алфавиту.
sort принимает функцию сравнения, которая отвечает «должен ли a стоять
раньше b». Её удобно объявить прямо внутри main:
def more_frequent(a: Tuple[String, Int], b: Tuple[String, Int]) -> Bool: if a[1] != b[1]: return a[1] > b[1] return a[0] < b[0]
sort(pairs, more_frequent)pairs.sort(key=lambda p: (-p[1], p[0]))sort(pairs, more_frequent)В Python сортируют по ключу, в Mojo — функцией сравнения. Результат одинаковый: мы сверили полные списки из 10 318 слов, которые выдают обе версии.
Строки сравниваются по кодам символов — как в Python. Поэтому заглавные
идут раньше строчных, а «ё» оказывается после «я»:
["яма", "ёж", "еж", "Юла", "ель"] после сортировки —
[Юла, еж, ель, яма, ёж]. Регистр нам не мешает — все слова уже
в нижнем. А вот «ё» среди слов с одинаковой частотой встанет после «я»;
для частотного словаря с этим можно жить.
Вывод таблицей
Заголовок раздела «Вывод таблицей»Метода rjust у строк в Mojo 1.1 нет, так что выравнивание пишется руками.
А ширину слова считаем в символах, а не в байтах, — иначе русские слова
съедут вдвое:
var number = String(i + 1)var left = " " * (3 - number.byte_length())var right = " " * max(16 - len(word.codepoints()), 1)print(left, number, ". ", word, right, pairs[i][1], sep="")Для номера байты и символы совпадают — там только цифры.
Сборка в исполняемый файл
Заголовок раздела «Сборка в исполняемый файл»Каждый запуск через mojo run заново компилирует программу. Для утилиты,
которую зовут десятки раз на дню, это неприемлемо. Собираем:
uv run mojo build wordfreq.mojo./wordfreq --helpПолучается исполняемый файл размером около 125 КБ. Время ответа на
маленький файл, вместе со стартом процесса (лучшее из 30 запусков,
для mojo run — из 5):
| Запуск | Intel Xeon 2,8 ГГц | AMD Ryzen 7 9700X |
|---|---|---|
mojo run wordfreq.mojo | 1100 мс | 500 мс |
python3.14 wordfreq.py | 12,2 мс | 6,1 мс |
собранный wordfreq | 7,4 мс | 4,8 мс |
Разница между первой и последней строкой — это и есть компиляция. Собранная программа стартует быстрее Python, но не мгновенно: пустая программа на Mojo на той же машине с Xeon запускается за 7,7 мс, на C — за 1,1 мс. Время уходит на загрузку и запуск рантайма Mojo.
Чтобы вызывать утилиту по имени из любого каталога, положите её в каталог
из PATH, например в ~/.local/bin. На своей машине этого достаточно —
пока на месте .venv проекта: библиотеки рантайма программа ищет
именно там.
Чтобы отдать её другим людям, понадобятся ещё библиотеки рантайма
и сборка под общий процессор — как это сделать, разобрано в главе
«Упаковка и распространение»:
mkdir -p dist/libuv run mojo build wordfreq.mojo -o dist/wordfreq \ --target-cpu x86-64-v3 \ -Xlinker -rpath -Xlinker '$ORIGIN/lib'for lib in $(ldd dist/wordfreq | awk '/modular/ {print $3}'); do cp -L "$lib" dist/lib/doneБыстро ли это?
Заголовок раздела «Быстро ли это?»Mojo обещает скорость, так что сравним честно. Ту же логику написали
на Python — строка в строку: lower(), split(), strip(), словарь,
сортировка. Обе версии выдают один и тот же список из 10 318 слов.
Вход — тексты курса, повторённые 100 раз (73,5 МБ). Лучшее из пяти
запусков, время всего процесса:
| Intel Xeon 2,8 ГГц | AMD Ryzen 7 9700X | |
|---|---|---|
| Python 3.14.7 | 3,23 с | 1,39 с |
| Mojo 1.1.0 | 2,74 с | 1,21 с |
| Mojo быстрее в | 1,18 раза | 1,15 раза |
Не в десять раз и не в сто. Чтобы понять почему, замерим этапы по отдельности, миллисекунды (лучшее из трёх):
| Этап | Python, Xeon | Mojo, Xeon | Python, Ryzen | Mojo, Ryzen |
|---|---|---|---|---|
| чтение файла | 515 | 273 | 202 | 87 |
lower() всего текста | 371 | 1083 | 138 | 501 |
split() | 549 | 452 | 243 | 211 |
цикл: strip(), ключ, словарь | 1458 | 1077 | 638 | 532 |
Картина на обеих машинах одна.
Цикл по 6,8 млн слов выиграл всего 1,2–1,35 раза. В Python внутри него
почти всё время работают функции на C — strip и словарь, — так что
интерпретатору достаётся не так много, и переписать обвязку на Mojo —
значит выиграть немного.
lower() в Mojo проиграл CPython в 2,9–3,6 раза. Он один съедает
большую часть того, что Mojo выиграл на чтении, split() и цикле.
Скорость языка не гарантирует скорость каждой функции его стандартной
библиотеки.
Отсюда главный вывод проекта: перенос программы на Mojo строка в строку даёт не больше, чем в ней было медленного интерпретируемого кода. Там, где Python и так вызывает C, чуда не будет. Большой выигрыш начинается, когда горячий цикл переписывается под Mojo — с его типами, без лишних копий и выделений памяти. Как найти такой цикл и что с ним делать — в проекте «Ускоряем Python-скрипт» (глава в работе); как правильно мерить — в главе «Как честно мерить скорость».
Весь код
Заголовок раздела «Весь код»"""Утилита wordfreq: частотный словарь текста."""
from std.sys import argv, exit, stderr
comptime USAGE = """Использование: wordfreq [-n N] [--min-len K] ФАЙЛ...
Показывает, какие слова чаще всего встречаются в текстовых файлах.
-n N сколько слов показать (по умолчанию 10) --min-len K не считать слова короче K букв (по умолчанию 1) -h, --help показать эту справку
Вместо имени файла можно указать «-»: текст прочитаетсяиз стандартного ввода."""
comptime PUNCTUATION = ".,:;!?()[]{}«»\"'`*_—–-…/\\|<>#=+~"
@fieldwise_initstruct Options(Movable): var top: Int var min_len: Int var files: List[String]
def positive_int(flag: String, value: String) raises -> Int: var n: Int try: n = Int(value) except: raise Error( String("после ", flag, " нужно целое число, а не «", value, "»") ) if n <= 0: raise Error(String("после ", flag, " нужно число больше нуля")) return n
def parse_args(mut options: Options) raises: var args = argv() var i = 1 while i < len(args): var arg = String(args[i]) if arg == "-h" or arg == "--help": print(USAGE) exit(0) elif arg == "-n" or arg == "--min-len": if i + 1 >= len(args): raise Error(String("после ", arg, " нужно число")) var value = positive_int(arg, String(args[i + 1])) if arg == "-n": options.top = value else: options.min_len = value i += 1 elif arg.startswith("-") and arg != "-": raise Error(String("неизвестный ключ ", arg)) else: options.files.append(arg) i += 1 if len(options.files) == 0: raise Error("не указано ни одного файла")
def read_all(path: String) raises -> String: var name = "/dev/stdin" if path == "-" else path with open(name, "r") as f: return f.read()
def count_words( text: String, min_len: Int, mut counts: Dict[String, Int]) -> Int: var total = 0 # split() режет только по ASCII-пробелам, а в русских текстах после # типографа часто стоит неразрывный пробел U+00A0: превращаем его в обычный for raw in text.lower().replace("\u00a0", " ").split(): var word = raw.strip(PUNCTUATION) if len(word.codepoints()) < min_len: continue total += 1 var key = String(word) counts[key] = counts.get(key, 0) + 1 return total
def main(): var options = Options(top=10, min_len=1, files=[]) try: parse_args(options) except e: print("wordfreq:", e, file=stderr) print("Подробнее: wordfreq --help", file=stderr) exit(2)
var counts = Dict[String, Int]() var total = 0 var failed = False for path in options.files: try: total += count_words(read_all(path), options.min_len, counts) except e: print(String("wordfreq: ", path, ": ", e), file=stderr) failed = True
var pairs = List[Tuple[String, Int]]() for item in counts.items(): pairs.append((item.key, item.value))
def more_frequent(a: Tuple[String, Int], b: Tuple[String, Int]) -> Bool: if a[1] != b[1]: return a[1] > b[1] return a[0] < b[0]
sort(pairs, more_frequent)
print(String("всего слов: ", total, ", различных: ", len(pairs))) for i in range(min(options.top, len(pairs))): var word = pairs[i][0] var number = String(i + 1) var left = " " * (3 - number.byte_length()) var right = " " * max(16 - len(word.codepoints()), 1) print(left, number, ". ", word, right, pairs[i][1], sep="")
if failed: exit(1)Проверка на небольшом тексте (файл sample.txt лежит рядом с примером
в репозитории курса):
uv run mojo run wordfreq.mojo -n 5 sample.txtвсего слов: 42, различных: 29 1. на 5 2. mojo 4 3. python 3 4. знает 2 5. и 2
Что можно добавить
Заголовок раздела «Что можно добавить»Утилита рабочая, но её легко развивать — хорошие упражнения:
- ключ
--ignoreсо списком стоп-слов (в,и,не,на…); - вывод в CSV для загрузки в таблицу;
- тесты на
count_wordsпо образцу главы «Тестирование и отладка» — для этого функцию стоит вынести в отдельный модуль; - обработку файлов частями, чтобы не держать в памяти весь текст;
- аргумент
--, после которого всё считается именами файлов (сейчас файл с именем на-можно передать только как./-файл), и форму--min-len=2; - понятное сообщение для слишком большого числа после
-n: сейчас99999999999999999999получает ответ «нужно целое число».
Что дальше
Заголовок раздела «Что дальше»Следующий проект — матричная библиотека: от трёх вложенных циклов почти до скорости OpenBLAS.
🎯 Проверь себя
Почему у ключа -n разбор идёт циклом while по индексу, а не for по аргументам?
Значение ключа — следующий аргумент. Его нужно прочитать и пропустить
вместе с ключом, то есть сдвинуть индекс на два. Цикл for так
не умеет.
Почему var options = parse_args() внутри try с exit(2) в except не компилируется?
Компилятор не знает, что exit() не возвращается, и считает, что после
except выполнение продолжится с неинициализированной переменной.
Решение — создать значение до try и заполнять его через mut.
Куда писать сообщения об ошибках и какой код возврата вернуть при неверном ключе?
В stderr (print(..., file=stderr)), чтобы они не смешивались
с результатом, и код 2 — как у argparse, ls и grep. Код 1 — когда
работа сделана, но с ошибками, 0 — когда всё хорошо.
Как в Mojo 1.1 узнать число символов в строке?
len(s.codepoints()). Просто len(s) не компилируется: строки
в UTF-8, и компилятор просит уточнить, нужны байты (byte_length()),
символы или видимые знаки.
Mojo-версия wordfreq оказалась быстрее Python всего в 1,15–1,2 раза. Почему так мало?
Почти вся работа в Python-версии и так идёт внутри функций на C:
lower, split, strip, словарь. Переписав обвязку, много
не выиграешь, а lower() в Mojo на этом тексте ещё и в 3 раза
медленнее, чем в CPython. Большой выигрыш даёт переписанный под Mojo
горячий цикл.
Тексты курса — CC BY-NC-SA 4.0, код примеров — Apache 2.0