Перейти к содержимому

Проект: утилита командной строки

Пора собрать всё вместе и сделать вещь, которой можно пользоваться: консольную утилиту. Не игрушечный «привет, мир», а программу с ключами, справкой, понятными ошибками и правильными кодами возврата — такую, которую не стыдно положить рядом с 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_init
struct 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 начинается так:

# первые строки функции 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)

Заодно в main не нужен raises: все ошибки обработаны внутри, и наружу не вылетит ничего, кроме кода возврата.

Хорошая консольная утилита различает два потока вывода. Результат идёт в 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 directory
wordfreq: /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)
🐍 Python
pairs.sort(key=lambda p: (-p[1], p[0]))
🔥 Mojo
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.mojo1100 мс500 мс
python3.14 wordfreq.py12,2 мс6,1 мс
собранный wordfreq7,4 мс4,8 мс

Разница между первой и последней строкой — это и есть компиляция. Собранная программа стартует быстрее Python, но не мгновенно: пустая программа на Mojo на той же машине с Xeon запускается за 7,7 мс, на C — за 1,1 мс. Время уходит на загрузку и запуск рантайма Mojo.

Чтобы вызывать утилиту по имени из любого каталога, положите её в каталог из PATH, например в ~/.local/bin. На своей машине этого достаточно — пока на месте .venv проекта: библиотеки рантайма программа ищет именно там. Чтобы отдать её другим людям, понадобятся ещё библиотеки рантайма и сборка под общий процессор — как это сделать, разобрано в главе «Упаковка и распространение»:

Окно терминала
mkdir -p dist/lib
uv 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.73,23 с1,39 с
Mojo 1.1.02,74 с1,21 с
Mojo быстрее в1,18 раза1,15 раза

Не в десять раз и не в сто. Чтобы понять почему, замерим этапы по отдельности, миллисекунды (лучшее из трёх):

ЭтапPython, XeonMojo, XeonPython, RyzenMojo, Ryzen
чтение файла51527320287
lower() всего текста3711083138501
split()549452243211
цикл: strip(), ключ, словарь14581077638532

Картина на обеих машинах одна.

Цикл по 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.mojo
"""Утилита 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_init
struct 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 горячий цикл.

Примеры проверены на Mojo 1.1.0

Тексты курса — CC BY-NC-SA 4.0, код примеров — Apache 2.0