Pull to refresh

Сравниваем Nim и Rust

Reading time10 min
Views34K
Original author: Arthur Liao
Предлагаю читателям «Хабрахабра» перевод статьи «A Quick Comparison of Nim vs. Rust». Мои замечания будут выделены курсивом.

Rust и Nim — два новых языка программирования за развитием которых я слежу. Вскоре, после моего первого поста о Rust, вышел в свет Nim 0.10.2. Это побудило меня поближе познакомиться с Nim и, естественно, сравнить его с Rust.

В этом посте я собираюсь показать вам две простых программы написанных на Nim и Rust с грубым сравнением их времени выполнения и выскажу мои субъективные впечатления от программирования на этих языках.



Пример №1: Подсчёт слов (wordcount)



В этом примере используется файловый I/O, регулярные выражения, хеш-таблицы (ассоциативные массивы) и парсинг аргументов переданных команде. Как следует из названия, прогрмма подсчитывает количество слов в файлах или stdin.

Пример использования:

Usage: wordcount [OPTIONS] [FILES]

Options:
    -o:NAME             set output file name
    -i --ignore-case    ignore case
    -h --help           print this help menu


Если мы передадим аргумент -i, то результат будет таким:

2       case
1       file
1       files
1       h
2       help
...


Nim версия



Программа на Nim достаточно проста. В ней используются tables.CountTable для подсчёта слов, parseopt2.getopt для парсинга аргументов команды и sequtils.mapIt для функциональной операции мэппинга. Для регулярных выражения я выбрал модуль pegs, который рекомендуется документацией Nim заместо re.

Деректива {.raises: [IOError].} на строке 3 гарантирует что процедура doWork выбрасывает только исключение IOError. Для этого я поместил input.findAll(peg"\w+") внутрь try выражения в строке 21 чтобы отловить исключения, которые, теоретически, могут возникнуть.

Часть кода wordcount.nim:

proc doWork(inFilenames: seq[string] = nil,
            outFilename: string = nil,
            ignoreCase: bool = false) {.raises: [IOError].} =
  # Open files
  var
    infiles: seq[File] = @[stdin]
    outfile: File = stdout
  if inFilenames != nil and inFilenames.len > 0:
    infiles = inFilenames.mapIt(File, (proc (filename: string): File =
      if not open(result, filename):
        raise newException(IOError, "Failed to open file: " & filename)
    )(it))
  if outFilename != nil and outFilename.len > 0 and not open(outfile, outFilename, fmWrite):
    raise newException(IOError, "Failed to open file: " & outFilename)

  # Parse words
  var counts = initCountTable[string]()
  for infile in infiles:
    for line in infile.lines:
      let input = if ignoreCase: line.tolower() else: line
      let words = try: input.findAll(peg"\w+") except: @[]
      for word in words:
        counts.inc(word)

  # Write counts
  var words = toSeq(counts.keys)
  sort(words, cmp)
  for word in words:
    outfile.writeln(counts[word], '\t', word)


Rust версия



Для лучшего понимания Rust я реализовал простую структуру BTreeMap сродни collections::BTreeMap, но в конечном итоге я использовал collections::HashMap для справедливого сравнения с Nim (код BTreeMap остался в репозитории для ознакомления). Пакет getopts используется для парсинга аргументов команды в мою структуру Config. Далее всё должно быть понятно.

Часть кода из моего проекта Rust wordcount:

