Простые функциональные методы программирования на Rust

  • 22 ноября, 15:11
  • 3312
  • 0

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

Что такое функциональное программирование?

Согласно Википедии:

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

Следовательно, в функциональном программировании есть два очень важных правила

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

Это означает:

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

Кроме этого, ниже представлены концепции функционального программирования, которые могут быть применены в Rust, мы коснемся их ниже.

  1. Функции первого класса и высшего порядка
  2. Затворы
  3. Карринг
  4. Рекурсия
  5. Ленивые оценки
  6. Ссылочная прозрачность

Функциональное программирование в Rust

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

Функции первого класса и высшего порядка

Функции первого класса означает, что вы можете назначать функции переменным, передавать функцию в качестве аргумента другой функции или возвращать функцию из другой. Функции в Rust немного сложнее, чем в других языках, не так просто, как в Go или JavaScript. Существуют разные виды функций и два разных способа их написания. Первая - это функция, которая не может запоминать свой внешний контекст, а вторая - замыкания, которые могут запоминать его внешний контекст. 

 Кроме того, функции, которые принимают замыкание, также могут принимать указатель на функцию в зависимости от контекста. Во многих местах функции и замыкания Rust могут быть взаимозаменяемыми. Было бы лучше, если бы функции были простыми, и мы могли бы делать все ниже, не полагаясь на замыкания. Но Rust выбрал эти компромиссы для повышения безопасности и производительности памяти.

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

В Rust это довольно просто сделать с замыканиямиfn main() {
    let list = vec![
        String::from("Orange"),
        String::from("Apple"),
        String::from("Banana"),
        String::from("Grape"),
    ];
    // we are passing the array and a closure as arguments to map_for_each method.
    let out = map_for_each(list, |it: &String| -> usize {
        return it.len();
    });

    println!("{:?}", out); // [6, 5, 6, 5]
}

// The higher-order-function takes an array and a closure as arguments
fn map_for_each(list: Vec<String>, fun: fn(&String) -> usize) -> Vec<usize> {
    let mut new_array: Vec<usize> = Vec::new();
    for it in list.iter() {
        // We are executing the closure passed
        new_array.push(fun(it));
    }
    return new_array;
}

Есть также более сложные версии, как показано для примера ниже

fn main() {
    let list = vec![2, 5, 8, 10];
    // we are passing the array and a closure as arguments to map_for_each method.
    let out = map_for_each(list, |it: &usize| -> usize {
        return it * it;
    });

    println!("{:?}", out); // [4, 25, 64, 100]
}

// The higher-order-function takes an array and a closure as arguments, but uses generic types
fn map_for_each<A, B>(list: Vec<A>, fun: fn(&A) -> B) -> Vec<B> {
    let mut new_array: Vec<B> = Vec::new();
    for it in list.iter() {
        // We are executing the closure passed
        new_array.push(fun(it));
    }
    return new_array;
}

Но тогда мы могли бы просто это сделать, используя встроенные функциональные методы, такие как map, Fold (Reduce) и т.д., Которые гораздо менее многословны. Rust предоставляет много полезных методов функционального стиля для работы с коллекциями, как map, fold, for_each, filter и так далее.

fn main() {
    let list = ["Orange", "Apple", "Banana", "Grape"];

    // we are passing a closure as arguments to the built-in map method.
    let out: Vec<usize> = list.iter().map(|x| x.len()).collect();

    println!("{:?}", out); // [6, 5, 6, 5]
}

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

fn main() {
    // this is a higher-order-function that returns a closure
    fn add(x: usize) -> impl Fn(usize) -> usize {
        // A closure is returned here
        // variable x is obtained from the outer scope of this method and memorized in the closure by moving ownership
        return move |y| -> usize { x + y };
    };

    // we are currying the add method to create more variations
    let add10 = add(10);
    let add20 = add(20);
    let add30 = add(30);

    println!("{}", add10(5)); // 15
    println!("{}", add20(5)); // 25
    println!("{}", add30(5)); // 35
}

Чистые функции

Как мы уже видели, чистая функция должна возвращать значения только на основе переданных аргументов и не должна влиять или зависеть от глобального состояния. Это можно легко сделать в Rust.

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

fn sum(a: usize, b: usize) -> usize {
    return a + b;
}

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

use std::collections::HashMap;

fn main() {
    let mut holder = HashMap::new();

    fn sum(a: usize, b: usize) -> usize {
        let c = a + b;
        holder.insert(String::from(format!("${a}+${b}", a = a, b = b)), c);
        return c;
    }
}

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

use std::collections::HashMap;

fn main() {
    let mut holder = HashMap::new();

    let sum = |a: usize, b: usize| -> usize {
        let c = a + b;
        holder.insert(String::from(format!("${a}+${b}", a = a, b = b)), c);
        return c;
    };

    println!("{}", sum(10, 20));
}

Но компиляция все равно не удастся с сообщением «нельзя заимствовать sum как изменяемый, так как он не объявлен как изменяемый». Таким образом, чтобы сделать мутацию внешнего состояния, мы должны были бы явно указать функцию как изменяемую: let mut sum = ...

Рекурсия

Функциональное программирование способствует рекурсии по циклу. Давайте посмотрим пример для вычисления факториала числа.

В традиционном итеративном подходе:

fn main() {
    fn factorial(mut num: usize) -> usize {
        let mut result = 1;
        while num > 0 {
            result *= num;
            num = num - 1;
        }
        return result;
    }

    println!("{}", factorial(20)); // 2432902008176640000
}

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

