Lab 04: Advanced D topics

Operator overloading

The topics covered in this chapter apply mostly for classes as well. The biggest difference is that the behavior of assignment operation opAssign() cannot be overloaded for classes.

Operator overloading enables defining how user-defined types behave when used with operators. In this context, the term overload means providing the definition of an operator for a specific type.

To better understand the use of operator overloading let us take an example:

struct Duration
{
    int minute;
}
 
struct TimeOfDay
{
    int hour;
    int minute;
    void increment(in Duration duration)
    {
        minute += duration.minute;
        hour += minute / 60;
        minute %= 60;
        hour %= 24;
    }
}
 
 
void main()
{
    auto lunchTime = TimeOfDay(12, 0);
    lunchTime.increment(Duration(10));
}

A benefit of member functions is being able to define operations of a type alongside the member variables of that type. Despite their advantages, member functions can be seen as being limited compared to operations on fundamental types. After all, fundamental types can readily be used with operators:

int weight = 50;
weight += 10;     // by an operator

According to what we have seen so far, similar operations can only be achieved by member functions for user-defined types:

auto lunchTime = TimeOfDay(12, 0);
lunchTime.increment(Duration(10));     // by a member function

Operator overloading enables using structs and classes with operators as well. For example, assuming that the += operator is defined for TimeOfDay , the operation above can be written in exactly the same way as with fundamental types:

lunchTime += Duration(10);   // by an operator, even for a struct

Before getting to the details of operator overloading, let's first see how the line above would be enabled for TimeOfDay . What is needed is to redefine the increment() member function under the special name opOpAssign(string op) and also to specify that this definition is for the '+' character. As it will be explained below, this definition actually corresponds to the '+=' operator:

struct TimeOfDay
{
// ...
    ref TimeOfDay opOpAssign(string op)(in Duration duration)//(1)
    if (op == "+")                                           //(2) 
    {
        minute += duration.minute;
        hour += minute / 60;
        minute %= 60;
        hour %= 24;
 
        return this;
    }
}

The template definition consists of two parts:

  1. opOpAssign(string op) : This part must be written as is and should be accepted as the name of the function. We will see below that there are other member functions in addition to opOpAssign .
  2. if (op == ”+” ) : opOpAssign is used for more than one operator overload. '”+”' specifies that this is the operator overload that corresponds to the '+' character.

Also note that this time the return type is different from the return type of the increment() member function: it is not void anymore. We will discuss the return types of operators later below. Behind the scenes, the compiler replaces the uses of the += operator with calls to the opOpAssign!”+“ member function:

lunchTime += Duration(10);
// The following line is the equivalent of the previous one
lunchTime.opOpAssign!"+"(Duration(10));

Note that the operator definition that corresponds to += is defined by + , not by += . The Assign in the name of opOpAssign() already implies that this name is for an assignment operator. Being able to define the behaviors of operators brings a responsibility: the programmer must observe expectations. As an extreme example, the previous operator could have been defined to decrement the time value instead of incrementing it. However, people who read the code would still expect the value to be incremented by the += operator.

To some extent, the return types of operators can also be chosen freely. Still, general expectations must be observed for the return types as well. Keep in mind that operators that behave unnaturally would cause confusion and bugs

For a list of all the operators that can be overloaded, check out this link.

Defining more than one operator at the same time

To keep the code samples short, we have used only the += operator above. It is conceivable that when one operator is overloaded for a type, many others would also need to be overloaded. For example, the ++, '+, , - and -= operators are also defined for the following Duration:

struct Duration
{
    int minute;
    ref Duration opUnary(string op)()
    if (op == "++")
    {
        ++minute;
        return this;
    }
 
    ref Duration opUnary(string op)()
    if (op == "--")
    {
        --minute;
        return this;
    }
 
    ref Duration opOpAssign(string op)(in int amount)
    if (op == "+")
    {
        minute += amount;
        return this;
    }
 
    ref Duration opOpAssign(string op)(in int amount)
    if (op == "-")
    {
        minute -= amount;
        return this;
    }
}

The operator overloads above have code duplications. The only differences between the similar functions are the operators that are used. Such code duplications can be reduced and sometimes avoided altogether by string mixins. We will see the mixin keyword in a later lab, but I would like to show briefly how this keyword helps with operator overloading. mixin inserts the specified string as source code right where the mixin statement appears in code. The following struct is the equivalent of the one above:

struct Duration
{
    int minute;
    ref Duration opUnary(string op)()
    if ((op == "++") || (op == "--"))
    {
       mixin(op ~ "minute;");
       return this;
    }
    ref Duration opOpAssign(string op)(in int amount)
    if ((op == "+") || (op == "-"))
    {
       mixin("minute " ~ op ~ "= amount;");
       return this;
    }
}