fn do_work(cfg: &config::Config) -> io::Result<()> {
    // Open input and output files
    let mut readers = Vec::with_capacity(std::cmp::max(1, cfg.input.len()));
    if cfg.input.is_empty() {
        readers.push(BufReader::new(Box::new(io::stdin()) as Box<Read>));
    } else {
        for name in &cfg.input {
            let file = try!(File::open(name));
            readers.push(BufReader::new(Box::new(file) as Box<Read>));
        }
    }
    let mut writer = match cfg.output {
        Some(ref name) => {
            let file = try!(File::create(name));
            Box::new(BufWriter::new(file)) as Box<Write>
        }
        None => { Box::new(io::stdout()) as Box<Write> }
    };

    // Parse words
    let mut map = collections::HashMap::<String, u32>::new();
    let re = regex!(r"\w+");

    // let re = Regex::new(r"\w+").unwrap();
    // let re = regex!(r"[a-zA-Z0-9_]+");
    // let re = Regex::new(r"[a-zA-Z0-9_]+").unwrap();
    for reader in &mut readers {
        for line in reader.lines() {
            for caps in re.captures_iter(&line.unwrap()) {
                if let Some(cap) = caps.at(0) {
                    let word = match cfg.ignore_case {
                        true  => cap.to_ascii_lowercase(),
                        false => cap.to_string(),
                    };
                    match map.entry(word) {
                        Occupied(mut view) => { *view.get_mut() += 1; }
                        Vacant(view) => { view.insert(1); }
                    }
                }
            }
        }
    }
    // Write counts
    let mut words: Vec<&String> = map.keys().collect();
    words.sort();
    for &word in &words {
        if let Some(count) = map.get(word) {
            try!(writeln!(writer, "{}\t{}", count, word));
        }
    }
    Ok(())
}


Zachary Dremann предложил pull request в котором используется find_iter. Я оставил captures_iter для согласованности с Nim версией, но немного улучшил свой код.

Сравнение времени выполнения



Я скомпилировал код с флагами -d:release для Nim и --release для Rust. Для примера взял файл в 5 мегабайт составленый из исходников компилятора Nim:

