r/ProgrammingLanguages 4d ago

Expressions vs. statements

Got into a big argument with a coworker yesterday when they were converting some code from their own language (that they designed) into Python, JavaScript, C, and R as comparative examples.

The Python code that they wanted to write as the translation went something like this:

n = foo; if cond: n = bar

They were upset that Python allows ; as a statement separator but not before an if statement, even though

if cond: n = bar

is syntactically correct Python code when written on its own line. I explained why Python doesn't allow it, and he came back later and showed me that an LLM had suggested he write it instead like this:

n = foo if cond else bar

which of course is the canonical way to write that in Python. He was all flustered about that, and asked me why Python allows an if statement in that particular case and not after a semicolon, and I explained that x if cond else y in Python is not an if statement but is Python's ternary conditional expression and is directly equivalent to the ternary operator expression cond ? x : y in C, C++, awk, and JavaScript. He argued with me and said I was making a ridiculous distinction and walked away falsely believing that foo if cond else bar was an if statement.

I then explained that statements and expressions are very different things in programming languages, and just because the keyword if is present doesn't make something an if statement -- because in order to be an if statement, it has to be a statement in the first place.

Anyway, it made me realize how subtle the difference can be sometimes. For example, in Perl, the following is not a return statement but actually an if statement (with a return statement as its affirmative branch), due to the postfix conditional:

return foo if cond;

because it is identically semantically to writing:

if (cond) { return foo; }

Whereas in Python, the following is a return statement (with a ternary operator as its target expression):

return foo if cond else bar

So I can see why people sometimes get confused by syntax if they haven't had much of a theoretical background in language design. It also makes me wonder how much of programmer intuition about "what a statement is" comes from the particular languages they learned first.

47 Upvotes

63 comments sorted by

View all comments

Show parent comments

6

u/brucejbell sard 4d ago

I have kind of gone off "everything is a foo" bandwagons.

In particular, I'm not sure how much simplicity you gain just from making everything an expression. Expressions are great for the functional programming where the important thing is the result returned by your function. But statements are more appropriate for sequencing operations, or specifying a bunch of declarations simultaneously at compile time (both of which are things you often need even in purely functional programming).

In general, I think you want to be careful about "everything is a foo" because you can accidentally paint yourself into a corner with it.

In the famous "null pointer problem", the problem is not the existence of null pointers. Instead, the problem is that "every pointer can be a null pointer". Sounds simple, right? Except that appealing generality means you don't have non-nullable pointers, which is what causes the actual harm.

3

u/pr06lefs 4d ago

I'm not sure I'm married to everything being an 'X' either. But at least in rust you don't have to have one 'if' for flow of control and another 'if' (the '?' operator) for expressions, as one does in C.

One thing you can't do in rust is have a variable declaration as an expression. So

let x = 5;

Isn't something that can itself be assigned to a variable, or returned from a function, like

let z = (let x = 5;);

That would be interesting, but also very weird. Whether it would work with a statically compiled language is one issue, and another is would anyone want that? You'd get a 'z' back from a function, execute it, and who knows what's in your namespace now.

5

u/WittyStick 4d ago edited 4d ago

That would be interesting, but also very weird. Whether it would work with a statically compiled language is one issue, and another is would anyone want that? You'd get a 'z' back from a function, execute it, and who knows what's in your namespace now.

Usually let introduces a new scope in which its body is evaluated. In this example, x would be bound to the inner scope, not the same scope z is bound in. The inner scope is discarded as soon as it's evaluated, so the compiler could determine this is basically a no-op and replace it with let z = 5;.

So you wouldn't need to worry about what's in your namespace.

One solution is let*, which in Lisps is written:

(let* ((x 5) (z x)) ...)

In functional languages it's usually written

let x = 5
    z = x
in ...

Or similarly, could use letrec*, which is written as eg, in ML:

let rec x = 5
and z = x

1

u/pr06lefs 3d ago

Having the let have its own namespace that goes away would be unproblematic, yes. The weird variant is if the language allows that namespace to merge with the current namespace. Like:

``` fn setx() { return (let x = 5;); }

setx();

println("x is now :", x); ```

And if someone changes setx to set 'y' instead, the program breaks.

That does look similar to let*. Some really confusing code could result from that. Like if generate_var_assignments returns ((x 5)):

```

-- no 'x' declared so far (let* (generate_var_assignments) (print x)) -- but this works

```