fn main() {
    fn factorial(num: usize) -> usize {
        return match num {
            0 => 1,
            _ => num * factorial(num - 1),
        };
    }

    println!("{}", factorial(20)); // 2432902008176640000
}

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

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

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

Ленивая оценка

Ленивая оценка или нестрогая оценка - это процесс задержки оценки выражения до тех пор, пока он не понадобится. В общем, Rust проводит строгую / энергичную оценку. Мы можем использовать функции высшего порядка, замыкания и методы запоминания для выполнения ленивых вычислений.

fn main() {
    fn add(x: usize) -> usize {
        println!("executing add"); // this is printed since the functions are evaluated first
        return x + x;
    }

    fn multiply(x: usize) -> usize {
        println!("executing multiply"); // this is printed since the functions are evaluated first
        return x * x;
    }

    fn add_or_multiply(add: bool, on_add: usize, on_multiply: usize) -> usize {
        if add {
            on_add
        } else {
            on_multiply
        }
    }
    println!("{}", add_or_multiply(true, add(4), multiply(4))); // 8
    println!("{}", add_or_multiply(false, add(4), multiply(4))); // 16
}

Это даст следующий вывод, и мы видим, что обе функции выполняются всегда

executing add
executing multiply
8
executing add
executing multiply
16

Мы можем использовать функции высшего порядка, чтобы переписать это в лениво оцененную версию

fn main() {
    fn add(x: usize) -> usize {
        println!("executing add"); // this is printed since the functions are evaluated first
        return x + x;
    }
    fn multiply(x: usize) -> usize {
        println!("executing multiply"); // this is printed since the functions are evaluated first
        return x * x;
    }
    type FnType = fn(t: usize) -> usize;

    // This is now a higher-order-function hence evaluation of the functions are delayed in if-else
    fn add_or_multiply(add: bool, on_add: FnType, on_multiply: FnType, t: usize) -> usize {
        if add {
            on_add(t)
        } else {
            on_multiply(t)
        }
    }
    println!("{}", add_or_multiply(true, add, multiply, 4)); // 8
    println!("{}", add_or_multiply(false, add, multiply, 4)); // 16
}

Это выводит то, что ниже, и мы можем видеть, что были выполнены только необходимые функции

executing add
8
executing multiply
16

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

use std::collections::HashMap;

fn main() {
    let mut cached_added = HashMap::new();

    let mut add = |x: usize| -> usize {
        return match cached_added.get(&x) {
            Some(&val) => val,
            _ => {
                println!("{}", "executing add");
                let out = x + x;
                cached_added.insert(x, out);
                out
            }
        };
    };

    let mut cached_multiplied = HashMap::new();

    let mut multiply = |x: usize| -> usize {
        return match cached_multiplied.get(&x) {
            Some(&val) => val,
            _ => {
                println!("executing multiply");
                let out = x * x;
                cached_multiplied.insert(x, out);
                out
            }
        };
    };

    fn add_or_multiply(add: bool, on_add: usize, on_multiply: usize) -> usize {
        if add {
            on_add
        } else {
            on_multiply
        }
    }

    println!("{}", add_or_multiply(true, add(4), multiply(4))); // 8
    println!("{}", add_or_multiply(false, add(4), multiply(4))); // 16
}

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

executing add
executing multiply
8
16

Они могут не выглядеть так элегантно, особенно для опытных программистов на Rust. К счастью, большинство функциональных API, таких как итераторы, предоставляемые Rust, выполняют ленивые вычисления, и существуют библиотеки, такие как rust-lazy и Thunk, которые можно использовать для того, чтобы сделать функции ленивыми. Кроме того, Rust предоставляет несколько расширенных типов, с помощью которых можно реализовать ленивые вычисления.

Ссылочная прозрачность

Из Википедии:

Функциональные программы не имеют операторов присваивания, то есть значение переменной в функциональной программе никогда не изменяется после определения. Это исключает любые вероятности побочных эффектов, поскольку любая переменная может быть заменена ее фактическим значением в любой точке выполнения. Таким образом, функциональные программы прозрачны по ссылкам.

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

Например, код ниже будет выдавать ошибку

fn main() {
    let list = ["Apple", "Orange", "Banana", "Grape"];

    list = ["John", "Raju", "Sabi", "Vicky"];
}
И так же все ниже
fn main() {
    let list = vec![
        String::from("Orange"),
        String::from("Apple"),
        String::from("Banana"),
        String::from("Grape"),
    ];

    list.push(String::from("Strawberry")); // This will fail as the reference is immutable

    fn mutating_fn(val: String) {
        val.push('!'); // this will fail unless the argument is marked mutable reference or value passed is marked mutable reference
    }

    mutating_fn(String::from("Strawberry")); // this will fail if the reference is not passed as mutable
}

Чтобы их скомпилировать, нам нужно было бы задать mut ключевыми словами

fn main() {
    let mut list = vec![
        String::from("Orange"),
        String::from("Apple"),
        String::from("Banana"),
        String::from("Grape"),
    ];

    list.push(String::from("Strawberry")); // This will work as the reference is mutable

    fn mutating_fn(val: &mut String) {
        val.push('!'); // this will work as the argument is marked as a mutable reference
    }

    mutating_fn(&mut String::from("Strawberry")); // this will work as the reference is passed as mutable
}

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

Структуры данных

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

Вывод

Функциональное программирование не является «панацеей», но предлагает множество полезных методов для более понятного, поддерживаемого и тестируемого кода. Оно может прекрасно сосуществовать с императивными и объектно-ориентированными стилями программирования.


0 комментариев
Сортировка:
Добавить комментарий