void is kind of strange in C, because it looks like an empty type, but it still behaves as though it has a single instance (a unit type). For example, a function with an empty return type can’t return (it’d have to supply a value of it); a void function can. You can’t cast things to an empty type (otherwise you’d get a value of it), you can cast things to void. Void a unit type, not an empty type, it’s just a bad one.
It would be somewhat more cohesive and less weird if we'd argued that the "void" return type means that a return value cannot be constructed which is taken to mean that the function has no return value, and simply returns without providing a value.
Try as I may, I can’t make sense of that. I’ve read something like it in books on C, but I still can’t. Maybe I’m infected with set theory too deeply.
In my mind, a computation (a “function”) must either return a value or hang/crash. If it appears as though it returns a member of ∅, it must hang/crash, because there are no members of ∅. If it returns a member of the single-element set, [1], it can return one, there’s just no use inspecting it afterwards (you know what it is already).
(For what it’s worth, if you use a prover-adjacent language such as Agda or Idris, this is exactly how things are going to work there.)
C functions (and in fact the "functions" of most mainstream programming languages) are not computations, they are algorithms. An algorithm doesn't necessarily have a result of any kind, at least not in the way that a (mathematical) function has. The result of the algorithm can be the state in which it leaves the World (e.g. an algorithm for cleaning a house doesn't have a return value, it changes the state of the house).
In fact the traditional programming name for what we mostly call functions today was "(sub)routine" - you call a subroutine, and when it finishes, it returns to where it originally started.
Consider also that at the assembly level (and below it), subroutines don't have return values, nor arguments. The program counter simply jumps to the beginning address of the subroutine, and the `return` instruction jumps back to the address right after that jump. The subroutine may read values from certain locations in memory, and possibly write some others back, but none of this is necessary or enforced in any way. C functions, and the corresponding keywords, are much closer to this conception of assembly subroutines than they are to the mathematical notions of functions or computations.
The calling convention does say where to look for the return value. So in a sense the return value always exists, but would not be meaningful if the function has a void return type.
The calling convention is part of the abstraction, it's not part of the processor's logic. Different languages often have different calling conventions, on the same OS and processor family. Different OSs have different calling conventions for their system calls.
> a computation (a “function”) must either return a value or hang/crash.
It can also simply return control, without returning anything. It's equivalent to invoking a continuation with zero arguments. Do you allow for zero-argument functions, at least?
Of course, if a function can't return no value whatsoever, you suddenly need new syntactical categories to support it: you need to prohibit using such functions in an expressions (only call statements are allowed), you need a way to return from such a function (naked "return", which is prohibited from taking any expressions), and it's also severely strains your generics/templates because you can't treat such functions uniformly etc.
> It can also simply return control, without returning anything. It's equivalent to invoking a continuation with zero arguments. Do you allow for zero-argument functions, at least?
There's nothing weird about a function taking 0 arguments, any more than there's anything weird about a function taking 3 or 5 arguments. A function can't take an argument of type void, so functions shouldn't be allowed to return void either.
> Of course, if a function can't return no value whatsoever, you suddenly need new syntactical categories to support it: you need to prohibit using such functions in an expressions (only call statements are allowed), you need a way to return from such a function (naked "return", which is prohibited from taking any expressions), and it's also severely strains your generics/templates because you can't treat such functions uniformly etc.
Or you do what sensible languages do: all functions return a value (if they return at all), functions that don't want to return anything in particular can return a unit value but that value is just a normal value that behaves normally in expressions etc., you don't make naked return a special case (you can make it syntax sugar for "return unit" if you really want), and you can treat those functions uniformly in generics, much more easily than with C++ templates where you have to make special cases for void functions.
I don't argue with any of that, you know. But we do have a historical (stupid in retrospect) split between functions and procedures, dating a-a-a-all the way back to ALGOL 60 at least.
You encounter the same problem with zero argument functions.
A function on a zero type would be unable to return any value, since there's no value you could apply it to. To have a function of zero arguments you should use the unit type (which means the function effectively picks out a single value).
This is also related to how a function with multiple arguments is a function of the product type, and an empty product is 1 not 0.
No, you just invoke the function and pass it zero arguments, that's it.
Sure, you can build your whole theoretical framework of computation with only the functions of exactly one argument, and then deal with tuples to fake multi-valued arguments/multiple return values — but you don't have to do that. You may as well start from the functions with arbitrary (natural) number of arguments/return values, it's not that hard.
Sure, and passing it zero arguments is exactly what it means to evaluate it on the single value of the unit set.
I mean surely we can agree that a pure function of 0 arguments picks out exactly 1 value, and that a function that accepts n different values (values not arguments) as input returns at most n different results? Why make an exception for n=0?
Your definition of a function of 0 arguments and that of a function over the unit set are identical. Or at least equivalent.
They're equivalent, but only up to whatever computational substrate one is actually using. You can build functions out of small-step operational semantics of, say, a simplistic imperative register machine with a stack. In this case, a function of 0 arguments and a function of 1 trivial unit argument are visibly different even though their total effect on the state is the same. After all, we're talking about theory of computation and so it better be able to handle computations as they are actually performed at the low level, too.
It's yet another example of "in theory, the theory and the practice are the same; in practice, they're different": I have written a toy functional language that compiles down to C, and unit-removal (e.g. transforming int*()*int into int*int, lowering "fun f() -> whatever = ..." into a "whatever f(void) {...}" etc.) is a genuine optimization. The same, I imagine, would apply to generating raw assembly: you want to special-case handling of unit so that passing it as an argument would not touch %rdi, and assigning a unit to a value should not write any registers, and "case unit_producing_function(...) of -> ... end" actually has no data dependency on the unit_producing_function etc.
> In this case, a function of 0 arguments and a function of 1 trivial unit argument are visibly different even though their total effect on the state is the same. After all, we're talking about theory of computation and so it better be able to handle computations as they are actually performed at the low level, too.
You don't have to push anything for the unit values on the stack though, their representation doesn't take up any space. Just like if you're passing a big argument (like a large integer, or a struct passed by value) it might take multiple registers or a lot of space in your stack frame, there's no 1:1 correspondence between arguments and stack space.
> you want to special-case handling of unit so that passing it as an argument would not touch %rdi, and assigning a unit to a value should not write any registers
This shouldn't need to be a special case though. For each datatype you have a way of mapping it to/from some number of registers, and for the unit type that number of registers is 0 and that mapping is a no-op.
One of the way to represent them doesn't take up any space. But if you want to e.g. have a generic function of e.g. (T,U,V) -> V type (that is, with no monomorphization in your compiler) then either your units have to take space (otherwise the layout of (unit,int,bool) is different from (int,int,bool) and the same), or you'll have to pass around type descriptors when invoking generic functions. Which many, many implementors of static languages rather wouldn't do unless absolutely necessary.
> if you want to e.g. have a generic function of e.g. (T,U,V) -> V type (that is, with no monomorphization in your compiler) then either your units have to take space (otherwise the layout of (unit,int,bool) is different from (int,int,bool) and the same), or you'll have to pass around type descriptors when invoking generic functions.
You have that problem already though surely, as T might be bigger than an int, or want to be passed in a floating-point register, or both.
Yes, that's the problem you face when you're dealing strictly with functions of a single argument. Still, there are two options: first, it's arguably is already written into the register so you don't need to do anything.
Alternatively, you may instead represent () as a full, 64-bit wide machine word and then map every 64-bit value to mean () so, again, you don't actually need to write anything: all registers contain a valid representation of () at all times. This is similar to how we usually represent booleans: 0 is mapped to mean False, and everything else is mapped to mean True, although in this case we sometimes do need to rematerialize some definite value into the register of choice.
In any case, it's mostly just a matter of correctly writing the constant materializer; but if you adopt multi-argument/multi-valued functions you simply never encounter this problem:
for arg, place in zip(arg_list, arg_places):
load(arg, place)
invoke(fun, kont)
for val, place in zip(ret_values, ret_places):
load(val, place)
kontinue(kont)
Notice how degenerate loops simply disappear with no additional handling.
> if you adopt multi-argument/multi-valued functions you simply never encounter this problem
Sure. If you have multiple return then unit values become a lot less important because you can just have a function that returns 0 values. But most languages, especially C-like languages, do not have multiple return.
The word "function" in mathematics and the word "function" in C (and C++) are homonyms. Two words spelled the same and pronounced the same but with entirely different meanings. Any effort to conflate the two will just end in tragedy.
It doesn't make sense from a strict type and set theory point of view because it doesn't make sense from a strict type and set theory point of view. Neither C nor C++ are rigorous languages.
We also have "void foo(void)" and here void takes on two entirely different meanings, while type theory would suggest this is a function that diverges if it were called, which you can't.
"If it appears as though it returns a member of ∅, it must hang/crash, because there are no members of ∅."
If you want to go with math, think more group theory. I'm specifically thinking about how you can always create a monoid if you have an associative binary operation, because even if your associative binary operation doesn't have an identity element, you can just declare one. Similarly, if you have "functions" that "return nothing", you just declare that nothing right into existence. Then you can just think of the C language layer basically erasing away any attempt to examine that value returned behind the scenes, because as you say, why?
It's not a function. A C function "returning" void is just C's syntax for writing a procedure. C doesn't call it a procedure, but that's what it is semantically.
The distinction between "function" and "procedure" doesn't map very well to whether the return type is void in C's syntax:
- On one hand, a lot of "functions" are actually procedures that just happen to return a value: think for example `write(2)`, which is clearly used for its side effect, not to compute how many bytes could be written -- even though that's what it returns.
- On the other hand, you can have a "procedure" (i.e., a function "returning" void) that actually has no side effects other than storing a computed value in a specified location (e.g. void square(int x, int *ret) { *ret = x*x; }). That's clearly a function in the mathematical sense, even though it "returns void".
The difference between a function and a procedure is strictly whether they can be used as a value or not in the syntax of the language, not the semantics. A function is a subroutine that can be used as either a value or a statement, a procedure is a subroutine that can only be used as a statement. That is, if x = foo(); is valid syntax, then foo() is by definition a function. If x = foo(); is invalid syntax, then foo() is a procedure.
> (For what it’s worth, if you use a prover-adjacent language such as Agda or Idris, this is exactly how things are going to work there.)
You don't even have to go that far, Rust supports this concept. The built-in empty type is called `!`, and cannot be constructed. It's partially unstable, and there's a bunch of things you can't do yet, but you can use it as a return type.