Table of Contents

Closures and first-class functions

Functions as values

In Part 1, you learned to define and call functions. But in Nyx, functions are more powerful than that — they are values, just like integers or strings. You can store a function in a variable, pass it to another function, or return it from a function.

This concept is called first-class functions, and it unlocks a whole new way of thinking about programs.

Passing functions to functions

Let's start with a simple example. Suppose you have two functions that transform a number:

fn double(n: int) -> int {
    return n * 2
}

fn square(n: int) -> int {
    return n * n
}

Now you want a function that applies any transformation to a number:

fn apply(f: Fn, x: int) -> int {
    return f(x)
}

fn main() {
    print(apply(double, 5))    // 10
    print(apply(square, 5))    // 25
}

The type Fn means "a function." You pass double and square by name — no parentheses, because you are not calling them, you are passing them.

Why is this useful?

Imagine you want to apply a transformation to every element of an array. Without first-class functions, you would need to write a separate loop for each transformation. With them:

fn double(n: int) -> int { return n * 2 }
fn negate(n: int) -> int { return 0 - n }
fn add_ten(n: int) -> int { return n + 10 }

fn map_array(arr: Array, f: Fn) -> Array {
    var result: Array = []
    var i: int = 0
    while i < arr.length() {
        result.push(f(arr[i]))
        i += 1
    }
    return result
}

fn main() {
    let nums: Array = [1, 2, 3, 4, 5]

    let doubled: Array = map_array(nums, double)
    print(doubled)    // [2, 4, 6, 8, 10]

    let negated: Array = map_array(nums, negate)
    print(negated)    // [-1, -2, -3, -4, -5]

    let shifted: Array = map_array(nums, add_ten)
    print(shifted)    // [11, 12, 13, 14, 15]
}

One function (map_array) handles all three cases. The behavior changes based on which function you pass.

Filter and reduce

Two more patterns that work beautifully with first-class functions:

fn is_even(n: int) -> bool { return n % 2 == 0 }
fn is_positive(n: int) -> bool { return n > 0 }

fn filter_array(arr: Array, predicate: Fn) -> Array {
    var result: Array = []
    var i: int = 0
    while i < arr.length() {
        if predicate(arr[i]) {
            result.push(arr[i])
        }
        i += 1
    }
    return result
}

fn add(a: int, b: int) -> int { return a + b }
fn multiply(a: int, b: int) -> int { return a * b }

fn reduce_array(arr: Array, initial: int, f: Fn) -> int {
    var acc: int = initial
    var i: int = 0
    while i < arr.length() {
        acc = f(acc, arr[i])
        i += 1
    }
    return acc
}

fn main() {
    let nums: Array = [1, 2, 3, 4, 5, 6, 7, 8]

    let evens: Array = filter_array(nums, is_even)
    print(evens)    // [2, 4, 6, 8]

    let sum: int = reduce_array(nums, 0, add)
    print(sum)      // 36

    let product: int = reduce_array(nums, 1, multiply)
    print(product)  // 40320
}

These three — map, filter, reduce — are the building blocks of data processing in many languages.

What is a closure?

A closure is a function that remembers variables from the scope where it was defined. In Nyx, you create closures by defining functions inside other functions:

fn make_adder(n: int) -> Fn {
    fn add(x: int) -> int {
        return n + x
    }
    return add
}

fn main() {
    let add5: Fn = make_adder(5)
    let add10: Fn = make_adder(10)

    print(add5(3))     // 8
    print(add10(3))    // 13
    print(add5(100))   // 105
}

When make_adder(5) runs, it creates a function add that remembers n = 5. Even after make_adder returns, the inner function still has access to n. That captured environment is what makes it a closure.

Closures capture variables

Closures can capture and even modify variables from their enclosing scope:

fn counter() -> int {
    var count: int = 0

    fn increment() {
        count = count + 1
    }

    fn get_count() -> int {
        return count
    }

    increment()
    increment()
    increment()

    return get_count()
}

