Привет, Хабр!

Представьте себе вот такую картину: вы сидите дома, и вокруг вас мирно мурлыкают котики. Но вдруг, что-то пошло не по плану: один начал ловить лазерный указатель, другой карабкается на шторы, третий — нагло укладывается на вашу клавиатуру. Ну, вы поняли, полный хаос. И тут возникает вопрос: как навести порядок в этом котячьем хаосе? Как упорядочить этот бесконечный поток пушистых данных?

Вот тут-то и приходит на помощь наш добрый друг — Rust, а точнее его функции map, filter и fold. Они помогают не только приручить самых неугомонных data-котиков, но и сделать это без компромиссов по производительности.

map для трансформации потоков данных

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

Представьте, что у вас есть список котиков, и каждый котик нуждается в небольшой трансформации, скажем, хочется переименовать их или добавить какие-то атрибуты. map позволяет сделать это.

Основная задача map — трансформация данных. Т.е берется каждая сущность из потока данных, применяется к ней функция и на выходе получается новый поток с изменёнными сущностями.

Некоторые моменты:

  • map не изменяет оригинальный итератор, а создает новый.

  • В Rust итераторы ленивы, т.е они не выполняют вычисления до тех пор, пока это не станет необходимым. Это означает, что вызов map сам по себе не выполнит никаких преобразований, пока не будет использован метод, потребляющий итератор.

Начнем с самого простого и классического примера — преобразования списка чисел:

fn main() {
    let numbers = vec![1, 2, 3, 4, 5];

    let squared_numbers: Vec<i32> = numbers.iter().map(|&x| x * x).collect();

    println!("{:?}", squared_numbers);
}

Здесь мы взяли вектор numbers, и с помощью map превратили каждый элемент в его квадрат. В итоге squared_numbers станет [1, 4, 9, 16, 25].

Обратите внимание на:

  • Мы использовали iter(), чтобы получить итератор по ссылкам на элементы, затем применили map, и в конце собрали результат в новый вектор с помощью collect().

  • Ленивость итераторов: map не выполняет преобразование до вызова collect.

Теперь усложним задачу. Допустим, есть список структур, и нужно извлечь из них определённое поле:

struct Cat {
    name: String,
    age: u8,
}

fn main() {
    let cats = vec![
        Cat { name: String::from("Mittens"), age: 2 },
        Cat { name: String::from("Whiskers"), age: 5 },
        Cat { name: String::from("Shadow"), age: 3 },
    ];

    let cat_names: Vec<String> = cats.iter().map(|cat| cat.name.clone()).collect();

    println!("{:?}", cat_names);
}

Здесь создали структуру Cat и список котов. С помощью map мы извлекаем имена всех котов в новый вектор. Заметьте, что пришлось использовать clone(), чтобы избежать проблем с владением (borrow checker в Rust не дремлет).

Но что если нужно преобразовать данные в соответствии с некоторым условием? Возьмем пример с преобразованием имен котов, чтобы добавить к ним возраст:

fn main() {
    let cats = vec![
        Cat { name: String::from("Mittens"), age: 2 },
        Cat { name: String::from("Whiskers"), age: 5 },
        Cat { name: String::from("Shadow"), age: 3 },
    ];

    let cat_descriptions: Vec<String> = cats.iter().map(|cat| {
        format!("{} is {} years old", cat.name, cat.age)
    }).collect();

    println!("{:?}", cat_descriptions);
}

Теперь каждый элемент стал строкой, описывающей имя кота и его возраст.

Несколько советов:

  1. Избегайте ненужных клонирований: Если нужно сохранить ссылки на оригинальные данные, лучше использовать iter() вместо into_iter(), чтобы не потреблять коллекцию.

  2. Убедитесь, что функция не имеет побочных эффектов: map предназначен для чистых преобразований. Если функция, передаваемая в map, имеет побочные эффекты (например, изменения внешнего состояния), это может привести к непредсказуемым результатам.

  3. Сочетание с другими итераторами: map отлично комбинируется с другими методами итераторов, такими как filter, enumerate и fold. Например, можно сначала отфильтровать элементы, а затем применить к ним map.

Фильтрация данных с помощью filter

По сути, filter — это условный сито, через которое просеиваются данные, и на выходе остаются только золотые крупицы.

filter принимает функцию-предикат, которая возвращает true или false. Если для элемента предикат вернул true, элемент остаётся в потоке данных; если false — он удаляется.

Предположим, есть список чисел, и нужно оставить только чётные:

fn main() {
    let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

    let even_numbers: Vec<i32> = numbers.into_iter().filter(|&x| x % 2 == 0).collect();

    println!("{:?}", even_numbers);
}

Результат будет [2, 4, 6, 8, 10].

