DEV Community

Refaktor
Refaktor

Posted on • Originally published at ryelang.org

When if is just a function

In Python, when you write if x > 5: print("big"), you're using special syntax baked into the language. You can't change how if works. You can't compose it, pipe it, or partially apply it. You can't pass if as an argument to another function.

But what if you could? What if if, for, while and even fn and var were just regular functions?

In languages like REBOL, Red and Rye they are.

Three Reasons This Matters

Consistency. Most languages treat control structures as special forms, exceptions to the normal rules. In languages like Rye and REBOL, they're ordinary functions that follow the same patterns as everything else.

Flexibility. Functions can be composed, passed around, and combined. When control structures are functions, you inherit all those capabilities.

Extensibility. If if and for are just functions, you can create your own specialized versions for specific purposes. No part of the language remains off-limits.

Let's see what this looks like in practice.

Looking at If

Here's a conditional in Python:

temperature = 36

# We can evaluate and print an expression
print(temperature > 30)
# prints: True

# We can't really print or evaluate a block of code
# except if we turn it back to string

# Standard conditional - special syntax
if temperature > 30:
    print("It's hot!")
# prints: It's hot!
Enter fullscreen mode Exit fullscreen mode

Compare this to Rye:

temperature: 36

; Evaluates expression and prints the result
print temperature > 30
; prints: true

; In Rye, blocks are data - we can print them
print { print "It's hot!" }
; prints: print "It's hot!"

; The 'do' function evaluates a block of Rye values / code
do { print "It's hot!" }
; prints: It's hot!

; And here's the conditional - also just a function
if temperature > 30 {
    print "It's hot!"
}
; prints: It's hot!
Enter fullscreen mode Exit fullscreen mode

Look at that last if statement. It's not special syntax it's a function call. While functions print and do take one argument, if function takes two arguments:

  1. A condition that evaluates to true or false
  2. A block of code to run if the condition is true

You might wonder: "Won't the block execute immediately when passed as an argument?" Here's the key insight: in Rye, code blocks { ... } are values. They don't evaluate until you explicitly tell them to. The if function receives the block as data and decides whether to evaluate it based on the condition.

When code is data, control flow doesn't need to be special.

Single pattern

In Python, every language feature has its own syntax:

# Conditionals - keywords and colons
if x == 5: 
    print("five")

# Loop
for i in range(10): 
    print(i)

# Iteration
for item in ["milk", "bread", "pickles"]: 
    print(item)

# Functions - def keyword, parentheses, colons, indentation
def add(a, b): 
    return a + b
Enter fullscreen mode Exit fullscreen mode

In Rye, one pattern applies everywhere:

; Conditionals - function taking a boolean and a block
if x = 5 { print "five" }

; Counting loop - function taking an integer and a block
loop 10 { .print }

; Iteration - function taking a collection and a block
for { "milk" "bread" "pickles" } { .print }

; Functions - function taking argument list and body block
add: fn { a b } { a + b }
Enter fullscreen mode Exit fullscreen mode

Every construct follows the same shape: a name, followed by arguments, some of which happen to be blocks of code.

There's no longer a meaningful distinction between "language features" and "library functions."

Consistency & Flexibility

In Python, if and for are statements, not values. But in Rye they are functions, and first of all we can compose functions.

loop either temperature > 32 { 3 } { 1 } { prns "Hot!" }
; Prints: Hot! Hot! Hot!

; We can use optional parenthesis to better show the evaluation order (similar to lisp-s)
( loop ( either ( temperature > 32 ) { 3 } { 1 } ) { prns "Hot!" } )
; Prints: Hot! Hot! Hot!
Enter fullscreen mode Exit fullscreen mode

prns prints a value with a space (no newline)

In Python when using the if statement, this would take multiple lines and a variable mutation:

repeats = 1
if temperature > 32:
    repeats = 3

for _ in range(repeats):
    print("Hot!", end='')
# Prints: Hot! Hot! Hot! 
Enter fullscreen mode Exit fullscreen mode

Luckily, python has another special syntax in which if keyword creates an expression:

for _ in range(3 if temperature > 31 else 1):
    print("Hot!", end='')
# Prints: Hot! Hot! Hot! 
Enter fullscreen mode Exit fullscreen mode

While special syntax is usualy cemented in place, there are multiple ways to provide arguments to function calls.

hot-code: { print "Hot!" }
is-hot: temperature > 30

if is-hot hot-code
; prints Hot!

loop 2 hot-code
; Prints: Hot!
;         Hot!
Enter fullscreen mode Exit fullscreen mode

Piping Into Control Flow

Functions in Rye can accept a first (or second) argument from the left, so the same applies to "flow control"-like functions of course.
Read more about this in Meet Rye.

; Pipe a condition into if
temperature > 30 |if { print "Hot!" }
; Prints: Hot!

