Menu

Syntax-Classes

Ove Kåven

Classes

Classes are structures that can hold data fields, method implementations, and operator overloads. They support standard single-dispatch polymorphism, but multiple-dispatch will also be supported at some point. (It's not decided whether multiple dispatch should be automatic or enabled with a keyword.) Here's an example:

import "stdio", "glib"
using GLib

class Printer
{
  protected print(msg: CString) { stdout.printf(msg) }
  public virtual hello() { }
}

class HelloPrinter: Printer
{
  override hello() { print("Hello World\n") }
}

public main(): int
{
  printer: Printer = HelloPrinter()
  printer.hello()
  return 0
}

Classes are reference types, meaning instances of them are usually allocated on the heap, and passed by reference. Thus, they can continue to exist after leaving the code block they were created in.

If the class supports it (defines the appropriate methods), they can be automatically reference counted when passed around. This means the programmer doesn't have to worry about memory management, as the system will itself keep track of whether an object is in use. Once nobody's using it, it will be automatically destroyed.

Structs

Structs are data structures meant to hold a fixed set of data fields. Structs can also define methods and operators. However, structs are not full classes. While they can inherit, they are not polymorphic and cannot have virtual methods.

Structs are value types, meaning instances of them are usually allocated on the stack, and passed by value (copied). They are, therefore, mostly useful for small (and perhaps short-lived) objects, such as numbers or iterators. When allocated on the stack, they are automatically destroyed when leaving the code block they were created in.

Interfaces

Interfaces are a special kind of abstract (non-instantiable) class. They can contain method declarations, but not method implementations or data fields. Interfaces help avoid some of the problems and ambiguities that occur with multiple inheritance, since nothing is inherited from interfaces.

Metamethods

To complement traditional method inheritance, MORTAL has metamethods. Traditional inheritance allows a subclass to use a method that was built for a base class. Metamethods, on the other hand, rebuilds the method for the subclass. That is, a separate implementation of that method is generated and compiled for every subclass in the class hierarchy.

While this feature should probably be used sparingly to avoid code bloat, it's very useful for certain things. The simplest use would be for implementing a generic algorithm that can be specialized by subclasses through overriding methods, but the overridden methods don't have to be virtual. They can even be static. This means the resulting code is faster.

Even more useful, though, is the way a class hierarchy can use virtual metamethods to implement RTTI (Run-Time Type Information). For example:

class base {
  public meta virtual get_name(): CString { @#owner }
}

class subclass: base {
  do_something()
}

...
obj: base = subclass()

Now, obj.get_name() would return the string "subclass". (Note that subclasses can still override metamethods by defining a method with the same name.)

Run-Time Type Information

Many class frameworks out there already have RTTI systems of their own, and therefore does not need the programming language to provide one. For example, the GObject framework (part of GLib) provides a powerful language-independent RTTI system (GType). Recognizing this, MORTAL assumes that class hierarchies that need polymorphism do implement their own RTTI, e.g. by using metamethods, or even defining a custom vtable.

To provide RTTI, classes must overload two operators: the typeid operator, and the is operator. (The language does not care what the typeid operator returns, but the corresponding is operator must accept whatever typeid returns.) At minimum, a static typeid and a non-static is should be defined, but for flexibility, it's also possible to overload a non-static typeid and a static is. The compiler will then choose the appropriate versions.

Once these operators are defined by the class framework, the compiler will use them to ensure full run-time type safety and multiple dispatch features (including dynamic casts, exception catching, multimethods, etc).

Constructors and Destructors

While languages like C++ provide constructors and destructors, MORTAL also provides allocators and deallocators, and even class initializers and deinitializers. Their roles are:

  • Initializers ("initializer()") are executed on program startup. They can be used to register classes in a class factory, for example. (Especially useful if the initializers are also metamethods.)
  • Deinitializers ("deinitializer()") are executed on program shutdown.
  • Allocators ("new()") are used to create a class instance. They must allocate memory and call the constructor, if any. The compiler can generate one for you if you want it to. Defining an allocator is roughly equivalent to overloading the "new" operator in C++, or providing a static "create" method. Unlike constructors, allocators are static methods and can be registered in and used by class factories.
  • Deallocators ("delete()") is the converse. The must call the destructor and free the memory.
  • Constructors ("constructor()") are used to initialize a class instance. They are called after memory has already been allocated.
  • Destructors ("destructor()") are used to finalize a class instance. They are called before memory is freed.

In MORTAL, constructors don't have to be filled with lots of assignments. If a constructor parameter and a class field has the same name, then an assignment between them is automatically generated. All other class fields are initialized to their default values. You may not have to write a constructor body yourself.

Transparent classes

Structs and classes can be transparent. Transparent structs and classes are designed mostly for integrating with external libraries, but their features also make them useful on their own.

Transparent classes may be used as "invisible" wrappers for some other feature. They are a bit like macros - in the source code, you use them like they're regular classes, but in the generated code, all method and operator calls are substituted with the implementation given in the class. That is, in the generated code the class doesn't exist, and it's like you wrote native code for the underlying feature directly.

This means that transparent classes have no runtime overhead, and that they reduce dependence on runtime libraries. (It might even be possible to write a platform-independent GUI framework using transparent classes and some metaprogramming, and have programs using it compile directly to native platform-specific GUI code - thus running faster and using less memory than if the framework used a runtime library.)

Note that if you want substitution, but don't want the whole struct or class to be transparent, you don't have to. You can also just mark individual methods and operators as transparent. (Though in most cases, simply marking them "inline" may be more preferable.)

There are two kinds of transparent structs/classes:

Those that inherit from some (external) type. The "file" type you can import from "stdio" is a wrapper for the "FILE" type in C:

transparent class file: __c_ptr.FILE
{
  extern new(path: __c_ptr.char, mode: __c_ptr.char) => __c_lib.fopen
  extern delete() => __c_lib.fclose
  ...
}

It does not have to define any data member, if it's supposed to be an opaque type. All that matters to the compiler is that it's a pointer, and it has methods. In the generated code, it's like you used the FILE type directly, just like native C code would.

Those that do not inherit from something. These must be structs, not classes, and are internally known as transcomplexes. The biggest difference from a regular struct is the substitution. If used as a local variable, each struct member is treated as a separate variable (thus, they may not be contiguous in memory). If passed to a function, each member is passed as a separate argument.

For example, GLib's linked list type is wrapped this way:

transparent class ListNode<T>: __c_ptr.GList {
  data: T => __c_lib.data
  next: ListNode<:T> => __c_lib.next
  prev: unowned ListNode<:T> => __c_lib.prev
  ...
}

transparent struct List<T>
{
  content: ListNode<:T>
  ...
  static extern do_append<T>(list: ListNode<:T>?, persisted data: def_owned T): ListNode<:T>
    => __c_lib.g_list_append
  ...
  empty: bool { get { content === null } }
  ...
  append(data: T) { content = do_list_append(content, data) }
  ...
}

This makes linked lists easier to use and more consistent with other container types. Again, in the generated code, it's like you used GList directly.

If a regular class derives from a transparent class/struct, then all the methods in it will be substituted into the subclass. Thus, transparent structs can also be used as mixins.

Generics

Structs, classes, and interfaces can be parametrized. Two approaches can be used.

Generics with regular classes:
If regular classes are parametrized, they work much like Java generics, in the sense that the class implementation itself is typically unaware of the actual type stored ("type erasure"), and treats it as a generic pointer.

class Container<T>
{
  stg: T
  set(d: T) { stg = d }
  get(): T { stg }
}

public main(): int
{
  info: Container<:CString>()
  info.set("Hello World\n")
  stdout.printf(info.get())
  return 0
}

In this case, only the caller (main) knows what actual type is stored in the container. The class may, however, constrain what types can be stored.

  • class Container<T> means that T can be any type.
  • class Container<class T> means that T can be any class (not a struct). Similar syntax works for structs and interfaces. These count as partial specializations.
  • class Container<T: base> means that T can be some subclass of base. It can be combined with the above, if desired. This counts as a partial specialization.
  • class Container<:int> means that this is a full specialization (in this case, for integers). (This is also the syntax used for instantiating a generic type.)

If the class needs more information about the type, it's possible to add implicit parameters to its constructor, possibly taking advantage of RTTI. Such implicit arguments are evaluated while the type is still known, and can then be stored in the class.

class Container<T>
{
  static delegate Destroyer(obj: T)

  stg: T
  type: Type
  destroy: Destroyer

  constructor(implicit type: Type = typeid(T),
              implicit destroy: Destroyer = T.delete)
  destructor() { destroy(stg) }
  set(d: T) { stg = d }
  get(): T { stg }
}

Now, the type field has the RTTI information for the stored type (assuming its typeid operator returns an object of type Type), and destroy has the type's deallocator. That way, if you destroy the container, its destructor can make sure the stored object itself is also destroyed.

Generics with transparent structs:
Should you need something more along the lines of C++ templates, then you can write your container as a parametrized mixin. You can then subclass it for the types you need. (Because implementing a container for each type is done explicitly, the compiler can always be sure in which module to place the code for that type.) You can combine this technique with partial specialization to minimize the number of implementations you need.

transparent struct ContainerTemplate<T>
{
  stg: T
  set(d: T) { stg = d }
  get(): T { stg }
}

class Container<:int>: ContainerTemplate<:int>
class Container<:bool>: ContainerTemplate<:bool>
class Container<class T>: ContainerTemplate<T> // all reference types

Related

Wiki: Syntax

Want the latest updates on software, tech news, and AI?
Get latest updates about software, tech news, and AI from SourceForge directly in your inbox once a month.