Table of Contents

1 Introduction

1.1 Command Line Interpreter

1.2 Hello World

1.3 Main Wafl Concepts

1.4 Introduction to the Types

2 Program Structure

2.1 Program Is an Expression

2.2 Comments

2.3 Tuples

2.4 Local Definitions

2.5 Function Definition

2.6 Named Expression Definition

2.7 No Variables

2.8 The Order of the Definitions

2.9 Conditional Expression if

2.10 Conditional Expression switch

2.11 Recursion

2.12 Libraries

3 Programming With Functions

3.1 Strict Type Checking

3.2 Automatic Type Inference

3.3 Polymorphism

3.4 Higher Order Functions

3.5 Partial Application

3.6 Lambda Functions

3.7 Lambda Closures

3.8 Operators as Functions

3.9 Dot Operator

3.10 Explicit Computation State

3.11 Cached Functions

4 Primitive Types

4.1 Literals

4.2 Operators

4.3 Conversion Functions

4.4 Integer Functions

4.5 Float Functions

4.6 String Functions

5 List Type

5.1 List Literals

5.2 Basic List Functions

5.3 Basic List Processing

5.4 Advanced List Processing

5.5 More List Functions

5.6 Functions map and zip

5.7 Functions foldr, foldl and fold

5.8 Functions filter, find and filterMap

5.9 Functions forall and exists

5.10 Functions count and countRep

5.11 Functions sort and sortBy

5.12 Functions in and contains

5.13 Lazy Lists

6 Structured Types

6.1 Array Type

6.2 Map Type

6.3 Tuple Type

6.4 Record Type

7 Elements of Wafl Library

7.1 Program Control

7.2 File Reading

7.3 File Writing

7.4 File Operations

7.5 Directory Operations

7.6 Regex Functions

7.7 Command Line

7.8 Web and HTTP Functions

7.9 Wafl to JSON

7.10 Wafl Program Evaluation

8 Parallelization

8.1 Wafl and Parallelization

8.2 Parallel Functions

9 Core Library Reference

9.1 Main Library

9.2 Command Line Library

9.3 Web Library

9.4 XML Library

9.5 Drawing Library (SDL)

9.6 Timer Library

9.7 Edlib Library

10 Command Line Reference

10.1 Command Line Options

10.2 Configuration Files

10.3 ANSI Control Codes

11 Advanced

11.1 JIT

11.2 Wafl Binary Libraries

12 More Content…

12.1 Soon…

 

 

Last update: 29.01.2025.

Wafl

Wafl

Tutorial / 6 - Structured Types

Open printable version

6 Structured Types

6.1 Array Type

The array is a sequence type, like a list, but with a focus on the efficiency of indexing and slicing operations.

6.1.1 Array Literals