fn main() {
    print(counter())    // 3
}

Both increment and get_count share the same count variable. When increment modifies it, get_count sees the updated value.

Closures as accumulators

fn accumulate(n: int) -> int {
    var total: int = 0

    fn add_to_total(x: int) {
        total = total + x
    }

    var i: int = 1
    while i <= n {
        add_to_total(i)
        i = i + 1
    }

    return total
}

fn main() {
    print(accumulate(10))    // 55 (1+2+3+...+10)
    print(accumulate(5))     // 15 (1+2+3+4+5)
}

Applying functions multiple times

fn apply_twice(f: Fn, x: int) -> int {
    return f(f(x))
}

fn apply_n_times(f: Fn, x: int, n: int) -> int {
    var result: int = x
    var i: int = 0
    while i < n {
        result = f(result)
        i += 1
    }
    return result
}

fn double(n: int) -> int { return n * 2 }
fn increment(n: int) -> int { return n + 1 }

fn main() {
    print(apply_twice(double, 3))         // 12 (3*2=6, 6*2=12)
    print(apply_n_times(double, 1, 10))   // 1024 (2^10)
    print(apply_n_times(increment, 0, 100))  // 100
}

Practical example: sorting with a comparator

You can use first-class functions to make a generic sort:

fn sort_array(arr: Array, compare: Fn) -> Array {
    // Simple bubble sort
    var result: Array = []
    var i: int = 0
    while i < arr.length() {
        result.push(arr[i])
        i += 1
    }

    var swapped: bool = true
    while swapped {
        swapped = false
        i = 0
        while i < result.length() - 1 {
            if compare(result[i], result[i + 1]) > 0 {
                let temp: int = result[i]
                result[i] = result[i + 1]
                result[i + 1] = temp
                swapped = true
            }
            i += 1
        }
    }

    return result
}

fn ascending(a: int, b: int) -> int { return a - b }
fn descending(a: int, b: int) -> int { return b - a }

fn main() {
    let nums: Array = [5, 2, 8, 1, 9, 3]

    let asc: Array = sort_array(nums, ascending)
    print(asc)     // [1, 2, 3, 5, 8, 9]

    let desc: Array = sort_array(nums, descending)
    print(desc)    // [9, 8, 5, 3, 2, 1]
}

Same sort algorithm, different behavior based on the function you pass.

Practical example: event callbacks

Closures are perfect for event-driven patterns:

fn on_threshold(values: Array, threshold: int, callback: Fn) {
    var i: int = 0
    while i < values.length() {
        if values[i] > threshold {
            callback(values[i])
        }
        i += 1
    }
}

fn alert(value: int) {
    print("WARNING: value " + int_to_string(value) + " exceeds threshold!")
}

fn main() {
    let temperatures: Array = [22, 35, 28, 41, 19, 38, 45]
    on_threshold(temperatures, 37, alert)
}

Output:

WARNING: value 41 exceeds threshold!
WARNING: value 38 exceeds threshold!
WARNING: value 45 exceeds threshold!

Exercises

  1. Write a function apply_to_all(arr: Array, f: Fn) -> Array that applies a function to every element and returns a new array. Test it with at least three different functions.
  1. Write count_matching(arr: Array, predicate: Fn) -> int that counts how many elements satisfy a predicate function.
  1. Write compose(f: Fn, g: Fn, x: int) -> int that applies g first, then f: compose(f, g, x) equals f(g(x)).
  1. Create a make_multiplier(factor: int) -> Fn closure that returns a function which multiplies its argument by factor. Use it to create triple and quadruple functions.
  1. Write a find_first(arr: Array, predicate: Fn) -> int that returns the first element matching the predicate, or -1 if none match.

Summary

Next chapter: Enums and pattern matching →

← Previous: Files Next: Enums and pattern matching →