filter проходит по каждому элементу итератора и применяет к нему функцию-предикат. Если предикат возвращает true, элемент остаётся в выходном потоке.

Как и map, filter ленив. Это означает, что никакие данные фактически не фильтруются до тех пор, пока вы не вызовете метод, который потребляет итератор, например, collect().

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

Допустим, есть массив чисел, и нужно выделить только те, которые делятся на 3:

fn main() {
    let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

    let divisible_by_three: Vec<i32> = numbers.into_iter().filter(|&x| x % 3 == 0).collect();

    println!("{:?}", divisible_by_three);
}

Вывод: [3, 6, 9].

Предположим, есть список котов, и вы нужно выбрать только тех, кто старше 3 лет:

struct Cat {
    name: String,
    age: u8,
}

fn main() {
    let cats = vec![
        Cat { name: String::from("Mittens"), age: 2 },
        Cat { name: String::from("Whiskers"), age: 5 },
        Cat { name: String::from("Shadow"), age: 3 },
        Cat { name: String::from("Luna"), age: 7 },
    ];

    let adult_cats: Vec<&Cat> = cats.iter().filter(|&cat| cat.age > 3).collect();

    for cat in adult_cats {
        println!("{} is {} years old", cat.name, cat.age);
    }
}

Здесь оставляем только тех котов, возраст которых больше 3 лет. Результат:

Whiskers is 5 years old
Luna is 7 years old

Иногда нужно фильтровать данные по более сложным критериям. Например, есть список пользователей, и нужно выбрать только тех, у кого активная подписка и возраст больше 18 лет:

struct User {
    name: String,
    age: u8,
    has_active_subscription: bool,
}

fn main() {
    let users = vec![
        User { name: String::from("Alice"), age: 22, has_active_subscription: true },
        User { name: String::from("Bob"), age: 17, has_active_subscription: true },
        User { name: String::from("Charlie"), age: 19, has_active_subscription: false },
        User { name: String::from("Dave"), age: 30, has_active_subscription: true },
    ];

    let active_adults: Vec<&User> = users.iter()
        .filter(|&user| user.age > 18 && user.has_active_subscription)
        .collect();

    for user in active_adults {
        println!("{} is {} years old and has an active subscription", user.name, user.age);
    }
}

Результат:

Alice is 22 years old and has an active subscription
Dave is 30 years old and has an active subscription

Если идет работа с большими объёмами данных, стоит обратить внимание на библиотеку Rayon, которая позволяет распараллелить итераторы.

Пример:

use rayon::prelude::*;

fn main() {
    let numbers: Vec<i32> = (0..1_000_000).collect();

    let even_numbers: Vec<i32> = numbers.par_iter().filter(|&&x| x % 2 == 0).cloned().collect();

    println!("Found {} even numbers", even_numbers.len());
}

Используем параллельный итератор par_iter, чтобы ускорить фильтрацию.

Агрегирование данных с использованием fold

В Rust функция fold используется для последовательного накопления значений из потока данных. Если другими словами, это инструмент для свертки, который позволяет свести поток данных к одному значению.

Основная идея заключается в том, что fold берёт начальное значение и последовательно применяет функцию, которая принимает аккумулятор и текущий элемент потока, обновляет аккумулятор и возвращает его на следующем шаге.

Сигнатура метода fold выглядит так:

fn fold<B, F>(self, init: B, f: F) -> B
where
    F: FnMut(B, Self::Item) -> B
  • init: начальное значение аккумулятора.

  • f: функция, которая применяется к аккумулятору и каждому элементу итератора.

  • Возвращаемое значение — это аккумулятор после обработки всех элементов.

Классический пример — суммирование чисел в списке:

fn main() {
    let numbers = vec![1, 2, 3, 4, 5];

    let sum: i32 = numbers.iter().fold(0, |acc, &x| acc + x);

    println!("Sum: {}", sum);
}

Здесь начинаем с аккумулятора, равного 0, и на каждом шаге добавляем к нему текущее значение из потока данных. В итоге sum будет равен 15.

Допустим, есть массив строк, и нужно объединить их в одну строку:

fn main() {
    let words = vec!["Hello", "Rust", "World"];

    let sentence: String = words.iter().fold(String::new(), |mut acc, &word| {
        acc.push_str(word);
        acc.push(' ');
        acc
    });

    println!("Sentence: {}", sentence.trim());
}

Начинаем с пустой строки, и на каждом шаге добавляем к аккумулятору следующее слово. Обратите внимание на вызов trim() при выводе результата, чтобы убрать лишний пробел в конце.

Можно юзать fold для вычисления факториала числа:

fn main() {
    let n = 5;
    
    let factorial: i32 = (1..=n).fold(1, |acc, x| acc * x);

    println!("Factorial of {} is {}", n, factorial);
}