The general array type is written as Array['1], where '1 denotes a type variable (a pure polymorphic type). Each specific array in a program must have a specific type, where this free type is fully defined.

The syntax of array literals is similar to that of list literals. The only difference is that square brackets with hash symbols are used instead of square brackets.

{#
    [# #],
    [#1,2,3#],                      //  Array[Int]
    [#'a','b','c','d'#],            //  Array[String]
    [#[#1,2#],[#3,4,5#],[#6,7,8#]#] //  Array[Array[Int]]
#}

{# [#  #], [# 1, 2, 3 #], [# 'a', 'b', 'c', 'd' #], [# [# 1, 2 #], [# 3, 4, 5 #], [# 6, 7, 8 #] #] #}

6.1.2 Common Sequence Functions

Many of the list functions also work with arrays:

Function / Type and Description

A == B

('1 * '1 -> Bool)
Equal-to operator.

A != B

('1 * '1 -> Bool)
Not-equal-to operator.

empty

(Indexable['1]['2]['3] -> Bool)
Checks whether the collection is empty.

size

(Indexable['1]['2]['3] -> Int)
Returns the size of the collection.

length

(Indexable['1]['2]['3] -> Int)
Returns the size of the collection.

longerThan

(SequenceStr['1]['2] * Int -> Bool)
Checks whether the sequence is longer than the given integer.

sub

(SequenceStr['2]['1] * Int * Int -> SequenceStr['2]['1])
Extracts the subsequence from given 0-based position and given length:
    sub(seq,pos,len)

A[ : B ]

(SequenceStr['2]['1] * Int -> SequenceStr['2]['1])
Extracts a prefix of the sequence with N elements:
    seq[:N]

A[ B : C ]

(SequenceStr['2]['1] * Int * Int -> SequenceStr['2]['1])
Extracts a sequence segment from Nth to (M-1)th element:
    seq[N:M]

A[ B : ]

(SequenceStr['2]['1] * Int -> SequenceStr['2]['1])
Extracts a suffix of the sequence with N elements:
    seq[N:]

A[ B ]

(Indexable['1]['2]['3] * '2 -> '3)
Extracts an element from the indexable collection:
    col[idx]

A ++ B

(SequenceStr['1]['2] * SequenceStr['1]['2] -> SequenceStr['1]['2])
Appends the the second sequence to the first sequence.

map

(Sequence['2]['1] * ('1 -> '3) -> Sequence['2]['3])
Maps the function to sequence elements:
    map(seq,fun)

mapIdx

(Sequence['1]['2] * (Int * '2 -> '3) -> Sequence['1]['3])
Maps the function to sequence elements:
    mapIdx(seq,fun)

zip

(Sequence['4]['1] * Sequence['4]['2] -> Sequence['4][Tuple['1, '2]])
Zips two sequences to a sequence of pairs.

zipBy

(Sequence['4]['1] * Sequence['4]['2] * ('1 * '2 -> '3) -> Sequence['4]['3])
Zips two sequences by given function:
    zipBy(seq1, seq2 ,fun)

zipByIdx

(Sequence['4]['1] * Sequence['4]['2] * (Int * '1 * '2 -> '3) -> Sequence['4]['3])
Zips two sequences and indexes by given function:
    zipByIdx(seq1, seq2 ,fun)

fold

(Sequence['2]['1] * ('1 * '1 -> '1) * '1 -> '1)
Returns the sequence fold by an associative function:
    fold == foldl == foldr

foldr

(Sequence['3]['2] * ('2 * '1 -> '1) * '1 -> '1)
Returns the right associative fold of sequence elements:
    foldr([a,b,c],fn,zero) = fn(a,fn(b,fn(c,zero)))

foldl

(Sequence['3]['2] * ('1 * '2 -> '1) * '1 -> '1)
Returns the left associative fold of sequence elements:
    foldl([a,b,c],fn,zero) = fn(fn(fn(zero,a),b),c)

filter

(Sequence['2]['1] * ('1 -> Bool) -> Sequence['2]['1])
Extracts the sequence elements which satisfy the given condition

filterN

(Sequence['2]['1] * ('1 -> Bool) * Int -> Sequence['2]['1])
Extracts at most N elements which satisfy the given condition.

filterMap

(Sequence['3]['1] * ('1 -> Bool) * ('1 -> '2) -> Sequence['3]['2])
Maps a function to sequence elements which satisfy the condition.
    mapFilter(seq,cond,fn) == map(filter(seq,cond),fn)

find

(Sequence['2]['1] * ('1 -> Bool) -> Sequence['2][Int])
Finds the indexes of the elements which satisfy the given condition.

findN

(Sequence['2]['1] * ('1 -> Bool) * Int -> Sequence['2][Int])
Finds at most N indexes of the elements which satisfy the given condition.

forall

(Sequence['2]['1] * ('1 -> Bool) -> Bool)
Checks whether the given condition applies to all sequence elements:
    forall(list,cond).

exists

(Sequence['2]['1] * ('1 -> Bool) -> Bool)
Checks whether any sequence element fulfills the condition:
    exists(sequence,cond)

in

('1 * Sequence['2]['1] -> Bool)
Checks whether the element is in the sequence:
    el.in(seq)

contains

(Sequence['2]['1] * '1 -> Bool)
Checks whether the element is contained in the sequence:
    seq.contains(el)

count

(Sequence['2]['1] * ('1 -> Bool) -> Int)
Counts the sequence elements that fulfill the condition:
    count(seq,cond)

sort

(Sequence['2][Value['1]] -> Sequence['2][Value['1]])
Sorts value sequence.

sortBy

(Sequence['2]['1] * ('1 * '1 -> Bool) -> Sequence['2]['1])
Sorts the sequence using given comparator.

append

(SequenceStr['1]['2] * SequenceStr['1]['2] -> SequenceStr['1]['2])
Appends the second sequence to the first sequence.

appendAll

(Sequence['1][SequenceStr['2]['3]] -> SequenceStr['2]['3])
Appends all sequences in the given sequence.

groupBy

(Sequence['3]['1] * ('1 -> '2) -> Map['2][List['1]])
Creates a catalog from the sequence. Each catalog element is a list of all elements mapped to the key value by the given function.

countRep

(Sequence['2]['1] -> Map['1][Int])
Creates a catalog from the sequence. Keys are the distinct elements, and values are the number of occurrences.

These functions have already been introduced for the list type and will not be discussed here again. Their application to arrays is identical to that for lists. The only difference is that the arguments and the results are arrays instead of lists.

6.1.3 Functions newArray and newArrayFn

Function / Type and Description

newArray

(Int * '1 -> Array['1])
Generates an array with given number of elements of given value:
    newArray(5,'a') == [# 'a', 'a', 'a', 'a', 'a' #]

newArrayFn

(Int * (Int -> '1) -> Array['1])
Generates an array with given number of elements and computes each of the elements using given function on its index:
    newArrayFn(5,\x:x*x) == [# 0, 1, 4, 9, 16 #]

The function newArray(n,x) returns an array with n elements, where all elements are set to x.

The function newArray(n,fn) returns an array with n elements, whereby the element at position idx is initialized as fn(idx).

{#
    newArray( 5, 1 ),
    newArray( 5, 'a' ),
    newArrayFn( 5, \x:x ),             
    newArrayFn( 5, \x:x*x ),
    newArrayFn( 5, \x: random(5) )
#}

{# [# 1, 1, 1, 1, 1 #], [# 'a', 'a', 'a', 'a', 'a' #], [# 0, 1, 2, 3, 4 #], [# 0, 1, 4, 9, 16 #], [# 2, 0, 0, 0, 2 #] #}

6.1.4 Array Conversion Functions

Arrays and lists are similar, but not the same types. They are both sequences, but have a different internal structure. Arrays are assumed to have a fixed content, which is usually used for indexing and slicing, while lists are often used by decomposing into head and tail. The usual sequence operations work the same for both.

Furthermore, the internal implementation of the list type can be the same as that of the array type. Depending on the context and the creation method used, the interpreter decides whether a list is structured as usual or as an array.

For this reason, there are functions for converting a list into an array and vice versa. To force the internal array-like organization of a list, convert it into an array and then back into a list: lst.asArray().asList().

Function / Type and Description

asArray

(Sequence['1]['2] -> Array['2])
Converts a sequence to an array

asList

(Sequence['1]['2] -> List['2])
Converts a sequence to a list

{#
    [].asArray(),
    [##].asList(),
    [1,2,3].asArray(),
    [1,2,3].asArray().asList(),
    ['1','2','3'].asArray(),
    ['1','2','3'].asArray().asList(),
    [#1,2,3#] == [#1,2,3#],
    [#1,2,3#] == [#1,2,3,4,5#],
    [#1,2,3#] != [#1,2,3#],
    [#1,2,3#] != [#1,2,3,4,5#]
#}

{# [#  #], [], [# 1, 2, 3 #], [1, 2, 3], [# '1', '2', '3' #], ['1', '2', '3'], true, false, false, true #}

6.2 Map Type

The map is an indexable type. It has a catalog structure in which the elements are accessed via keys. Both the keys and the values can be of any valid type.

6.2.1 Creating a Map

Function createMap

There are no map literals in Wafl. The maps are normally created with the createMap function.

Function / Type and Description

createMap

(Array[Value['1]] * Array['2] -> Map[Value['1]]['2])
Creates a catalog from given arrays.

The function createMap takes two arrays of the same size and creates a map with the keys from the first array and the corresponding values from the second.

The elements are accessed by indexing.

In the following example, a map is created with string keys and list values. The map is indexed by some keys:

{#
    aMap['a'],
    aMap
#}
where {
    aMap = createMap(
        [# 'a', 'b', 'c' #],
        [#
            [1,2,3],
            [4,5],
            [6]
        #]
    );
}

{# [1, 2, 3], [<'a',[1, 2, 3]>, <'b',[4, 5]>, <'c',[6]>] #}

Map is not a safe type. The behavior of the indexing operator is not defined if a non-existent index is used.

Function groupBy

If it is necessary to categorize list elements according to certain criteria, it can be useful to create a map that contains different values of the categorization criteria as keys and the lists of categorized elements as values. This is what the function groupBy computes.

Function / Type and Description

groupBy

(Sequence['3]['1] * ('1 -> '2) -> Map['2][List['1]])
Creates a catalog from the sequence. Each catalog element is a list of all elements mapped to the key value by the given function.

For example, we will categorize a list of integers by their reminder when dividing by 4:

{#  aMap, aMap[1] #}
where {
    aMap = (1..20).groupBy(\x:x%4);
}

{# [<1,[1, 5, 9, 13, 17]>, <2,[2, 6, 10, 14, 18]>, <3,[3, 7, 11, 15, 19]>, <0,[4, 8, 12, 16, 20]>], [1, 5, 9, 13, 17] #}

6.2.2 Using a Map

The usual way to use the maps is to index them. However, if it is not clear which exact key values are allowed, some other usage methods may be useful.

Functions keys and values

Function / Type and Description

keys

(Map['2]['1] -> Array['2])
Returns map keys.

values

(Map['2]['1] -> Array['1])
Returns map values.

key

(Map['2]['1] * Int -> '2)
Returns Nth key (from 0).

value

(Map['2]['1] * Int -> '1)
Returns Nth value.

The functions keys and values return the arrays that were used to build the map.

The functions key(n) and value(n) return the nth key (or value) from the corresponding arrays. They are equivalent to indexing the arrays, such as keys()[n] and values()[n].

{#
    aMap.keys(),
    aMap.key(0),
    aMap.values(),
    aMap.value(0)
#}
where {
    aMap = createMap([# 'a', 'b', 'c' #], [# 11, 22, 33 #]);
}

{# [# 'a', 'b', 'c' #], 'a', [# 11, 22, 33 #], 11 #}

Functions hasKey, hasValue, findKey and findValue

Function / Type and Description

hasKey

(Map['2]['1] * '2 -> Bool)
Checks whether there is a given key in the map.

hasValue

(Map['2]['1] * '1 -> Bool)
Checks whether there is a given value in the map.

findKey

(Map['2]['1] * '2 -> Int)
Finds a position of the given key, or -1 if not found.

findValue

(Map['2]['1] * '1 -> Int)
Finds a position of the given value, or -1 if not found.

The functions hasKey and hasValue check whether there is a map key (or value) with the given value.

The functions findKey and findValue search for the key (or value) in the corresponding array and return the index of the key (or value) found, or -1 if it was not found.

So if a key x exists in a map m, then the following must be true:

m.keys()[ m.findKey(x) ] == x m.key( m.findKey(x) ) == x

and the same applies to the values.

{#
    aMap['a'],
    aMap.hasKey('a'),
    aMap.hasKey('A'),
    aMap['c'],
    aMap.findKey('c'),
    aMap.findKey('C')
#}
where {
    aMap = createMap([# 'a', 'b', 'c' #], [# 11, 22, 33 #]);
}

{# 11, true, false, 33, 2, -1 #}

6.3 Tuple Type

Tuple is a structured data type. It consists of a series of unnamed elements that are referenced by a position. Tuple elements can have different types.

6.3.1 Tuple Literals

Tuples are written with curly brackets and hashes: {# ... #}, and tuple elements are separated by commas. The elements can be listed as literals or as expressions.

Tuples must not be empty.

It is permitted to specify the element separator after the last element.

Although the syntax of a hanging separator may seem too loose, it is very useful in practice. Tuple literals are often written with one element in a line (see the following example). If a hanging separator were not allowed in such cases, commenting the last element would require deleting or commenting the previous separator.

In the following example there is a tuple with three tuple elements of different types.

{#
    {# 1 #}, 
    {# 1 + 2,'a'+'b' #}, 
    {# 1,{# 'list',[1,2,3] #}#},
    // 'last' - this element is declared as a comment
    //  and the previous separator `,` is hangs, but that's OK 
#}

{# {# 1 #}, {# 3, 'ab' #}, {# 1, {# 'list', [1, 2, 3] #} #} #}

The type of this tuple is:

Tuple[
    Tuple[Integer],
    Tuple[Integer, String],
    Tuple[Integer,Tuple[String,List[Integer]]]
]

6.3.2 Tuple Selectors

Tuple elements are accessed via tuple selectors. As tuple elements are not named, their position is used to select them. The positions of the tuple elements are 1-based.

Tuple selectors use the dot-syntax <tuple>.<n>, where a positive integer indicates the position of the element to be read.

{# 1, 2, 3, 4, 5 #}.f()
where{
    f(t) = {#
        t.1,
        t.1 + t.2,
        t.1 + t.2 + t.3,
        t.1 + t.2 + t.3 + t.4,
        t.1 + t.2 + t.3 + t.4 + t.5
    #};
}

{# 1, 3, 6, 10, 15 #}

The selector indices and element types are checked in the type-checking phase so that it is not possible to use an invalid selector.

To support the syntax similar to records, the symbol ‘$’ can be used instead of the dot. The two syntaxes are equivalent - x$1 is the same as x.1.

6.3.3 Tuple Updater

Tuple updaters are operators that return a copy of a tuple in which one of the tuple elements has been replaced by the given value. They are called updaters, but they do not modify the given tuple - instead they construct a new tuple with the copied and changed values.

Tuple updaters have the following basic syntax: <tuple>^<n>(<value>), where a positive integer <n> specifies the position of the element to be replaced and the <value> specifies its new value.

{# 
    {# 1, 2, 3 #}^1(11),
    {# 1, 2, 3 #}^2(22),
    {# 1, 2, 3 #}^3(33)
#}

{# {# 11, 2, 3 #}, {# 1, 22, 3 #}, {# 1, 2, 33 #} #}

As with the selectors, the indexes and types of the updaters are checked in the type-checking phase, so that it is not possible to use an invalid selector.

6.3.4 Tuple Type-Checking

The tuple data type is written as follows: Tuple[<el1-type>, <el2-type>, ..., <eln-type>]

If T1 is a tuple type with N elements, and T2 is another tuple type with K elements, where 0 < K < N, and for each i (0 < i <= K) the type of the ith element of T1 is the same as the type of the ith element of T2, then T1 is a subtype of T2.

If a selector works with a tuple of type T2, then it also works for T1 (this is obvious: the selector has the index n <= K, so it is also n <= N and works with T1). The same rule also applies to updaters.

If a function is defined in a such way that it works with an argument of a tuple type, then it can also be applied to subtypes of this type.

In the following example, the function f sums the first two elements of a tuple. The function can be applied to all tuples with at least two elements, whereby the first two elements are naturally of the same value type:

{#
    {# 1, 2 #}.f(),
    {# 1, 2, 3 #}.f(),
    {# 1, 2, 'a string' #}.f(),
    {# 1.2, 2.4, 'a string' #}.f(),
    {# 'a', 'b', 1.2, 2.4, 'a string' #}.f()
#}
where {
    f(t) = t.1 + t.2;
}

{# 3, 3, 3, 3.6, 'ab' #}

The types of the .2 selector and the ^2 updater are for example:

(TupleX['1,'2]['3] -> '2)
(TupleX['1,'2]['3] * 2 -> Tuple['1,'2]['3])

where TupleX stands for extendable tuple and '3 denotes an optional tuple extension type. This is necessary for the subtypes to work properly.

On the other hand, each tuple has an exact tuple type. For example, the result tuple in the last example has the type: Tuple[ Int, Int, Int, Float, String ].

6.4 Record Type

Record is a structured data type. It consists of a series of named values. Record elements can have different types.

6.4.1 Record Literals

Literal records are written in curly brackets { ... }. The elements are separated by commas or semicolons. Each element has a name and a value. Names and values are separated by colons ‘:’ or the equals sign ‘=’.

The syntax is flexible in order to be compatible with different languages, but it must be strictly adhered to - it is not permitted to combine different separators in one record.

As with tuples, it is permitted to specify the element separator after the last element.

Although the syntax of a hanging separator may seem too loose, it is very useful in practice. Record literals are often written with one element in a line (see the following example). If a hanging separator were not allowed in such cases, commenting the last element would require deleting or commenting the previous separator.

In the following example, many different records are presented and various combinations of separators are used:

{
    fst: { a=1 }, 
    scd: { i:1, s:'a', trd:{ nme='list'; lst=[1,2,3] }},
    // third: "This element is commented out, but previous separator is OK."
}

{ fst: { a: 1 }, scd: { i: 1, s: 'a', trd: { lst: [1, 2, 3], nme: 'list' } } }

6.4.2 Record selectors

Record elements are accessed via record selectors. Record selectors use names to access the elements. The syntax is similar to tuple selectors, but the dot cannot be used here, instead ‘$’ is used: <record>$<name>, where <name> is a record element name.

A selector .<name> is applicable to any record that has an element with the specified name.

{ a:1, b:2, c:3, d:4, e:5 }.f()
where{
    f(t) = {#
        t$a,
        t$a + t$b,
        t$a + t$b + t$c,
        t$a + t$b + t$c + t$d,
        t$a + t$b + t$c + t$d + t$e
    #};
}

{# 1, 3, 6, 10, 15 #}

The selector names and element types are checked in the type-checking phase, so that it is not possible to use an invalid selector.

6.4.3 Record Updaters

Record updaters are operators that replace one of the record elements with a given value. As with the tuple updaters, the given record is not modified - instead, a new record is constructed with the copied and replaced values.

The basic form of the record update selector is: <record>^<name>(<value>). A simple update selector ^name(...) can be applied to any record that contains an element with the specified name.

In an advanced form, a sequence of update selectors can be used. In this case, the syntax is R^a^b^c(x), which is semantically equivalent to: R^a( R$a^b( R$a$b^c(x) ) ).

Please note that the expressions R^a^b(x) and R$a^b(x) are not semantically equivalent. The first evaluates the record R, whereby the value of R$a$b is replaced by x. The second evaluates R$a, whereby the value of the attribute b is replaced by x.

{#
    { a:1, b:2 }^a(5),
    { a:1, b:{x:2, y:3} }^b^x(5),
    { a:1, b:{x:2, y:3} }$b^x(5)
#}

{# { a: 5, b: 2 }, { a: 1, b: { x: 5, y: 3 } }, { x: 5, y: 3 } #}

6.4.4 Record Type-Checking

The record data type is written as follows:

Record[<name1>:<type1>, <name2>:<type2>, ..., <namen>:<typen>].

If R1 is a record type and R2 is a record type with some elements the same as in the R1 and with no other elements, then we say that R1 is a subtype of R2.

If a selector (updater) works with R1, then it must also work with R2. Then any function that works with R1 and uses its selectors and updaters will also work with R2.

In the following example, the function f sums the elements a and b of a given record. The function can also be applied to subtypes. It is only necessary that the argument is a record whose elements a and b have the same value type.

{#
    { a:1, b:2 }.f(),
    { q:1, b:2, a:3 }.f(),
    { a:1, b:2, s:'a string' }.f(),
    { a:1.2, b:2.4, s:'a string' }.f(),
    { a:'a', b:'b', x:1.2, y:2.4, s:'a string' }.f()
#}
where{
    f(t) = t$a + t$b;
}

{# 3, 5, 3, 3.6, 'ab' #}

If a function or another context allows a record type or its subtype, the type is usually annotated as follows: RecordX[name:type]['2], where RecordX stands for extendable record and '2 represents an optional record extension.

For example, the types of the $name selector and the ^name updater are:

(RecordX[name:'1]['2] -> '1)
(RecordX[name:'1]['2] * '1 -> RecordX[name:'1]['2])

6.4.5 A Record Example

Tuple and record elements can have functional types. The following example uses a collection of named float functions to compute a table with its values.

apply( 
    [
        { name:"Id     "; fn:\x:x },
        { name:"Sine   "; fn:sin },
        { name:"Cosine "; fn:cos },
        { name:"Square "; fn:\x:x*x }
    ],
    [0.0, 0.5, 1.0, 1.5] 
)
where{
    apply( functions, values ) =
        functions
        .map(\fn [values]: fn$name + applyFn(fn$fn,values) )        
        .strJoin( '\n' )
    ;
    applyFn( fn, values ) =
        values
        .map( fn )
        .map( asString )
        .strJoin(' ')
    ;
}

Id     0 0.5 1 1.5
Sine   0 0.4794255386 0.8414709848 0.9974949866
Cosine 1 0.8775825619 0.5403023059 0.07073720167
Square 0 0.25 1 2.25

Wafl Home            Downloads            Wafl Tutorial v.0.6.8 © 2021-2025 Saša Malkov            Open printable version