Operator Overloading
Chapter 10 left a gap: two Points could be copied but not compared, because the compiler would not
guess what equality meant. Chapter 12 gave types methods, so a vector could have an Add method —
and code that reads a.Add( b.Scale( 2 ) ) where a mathematician would write a + 2b.
An operator overload is a procedure that the compiler calls when it sees an operator applied to your type. It is ordinary code with an unusual name, and it costs exactly what the same code costs as a method. What it buys is notation: a type that behaves like a number can be written like one.
The chapter ends with the other side of that — what not to overload — because notation that lies about what it does is worse than a method name that tells the truth.
A value type that reads like a number
'' vec2.tiko -- a value type that reads like a number.
type Vec2
x as float64
y as float64
declare operator +=( byref o as Vec2 ) '' members: they
declare operator cast( ) as ustring '' change or convert
end type '' this object
'' Binary operators are global: neither operand is "this".
operator + ( byref a as Vec2, byref b as Vec2 ) as Vec2
return type( a.x + b.x, a.y + b.y )
end operator
operator - ( byref a as Vec2, byref b as Vec2 ) as Vec2
return type( a.x - b.x, a.y - b.y )
end operator
operator - ( byref a as Vec2 ) as Vec2 '' unary minus
return type( -a.x, -a.y )
end operator
'' Scaling needs both orders, or 2 * v will not compile.
operator * ( byref a as Vec2, byval k as float64 ) as Vec2
return type( a.x * k, a.y * k )
end operator
operator * ( byval k as float64, byref a as Vec2 ) as Vec2
return a * k
end operator
operator = ( byref a as Vec2, byref b as Vec2 ) as boolean
return a.x = b.x andalso a.y = b.y
end operator
operator <> ( byref a as Vec2, byref b as Vec2 ) as boolean
return not ( a = b )
end operator
operator abs ( byref a as Vec2 ) as float64 '' the length
return sqr( a.x * a.x + a.y * a.y )
end operator
operator Vec2.+=( byref o as Vec2 )
x += o.x
y += o.y
end operator
operator Vec2.cast( ) as ustring
return "(" & x & ", " & y & ")"
end operator
dim as Vec2 a = ( 1, 2 ), b = ( 3, 4 )
print "a + b = "; a + b
print "b - a = "; b - a
print "-a = "; -a
print "2 * a = "; 2 * a
print "a + b * 2 = "; a + b * 2; " (* still binds first)"
print "abs( b ) ="; abs( b )
print "a = b: "; a = b; ", a <> b: "; a <> b
a += b
print "a += b -> "; a
a + b = (4, 6)
b - a = (2, 2)
-a = (-1, -2)
2 * a = (2, 4)
a + b * 2 = (7, 10) (* still binds first)
abs( b ) = 5
a = b: false, a <> b: true
a += b -> (4, 6)
An operator is defined like a function whose name is the operator: operator + ( a, b ) as Vec2.
From then on, a + b on two Vec2s is a call to it.
A few things in that listing are deliberate:
- Scaling needs both orders.
a * 2and2 * aare two different operators — the left operand's type differs — so the second is defined in terms of the first. Leave one out and the other order does not compile. - Unary minus is its own operator, with one parameter.
=and<>are separate. Defining one does not define the other, and they must agree, so<>is written asnot ( a = b ).absis overloadable, and for a vector the natural meaning is the length.- Precedence does not change.
a + b * 2still multiplies first, because the compiler parses the expression before it decides which procedures to call.
operator cast( ) as ustring is what makes print a + b work: print takes the vector's conversion
to text. It is also what makes "x" & v work, an assignment to a ustring, and the string
functions — ucase( v ), left( v, 2 ) — treat the vector as its text.
Member or global
Some operators are declared inside the type and some outside it, and the compiler enforces which:
'' wrongplace.tiko -- a binary operator declared as a member.
type Vec2
x as float64
y as float64
declare operator +( byref o as Vec2 ) as Vec2 '' refused
end type
wrongplace.tiko(5,13): error TKO0790: operator '+' cannot be a member of a type
|
5 | declare operator +( byref o as Vec2 ) as Vec2 '' refused
| ^
The rule follows from what each operator acts on:
| Kind | Where | Why |
|---|---|---|
+ - * / \ mod ^, comparisons, and or xor, unary - not, abs | global | neither operand is privileged; either side may be another type |
+= and the other compound assignments | member | they change the object on the left |
let, cast | member | they assign into or convert from this object |
[], @ | member | they reach into this object |
->, * (dereference) | global | they take the object as their operand |
for, step, next | member | the loop's counter is the object |
new, delete, new[], delete[] | member | they allocate for this type |
@ is the opposite case from +: as a global it is refused with
TKO0697: operator '@' must be a member of a type.
A global operator is not a method, so it sees only the public parts of the type. That is usually right — an operator built from the type's public interface cannot break its invariants — and when it is not, the operator can call a method that does the work.
Conversions in both directions
'' fixedpoint.tiko -- exact decimals, stored as whole thousandths.
''
'' 0.1 + 0.2 is not 0.3 in floating point. Stored as an integer count
'' of thousandths, it is exactly 300.
type Fixed
raw as int64 '' thousandths
declare operator let( byval v as float64 )
declare operator cast( ) as float64
declare operator cast( ) as string
end type
'' let from another type: assigning a float64 rounds it once, here.
operator Fixed.let( byval v as float64 )
raw = cint( v * 1000 )
end operator
operator Fixed.cast( ) as float64
return raw / 1000
end operator
operator Fixed.cast( ) as string
dim as int64 whole = raw \ 1000
dim as int64 part = abs( raw ) mod 1000
dim as ustring sign = iif( raw < 0 andalso whole = 0, "-", "" )
return sign & whole & "." & right( "00" & part, 3 )
end operator
operator + ( byref a as Fixed, byref b as Fixed ) as Fixed
dim as Fixed r
r.raw = a.raw + b.raw
return r
end operator
operator * ( byref a as Fixed, byref b as Fixed ) as Fixed
dim as Fixed r
r.raw = ( a.raw * b.raw ) \ 1000
return r
end operator
operator < ( byref a as Fixed, byref b as Fixed ) as boolean
return a.raw < b.raw
end operator
operator = ( byref a as Fixed, byref b as Fixed ) as boolean
return a.raw = b.raw
end operator
dim as Fixed p, q, sum, want
p = 0.1
q = 0.2
want = 0.3
sum = p + q
print "fixed: 0.1 + 0.2 = "; sum; " equal to 0.3: "; sum = want
dim as float64 fp = 0.1 + 0.2
print "float64: 0.1 + 0.2 ="; fp; " equal to 0.3: "; cbool( fp = 0.3 )
dim as Fixed price, qty
price = 19.99
qty = 3
print "19.99 * 3 = "; price * qty
print "as a float64:"; cast( float64, price * qty )
dim as Fixed twenty
twenty = 20
print "19.99 < 20: "; price < twenty
fixed: 0.1 + 0.2 = 0.300 equal to 0.3: true
float64: 0.1 + 0.2 = 0.3 equal to 0.3: false
19.99 * 3 = 59.970
as a float64: 59.97
19.99 < 20: true
Fixed stores an amount as a whole number of thousandths, so 0.1 + 0.2 is exactly 300 thousandths
and compares equal to 0.3. The float64 line beside it compares unequal — although print shows
it as 0.3, because print rounds to fifteen significant digits. The difference is real and
invisible; that is the whole case for a fixed-point type.
Two conversions make it convenient:
operator let( byval v as float64 )lets afloat64be assigned to aFixed. The rounding happens once, there, and nowhere else.operator cast( ) as float64lets aFixedbe used where afloat64is wanted.cast( float64, price * qty )calls it explicitly; passing aFixedto a procedure that takes afloat64calls it implicitly.
A type may have several cast operators — Fixed has two — distinguished by the type they produce.
The built-in maths functions take it too. sqr, abs, int and the rest accept a type with a
numeric cast, convert, and work on the number:
'' castmath.tiko -- a built-in maths function given a type with a cast.
''
'' Fixed converts itself to float64, so sqr( f ) is sqr( 2.5 ):
'' 1.58113883008419.
type Fixed
raw as int64
declare operator cast( ) as float64
end type
operator Fixed.cast( ) as float64
return raw / 1000
end operator
dim as Fixed f
f.raw = 2500
dim as float64 r = sqr( f )
print "sqr( f ) ="; r
sqr( f ) = 1.58113883008419
A type with no numeric cast — and no operator sqr of its own — is refused: TKO0928.
A cast operator is an implicit conversion, like a one-parameter constructor. Chapter 12's
warning applies in the other direction: once Fixed converts to float64, it converts wherever a
float64 would be accepted, including places nobody intended.
Two conversions are worse than one. Give a type casts to both float64 and int64, call a procedure
overloaded for both, and the call is genuinely ambiguous — there is no reason to prefer either — so
it is refused, TKO0660. Adding a second cast can therefore turn a line that compiled into one
that does not.
Give a type the conversions its users genuinely need, and prefer a named method — ToFloat( ) —
for the rest.
Reaching into an object
'' indexing.tiko -- [] , let from another type, and smart handles.
type Grid
declare constructor( byval w as int32, byval h as int32 )
declare destructor( )
declare operator []( byval i as int32 ) byref as int32
declare operator let( byval fill as int32 )
declare operator @( ) as int32 ptr
w as int32
h as int32
private:
cells as int32 ptr
end type
constructor Grid( byval w as int32, byval h as int32 )
this.w = w
this.h = h
cells = callocate( w * h, sizeof( int32 ) )
end constructor
destructor Grid( )
deallocate( cells )
end destructor
'' Returning byref is what lets g[4] appear on the LEFT of =.
operator Grid.[]( byval i as int32 ) byref as int32
return cells[i]
end operator
'' let from an int32: "g = 7" fills every cell.
operator Grid.let( byval fill as int32 )
for i as int32 = 0 to w * h - 1 : cells[i] = fill : next
end operator
'' @g gives the first cell, which is what a C function would want.
operator Grid.@( ) as int32 ptr
return cells
end operator
dim as Grid g = Grid( 3, 2 )
g = 7
g[4] = 42
print "g[0], g[4], g[5] ="; g[0]; g[4]; g[5]
print "via @g:"; ( @g )[4]
'' A handle that forwards -> and * to the Grid it refers to. These
'' two are global operators, taking the handle as their operand.
type GridHandle
target as Grid ptr
end type
operator -> ( byref hnd as GridHandle ) byref as Grid
return *hnd.target
end operator
operator * ( byref hnd as GridHandle ) byref as Grid
return *hnd.target
end operator
dim as GridHandle hd
hd.target = @g '' careful: this is Grid's own @, so a cell!
print "hd.target is the cells, not the Grid: "; _
cbool( hd.target = cast( Grid ptr, @g ) )
hd.target = varptr( g ) '' varptr is never overloaded
print "through ->: w ="; hd->w; ", through *: h ="; ( *hd ).h
g[0], g[4], g[5] = 7 42 7
via @g: 42
hd.target is the cells, not the Grid: true
through ->: w = 3, through *: h = 2
operator [] returning byref is what lets g[4] appear on the left of an assignment: the
caller receives the element itself, not a copy of it. A [] that returns by value gives a read-only
index.
operator let( byval fill as int32 ) is let from a different type: g = 7 fills every cell.
let from the type's own type is the copy assignment Chapter 12 covered; let from anything else
is a conversion into the object.
GridHandle forwards -> and * to the Grid it holds, which is the shape of every "smart"
handle — a type that behaves like a pointer while doing something extra, such as counting references,
which Chapter 21's SharedPtr does.
The middle of the listing is a warning about @. Because Grid overloads @ to return its cells,
@g no longer means the address of g — and assigning it to a Grid ptr hands the handle the
cells instead of the grid. The compiler noticed:
indexing.tiko(61,1): warning TKO0830: suspicious pointer assignment: a 'grid ptr' from a 'int32 ptr'
varptr( g ) is never overloaded, and always means the address of g, which is why the listing uses
it for the real assignment.
A type that counts
'' rangecounter.tiko -- a type that a for loop can count with.
''
'' for e as T = first to last [step s] calls five operators on T:
'' for (once, at the start), next (should the loop go on?) and step
'' (advance) -- each in a form with and without the step value.
type Every
declare constructor( byval startAt as int32, byval stepBy as int32 )
declare operator for( )
declare operator for( byref stp as Every )
declare operator step( )
declare operator step( byref stp as Every )
declare operator next( byref last as Every ) as integer
declare operator next( byref last as Every, _
byref stp as Every ) as integer
declare operator cast( ) as string
value as int32
by_ as int32
end type
constructor Every( byval startAt as int32, byval stepBy as int32 )
value = startAt
by_ = stepBy
end constructor
operator Every.for( ) '' nothing to prepare
end operator
operator Every.for( byref stp as Every )
end operator
operator Every.step( ) '' no step clause: own stride
value += by_
end operator
operator Every.step( byref stp as Every ) '' step clause given
value += stp.value
end operator
operator Every.next( byref last as Every ) as integer
return value <= last.value
end operator
operator Every.next( byref last as Every, _
byref stp as Every ) as integer
return value <= last.value
end operator
operator Every.cast( ) as string
return str( value )
end operator
print "every third from 1 to 12:";
for e as Every = Every( 1, 3 ) to Every( 12, 0 )
print " "; e;
next
print
print "0 to 20, step 5:";
for e as Every = Every( 0, 1 ) to Every( 20, 0 ) step Every( 5, 0 )
print " "; e;
next
print
every third from 1 to 12: 1 4 7 10
0 to 20, step 5: 0 5 10 15 20
for e as T = first to last step s works for any type that implements the counting protocol:
| Operator | Called | Job |
|---|---|---|
for | once, before the first pass | prepare; usually nothing |
next( last ) | before each pass | return non-zero to keep going |
step | after each pass | advance |
Each comes in two forms, with and without the step value, matching whether the for has a step
clause. Nothing in the protocol assumes a direction or even a number — next decides when to stop —
so a counter can walk dates, a range of keys or a countdown, as Exercise 2 does.
For walking the elements of a container rather than counting, Chapter 20's for each and its
iterator protocol are the better tool.
Taking over allocation
'' allocators.tiko -- a type that controls where its objects live.
type Tracked
declare operator new( byval size as uinteger ) as any ptr
declare operator delete( byval p as any ptr )
n as int32
end type
dim shared as int32 live '' a count every Tracked shares
'' new and delete are static members: there is no object yet in new,
'' and none left in delete. They allocate; they do not construct.
operator Tracked.new( byval size as uinteger ) as any ptr
live += 1
print " allocating "; size; " bytes"
return allocate( size )
end operator
operator Tracked.delete( byval p as any ptr )
live -= 1
print " releasing"
deallocate( p )
end operator
dim as Tracked ptr a = new Tracked
dim as Tracked ptr b = new Tracked
print "live objects:"; live
delete a
delete b
print "live objects:"; live
allocating 4 bytes
allocating 4 bytes
live objects: 2
releasing
releasing
live objects: 0
operator new and operator delete replace the allocation step for one type. They do not construct
or destroy — the constructor and destructor still run, before and after — they only decide where the
bytes come from and go back to. That makes them the place for a memory pool, an allocation counter
as here, or a check that every object was freed.
They are implicitly static: in new the object does not exist yet, and in delete it no longer
does, so there is no this — and neither this nor a bare field may be used in either
(TKO0937).
What not to overload
An operator is a promise about meaning that the reader will hold you to without reading the code. Break it and every expression using the type becomes a puzzle.
- Keep the meaning.
+combines,-takes away,=is equality and nothing else. A+that appends to a log or a*that means "repeat" makes correct-looking code wrong. - Keep the algebra where it applies. If
a + bexists,a - b,-aanda += bshould exist and agree with it. If=exists,<>must be its negation. - Keep the cost obvious.
a + breads as cheap. If it allocates, copies a megabyte or touches a disk, give it a name that says so. - Do not overload
@unless the type genuinely is a handle to something else — the listing above shows what it does to every@in the program. - Do not overload
and,orandnotfor logic. The built-inandalsoandorelseshort-circuit; an overloadedandcannot, and a reader cannot tell which they are looking at. - When the operation has no single obvious meaning, use a method.
Dot( a, b )andCross( a, b )are better than picking one of them to be*.
What it costs
- An operator is a procedure call with the same cost as the method it replaces, and short ones are inlined. Notation is free.
- Returning a type by value copies it. For a small value type like
Vec2that is the right design; for a type that owns memory, eacha + bconstructs a temporary and runs its copy constructor. - Conversions run silently. An implicit
castorletin a loop is a call per iteration that nothing in the source shows.
Summary
operator +and its kind are procedures the compiler calls for your type. Precedence and associativity never change.- Binary and unary arithmetic, comparisons,
->and*are global; compound assignment,let,cast,[],@, theforprotocol andnew/deleteare members — the compiler enforces it (TKO0790,TKO0697). - Define both orders of a mixed-type operator, and define
<>alongside=. cast( ) as ustringmakes a type printable, and usable with&and the string functions.letfrom another type andcastto another type are conversions in and out; the built-in maths functions use a numericcast.[]returningbyrefgives an assignable index.- Overloading
@changes the meaning of every@;varptris never overloaded. for,stepandnextmake a type aforcounter, in either direction.operator new/deletechoose where memory comes from; construction and destruction still happen around them.- Overload only where the meaning is the obvious one, and keep the algebra and the cost honest.
Exercises
- Write
Money, stored in whole cents, with+,-, multiplication by a count,<,=and acastto text like$15.30. Add a thousand ten-cent amounts and check the total is exactly $100. Why is there noMoney * Money? {solution="ex13-01"} - Write a
Countdowntype that aforloop counts down with, printingliftoffat zero. What happens when the start is below the end? {solution="ex13-02"} - Add
operator *toVec2as a dot product, then try to add a second*for the cross product. What stops you, and what does that tell you about overloading*here at all? - Give
Fixedanoperator /. What should1 / 3give, and where exactly does the rounding happen? - Remove
operator <>fromvec2.tikoand keep thea <> bline. What does the compiler say, and would you rather it had derived<>from=for you?