3 .loop { .prns }
; Prints: 0 1 2

; Pipe a collection into for
{ "Hot" "Pockets" } |for { .print }
; Prints: Hot
;         Pockets
Enter fullscreen mode Exit fullscreen mode

In Python, you can't pipe into if or for because they're not values, they're syntax.

Applying functions

In Rye we can apply functions to it's arguments.

apply ?concat { "Bob" "Odenkirk" }

woof: { print "woof" }
meov: { print "meov" }

animals: [ [ false woof ] [ true meov ] ]

for animals { .apply* ?if }
; Prints: meov
Enter fullscreen mode Exit fullscreen mode

?word - is a get-word, it doesn't evaluate a function bound to the word but returns it
word* - star at the end of the word causes function to take second argument from the left, not the first one

Partial Application

In Rye we can also partially apply functions.

add-five: partial ?_+ [ _ 5 ]

add-five 10
; returns 15

three-times: partial ?loop [ 3 _ ]

; Use it like any other function
three-times { prns "Hey!" }
; Hey! Hey! Hey!
Enter fullscreen mode Exit fullscreen mode

We've created a custom "control structure" three-times by partially applying a built-in loop.

Higher-Order Control Flow

We can pass function as arguments to other functions.

Here we create a function that takes a function and reates the same function that prints it's arguments.

verbosify\2: fn { fnc } {
    closure { a b } {
    probe a 
        probe b
        fnc a b
    }
}

myconcat: verbosify\2 ?concat
myconcat "AAA" "BBB"
; Prints:
;  [String: AAA]
;  [String: BBB]
; Returns:
;  AAABBB

myif: verbosify\2 ?if
myif temperature < 30 { print "cold!" }
; Prints:
;  [Boolean: false]
;  [Block: ^[Word: print] [String: cold!] ]
Enter fullscreen mode Exit fullscreen mode

Extensibility

Since control flow is just functions, you can write your own control structures indistinguishable from built-ins.

Unless and Until

; Want the opposite of if?
unless: fn { condition block } {
    if not condition block
}

unless tired { 
    print "Keep working!" 
}

; Need an until loop?
until: fn { condition block } {
    loop {
        r:: do block
        if do condition { return r }
    }
}

count:: 0
until { count > 5 } {
    print count
    count:: inc count
}
Enter fullscreen mode Exit fullscreen mode

Unlimited control flow functions

When you accept this you see that there is no hard border of hard limit to what control strucutre like function you should have. You load them on library level
and specific libraries can provide new ones or offer you functionality in shape that would usually be reserved to special forms.

Part of the base functions, but could be considered special


switch password {
    "sesame" { "Opening ..." }
    "123456" { "Self destructing!" }
}

; I'l admit, I added cases control-function just for the fizz-buzz 
; because I could :P

for range 1 100 { :n
    cases " " {
        { n .multiple-of 3 } { "Fizz" }
        { n .multiple-of 5 } { + "Buzz" }
        _ { n }
    } |prns
}

; outputs: 1 2 Fizz 4 Buzz Fizz 7 8 Fizz Buzz 11 Fizz 13 14 FizzBuzz 16 ...

Enter fullscreen mode Exit fullscreen mode

Higher order functions map filter reduce in Rye also function just like control structures accepting direct code blocks.

They can also accept functions so you can also use them as classic HOF-s.


{ "anne" "bob" "alan" } 
|filter { .start-with "a" }
|map { uppercase } :names
|for { .print }

names .reduce 'acc { .concat acc }

; classic HOF patterns also work
{ 1 2 3 } .map fn { x } { x + 1 }
Enter fullscreen mode Exit fullscreen mode

Even external libraries can have their own functions that utilize blocks of code directly.

For example OpenAI library has a Chat\stream function that accepts a block of
code into which it injects a part of string for each stream event, similar to for loop.

openai Read %.token
|Chat\stream "A joke of the day?" { .prn }
Enter fullscreen mode Exit fullscreen mode

prn - prints a string without adding a newline or space at the end

The Trade-offs

Performance

Python can optimize special forms at compile time. In Rye, if is a function call with some runtime overhead.

Tooling

IDEs know what Python's if, for, def are and can provide specialized support. When everything is a function, you are not limited to a few keywords.

On the other hand

With langauge like Python you have to optimize and provide tools for every special syntax and construct separately.

In Rye, you "just" have to make function calls (builtin function and Rye functions) as fast as possible. And
builtins and functions are self-documenting so if you do make good tools for them you could solve all tooling problems at once.


Interested in learning more? Check out Rye, REBOL or Red to see these ideas in action.

And follow our Github Repo.


Updates after publishing

20.Okt.2025 - Front page of HN produced a lot of interesting comments about this and adjacent themes: hn comments

20.Okt.2025 - Front page of Lobsters also: lobste.rs comments

Top comments (0)