Здесь начинаем с аккумулятора, равного 1, и на каждом шаге умножаем его на текущее значение в диапазоне от 1 до n. В результате для n = 5 факториал будет равен 120.

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

Допустим, есть список чисел и нужно получить сумму чётных чисел и сумму нечётных чисел в одной операции:

fn main() {
    let numbers = vec![1, 2, 3, 4, 5, 6];

    let (sum_even, sum_odd) = numbers.iter().fold((0, 0), |(even_acc, odd_acc), &x| {
        if x % 2 == 0 {
            (even_acc + x, odd_acc)
        } else {
            (even_acc, odd_acc + x)
        }
    });

    println!("Sum of even numbers: {}", sum_even);
    println!("Sum of odd numbers: {}", sum_odd);
}

Здесь начинаем с кортежа (0, 0) для хранения двух аккумуляторов: для чётных и нечётных чисел. Затем на каждом шаге проверяем, является ли число чётным, и обновляем соответствующий аккумулятор.

Сложная структура данных — это не проблема для fold. Допустим, нужно сгруппировать элементы по какому-то ключу, например, по первой букве строки:

use std::collections::HashMap;

fn main() {
    let words = vec!["apple", "banana", "apricot", "blueberry", "avocado"];

    let grouped: HashMap<char, Vec<&str>> = words.iter().fold(HashMap::new(), |mut acc, &word| {
        acc.entry(word.chars().next().unwrap())
            .or_insert(Vec::new())
            .push(word);
        acc
    });

    for (key, value) in &grouped {
        println!("{}: {:?}", key, value);
    }
}

Здесь начинаем с пустого HashMap и на каждом шаге добавляем слова в соответствующие группы по первой букве.

Одной из крутых фич fold — способность работать с разными типами данных. Например, вы можете агрегировать информацию о пользователях, собирая данные из нескольких полей:

struct User {
    name: String,
    age: u8,
}

fn main() {
    let users = vec![
        User { name: String::from("Alice"), age: 30 },
        User { name: String::from("Bob"), age: 25 },
        User { name: String::from("Charlie"), age: 35 },
    ];

    let total_age: u8 = users.iter().fold(0, |acc, user| acc + user.age);
    let names: String = users.iter().fold(String::new(), |mut acc, user| {
        acc.push_str(&user.name);
        acc.push(' ');
        acc
    });

    println!("Total age: {}", total_age);
    println!("Names: {}", names.trim());
}

Здесь fold используется для суммирования возрастов пользователей и объединения их имён в одну строку.


Заключение

Можно легко объединить map, filter и fold. Например, можно сначала отфильтровать нежелательные элементы с помощью filter, затем преобразовать оставшиеся данные с помощью map, и, наконец, свести их в одно значение с помощью fold.

Пример:

fn main() {
    let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

    let sum_of_squares_of_even_numbers: i32 = numbers.iter()
        .filter(|&&x| x % 2 == 0)  // Отфильтруем чётные числа
        .map(|&x| x * x)           // Преобразуем их в квадраты
        .fold(0, |acc, x| acc + x); // Просуммируем их

    println!("Sum of squares of even numbers: {}", sum_of_squares_of_even_numbers);
}

Так что не стесняйтесь комбинировать map, filter, fold и другие методы итераторов в своих проектах!

Теперь настало время внедрить их в свои проекты и ощутить всю силу функционального программирования на Rust.

Greenplum, аналитическая MPP СУБД, внезапно перестала быть доступной системой с открытым исходным кодом. Как это влияет на индустрию? Какие системы её могут заменить? Придётся ли менять архитектуру систем обработки данных? Обсудим это на открытом уроке 21 августа.

На уроке будет произведен анализ текущей ситуации, а также рассмотрены варианты дальнейших действий. В результате урока слушатели получат рекомендации по построению систем обработки данных на альтернативных решениях, которые далее смогут использовать в работе. Записывайтесь по ссылке.