If the Duration objects also need to be multiplied and divided by an amount, all that is needed is to add two more conditions to the template constraint:

struct Duration
{
// ...
 
    ref Duration opOpAssign(string op)(in int amount)
    if ((op == "+") || (op == "-") || (op == "*") || (op == "/"))
    {
        mixin ("minute " ~ op ~ "= amount;");
        return this;
    }
}

In fact, the template constraints are optional:

ref Duration opOpAssign(string op)(in int amount)
/* no constraint */
{
    mixin ("minute " ~ op ~ "= amount;");
    return this;
}

alias

The alias keyword assigns aliases to existing names. alias is useful in 3 types of situations:

1. Shortening a long name

Stack!(Point!double) randomPoints(size_t count)
{
    auto points = new Stack!(Point!double);
// ...
}

Having to type Stack!(Point!double) explicitly in multiple places in the program has a number of drawbacks:

  • Longer names can make the code harder to read.
  • It is unnecessary to be reminded at every point that the type is the Stack data structure that contains objects of the double instantiations of the Point struct template.
  • If the requirements of the program change and e.g. double needs to be changed to real , this change must be carried out in multiple places.

These drawbacks can be eliminated by giving a new name to Stack!(Point!double) :

alias Points = Stack!(Point!double);
// ...
Points randomPoints(size_t count)
{
    auto points = new Points;
// ...
}

2. Design Flexibility

For flexibility, even fundamental types like int can have aliases:

alias CustomerNumber = int;
alias CompanyName = string;
// ...
struct Customer
{
    CustomerNumber number;
    CompanyName company;
// ...
}

3. Revealing hidden names of superclass

When the same name appears both in the superclass and in the subclass, the matching names that are in the superclass are hidden. Even a single name in the subclass is sufficient to hide all of the names of the superclass that match that name:

class Super
{
    void foo(int x) { /* ... */ }
}
class Sub : Super
{
    void foo() {/* ... */ }
}
void main()
{
    auto object = new Sub;
    object.foo(42);            // ---> Compilation error
}

Since the argument is 42, an int value, one might expect that the Super.foo function that takes an int would be called for that use. However, even though their parameter lists are different, Sub.foo hides Super.foo and causes a compilation error. The compiler disregards Super.foo altogether and reports that Sub.foo cannot be called by an int :

Error: function deneme.Sub.foo () is not callable
using argument types (int)

Note that this is not the same as overriding a function of the superclass. For that, the function signatures would be the same and the function would be overridden by the override keyword.

Here, not overriding, but a language feature called name hiding is in effect. If there were not name hiding, functions that happen to have the same name foo that are added to or removed from these classes might silently change the function that would get called. Name hiding prevents such surprises. It is a feature of other OOP languages as well. alias can reveal the hidden names when desired:

class Super
{
    void foo(int x) { /* ... */ }
}
 
class Sub : Super
{
    void foo() { /* ... */ }
    alias foo = Super.foo;
}

Alias This

struct S
{
    int x;
    alias x this;
}
 
int foo(int i) { return i * 2; }
 
void test()
{
    S s;
    s.x = 7;
    int i = -s;  // i == -7
    i = s + 8;   // i == 15
    i = s + s;   // i == 14
    i = 9 + s;   // i == 16
    i = foo(s);  // implicit conversion to int
}

An alias this declaration names a member to subtype. A class or struct can be implicitly converted to the alias this member. If the member is a class or struct, undefined lookups will be forwarded to the alias this member.

Pure Functions

Pure functions are functions that cannot directly access global or static mutable state. pure guarantees that a pure function call won't access or modify any implicit state in the program. Unlike other functional programming languages, D's pure functions allow modification of the caller state through their mutable parameters.

pure int foo(int[] arr) { arr[] += 1; return arr.length; }
int[] a = [1, 2, 3];
foo(a);
assert(a == [2, 3, 4]);

A pure function accepting parameters with mutable indirections offers what's called “weak purity” because it can change program state transitively through its arguments. A pure function that has no parameter with mutable indirections is called “strongly pure” and fulfills the purity definition in traditional functional languages. Weakly pure functions are useful as reusable building blocks for strongly pure functions.

To prevent mutation, D offers the immutable type qualifier. If all of a pure function's parameters are immutable or copied values without any indirections (e.g. int), the type system guarantees no side effects.

struct S { double x; }
pure int foo(immutable(int)[] arr, int num, S val)
{
    //arr[num] = 1; // compile error
    num = 2;        // has no side effect to the caller side
    val.x = 3.14;   // ditto
    return arr.length;
}

Nothrow Functions

Nothrow functions can only throw exceptions derived from class Error (not from class Exception):