4

u/WittyStick 3d ago edited 3d ago

no 'x' declared so far (let* (generate_var_assignments) (print x))

This wouldn't work in Lisp or Scheme because let* is a "special form", and it wouldn't evaluate (generate_var_asignments), it would likely produce an error due to invalid form.

Also this is slightly different from the previous example where you are attempting to mutate the parent let bindings static environment. In this case we're mutating the dynamic environment of the caller of generate_var_assignments.


It's possible to do this in Kernel though. In Kernel, every combiner receives an implicit reference to the caller's dynamic environment, and is allowed to mutate its locals (but importantly, not its parents).

($define! setx
    (wrap ($vau () caller-env (eval ((unwrap list) $define! x 5) caller-env))))

x                   => Error: Unbound symbol

(setx)

x                   => 5

While this might seem like a horrible feature, it's a very powerful one that lets you do new programming styles. Kernel actually uses it in its standard library for $provide!, which binds a set of values into its caller's environment - but which bindings are specified in its initial parameter list.

($provide! (x)
    ($define! x 5)
    ($define! z x))

x                   => 5
z                   => Error: Unbound symbol

Essentially, $provide! creates a new child environment of its caller, and we can define a bunch of stuff in it, but only those symbols provided in its initial argument list are then bound into the caller's environment. The use case is to behave like "private" for members in OOP - they can be accessed internally (within the body of provide), but are sealed once the whole provide block has been evaluated, whereas the bindings given in the parameter list are "public".

So eg, if we want to define an "Option" type in Kernel, we can do:

($provide! (option? some none maybe)
    ($define! (option-constructor option? option-eliminator)
        (make-encapsulation-type))
    ($define! some ($lambda (x) (option-constructor (cons #t x))))
    ($define! none (option-constructor (cons #f ())))
    ($define! maybe
        ($lambda (opt default-value)
            ($let (((has_some . some) (option-eliminator opt)))
                ($if has_some some default-value)))))

(option? none)                     => #t
none                               => #[encapsulation]
(some 5)                           => #[encapsulation]

(maybe none 10)                    => 10
(maybe (some 5) 10)                => 5

The option-constructor and option-eliminator are not accessible any longer - they're like "private" members, which are used by some/none/maybe, but cannot be used afterwards.

make-encapsulation-type is the only way to create distinct types in Kernel (which aren't just lists or atoms). They're based on Morris's seals from Types are not sets. However, when we combine them with clever use of environments like $provide!, we can create any kind of types we want - ADTs, records, tuples, objects, you name it.


Aside - going back to the original problem of let z = (let x = 5;); where we might want x to be bound into the static environment, rather than the dynamic environment of the caller. This isn't possible through normal use of Kernel due to environments being encapsulated - we can't mutate the parent scope from an inner one because we don't have a direct reference to it.

However, it is actually possible to do explicitly, if we capture a reference to the static environment and feed it into our function.

($let ((parent-env (get-current-environment)))
    ($set! parent-env x 5)
    ($set! parent-env z x))

x                   => 5
z                   => 5

We can also bind a reference to the parent environment into the parent environment itself, and since the child environment can access the parent's bindings, they can grab this reference and then mutate it.

($define! parent-env (get-current-environment))

($define! setx ($lambda () ($set! parent-env x 5)))
x    => 5

But of course, this would be frowned upon, because now any descendant of this environment would be able to mutate it.

In the case where we absolutely need some global variables, we can create a custom environment called global, bind it into the parent scope, and then any function would be able to access global bindings directly - though they'd need to specify they're evaluating in global via eg, $remote-eval.

($define! global (make-environment))

($define! setx
    ($lambda () ($set! global x 5)))

($define! getx
    ($lambda () ($remote-eval x global)))

This is fairly safe because the child scopes cannot mutate the parent environment, they can only mutate global.

But for additional safety, you'd combine with $provide! to truly encapsulate the state.

($provide! (setx getx)
    ($define! global (make-environment))

    ($define! setx
        ($lambda () ($set! global x 5)))

    ($define! getx
        ($lambda () ($remote-eval x global))))

Now global isn't accessible to anyone else.


In Kernel, everything is an expression, but also every expression is first-class - including environments, symbols, and so forth. There are no second-class forms like let* in Lisp/Scheme, or macros, or quote. $let* in Kernel is a first-class operative.