Runtime Reflection in Turing: You're in the big leagues now
Author |
Message |
Foundry
|
Posted: Sun Oct 02, 2016 3:16 pm Post subject: Runtime Reflection in Turing: You're in the big leagues now |
|
|
Reflection in Turing
I'll be honest here; you might not like this. The code you are about to see is platform dependent and will work exclusively with Turing code ran on Holtsoft's most recent Turing interpreter versions. Things will get messy. Your code will probably fail, spectacularly. But the rule to remember is that when the interpreter yells stop, goes limp, taps out, it doesn't mean the fight is over. It just means that you've got a bit more work ahead of you before you succeed. All that being said, you might just learn a thing or two by the end of this.
You should have a solid understanding of classes and pointers before reading this tutorial, otherwise much of this may not make much sense to you.
What is reflection?
Reflection is the ability of a computer program to examine, introspect, and modify its own structure and behavior at runtime. A reflective program has the ability to determine the parts making up its sum, for lack of better words. Languages like Java and C# have robust reflection APIs that allow developers to do all kinds of things with running code, including dynamically creating class instances, enumerating functions in classes, checking constructs for annotations, reflectively invoking functions, and more. These incredible capacities are available because the language creators opted to accessibly store important pieces of information regarding the composition of source-level constructs in the compiled code, allowing them to be retrieved at runtime.
Though the Turing language itself does not support this as a part of its specification, the reference implementation of Turing that most Turing code written today runs on does in fact store important code composition information in an accessible fashion. This means that a Turing program running in Holtsoft's Turing interpreter has the potential to introspect its own structure!
Going down the rabbit hole
To get started with reflection in Turing, you'll need to grab the last library you'll ever use: turing.reflect.
In the ZIP file linked above, you'll find a folder named reflect. Copy that into the same folder that the Turing executable is present in, or if you are using the newer Qt-based OpenTuring editor, the upper-level support directory containing all the Qt DLLs and the larger OpenTuring.exe file.
From there, the magic begins by importing the universe. Create a new Turing file and add this import statement to the top of the file
Turing: | import "%oot/reflect/universe"
|
This exposes two functions, reflectc and reflectf. Let's start exploring what we can do with these functions by creating a sample class in the file
Turing: | class Greeter
export greet, getGreeterName, setGreeterName
%the name of this greeter
var greeterName: string := "Somebody"
%get the name of this greeter
fcn getGreeterName(): string
result greeterName
end getGreeterName
%set the name of this greeter
proc setGreeterName(newGreeterName: string)
greeterName := newGreeterName
end setGreeterName
%greet people with the specified message
proc greet(greeting: string)
put greeterName, " says: \"", greeting, "\""
end greet
end Greeter
|
Fairly straightforward. The class represents something that delivers greetings under a specific, mutable name that defaults to "Somebody".
If we wanted to work with this class under normal circumstances, we could create an instance of it with the new keyword and start calling its functions.
Turing: | %make an instance of the greeter class
var myGreeter: ^Greeter
new myGreeter
%name this greeter "Tom"
myGreeter -> setGreeterName("Tom")
%Tom likes to greet people with "Hello World!"
myGreeter -> greet("Hello World!")
|
We all understand the premise of the Greeter class now. But how does reflection play in to this?
Creating class instances
In the previous code snippets, you saw how you could get a new Greeter, give it a name, and make it start giving out greetings.
However, turing.reflect lets you do the same thing in a slightly different fashion.
Turing: | %reflect the class type of the pointer myGreeter. this is equivalent to reflectc(Greeter)
var clazz := reflectc(myGreeter)
%make a new instance of whatever class "clazz" represents
var newGreeter:= clazz -> newInstance()
%name our dynamically created greeter "Mike"
Greeter(newGreeter).setGreeterName("Mike")
%Mike greets people with a bit more style
Greeter(newGreeter).greet("Sup y'all?")
|
Whoa. What just happened there? To break down the functions that were used there:
- reflectc provides TClass objects by passing it either a pointer to a class or a class directly. A TClass is a reflective representation of a class declared somewhere in the Turing runtime, and provides access to information regarding the composition of the class, its functions, its fields, its annotations, and more.
- the newInstance function, as the name implies, creates a new instance of the class represented by the TClass object it was called from. In this case, since our TClass was representing the Greeter class, it created a new Greeter class exactly how it would have been made if the new keyword were used.
Doing this allows us to dynamically create instances of arbitrary classes at runtime, something the Turing language specification typically forbids as described in the documentation of the objectclass function.
Accessing functions reflectively
turing.reflect also allows developers to dynamically discover functions in classes and access them through TFunction objects. A TFunction acts as a handle for a function or procedure declared somewhere in the Turing runtime, and provides useful functions for invocation, enumerating annotations, determining if a function is actually a procedure, and determining if it is in a class.
In this example we delve into reflective function invocation with arguments, something which requires another module to be imported. The import line at the top of the file needs to have "%oot/reflect/invoke" appended to it, making it look like
Turing: | import "%oot/reflect/universe", "%oot/reflect/invoke"
|
Now that that's out of the way, we could rewrite the code in the snippet above like this
Turing: | %reflect the class Greeter
var clazz := reflectc(Greeter)
%make a new instance of whatever class clazz represents
var instance := clazz -> newInstance()
%find the second function explicitly declared in the class represented by clazz, invoke it using the specified class instance and a string argument of "Leo"
clazz -> getDeclaredFunction(2) -> invokeArgs(0, instance) -> with(stringArg("Leo")) -> do()
%do the same as described above, except resolving the third explicitly declared function and using "Hola mis amigos!" as an argument
clazz -> getDeclaredFunction(3) -> invokeArgs(0, instance) -> with(stringArg("Hola mis amigos!")) -> do()
|
This is a lot to take in, so I'll go through it step by step:
- getDeclaredFunction is used to locate explicitly declared functions in a class. The inheritance-friendly counterpart to this would be getFunction, which take the superclasses of the reflected class into account when determining which function to reflect. The number of functions in a class can be determined through the getDeclaredFunctionCount and getFunctionCount functions with the same restrictions as described for locating functions.
- invokeArgs is used to invoke a function that takes one or more arguments by passing it a return address and a class instance to use for invocations, returning an InvocationContext object with two functions, with (for providing arguments) and do (for doing the invocation). "with" takes an argument of type InvocationArgument, something that the stringArg function provides by wrapping a provided string. Convenience functions like that are provided for all primitive types in Turing, but you can look at all the InvocationArgument providers in the invoke module to see exactly what you can do. A return address of 0 can be used for TFunctions that represent procedures, and a nil instance can be used for TFunctions that either exist outside a class or don't access any class fields.
The key difference between this and the previous code snippets is that we don't actually reference the class we are working with in the calling code. The code itself doesn't care whether we're working with a Greeter class or a MagicBunny class so long as they both satisfy the requirements of having at least three declared functions, the second and third of which are procedures that take one string argument. However, here we come to one of the limitations of the Turing environment: function names are not stored as retrievable data. The only way we can reflect functions in classes is via their index in the class function hierarchy, making the system somewhat less convenient than what is possible in other languages like Java and C#. There are, however, ways of reflectively finding functions with certain special characteristics...
Working with annotations
A common use of reflection is retrieving metadata associated with specific code constructs in projects. Java and C# do this through the use of annotations and attributes respectively, but they both boil down to being small snippets of code that can be put next to code constructs to give them certain metadata.
The Turing language does not support these kinds of constructs out of the box, but turing.reflect supplements this shortcoming by allowing you to define custom annotations that can be applied to various parts of your code. To do this, we first have to include the annotations module by adding "%oot/reflect/annotations" to the import line of your file header, giving you something along the lines of
Turing: | import "%oot/reflect/universe", "%oot/reflect/annotations"
|
Making an annotation is simple. All you have to do is declare a procedure somewhere, and then put the word annotation immediately preceding it
Turing: | %make an annotation named "test" that doesn't have any elements
annotation proc test
end test
%make an annotation named "named" with a single string element
annotation proc named(name: string)
end named
|
And just like that, both test and named can now be scanned for as annotations by turing.reflect's reflective objects.
Annotations can be applied to and reflectively retrieved from many constructs in Turing, including classes, functions, and fields. An example of the flexible nature of annotations is shown below
Turing: | class TestSuite
import test, named
named ("my named variable")
var myInt: int
test
proc testDoingSomething()
put "Doing something!"
end testDoingSomething
test
named ("test for doing something else")
proc testDoingSomethingElse()
put "Doing something else!"
end testDoingSomethingElse
named ("my test suite")
end TestSuite
|
Each of the places in the example above where test and named exist is an annotation attachment point. There are currently three of these, those being immediately preceding a variable, immediate preceding a function, and immediately preceding the end of a class. To demonstrate what we can do with annotations, let's try scanning the class above for all functions marked as both "test" and "named", then print the names provided by the latter.
Turing: | %reflect the class TestSuite
var clazz := reflectc(TestSuite)
%for every declared function in the class described by clazz
for i: 1..clazz -> getDeclaredFunctionCount()
const func := clazz -> getDeclaredFunction(i)
%check if both the "test" and "named" annotations are present
if (func -> isAnnotationPresent(test) & func -> isAnnotationPresent(named)) then
const name := func -> getDeclaredAnnotation(named)
%print the name given to the annotated function we found, which is stored as the first element of the "named" annotation
put string @ (name -> getElement(1))
end if
end for
|
Running that example yields the output "test for doing something else", showing that annotation information is indeed saved. To break down the code above:
- isAnnotationPresent is used to determine if at least one annotation of the provided type is present on the annotation element the function is being called from. In this case, it is checking to see if the function it is being called from has both the test and named annotations.
- getDeclaredAnnotation is used to get an explicitly declared annotation of a type from an annotated element. Similar to how function reflection functions work, there is a corresponding getAnnotation function for retrieving an annotation from an annotated element while also taking the inheritance hierarchy of that element into account. Furthermore, there are also getDeclatedAnnotations and getAnnotations functions for retrieving all declared annotations of a type from an annotated element if you expect more than one annotation of a type to be present.
- getElement is used to retrieve elements associated with a retrieved annotation. Each element corresponds to a parameter defined in the declaration of the annotation being retrieved, which in this case means the "name" string on the "named" annnotation. getElement returns a pointer to the region of memory that the element occupies, letting you access it whatever way you wish.
Accessing fields reflectively
Along with functions, class fields can also be reflectively accessed. Going back to our original Greeter class example, the code for creating a new greeter could be modified to work with the greeterName field directly (instead of calling the setGreeterName procedure) like this
Turing: | %reflect the class Greeter
var clazz := reflectc(Greeter)
%make a new instance of whatever class clazz represents
var instance := clazz -> newInstance()
%access the first declared field in the class represented by clazz and change the value mapped to that field for the given instance to "Carl"
string @ (clazz -> getDeclaredField(1) -> fetch(instance)) := "Carl"
%find the third function explicitly declared in the class represented by clazz, invoke it using the specified class instance and a string argument of "Suh dude"
clazz -> getDeclaredFunction(3) -> invokeArgs(0, instance) -> with(stringArg("Suh dude")) -> do()
|
The fetch function returns a pointer to the area of memory corresponding to the specified field. Much like how functions are represented, the Turing runtime does not actually store the names of fields in a user-accessible manner leaving index-based access as the only option. The functions for accessing class fields are also similar to those of accessing class functions, with getDeclaredField and getField functions being provided for specifically declared and inheritance-sensitive field access.
Fields can also have annotations applied to them, and can access those annotations in a similar fashion to how annotations were accessed in the example above.
Wrapping up
Hopefully through these examples you've had a chance to see how the Turing language still has a bit of life that can be wrung from it yet. It may be unreasonable to expect that more serious libraries may be developed in Turing, but if anyone has the motivation to write a dependency injection or unit testing framework for Turing, this very well may give you the power to do so.
If you've taken the time to read through all of this, you have my thanks. |
|
|
|
|
|
Sponsor Sponsor
|
|
|
|
|