Object Oriented Programming with Nasal

From FlightGear wiki
Revision as of 12:46, 26 April 2020 by Hooray (talk | contribs) (As per PM / to be reviewed by User:Jsb)
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
Jump to navigation Jump to search


In Nasal, objects ("classes") are regular hashes. Self-reference and inheritance are implemented through special variables me (this pointer equivalent) and parents. The me keyword must be used whenever an internal field/member is accessed. There's no access specifiers or encapsulation in the form of "public/protected/private" - it's all basically like a C struct, i.e. members/methods always have public visibility/access.

Note  there is no real OOP support in Nasal, basically you should not define member variables in the class hash but in the constructor like this:
var myClass = {
     class_name: "myClass", # "static"/"shared variable

     new: func() {
         #create an instance / object
         var obj = {
             parents: [me, parent1, parent2],
             member1: 1,
             member2: [],
         };
         obj.init();
         return obj;
     },

     init: func () {
         print(me.class_name);
         return ;
     },
     foo: func (a,b) {
         me.member1 = a + b;
         return ;
     },
};

[1]

To get a better understanding of the concept, let's start with the very basics. A detailed step by step introduction is available here: Howto: Start using vectors and hashes in Nasal. A more high level description of the ideas behind OOP is here: Object oriented programming in Nasal.


Hashes

Hashes, also known as "dictionaries" in Python or "maps" in C++/STL are data structures that hold key/value pairs in a way that allows quick access to a value via its key.

var myHash = {
    key: "value",
};

print( myHash.key );               # prints "value"

# let's add another key/value pair
myHash.key2 = 200;
print( myHash.key2 );               # prints "200"

key/value pairs can also be declared "inline":


var airport = {
    "LOXZ": "Zeltweg",
    "LOWI": "Innsbruck",
    "LOXL": "Linz Hoersching",     # the last comma is optional
};

print(airport["LOXZ"]);            # prints "Zeltweg"
airport["LOXA"] = "Aigen";         # adds LOXA to the hash

The built-in function keys() returns a vector with the keys of the hash. The function values() returns a vector with the values of the hash. For example:

debug.dump (keys(airport)); #prints ['LOXZ', 'LOWI', 'LOXL']
debug.dump (values (airport)); #prints ['Zeltweg', 'Innsbruck', 'Linz Hoersching']

The quotes around keys can be left away in a hash definition if the key is a valid variable name or a number. This works just as well:

var airport = {
    LOXZ: "Zeltweg",
};

There's also an alternative way to access hash members if the keys are valid variable names: airport.LOXI can be used instead of airport["LOXI"]. There is 2 differences, though, one of which is described in the section about parents, the other is that the first will produce an error if the member does not exist, where the second will be nil.

Note that assigning a hash (or a vector) to another variable does never copy the contents. It only creates another reference to the same data structure. So manipulating the hash via its new name does in fact change the one, original hash.

var a = airport;
a.LOXL = "Linz";           # same as airport.LOXL!
print(airport.LOXL);       # prints now "Linz", not "Linz Hoersching"