$ cat c_code/3_3/*.c > /tmp/input.txt
$ wc /tmp/input.txt
  217898  593776 5503592 /tmp/input.txt


Команда для запуска программы:

$ time ./wordcount -i -o:result.txt input.txt


Вот результат на моём Mac mini с процессором 2.3 GHz Intel Core i7 и памятью 8 GB: (1x = 0.88 секунды)

Rust regex! \w Regex \w regex! […] Regex […] Nim
release, -i 1x 1.30x 0.44x 1.14x 0.75x
release 1.07x 1.33x 0.50x 1.24x 0.73x
debug, -i 12.65x 20.14x 8.77x 19.42x 3.51x
debug 12.41x 20.09x 8.84x 19.33x 3.25x


Примечания:
  1. В Rust regex! работает быстрее чем Regex, и r"[a-zA-Z0-9_]+" быстрее чем r"\w+". Все 4 комбинации были протестированы.
  2. Версия «debug» просто для сравнения
  3. Nim работает на 1-2% медленнее с флагом --boundChecks:on, я не стал добавлять этот результат в пример.


Пример №2: Игра «Жизнь»



Этот пример запускает «Жизнь» в консоле с фиксированными размером поля и шаблоном (для изменения размера или шаблона отредактируйте исходный код). В нём используется ANSI CSI код для перерисовки экрана.

После запуска экран будет выглядеть примерно так:

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . . (). (). . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . (). . . (). . . . . . . . . . .
. . . . . . . . . . . . . . (). . . . . . . (). . . . . . . . . . . . ()().
. . . . . . . . . . . . . ()()()(). . . . (). . . . (). . . . . . . . ()().
. ()(). . . . . . . . . ()(). (). (). . . . (). . . . . . . . . . . . . . .
. ()(). . . . . . . . ()()(). (). . (). . . (). . . (). . . . . . . . . . .
. . . . . . . . . . . . ()(). (). (). . . . . . (). (). . . . . . . . . . .
. . . . . . . . . . . . . ()()()(). . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . (). . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . . . (). . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . (). (). . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . . ()(). . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . (). . . . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . (). . . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . ()()(). . . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
n = 300   Press ENTER to exit


Прграмма использует другой поток для чтения из stdin и прерывает игру при получении любого символа.

Nim версия



Вот часть кода из моего проекта Nim conway:

type
  Cell = bool
  ConwayMap* = array[0.. <mapHeight, array[0.. <mapWidth, Cell]]

proc init*(map: var ConwayMap, pattern: openarray[string]) =
  ## Initialise the map.
  let
    ix = min(mapWidth, max(@pattern.mapIt(int, it.len)))
    iy = min(mapHeight, pattern.len)
    dx = int((mapWidth - ix) / 2)
    dy = int((mapHeight - iy) / 2)
  for y in 0.. <iy:
    for x in 0.. <ix:
      if x < pattern[y].len and pattern[y][x] notin Whitespace:
        map[y + dy][x + dx] = true

proc print*(map: ConwayMap) =
  ## Display the map.
  ansi.csi(AnsiOp.Clear)
  ansi.csi(AnsiOp.CursorPos, 1, 1)
  for row in map:
    for cell in row:
      let s = if cell: "()" else: ". "
      stdout.write(s)
    stdout.write("\n")

proc next*(map: var ConwayMap) =
  ## Iterate to next state.
  let oldmap = map
  for i in 0.. <mapHeight:
    for j in 0.. <mapWidth:
      var nlive = 0
      for i2 in max(i-1, 0)..min(i+1, mapHeight-1):
        for j2 in max(j-1, 0)..min(j+1, mapWidth-1):
          if oldmap[i2][j2] and (i2 != i or j2 != j): inc nlive
      if map[i][j]: map[i][j] = nlive >= 2 and nlive <= 3
      else: map[i][j] = nlive == 3


Rust версия



Вот часть кода из моего проекта Rust conway:

type Cell = bool;

#[derive(Copy)]
pub struct Conway {
    map: [[Cell; MAP_WIDTH]; MAP_HEIGHT],
}

impl Conway {
    pub fn new() -> Conway {
        Conway {
            map: [[false; MAP_WIDTH]; MAP_HEIGHT],
        }
    }

    pub fn init(&mut self, pattern: &[&str]) {
        let h = pattern.len();
        let h0 = (MAP_HEIGHT - h) / 2;
        for i in 0..(h) {
            let row = pattern[i];
            let w = row.len();
            let w0 = (MAP_WIDTH - w) / 2;
            for (j, c) in row.chars().enumerate() {
                self.map[i + h0][j + w0] = c == '1';
            }
        }
    }

    /// Iterate to next state. Return false if the state remains unchanged.
    pub fn next(&mut self) -> bool {
        let mut newmap = [[false; MAP_WIDTH]; MAP_HEIGHT];
        for i in 0..(MAP_HEIGHT) {
            for j in 0..(MAP_WIDTH) {
                let mut nlive = 0;
                for i2 in i.saturating_sub(1)..cmp::min(i+2, MAP_HEIGHT) {
                    for j2 in j.saturating_sub(1)..cmp::min(j+2, MAP_WIDTH) {
                        if self.map[i2][j2] && (i2 != i || j2 != j) {
                            nlive += 1;
                        }
                    }
                }
                newmap[i][j] = match (self.map[i][j], nlive) {
                    (true, 2) | (true, 3) => true,
                    (true, _) => false,
                    (false, 3) => true,
                    (false, _) => false,
                };
            }
        }
        // let changed = self.map != newmap;
        let changed = true;
        self.map = newmap;
        changed
    }
}

impl fmt::Display for Conway {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        for row in self.map.iter() {
            for cell in row.iter() {
                try!(write!(f, "{}", if *cell { "()" } else { ". " }));
            }
            try!(write!(f, "\n"));
        }
        Ok(())
    }
}


В строке 49 я определил переменную для отслеживания изменения отображения, но простое сравнение self.map != newmap не работает для массивов длинной более 32 элементов, пока вы не реализуете трейт PartialEq.

Отмечу, что я использовал небезопасный libc::exit в моём main.rs, что очень не характерно для Rust. Zachary Dremann предложил pull request в котором элегантно избегается libc::exit с использованием макроса select! и неблокирующего таймера. Возможно вы захотите посмотреть.

Сравнение времени выполнения



Для сравнения времени выполнения необходимо произвести некоторые изменения в коде:

  1. Закомментировать вызов sleep в conway.nim и
    main.rs
  2. Изменить количество итераций цикла с 300 до 3000
  3. Перерисовка поля тратит много времени, поэтому произведены два замера (1) с перерисовкой и (2) без неё (т.е. с закомментированными строками вывода поля на печать в conway.nim и main.rs)


Вот результаты при компиляции с флагами -d:release для Nim и --release для Rust:

Rust Nim Nim/bc:on n=30000
(1) with map print 1x 1.75x 1.87x 1x=3.33s
(2) without map print 1x 1.15x 1.72x 1x=0.78


Т.к. Rust делает проверку на выход за границу списка, для справедливости я добавил колонку Nim/bc:on для Nim версии скомпилированной с флагом --boundChecks:on.

Nim или Rust



Хоть Nim и Rust компилируемые языки с рассчётом на хорошую производительность они очень разные. Для меня их сходства заключаются в следующем:

  • компилируемые и статически типизированные
  • рассчёт на хорошую производительность (каждый из них может отработать быстрее в зависимости от реализации программы и её дальнейших оптимизаций)
  • композиция вместо наследования (похоже на тренд в новых языках?)
  • простая связка с Си
  • популярные языковые лакомства: дженерики, замыкания, функциональные подходы, вывод типов, макросы, операторы в виде инструкций и т.д.


Но их различия более интересны.

Философия: свобода или дисциплина



При программировании на Nim складывается ощущение что пишешь на скриптовом языке. Он действительно стирает грань. Nim старается избавиться от шума в коде настолько, насколько это возможно и поэтому программировать на нём в радость.

Однако есть и обратная сторона у такой свободы: могут пострадать ясность, чистота и поддерживаемость. Вот небольшой пример: в Nim import импортирует все имена модуля в ваше пространство имён. Имена из импортируемого модуля могут быть ограничены использованием синтаксиса module.symbol или использованием from module import nil для контролируемого импорта имён, но, скажите мне, кто этим пользуется? Тем более, что этот подход не характерен для Nim. В результате вы не сможете понять какие имена пришли из какого модуля при чтении чужого (или собственного) кода (к счастью, противоречий наименований не случается, т.к. в таких случаях Nim заствляет отделять мух от котлет).

Ещё примеры: UFCS позволяет использовать len(x), len x, x.len() или x.len как вам вздумается; не разделяет имена с подчёркиванием и разным регистром, так mapWidth, mapwidth и map_width будут преобразованы в одно и тоже имя (я рад что они включили правило «частичной чувствительности к регистру» в версии 0.10.2, поэтому Foo и foo будут считаться различными именами); в порядке вещей является использование неинициализированных переменных. В теории, вы можете следовать строгим принципам кодирования, но при программировании в Nim вы будете чувствовать себя более раскованно.

С другой стороны, Rust чтит дисциплину. Его компилятор очень строгий. Всё должно быть предельно ясно. Вы заранее получаете правильные подходы. Неоднозначность — это не про код на Rust…
Такой подход, как правило, хорош для долгоживущих проектов и для поддерживаемости, но при программировании на Rust вы начинаете заботиться о таких деталях, которые могут быть вам совсем не интересны. Вы начинаете задумываться об использовании памяти или увеличении производительности, даже если это не является приоритетом для вашей задачи. Rust делает вас более дисциплинированным.

Оба имеют свои плюсы и минусы. Как программист, я больше наслаждаюсь от Nim; как майнтейнер, я бы лучше сопровождал продукты написанные на Rust.

Визуальный стиль: Python или C++



Как и Python, Nim использует отступы для отделения блоков кода и в нём меньше всяких знаков. Rust более похож на C++. {}, ::, <> и & будут знакомы C++ программистам, плюс Rust добавляет некоторы новые вещи вроде 'a.

Иногда Nim может быть слишком буквальным. Для примера, я думаю синтаксис match в Rust:

match key.cmp(&node.key) {
    Less    => return insert(&mut node.left, key, value),
    Greater => return insert(&mut node.right, key, value),
    Equal   => node.value = value,
}


выглядит чище, чем выражение case в Nim:

case key
of "help", "h": echo usageString
of "ignore-case", "i": ignoreCase = true
of "o": outFilename = val
else: discard


Но, в целом, код на Nim менее зашумлен. По моему мнению, особенный беспорядок в Rust вносят параметры времени жизни (lifetime parameters) и это уже не изменится.

Управление памятью: Сборщик мусора или ручное управление



Хоть Nim и позволяет небезопасное управление памятью и обеспечивает поддержку управления сборщиком мусора в рантайме для более предсказуемымого поведения. Это всё ещё язык со сборщиком мусора, который обладает всеми плюсами и минусами от него. Объектам в Nim присваиваются копии значений. Если для вашей задачи сборщик мусора не помешает, то управление памятью в Nim не будет вызывать у вас проблем.

Rust обеспечивает ограниченную поддержку собрщика мусора, но чаще вы будете полагаться на систему владения в управлении памятью. Будучи программистом на Rust вы должны полностью разобраться в его модели управления памятью (владение, заимствование и время жизни) прежде чем вы начнёте эффективно писать програмы, что является первым барьером для новичков.

С другой стороны, в этом же заключается и сила Rust — безопасное управление памятью без использования сборщика мусора. Rust прекрасно справляется с этой задачей. Наряду с безопасностью разделяемых ресрусов, безопасностью конкурентного доступа к данным и устранением null указателей Rust является черезвычайно надёжным языком программирования с отсутствием накладных расходов на потребление ресурсов в рантайме.

В зависимости от ваших требований, либо вам будет достаточно сборщика мусора Nim, либо ваш выбор падёт на Rust.

Другие различия



Сильные стороны Nim:

  1. Продуктивность: в одинаковых временных рамках вы запилите больше возможностей в Nim
  2. Простота в изучении
  3. Компилируемый язык как скриптовый, хорош для протипирования, интерактивного исследования, пакетной обработки данных и т.д
  4. Фишечки:
    • переопределение методов
    • определение новых опереаторов
    • именованные аргументы и значения по-умолчанию
    • мощные макросы



Сильные стороны Rust:

  1. Настоящий системный язык прогрммирования: встраиваемый, без сборщика мусора, близок к железу
  2. Безопасный, дисциплинирующий, надёжный
  3. Сильная команда ядра и активное сообщество
  4. Фишечки:
    • превосходная реализация сопоставления с образцом (pattern matching)
    • перечисления (enum), хотя в Nim перечисления тоже хороши
    • let mut вместо var (маленькая, но важная вещь)
    • мощный синтаксис разыменования структур



Обработка ошибок: в Nim используется общий механизм исключений, Rust использует возвращаемый тип Result (и макрос panic!). У меня нет предпочтений в этом, но я посчитал важным упомянуть это различие.

Релиз 1.0 на подходе



Nim и Rust должны зарелизиться в этом году (Rust зарелизился). Это очень здорово! Rust получил уже достаточно много внимания, но и Nim становится более известным. Они очень разные на вкус, но оба великолепные новые языки программирования. Rust показывает себя с лучшей стороны в вопросах производительности и безопасности. Nim проворный (игра слов: Nim is nimble), выразительный, реализует сильные стороны скриптовых и компилируемых языков. Они оба станут отличным дополнением вашего инструментария.

Надеюсь, после прочтения этой статьи, вы составили своё мнение об этих языках программирования.
Tags:
Hubs:
+54
Comments50

Articles

Change theme settings