Комментарии (13)


  1. gochaorg
    16.08.2024 17:49

    Извеняюсь, а где операция flatMap?

    Как то мало операций, да и не те, filter и map легко можно заменить на flatMap, а fold, size для ограниченный (конечных) итераторов годиться


  1. CrazyOpossum
    16.08.2024 17:49
    +10

    Я всегда думал, что три столпа функционального программирования - функции высшего порядка, чистые данные и вызов-по-необходимости.


    1. BenGunn
      16.08.2024 17:49

      Удваиваю.


    1. Nansch
      16.08.2024 17:49

      Эдак вы на любом языке начнёте функциональщиной заниматься и всю магию "функциональных" языков испортите.


  1. SpiderEkb
    16.08.2024 17:49
    +1

    • map не изменяет оригинальный итератор, а создает новый.

    А если у нас в исходной коллекции миллионы элементов и начальная коллекция после применения map нас перестанет интересовать?

    Нет ли возможности применить map к исходной коллекции, не создавая ее дубля?

    Аналогично для filter - не создать новую отфильтрованную коллекцию, но удалить из исходной те элементы, которые не удовлетворяют условиям фильтра.

    Все это хорошо и красиво, пока вы работаете с десятком котиков. А когда вам нужно будет работать с десятком миллионов реальных данных, все это становится достаточно громоздко по памяти.


    1. Nail_S
      16.08.2024 17:49

      А разве есть возможность модифицировать данные «на месте». Данные в векторе лежат линейно, физически не получится положить что то большее, в теории можно положить что-то равное либо меньшее. Если с чем то равным профит реален, то с меньшим уже вопрос. Вся эта магия с переразметкой памяти уже относится к хакам и этому нет места в стандартной библиотеке.

      А по filter, есть еще retain - оставляет то что нужно в текущей коллекции сохраняя порядок, но требует линейной памяти [bool].


      1. SpiderEkb
        16.08.2024 17:49

        Ну ситуации бывают разные. Я часто сталкивался с тем, что требуется именно модификация в рамках того же размера памяти. Или фильтрация когда порядок не важен, но важно уменьшение количества элементов вектора (play-off алгоритмы когда на каждом из нескольких проходов размер вектора уменьшается и время цикла на каждом проходе сокращается).

        В любом случае, подобные вещи достаточно сильно зависят от задачи и не являются универсальными. Представьте что у вас в векторе хотя бы 500 000 элементов (по нашим меркам это относительно немного). И вам нужно последовательно применить map или filter 10-15-20 раз...


    1. FlyingDutchman2
      16.08.2024 17:49

      Нет ли возможности применить map к исходной коллекции, не создавая ее дубля?

      Аналогично для filter - не создать новую отфильтрованную коллекцию, но удалить из исходной те элементы, которые не удовлетворяют условиям фильтра.

      В принципе, для таких вещей давно придуманы функциональные структуры данных (описаны в книге Chris Okasaki "Pure Functional Data Structures"). Но я не знаю, как с этим обстоит дело в Rust.


      1. SpiderEkb
        16.08.2024 17:49

        Так много что придумано. Вопрос в реализации...


      1. ris58h
        16.08.2024 17:49

        В принципе, для таких вещей давно придуманы функциональные структуры данных

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


    1. ris58h
      16.08.2024 17:49

      Нет ли возможности применить map к исходной коллекции, не создавая ее дубля?

      Это уже какой-то ограниченный map, т.к. тип на выходе должен совпадать с типом на входе.

      А если у нас в исходной коллекции миллионы элементов и начальная коллекция после применения map нас перестанет интересовать?

      map/filter работают с (потенциально бесконечными) итераторами, а не коллекциями.


      1. SpiderEkb
        16.08.2024 17:49

        Это уже какой-то ограниченный map, т.к. тип на выходе должен совпадать с типом на входе.

        Я вам больше скажу. Бывают случаи, когда нужен не map, а action - некоторое действие с каждым элементов коллекции.

        map/filter работают с (потенциально бесконечными) итераторами, а не коллекциями.

        Ну это уже сферические map/filter в вакууме. В реальности все ограничивается размерами памяти и накладными расходами на ее выделение/освобождение (когда объемы данных достаточно велики и оценить заранее их не представляется возможным)

        Я не спорю и не критикую, просто мысленно примеряю под те задачи, с которыми приходится сталкиваться чаще всего - насколько все это может быть удобно и применимо лично мне. Не более того.


        1. PROgrammer_JARvis
          16.08.2024 17:49
          +3

          Я вам больше скажу. Бывают случаи, когда нужен не map, а action - некоторое действие с каждым элементов коллекции.

          И для этого также есть разные средства (к сожалению, в статье не показанные).
          Если этот action просто использует значение (скорее, ссылку на него), то for, который работает на всём, что реализует IntoIterator, который включает в себя (но не ограничивается этим) и сами итераторы:

          for item in &vec {
              println!("Item is {item}");
          }
          

          Если же нужно in-place, то... Снова for, потому что итерироваться можно ещё и по mut-ссылкам:

          for item in &mut vec {
              // in-place увеличиваем каждый элемент на 10
              *item += 10;
          }
          

          Ну это уже сферические map/filter в вакууме. В реальности все ограничивается размерами памяти и накладными расходами на ее выделение/освобождение

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

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

          Например, в std есть iter::repeat, который просто неограниченно генерирует один и тот же элемент, а уж что с ним будет делать получатель данных - его выбор. И при этом самому итератор нужно O(1) памяти на своё внутренне представление. Или, например, в крейте rand ГПСЧ позволяют получить из них итераторы, возвразающие бесконечный итератор псевдослучайных чисел.