int add(int lhs, int rhs) nothrow
{
    writeln("adding");    // ← compilation ERROR
    return lhs + rhs;
}

The compiler rejects the code because add() violates the no-throw guarantee:

Error: function 'deneme.add' is nothrow yet may throw

This is because writeln is not (and cannot be) a nothrow function.

The compiler can infer that a function can never emit an exception. The following implementation of add() is nothrow because it is obvious to the compiler that the try-catch block prevents any exception from escaping the function:

int add(int lhs, int rhs) nothrow
{
    int result;
 
    try
    {
        writeln("adding");    // ← compiles
        result = lhs + rhs;
 
    } catch (Exception error)   // catches all exceptions
    {   
        // ...
    }
 
    return result;
}

As mentioned above, nothrow does not include exceptions that are under the Error hierarchy. For example, although accessing an element of an array with [] can throw RangeError, the following function can still be defined as nothrow:

int foo(int[] arr, size_t i) nothrow
{
    return 10 * arr[i];
}

Nogc Functions

D is a garbage collected language. Many data structures and algorithms in most D programs take advantage of dynamic memory blocks that are managed by the garbage collector (GC). Such memory blocks are reclaimed again by the GC by an algorithm called garbage collection.

Some commonly used D operations take advantage of the GC as well. For example, elements of arrays live on dynamic memory blocks:

// A function that takes advantage of the GC indirectly
int[] append(int[] slice)
{
    slice ~= 42;
    return slice;
}

If the slice does not have sufficient capacity, the ~= operator above allocates a new memory block from the GC.

Although the GC is a significant convenience for data structures and algorithms, memory allocation and garbage collection are costly operations that make the execution of some programs noticeably slow.

@nogc means that a function cannot use the GC directly or indirectly:

void foo() @nogc
{
    // ...
}

The compiler guarantees that a @nogc function does not involve GC operations. For example, the following function cannot call append() above, which does not provide the @nogc guarantee:

void foo() @nogc
{
    int[] slice;
    // ...
    append(slice);    // ← compilation ERROR
}
Error: @nogc function 'deneme.foo' cannot call non-@nogc function
'deneme.append'

For an extensive list of operations forbidden in @nogc code, check this link

Ranges

Any object which fulfills the following interface is called a range (or more specific InputRange) and is thus a type that can be iterated over:

    interface InputRange(E)
    {
        bool empty();
        E front();
        void popFront();
    }

Check this link for an example of a Fibonaci range implementation.

Besides the dumbest form of range, the InputRange, the are other forms of ranges:

  • ForwardRange is an InputRange and additionally requires the save method that saves the state of the range.
  • BidirectionalRange is a ForwardRange and additionally requires the back and popBack methods.
  • RandomAccessRange is a BidirectionalRange and additionally requires the [] operator (and another property depending whether the range is finite or infinite)

For more informations on ranges, you can read the outstanding description in Ali's book.

Exercises

The lab can be found at this link.

1. Complex numbers

Implement a struct that will emulate a complex number. The operations that need to be implemented on complex numbers are: assignment, addition, subtraction, multiply and division. Write a unittest to assure the validity of the implementation.

What happens if you comment the assignment operator?

2. Alias

Navigate to the 2-alias directory. Inspect the two source files. Compile de code:

dmd main.d func.d

What happens? Why? Before invoking function fun, add the line:

alias fun = func.fun;

What happens? Why?

3. Nullable

Implement a Nullable object. In D, there are certain types that cannot be null (such as int, struct objects etc.), also there are algorithms that work only for types that can be in the null state; for those algorithms to work with non-nullable types, an abstraction is considered in the form of a Nullable object.

  • Implement the Nullable(T) struct by having a templated field which is the value and a boolean that keeps track whether the value is null or not; the Nullable object is considered null if the field holds the .init value
  • Implement the methods:
  1. get: returns the value of the object if not null; if null, halts execution raising an assert error with the appropriate message;
  2. opAssign: a Nullable!T object can be assigned a value of type T;
  3. a constructor that takes a value of type T;

What is the problem with this implementation?

4. Alias this

Solve the problem of the previous Nullable!T implementation by forwarding unknown operations to the T object contained. Test the new implementation by adding instances of Nullable with int, struct and class.

5. Ranges

Implement a stack that respects the range interface.

  1. Implement the stack as an input range (you may use builtin arrays as the underlying data structure);
  2. Improve the stack by adding support for forward range methods;
  3. Improve the stack by adding support for bidirectional range methods;
  4. Improve the stack by adding support for random access range methods;

To test your implementation you can use these range primitives

dss/laboratoare/04.txt · Last modified: 2021/07/12 18:58 by razvan.nitu1305
CC Attribution-Share Alike 3.0 Unported
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0