Skip to content
Randall O'Reilly edited this page Apr 10, 2020 · 4 revisions

This page discusses some of the key design considerations for the core Ki / Node elements of GoKi:

First, the essential requirements for a scene graph, and structural trees of that sort more generally, are:

  • A common API for navigating, managing the tree
  • But a diversity of functionality at each node (i.e., many different possible node types)

Thus, a common embedded base type that does all the basic tree stuff, with lots of "derived" types for all the specific node types, seems like the most natural solution. But there is a concern that this is a classic C++-style OOP design, and not very "Go-like".

But trees are not one-off things where a simple “Stringer” kind of paradigm is going to work. They are containers.

Containers in Go are a bit of a controversial thing, due to the lack of generics in the language. A major mitigating factor is that the built-in, "privileged" containers have "magic" generics functionality, and they cover so much of the relevant functional space, very conveniently and simply, so in practice it isn't that big of an issue, despite all the discussion around this topic (people seeking a fully self-consistent language may argue that it is inconsistent and thus ugly for built-in containers to have super powers that are unavailable to other user-defined types in the language, but OTOH adding all the other ugly, complicated stuff like templates that is necessary to support these powers within the language may be too much of a cost).

In any case, given that a Tree is a container, it bumps right up against these core issues in Go.

Furthermore, the following functionality also seems desirable for a robust tree system:

  • An "end user" (in an app, or through a GUI) should be able to create nodes of any type at any point in the hierarchy, through some kind of general-purpose InsertNewChild kind of method (or equivalent GUI action), which takes as an arg the type of child to create.
  • The tree should automatically support basic infrastructure such as JSON saving / loading, copying parts of the tree, etc.

These then introduce yet more "generics" kinds of demands.

The approach taken in GoKi is to just bite the bullet and implement the classic OOP base-type design, using a ki.Ki interface type that defines a relatively large number of methods (by Go interface standards) to support the full set of functionality listed above, plus a few other key things. This interface is implemented by the ki.Node struct, which has as the necessary fields for containing the Children of each tree node, etc. The design fully leverages the power of slices for implementing the Children list, so all the generic capability there applies. Furthermore, the reflect system in Go is explicitly designed for dealing with all these kinds of issues, and works perfectly in this case to support all the desired functionality.

Another important consideration is that each node in the tree needs to support the relevant capabilities for them to work properly: e.g., properties can be inherited, and signals need to disconnect when a node has been deleted (and more generally, the node signals like Updated are an essential part of the basic functionality, and could not be made optional). Thus, again, the Ki interface is "heavier" than typical in Go, but the goal is to have this interface still be minimal for supporting the core framework that leverages the full power of the tree structure in the first place (see Motivation).

A careful look at the GoGi GUI system should convince the skeptical that each of the features in the Ki interface is doing a lot of heavy lifting there.

Furthermore, it does seem that perhaps the allergy to the standard OOP framework has inhibited development of a GUI in Go up to this point -- e.g., in both the Shiny and https://github.com/walesey/go-engine frameworks there was some significant awkwardness about the generic container nature of a scene graph, with separate types defined to do the containing / embedding job, creating additional complexity and inflexibility.

Here are a few additional points:

  • Ki is basically a "Node" interface, but you can't name a struct and an interface the same thing, so we have Ki and Node as two sides of the same thing..

  • The Ki interface allows specific nodes to override any of the functions, and, critically, ONLY an interface type knows what the actual underlying type of a struct is, so we need that to be able to access the fields etc of the actual struct at each node. In general, having an interface in Go opens up lots of extra powers — could not have done many other things without it..

  • There is no point in supporting a fully generic interface{} in a Tree container because the tree structure and logic requires each node to implement the same basic parent / child structure etc, so we need to enforce this minimal interface in any case. So Ki is the "minimal" interface for all tree nodes, and the Ki tree is a container of nodes that support that interface.

  • The Node struct provides the default impl of the Ki interface, and typically you don’t need to impl it again in a different way, so almost always new Node types will just put Node as an anonymous embedded type, and perhaps modify some part of the Ki api as needed.

  • You still have all the critical Go interface mix-and-match power to employ on top of the core Ki base type: Any number of polymorphic interfaces can be implemented on top of the basic Ki tree structure, and multiple different anonymous embedded types can be used, so there is still none of the C++ rigidity and awkwardness of dealing with multiple inheritance, etc.

  • Furthermore, all of the separable elements of the Ki interface are implemented in separate types, including the ki.Props property map and ki.Slice slice of Ki elements. A number of useful utilities were split out into the kit package.

Type Registry

Another critical element of the GoKi infrastructure is a type registry, implemented in the kit package, which is simply a map between a string type name and the corresponding reflect.Type. This allows type names to be saved in JSON files, and used at load time to create objects of the correct type in the Ki tree nodes. This enables for example the GoGi GUI designer to save and load JSON files of a scene graph composed of arbitrary GUI types. The package name is included in the type name so names should be unique, and the same tech applies to user-defined GUI types, etc.

KiT_ prefix

The naming convention is to call the reflect.Type variable KiT_MyType for type MyType. The linter complains about the underscore in the KiT_ prefix, but this seems like a reasonable exception to the standard Go naming conventions, given that these are a whole class of names that shadow their source types -- it seems intuitive for that to be a prefix that is clearly set off from the original type name, and while we could have used T_, KiT_ is less likely to conflict in any way and clarifies what these names are associated with.

Signals

The signal function passes 4 arguments (recv, send, signal, and data) -- but in many cases you could just rely on capturing closures to access relevant variables. However, when used inside loops, there are hard-to-recognize bugs produced in this case (you're capturing the variable, not its exact value at each point in the loop), and some of these signals can be very long-lived and thus could potentially put unnecessary stress on the GC system through the captures, so in general it is more robust to use the function args. You can usually just copy the function signature from nearby code, and use capturing closures when it is reasonable to do so.

Clone this wiki locally