Procedures
A procedure is the unit you give a name to, and naming a piece of work is most of what programming is. tiko's are ordinary — until you look at how arguments get in and results get out, where three things behave differently from what the syntax suggests.
Start with the reason parameters exist at all. A procedure cannot see the variables around it:
'' sealed.tiko -- a procedure cannot see the module's variables.
dim as int32 counter = 5
sub ShowIt( )
print counter '' error: procedures start with an empty slate
end sub
ShowIt( )
sealed.tiko(5,11): error TKO0743: 'counter' is not declared
|
5 | print counter '' error: procedures start with an empty slate
| ^
A module-level variable is visible to the module's own statements and to nothing else. Adding
shared to the declaration would open it up to every procedure in the file, which is the reason
this book almost never does: a procedure whose inputs all arrive as parameters can be read on its
own, and tested on its own.
Subs, functions, and three ways to answer
'' basics.tiko -- a sub does something; a function answers something.
sub Banner( byref title as ustring )
print "== " & title & " =="
end sub
'' Three ways to hand back a result. They compile to the same thing.
function Doubled( byval n as int32 ) as int32
return n * 2
end function
function Tripled( byval n as int32 ) as int32
function = n * 3
end function
function Quadrupled( byval n as int32 ) as int32
Quadrupled = n * 4
end function
Banner( "results" )
print Doubled( 5 ), Tripled( 5 ), Quadrupled( 5 )
'' A function called for its answer and ignored is still called.
function Noisy( ) as int32
print "Noisy ran"
return 0
end function
Banner( "called for effect" )
Noisy( )
== results ==
10 15 20
== called for effect ==
Noisy ran
A sub does something. A function answers something, and has three spellings for handing that
answer back:
return value— sets the result and leaves immediately.function = value— sets the result and carries on. Useful when the answer is refined further down.Name = value, the function's own name — the oldest form, and the one most likely to be misread as a recursive call.
They compile to the same thing. This book uses return, and function = only where the procedure
really does keep working after the answer is settled.
A function may also be called and its answer discarded, as Noisy( ) is above — nothing forces you
to use it.
The answer is usually a single value, but it can be a whole array: write ( ) after the result type,
as in function Squares( ... ) as int32( ). Chapter 8 covers what such a function may return and who
owns the result.
A function that reaches end function without ever setting its result returns a zero-initialised
value, silently. There is no warning. If a branch can fall off the end, put an explicit return
there and say what the answer should be.
declare, for a name the compiler has not read yet
The compiler reads a file once, top to bottom, and a name has to exist before it is used. Two procedures that call each other cannot both be written first:
'' forward.tiko -- declare names a procedure before it exists.
''
'' IsEven and IsOdd call each other, so whichever is written first
'' refers to a name the compiler has not read yet.
declare function IsOdd( byval n as int32 ) as boolean
function IsEven( byval n as int32 ) as boolean
if n = 0 then return true
return IsOdd( n - 1 )
end function
function IsOdd( byval n as int32 ) as boolean
if n = 0 then return false
return IsEven( n - 1 )
end function
for i as int32 = 0 to 4
print i; " even="; IsEven( i ); " odd="; IsOdd( i )
next
0 even=true odd=false
1 even=false odd=true
2 even=true odd=false
3 even=false odd=true
4 even=true odd=false
declare states the signature and nothing else. Once it has been seen, the name can be called
anywhere below, and the body can arrive whenever it likes. Without the declare, the call is
TKO0743: '…' is not declared — the same error a typo gets, because to the compiler it is the same
mistake.
When you name a procedure you are competing with the names the compiler predeclares.
function Double( ) does not compile — double is one of the classic type names — and it fails
with TKO0600: 'double' is already declared in this scope rather than with anything about types.
Appendix A lists the names that collide.
How arguments get in
This is the part to read twice. tiko lets you write a parameter with no byval or byref, and
what that means depends on the parameter's type:
'' passing.tiko -- what a parameter with no byval or byref means.
''
'' Every procedure here writes to its parameter. Whether the caller
'' sees that write is the whole question.
sub PlainInt( n as int32 )
n = 99
end sub
sub PlainStr( s as ustring )
s = "changed"
end sub
'' The same two, written the other way round.
sub ByrefInt( byref n as int32 )
n = 99
end sub
sub ByvalStr( byval s as ustring )
s = "changed"
end sub
dim as int32 a = 1
PlainInt( a )
print "int32 with no keyword: "; a
dim as ustring t = "kept"
PlainStr( t )
print "ustring with no keyword: " & t
dim as int32 b = 1
ByrefInt( b )
print "int32 byref: "; b
dim as ustring u = "kept"
ByvalStr( u )
print "ustring byval: " & u
int32 with no keyword: 1
ustring with no keyword: changed
int32 byref: 99
ustring byval: kept
With no keyword, an int32 was copied — the caller still holds 1. A ustring was shared —
the caller's text changed under it. Both procedures are written identically.
The rule the compiler applies:
| Parameter type | Default |
|---|---|
Numbers, boolean, pointers, enums | byval — a copy |
string, ustring | byref — the caller's own variable |
| Types you define | byref — the caller's own variable |
An omitted keyword means two different things. The same spelling copies a number and aliases a
string, so a procedure that tidies up its own parameter is harmless with an int32 and quietly
corrupts the caller's data with a ustring.
It is the direction of the surprise that makes it expensive. Nobody writes
sub Fix( n as int32 ) expecting to change the caller's number. Plenty of people write
sub Fix( s as ustring ) and use s as scratch space — and the damage then appears somewhere
else entirely, in a variable they were not thinking about.
Write byval or byref on every parameter. Every listing in this book does. It costs six
characters and it removes the question.
byref is not a wart to be avoided — it is how you pass something large without copying it, and
how a procedure reports more than one answer. Exercise 1 builds a MinMax that returns two values
that way, which is the tool to reach for until Chapter 20 introduces tuples.
const, for a reference you promise not to write through
byref gives speed and gives up safety: the callee can now write to the caller's variable. Adding
const takes the safety back while keeping the speed.
'' constparam.tiko -- const says the procedure will not write to it.
sub Bump( byval n as const int32 )
n += 1 '' error: that is what const forbids
end sub
Bump( 1 )
constparam.tiko(3,5): error TKO0617: cannot modify 'n': it is a constant
|
3 | n += 1 '' error: that is what const forbids
| ^
byref x as const T is the right shape for any parameter you only intend to read, and it is
documentation the compiler checks — for every type, text included: byref s as const ustring may not
be assigned either.
Arguments you can leave out
'' optional.tiko -- defaults, and leaving an argument out of the middle.
sub Label( byref who as ustring, byval times as int32 = 1, _
byref mark as ustring = "!" )
for i as int32 = 1 to times
print who & mark
next
end sub
Label( "once" )
Label( "twice", 2 )
Label( "marked", , "?" ) '' times keeps its default
once!
twice!
twice!
marked?
A parameter with = value may be omitted at the call. Optional parameters must come after the
required ones, and an argument in the middle can be skipped by leaving its slot empty —
Label( "marked", , "?" ) takes the default for times and supplies mark.
The empty slot is easy to misread. Two of them in a row, Label( "x", , ), is legal and tells the
reader nothing; when a call needs more than one hole, the parameters are in the wrong order.
One name, several signatures
'' overloads.tiko -- one name, several signatures.
declare function Area overload ( byval side as float64 ) as float64
declare function Area overload ( byval w as float64, _
byval h as float64 ) as float64
declare function Area overload ( byref tag as ustring ) as float64
function Area overload ( byval side as float64 ) as float64
print " (square)"
return side * side
end function
function Area overload ( byval w as float64, _
byval h as float64 ) as float64
print " (rectangle)"
return w * h
end function
function Area overload ( byref tag as ustring ) as float64
print " (by name: " & tag & ")"
return 0.0
end function
'' Each call prints which overload ran, then its answer.
var square = Area( 3.0 )
print "Area( 3.0 ) ="; square
var rect = Area( 2.0, 5.0 )
print "Area( 2.0, 5.0 ) ="; rect
var widened = Area( 3 )
print "Area( 3 ) ="; widened
var named = Area( "lot" )
print "Area( ""lot"" ) ="; named
(square)
Area( 3.0 ) = 9
(rectangle)
Area( 2.0, 5.0 ) = 10
(square)
Area( 3 ) = 9
(by name: lot)
Area( "lot" ) = 0
overload says that a name is deliberately shared. It is required: declaring a second Area
without it is TKO0600: 'area' is already declared in this scope, the same error as any duplicate
name — which is the right default, because most repeated names are accidents.
Note what happened to Area( 3 ). There is no int32 overload, so the argument was converted to
float64 and the one-parameter version ran. The compiler scores the conversions each candidate
would need and takes the cheapest total. When two candidates tie, it refuses rather than guessing:
'' ambiguous.tiko -- neither overload is a better match than the other.
declare function Pick overload ( byval a as int32, _
byval b as float64 ) as int32
declare function Pick overload ( byval a as float64, _
byval b as int32 ) as int32
function Pick overload ( byval a as int32, _
byval b as float64 ) as int32
return 1
end function
function Pick overload ( byval a as float64, _
byval b as int32 ) as int32
return 2
end function
print Pick( 1, 1 ) '' error: int32 converts to float64 either way
ambiguous.tiko(17,11): error TKO0660: the call to 'pick' matches more than one overload
|
17 | print Pick( 1, 1 ) '' error: int32 converts to float64 either way
| ^
Scoring counts conversions; it does not rank them by whether they can lose information. Exercise 2
shows an int64 argument picking an int32 overload — narrowing — in preference to widening to
float64. If a width matters, give it an overload of its own.
Recursion, and locals that remember
'' gcd.tiko -- recursion, and a static local that outlives the call.
function Gcd( byval a as int64, byval b as int64 ) as int64
static as int32 calls = 0
calls += 1
if b = 0 then
print " reached the base case after"; calls; " calls"
calls = 0
return a
end if
return Gcd( b, a mod b )
end function
print "Gcd( 1071, 462 ) ="; Gcd( 1071, 462 )
print "Gcd( 270, 192 ) ="; Gcd( 270, 192 )
Gcd( 1071, 462 ) = reached the base case after 4 calls
21
Gcd( 270, 192 ) = reached the base case after 5 calls
6
A procedure may call itself; each call gets its own parameters and its own locals. static is the
exception — a static local is created once, keeps its value between calls, and so is shared by
every level of a recursion. That is what makes it usable as a call counter, and it is also why
Gcd has to reset it before returning: the count belongs to one top-level call, and nothing else
will clear it.
static initialisers run once, on the first call. A static is the smallest thing that gives a
procedure memory, and the smallest thing that stops it being safe to call from two threads at once
— Chapter 23 returns to that.
Returning a place instead of a value
A function can hand back a reference to storage, which the caller may then assign to. Write
byref after the parameter list:
'' byrefresult.tiko -- a function that returns a place, not a value.
function SlotAt( byval i as int32 ) byref as int32
static as int32 first = 0
static as int32 second = 0
if i = 0 then return first
return second
end function
print "start: "; SlotAt( 0 ); SlotAt( 1 )
'' The obvious spelling. Watch what it does.
SlotAt( 0 ) = 42
print "after SlotAt(0)=42:"; SlotAt( 0 ); SlotAt( 1 )
'' Parenthesise the call, and the assignment lands.
( SlotAt( 0 ) ) = 42
print "after (SlotAt(0))=42:"; SlotAt( 0 ); SlotAt( 1 )
'' Or bind the reference to a name first, which reads better.
dim byref as int32 slot = SlotAt( 1 )
slot = 7
print "after slot = 7: "; SlotAt( 0 ); SlotAt( 1 )
start: 0 0
after SlotAt(0)=42: 0 0
after (SlotAt(0))=42: 42 0
after slot = 7: 42 7
Read the second line of that output before the third.
SlotAt( 0 ) = 42 does nothing, and says nothing.
Inside an argument list = is the equality operator, so the compiler reads SlotAt( 0 = 42 ): a
call with one boolean argument, whose result is thrown away. It is a complete, legal statement.
The assignment you wrote is gone, and the program carries on with the old value.
Two spellings work, both in the listing above:
( SlotAt( 0 ) ) = 42 '' parenthesise the call
dim byref as int32 slot = SlotAt( 1 ) '' or bind it to a name
slot = 7
The second is worth preferring. It names the thing being modified, and it cannot be broken later by someone removing a pair of parentheses that looked redundant.
The compiler says so, at least: it warns TKO0931: '=' is read as a comparison inside the argument of 'slotat', not as an assignment to its byref result. Read that warning as an error. (With two or
more arguments there is no ambiguity — ( a, b ) = v is not an expression — and f( a, b ) = v
assigns through the result as written.)
Any number of arguments
A Sum that adds two numbers is easy, and so is one that adds three. A Sum that adds however
many it is given is the one you actually want, and overloads cannot get there: there would have to
be one per count. An array parameter can, but then every caller builds an array before every call.
A typed variadic is the array parameter with the caller's convenience:
'' typedvariadic.tiko -- any number of arguments, every one typed.
''
'' The '...' after the type makes nums an array parameter that the
'' caller fills with a plain list. Each argument is converted to
'' int32 exactly as it would be for an ordinary int32 parameter.
function Sum( nums() as const int32 ... ) as int64
dim as int64 total = 0
for i as int32 = lbound( nums ) to ubound( nums )
total += nums( i )
next
return total
end function
'' Fixed parameters come first; the variadic is always the last.
sub Report( byref title as const ustring, _
nums() as const int32 ... )
print title; ":"; ubound( nums ) - lbound( nums ) + 1; _
" values, sum"; Sum( nums( ) )
end sub
print "Sum( 10, 20, 30 ) ="; Sum( 10, 20, 30 )
print "Sum( 7 ) ="; Sum( 7 )
print "Sum( ) ="; Sum( )
Report( "three", 1, 2, 3 )
Report( "none" )
'' A whole array of the element type IS the list -- nothing is
'' copied, and no count has to be passed alongside it.
dim as int32 week( 0 to 6 ) = { 3, 5, 2, 8, 6, 4, 7 }
Report( "week", week( ) )
Sum( 10, 20, 30 ) = 60
Sum( 7 ) = 7
Sum( ) = 0
three: 3 values, sum 6
none: 0 values, sum 0
week: 7 values, sum 35
nums() as const int32 ... is an array parameter with ... after its type. The caller writes a
plain list. The compiler converts each argument to int32, exactly as it would for an ordinary
int32 parameter, and packs them into a temporary array that lives until the end of the
statement. Inside, nums is an ordinary array: lbound is 0, and ubound is -1 when nothing was
passed, so the loop above needs no special case for Sum( ).
Fixed parameters come first and the variadic is always last — anywhere else is TKO0973. It takes
no default value (TKO0974), because no arguments already means an empty array.
A whole array of the element type is the list itself. Report( "week", week( ) ) passes
week as it stands: nothing is copied and no count travels alongside it, because the array
carries its own bounds. That is the form to use when the values are already in an array, or when
the list is built while the program runs.
Every argument is checked, which is the whole difference from the C mechanism further down:
'' variadictyped.tiko -- every packed argument is checked.
function Sum( nums() as const int32 ... ) as int64
dim as int64 total = 0
for i as int32 = lbound( nums ) to ubound( nums )
total += nums( i )
next
return total
end function
print Sum( 1, "two", 3 ) '' error: "two" is no int32
variadictyped.tiko(11,15): error TKO0698: this argument does not convert to the parameter's type
|
11 | print Sum( 1, "two", 3 ) '' error: "two" is no int32
| ^
It is the same error a non-converting argument to an ordinary parameter gets, at the argument that caused it.
An overload that takes its arguments without packing them always wins over one that needs to:
'' variadicpick.tiko -- an exact match beats a packed one.
sub Show overload ( byval n as int32 )
print "one int32:"; n
end sub
sub Show overload ( nums() as const int32 ... )
print "a list of"; ubound( nums ) - lbound( nums ) + 1
end sub
Show( 5 ) '' the plain overload: no packing needed
Show( 5, 6 ) '' only the list can take two
Show( ) '' ... or none
one int32: 5
a list of 2
a list of 0
So adding a variadic overload never changes which procedure an existing call runs. Among candidates that all pack, each packed argument is scored like any other argument.
A packed list is a copy; a spread array is the caller's own. Without const, a variadic
procedure may write to its array, and where the writes land depends on how it was called:
'' variadicalias.tiko -- a packed list is a copy; a spread array
'' is the caller's own.
sub Bump( nums() as int32 ... ) '' no const: it may write
for i as int32 = lbound( nums ) to ubound( nums )
nums( i ) += 100
next
end sub
dim as int32 a = 1, b = 2
Bump( a, b ) '' packed into a temporary
print "after Bump( a, b ): "; a; b
dim as int32 both( 0 to 1 ) = { 1, 2 }
Bump( both( ) ) '' the array itself
print "after Bump( both( ) ): "; both( 0 ); both( 1 )
after Bump( a, b ): 1 2
after Bump( both( ) ): 101 102
Bump( a, b ) changed a temporary, which disappeared at the end of the statement; a and b are
untouched. Bump( both( ) ) changed both. The procedure is the same and the call looks almost
the same, and the two do different things without a word from the compiler.
Declare the variadic const unless writing to the caller's array is the point. Then the
question cannot arise.
The element type can be one of your own, and the conversion then goes through that type's
constructors — Chapter 12. That is how strings.Format in Chapter 9 takes values of every type in
one list: its element type has a constructor for each.
C's ...
'' varargs.tiko -- a procedure taking any number of arguments.
''
'' This is C's mechanism, so it needs C's calling convention and
'' none of the type checking you get everywhere else.
#include once "crt/stdarg.bi"
function SumOf cdecl ( byval count as int32, ... ) as int64
dim as cva_list args
dim as int64 total = 0
cva_start( args, count )
for i as int32 = 1 to count
total += cva_arg( args, int32 )
next
cva_end( args )
return total
end function
print "SumOf( 3, 10, 20, 30 ) ="; SumOf( 3, 10, 20, 30 )
print "SumOf( 1, 7 ) ="; SumOf( 1, 7 )
print "SumOf( 0 ) ="; SumOf( 0 )
SumOf( 3, 10, 20, 30 ) = 60
SumOf( 1, 7 ) = 7
SumOf( 0 ) = 0
A bare ... as the last parameter, with no name and no type, is C's mechanism, with C's
consequences: the procedure must be cdecl, there is no type checking, and the count is recorded
nowhere — SumOf is told how many to read because there is no other way for it to find out.
Reading one argument too many reads whatever happened to be next.
Declaring the procedure without cdecl is refused: TKO0781: '...' needs a 'cdecl' procedure, and this one is 'stdcall'.
C's variadics exist so that C libraries can be called; printf is the reason the machinery is
there. In tiko code, use a typed variadic. Chapter 29 covers cva_list in full, alongside the rest
of C interoperation.
Procedures as values
'' callbacks.tiko -- a procedure held in a variable.
function Twice( byval x as float64 ) as float64
return x * 2.0
end function
function Square( byval x as float64 ) as float64
return x * x
end function
'' The parameter type names a shape, not a procedure.
sub Tabulate( byref title as ustring, _
byval f as function( byval as float64 ) as float64 )
print title
for i as int32 = 1 to 3
print " f("; i; " ) ="; f( i )
next
end sub
Tabulate( "doubling", @Twice )
Tabulate( "squaring", procptr( Square ) )
'' A variable of that type can be reassigned like any other.
dim as function( byval as float64 ) as float64 op = @Twice
print "op( 4 ) ="; op( 4.0 )
op = @Square
print "op( 4 ) ="; op( 4.0 )
doubling
f( 1 ) = 2
f( 2 ) = 4
f( 3 ) = 6
squaring
f( 1 ) = 1
f( 2 ) = 4
f( 3 ) = 9
op( 4 ) = 8
op( 4 ) = 16
A procedure has a type, spelled the way it is declared minus the names:
function( byval as float64 ) as float64. A variable or parameter of that type holds any procedure
matching the shape, and is called with ordinary call syntax.
Two spellings get the address: @Twice and procptr( Twice ). They are the same thing, and
procptr is the one to use for an overloaded name, where the signature picks which one you meant.
A procedure pointer is the plainest form of the idea that the caller decides what to do and the procedure decides when. Chapter 21 extends it to lambdas, which add the ability to capture variables from around them — the one thing a procedure pointer cannot do, because a pointer is a single address with no room for anything else.
What it costs
A call is cheap and not free: arguments are placed, a return address is pushed, the callee may save registers. None of that matters at ordinary granularity, and all of it matters in a loop running millions of times.
The costs worth knowing are the ones you choose:
byvalon a large value copies it, every call. That is whatbyref ... as constis for.byrefon a small value adds an indirection to save copying eight bytes. It is a loss.- A procedure pointer cannot be inlined, because the target is not known until it runs. A direct call can be.
- A packed variadic call builds a temporary array, every call. Passing an array you already have costs nothing extra.
staticlocals make a procedure stateful, which is a correctness cost before it is ever a performance one.
Summary
- A procedure sees only what it is given; module-level variables need
shared, which is rarely worth it. return,function =and assigning the procedure's own name are three spellings of one thing. A function that sets no result returns zero, silently.declareintroduces a name before its body, which mutual recursion requires.- With no keyword, numbers are passed
byval, and strings and your own typesbyref. Write the keyword on every parameter. byref x as const Tis a reference you cannot write through, whatever T is.- Optional parameters follow the required ones, and a middle argument can be left empty.
overloadis required to share a name; resolution scores conversions and refuses ties withTKO0660. It does not avoid narrowing.staticlocals persist between calls and are shared by every level of a recursion.- Assigning to a one-argument
byreffunction result needs( f( x ) ) = vor adim byrefname — the bare form parses as an equality test, andTKO0931warns that it did. - A typed variadic,
nums() as const T ..., takes any number of arguments, each converted toTand checked; the procedure sees an ordinary array. A whole array ofTis the list itself, with no copy. - An overload that needs no packing beats one that does. A packed list is a copy and a spread
array is the caller's own — declare the variadic
const. - C's bare
...needscdecland does no type checking; it is for calling C. - Procedure pointers hold any procedure of a matching shape;
@Pandprocptr( P )are the same. - The costs you choose: a
byvalcopy of something large, abyrefindirection to save eight bytes, a procedure pointer that cannot be inlined, and astaticthat makes a procedure stateful.
Exercises
- Write
MinMax, which takes threeint32values and reports the smallest and largest throughbyrefparameters. Which of its five parameters arebyvaland whichbyref, and what happens if you omit the keywords entirely? {solution="ex07-01"} - Overload
Describeforint32,float64andustring, then call it with anint16and anint64. Predict which overload each picks before you run it. {solution="ex07-02"} - Write a function with a
byrefresult, then assign to it with the baref( i ) = vform. Before running the program, say what you expect; afterwards, explain what the compiler actually compiled. - Give a recursive procedure a
staticcall counter and don't reset it. Call the procedure twice and explain the second number. - Take
Tabulatefromcallbacks.tikoand add a third operation without touchingTabulate. Then do the same with an overload instead of a procedure pointer. Which change would you rather review? - Write
Average( xs() as const float64 ... ). Decide whatAverage( )should return, and make it do that. Then pass it a wholeint32array: why is that refused, whenAverage( 1, 2, 3 )withint32arguments is not?