(True copies of vectors can be made by assigning a full slice: var copy = vec[:]. There's no such method for hashes.)

What are classes?

A class is a simple way to come up with a new data type (think "aircraft", "house", "vehicle", "tree") that may consist of an arbitrary number of other types:

  • position: latitude, longitude
  • altitude
  • orientation

and also functions, such as:

  • setPosition(lat,lon)
  • setOrientation(heading)
  • setAltitude()

Rather than implementing functions separately and passing the instance operands as arguments, the functions are implemented next to the data, and have an implicit operand pointing to the active instance of the object, so that all fields and functions belonging to each other, are implemented/wrapped inside the same enclosing context (hash/class).

Using inheritance, it is easy to create new instances of such a class that automatically shares all properties of the parent class, including member fields and member functions (so called methods).

So, rather than calling:

setAltitude(house, 1000);

You would end up with a "house" object that implicitly contains a method named "setAltitude" that can be called:

house.setAltitude( 1000 );


Self-reference: "me"

Values stored in a hash can be of any type, even of type function. Member functions ("methods") can reference their own enclosing hash via reserved keyword me. This is comparable to the this keyword in C++ classes, or the self keyword in Python.

var value = "test";

var data = {
    value: 23,                         # scalar member variable
    write1: func { print(value); },    # function member
    write2: func { print(me.value); }, # function member
};

data.write1();     # prints "test"
data.write2();     # prints 23

The above example is already a simple form of an object. It has its own variable namespace (data), its own methods, and it can be passed around by-reference as one unit. Such classes are sometimes called singleton classes, as they are unique, with no independent class instances. They mostly serve as a way to keep data and methods nicely encapsulated within a Nasal module. Often they contain a method for initializing, which is usually called init.

Inheritance: "parents"

What we learned about me in the last section is only half the truth. "me" doesn't only reference an object's own hash, but also one or more parent hashes. parents is another reserved keyword. It denotes a vector referencing other object hashes, which are "inherited" that way.

Please note that Nasal's currently supported form of encapsulation does not provide support for any form of data/information hiding (restricting access), i.e. all hash fields (but also all hash methods) are always publicly accessible (so there's nothing like the "private" or "protected" keywords in C++: in this sense, Nasal's inheritance mechanism can be thought of like C++ structs which are also public by default).

The major difference being, that all members (functions and fields) are also always mutable, which means that functions can modify the behavior of other functions quite easily, this also applies to the parents vector, too.

var parent_object = {
    value: 123,
};

var object = {
    parents: [parent_object],
    write: func { print(me.value) },
};

object.write();    # prints 123

Even though object itself doesn't contain a member value, it finds and uses the one of its parent object. parents is a vector that can contain several parent objects. These are then searched in the order from left to right, until a matching member variable or method is found. Each of the parents can itself have parents, which are all recursively searched.

In the section about hashes it was said that hash members can be accessed in two alternative ways, and that's also true for methods. object.write() could also be called as object["write"](). But only in the first form will members also be searched in parent hashes if not found in the base hash, whereas the second form creates an error if it's not a direct member.

Creating class instances

With me and parents we can implement a class object and create independent instances from that:

var Class = {
    write:     func { print(me.value); },
    increment: func { me.value += 1; },
};

var instance1 = { parents: [Class], value: 123 };
var instance2 = { parents: [Class], value: 456 };

instance1.write();    # prints 123
instance2.write();    # prints 456

As you can see, the two class instances are separate, independent objects, which share another object as parent -- they "inherit" from object Class. One can now easily change members of any of these three objects. The following will redefine the parent's write method, and all instances will automatically use this new version:

Class.write = func { print("VALUE = " ~ me.value) }

But one can also add a method to just one instance:

instance1.write = func { print("VALUE = " ~ me.value) }

Because instance1 does now have its own write method, the parents won't be searched for one, so Class.write is now overridden by instance1's own method. Nothing changed for instance2 -- it will still only find and use Class.write via its parent.

Note, the we couldn't create a class instance by simple assignment, because, as we learned above, this wouldn't create a separate copy of the Class object. All "instances" would reference the same hash!

var bad_instance1 = Class;   # bad
var bad_instance2 = Class;   # bad

bad_instance1.value = 123;   # sets Class.value to 123
bad_instance2.value = 456;   # sets Class.value to 456

bad_instance1.write();       # prints 456, not 123


Constructor

Defining each class instance by explicitly creating a hash with parents is clumsy. It is nicer to have a function that does that for us. Then we can also use function arguments to initialize members of this instance.

var new_class = func(val) {
    return { parents: [Class], value: val };
}

var instance1 = new_class(123);
var instance2 = new_class(456);

instance1.write();   # prints 123
instance2.write();   # prints 456

Because the class generating function new_class() really belongs to class Class, it would be nicer to put it into the class hash as well. In this case we call it a class "constructor", and as a convention, give it the name new. It could have any name, though, and there could be more than one constructor.

var Class = {
    new: func(val) {
        return { parents: [Class], value: val };
    },
    write: func {
        print("VALUE=" ~ me.value);
    },
};

var instance1 = Class.new(123);
var instance2 = Class.new(456);

As you can see, new() doesn't return a copy of Class, but rather a small hash that contains only a list of parents and one individual member value.

Advanced Constructors

Classes aren't always as simple as in our example. Usually they contain several members, of which some may have yet to be calculated in the constructor. In that case it's easier to create a local object hash first, and to let the constructor finally return it. Such local hashes are often named m (as a short reference to me), or obj.

var Class = {
    # add a field named 'new', assign it to a function that takes a single argument named 'val'
    new: func(val) {
        # create a temporary hash named m, derived from Class
        var m = { parents: [Class] };
        # add a field named 'value' to m, and assign the 'val' passed to the constructor
        m.value = val;
        # finally return the temporary object to the caller, which gets an initialized object, derived from Class
        return m;
    },
    # add another field, named 'write' - assign it to a function that doesn't take any named arguments
    write: func {
        print("VALUE=" ~ me.value);
    },
};

This last example is the most frequently used form of class definitions in FlightGear-Nasal.

Destructor

There's no such thing in Nasal. In other languages destructors are automatically called when the class gets destroyed, so that memory and other resources that were allocated by the constructor can be freed. In Nasal that's all done by the Garbage Collector (GC), anyway. In the FlightGear context, however, there are resources that should get freed. Listeners should get removed, self-calling functions ("loops") stopped. For that it's recommended to create a destructor function and to call that manually. Such functions are often called del, similar to Python and to pair nicely with the three-letter constructor name new.


Memory management

Finally, as you know now, Nasal, being a dynamic programming language, doesn't require or support any manual memory management, so unlike C++, you don't need to call operators like "new" or "delete" to allocate or free your memory.

However, if you do know that you don't need a certain variable anymore, you can certainly give a hint to the built-in garbage collector to free it, by assigning a "nil" value to it.

This can certainly pay off when using more complex data structures such as nested vectors or hashes, because it will tell the built-in garbage collector to remove all references to the corresponding symbols, so that they can be freed.

It is also possible to make use of Nasal's delete() function to remove a symbol from a namespace (hash).

So, if you are concerned about your script's memory requirements, using a combination of setting symbols to nil, or deleting them as appropriate, would allow you to create helper functions for freeing data structures easily.

In addition, it is probably worth noting that this is also the only way to sanely reset an active Nasal namespace or even the whole interpreter. You need to do this in order to reload or re-initialize your code without restarting the whole FlightGear session Nasal_scripting_language#Managing_timers_and_listeners.

Obviously, you should first of all ensure that there is no more code running, this includes any registered listeners or timers, but also any others loops or recursive functions.

Thus, if you'd like to reload a Nasal source file at run time, you should disable all running code, and then reset the corresponding namespace, too. This is to ensure that you get a clean and consistent namespace.

Nasal provides a number of core library functions to manipulate namespaces, such as:

  • caller() - to get a strack trace of active functions currently on the Nasal stack
  • compile() - to compile new Nasal code "on the fly", i.e. dynamically from a string
  • closure() - to query the lexical namespace of active functions
  • bind() - to create new function objects

More information is available here: http://www.plausible.org/nasal/lib.html

If, on the other hand, you are using these data structures in some repeated fashion, it might make sense to keep the data structure itself around and simply re-use it next time (overwriting data as required), instead of always allocating/creating a new one, this is called "caching" and can pay off from a performance perspective.

Multiple inheritance

A class can inherit from one or more other classes. It can then access all methods and class members of all parent classes, but also override them and add additional members.

var A = {                                            # simple class A
    new: func {
        return { parents: [A] };
    },
    alpha: func print("\tALPHA"),
    test:  func print("\tthis is A.test"),
};

var B = {                                            # simple class B
    new: func(v) {                                   # ... whose constructor takes an argument
        return { parents: [B], value: v };
    },
    bravo: func print("\tBRAVO"),
    test:  func print("\tthis is B.test"),
    write: func print("\tmy value is: ", me.value),
},

var C = {                                            # class C that inherits ...
    new: func(v) {
        return { parents: [C, A.new(), B.new(v)] };  # ... from class A and B
    },
    charlie: func print("\tCHARLIE"),
    test:    func print("\tthis is C.test"),         # overrides A.test() and B.test()
};


print("A instance");
var a = A.new();
a.alpha();

print("B instance");
var b = B.new(123);
b.bravo();
b.write();
 
print("C instance");
var c = C.new(456);
c.alpha();                        # use alpha from the A parent
c.bravo();                        # use bravo from the B parent
c.charlie();                      # use charlie from C itself
c.test();                         # use C.test(), which overrides A.test() and B.test()
c.write();

Even if a class overrides a method of a parent with the same name, the parent's version can still be accessed via parents vector.

c.test()               # use C.test()
c.parents[0].test();   # use C.test()
c.parents[1].test();   # use A.test()
c.parents[2].test();   # use B.test()

Sub-Classing

Nasal objects can simply be extended directly to add new fields/methods for sub-classing purposes, for example:

var obj = geo.Coord.new(); 
obj.foo = "a custom field";
obj.bar = func() {
 print("a custom method added to the object"
};

Obviously, you can also just create a helper class first and then extend the class via the constructor accordingly:

var myCoord = {
 new: func() 
 {
  var m = geo.Coord.new();
  m.foo = "a custom field";
  m.bar = func() {
  print("a custom method added to the new object");
  };
  return m;
 }
};

Equally, multiple-inheritance can be used to accomplish the same thing by combining two classes (e.g. geo.Coord and myCoord). That way, there's no need to touch the underlying geo.Coord class to extend it:

var myBehavior = {
new: func() {
  var m = {parents: [geo.Coord.new(), myBehavior.new()] ;
  m.foo = "a custom field";
  m.bar = func() {
  print("a custom method added to the new object");
  };
  return m;
},
};

var myCoord = {
 new: func() 
 {
  var m = {parents: [geo.Coord.new(), myBehavior.new()] ;
  return m;
 }
};

More on methods: Chaining

Methods are function members of a class hash. They can access other class members via the me variable, which is a reference to the class hash. For this reason, a method returning me can be used like the class itself, and one can apply further methods to the return value (this is usually called "method chaining"):

var Object = {
    new: func(coords...) {
        return { parents: [Object], coords: coords };
    },
    rotate: func(angle) {
        # do the rotation
        return me;
    },
    scale: func(factor) {
        # do the scaling
        return me;
    },
    translate: func(x, y) {
        # do the translation
        return me;
    },
};

var triangle = Object.new([0, 0], [10, 0], [5, 7]);
triangle.translate(-9, -4).scale(5).rotate(33).translate(9, 4);    # concatenated methods thanks to "me"

More on methods: Methods as Listener Callbacks

me, however, is only known in the scope of the class. If a method is to be called as a listener callback or a timer function, me has to get wrapped in a function, so that it's stored in the function closure.

var Manager = {
    new: func {
        return { parents: [Manager] };
    },
    start_timers: func {  
        settimer(do_stuff, 5);            # BAD: there's no "do_stuff" function in the scope
        settimer(me.do_stuff, 5);         # BAD: function exists, but "me" won't be known
                                          #      when the timer function is actually executed
        settimer(func me.do_stuff(), 5);  # GOOD: new function object packs "me" in the closure

        setlistener("/sim/foo", func me.do_stuff());  # GOOD  (same as with timers) 
    },         
    do_stuff: func {
        print("doing stuff");
    },
};

var manager = Manager.new();
manager.start_timers();

See Namespaces and Methods#Methods for more information.