Резюме:

Используя «открытую» рекурсию:

  • Мы можем отделить логику мемоизации от функциональной логики.
  • Мы можем мемоизировать функции, не изменяя синтаксис функции.
  • Мы можем сделать функции универсальными для нескольких типов мемоизации.

Мемоизация в Rust.

Рассмотрим рекурсивную функцию. Самый простой пример - последовательность Фибоначчи. Вот наивная реализация:

fn fib_naive(arg: u32) -> u32 {
    match arg {
        0 => 0,
        1 => 1,
        n => fib_naive(n - 1) + fib_naive(n - 2),
    }
}
fn main() {
    assert_eq!(fib_naive(40), 102334155);
}

Расчет fib_naive(40) на моем компьютере занимает почти секунду. Если мы посмотрим на эту функцию, мы сможем судить о ее производительности. Результатом fib_naive будет 0, 1 или результат двух вызовов fib_naive, сложенных вместе. Если в нашем распоряжении есть только 0, 1 и сложение, то для вычисления fib_naive(n) потребуется не менее fib_naive(n) сложений. Это неприемлемая производительность для чего-либо, кроме наименьших значений n.

Запоминание результатов

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

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

fn fib_memo (cache: &mut HashMap<u32, u32>, arg: u32) -> u32 {
    match cache.get(&arg).map(|entry| entry.clone()) {
        Some(result) => result,
        None => {
            let result = match arg {
                0 => 0,
                1 => 1,
                n => fib_memo(cache, n - 1) + fib_memo(cache, n - 2),
            };
            cache.insert(arg, result.clone());
            result
        }
    }
}
fn main () {
    assert_eq!(fib_memo(&mut HashMap::new(), 40), 102334155);
}

Это все еще достаточно просто, нет ничего слишком сложного, а производительность этой функции значительно улучшена по сравнению с fib_naive. Обратите внимание, что исходное тело функции все еще находится в ветви None оператора match. Мы также должны вызвать функцию с дополнительным аргументом: &mut HashMap::new().

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

fn memoize<A, R, F> (cache: &mut HashMap<A, R>, func: F, arg: A) -> R where
    A: Eq + Hash + Clone,
    R: Clone,
    F: Fn(&mut HashMap<A, R>, A) -> R
{
    match cache.get(&arg).map(|x| x.clone()) {
        Some(result) => result,
        None => {
            let result = func(cache, arg.clone());
            cache.insert(arg, result.clone());
            result
        }
    }
}

Эта функция позволяет нам переписать нашу fib_memo функцию:

fn fib_memo2 (cache: &mut HashMap<u32, u32>, arg: u32) -> u32 {
    match arg {
        0 => 0,
        1 => 1,
        n => memoize(cache, fib_memo2, n - 1) + 
             memoize(cache, fib_memo2, arg - 2),
    }
}
fn main() {
    assert_eq!(memoize(&mut HashMap::new(), fib_memo2, 40), 102334155);
}

Теперь мы можем использовать memoize для любой функции, не записывая ее постоянно. В этом коде все еще есть некоторые проблемы, которые мы попытаемся решить:

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

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

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

Введение в открытую рекурсию

Отказ от ответственности: следующее в настоящее время работает только на Rust nightly.

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

struct HashCache <A, R> {
    data: HashMap<A, R>,
    func: fn(&mut HashCache<A, R>, A) -> R,
}
impl<A, R> HashCache<A, R> where
    A: Eq + Hash
{
    fn from_func(func: fn(&mut Self, A) -> R) -> Self {
        HashCache {
            data: HashMap::new(),
            func: func,
        }
    }
}

Подпись func очень похожа на подпись fib_memo, но вместо HashMap в качестве кеша у нас есть собственный HashCache.

Затем давайте реализуем FnMut для HashCache:

impl<A, R> FnMut<(A,)> for HashCache<A, R> where
    A: Eq + Hash + Clone,
    R: Clone,
{
    extern "rust-call" fn call_mut(&mut self, args: (A,)) -> Self::Output {
        let arg = args.0;
        match self.data.get(&arg).map(|x| x.clone()) {
            Some(result) => result,
            None => {
                let result = (self.func.clone())(self, arg.clone());
                self.data.insert(arg, result.clone());
                result
            }
        }
    }
}
// We need to implement `FnOnce` to implement `FnMut`.
impl<A, R> FnOnce<(A,)> for HashCache<A, R> where
    A: Eq + Hash + Clone,
    R: Clone,
{
    type Output = R;
    extern "rust-call" fn call_once(mut self, args: (A,)) -> Self::Output {
        self.call_mut(args)
    }
}

За исключением некоторых незначительных деталей реализации, код для call_mut совпадает с кодом для memoize. Вот некоторые ключевые моменты:

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

Затем давайте перепишем нашу функцию, чтобы использовать открытую рекурсию:

fn fib_open<F>(recurse: &mut F, arg: u32) -> u32 where
    F: FnMut(u32) -> u32
{
    match arg {
        0 => 0,
        1 => 1,
        n => recurse(n - 1) + recurse(n - 2),
    }
}

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

fib_open также имеет сходство с fib_memo2, вместо аргумента кеша у нас есть &mut F с именем recurse, и вместо вызова memoize мы вызываем recurse.

HashCache<u32, u32> реализует FnMut(u32) -> u32, поэтому это подходящее значение для recurse. Мы также можем назвать это отдельной функцией:

fn main() {
    let mut memoised = HashCache::from_func(fib_open);
    assert_eq!(memoised(40), 102334155);
}

Замена рекурсивных методов:

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

struct NoCache<A, R>(fn(&mut NoCache<A, R>, A) -> R);

В отличие от структуры HashCache, поскольку мы ничего не запоминаем, нам не нужно хранить кеш, только указатель на функцию. Мы можем реализовать FnMut для NoCache:

impl<A, R> FnMut<(A,)> for NoCache<A, R>
{
    extern "rust-call" fn call_mut(&mut self, args: (A,)) -> Self::Output {
        (self.0.clone())(self, args.0)
    }
}
impl<A, R> FnOnce<(A,)> for NoCache<A, R>
{
    type Output = R;
    extern "rust-call" fn call_once(mut self, args: (A,)) -> Self::Output {
        self.call_mut(args)
    }
}

Подобно HashCache, NoCache является подходящим значением для recurse, поэтому мы можем передать его в fib_open и вызвать его отдельно:

fn main() {
    let mut regular = NoCache(fib_open);
    assert_eq!(regular(40), 102334155);
}

В заключение нам удалось решить все три исходные задачи:

  • Используя HashCache, мы не можем перепутать функцию и соответствующий кеш.
  • Мы можем вызвать мемоизированную функцию, используя обычный синтаксис вызова функции.
  • Мы можем заменить мемоизацию на обычную рекурсию, не изменяя саму функцию.