Struct

new to Analytica 7.0

Most programming languages have a way to bundle a set of disparate data into a packet, often called Records, Classes, or Structs. In Analytica, you can do this using an array with an Index, but this doesn't work so well if the values in the array are disparate, for example, if they include numbers, text, references to other data, or array values with different dimensions. For example, if you want to create lists or hierarchies, represent JSON data or html. Thta's why Analytica has introduced Structs to represent these kinds of data structures.

A struct instance can have multiple member fields, which can be of varioust data types and dimensionality. For example, a struct could have a scalar member, a 1-D member and a 2-D member, where the dimensions of the members are unrelated. Each struct value is treated as an atomic element, and it does not apply Array abstraction within the Struct.

Struct class

A Struct object defines a new atomic data type. It appears on a diagram with a shape that resembles a function, but with flat midsections on both the left and right.

MyDtype Struct.png

After you define a Struct, you can create instances of it. Instances are atomic and immutable: Atomic meaning that Array Abstraction treats it as a single cell -- even though it may have multiple elements. Immutable meaning that, it cannot be destructively altered after it's been created.

A Struct is an Object. An instance of a struct is a Value.

When to use a Struct

These are examples of when a Struct may be convenient:

  • To represent hierarchical data, such as from html or JSON.
  • If you want your data type to be an opaque atomic unit with multiple internal items.
  • To pass complex data structures among user-defined functions.
  • To enforce a distinction that does not exist in Analytica, but is used in another platform or service that you are integrating with.

Defining a Struct

To define a Struct, you can first create a user function object by dragging the function node from the object Toolbar). Then change its class to Struct in its Object window or Attribute window.

A Struct uses the Parameters attribute with the same Function Parameter Qualifiers as a User-Defined Function (UDF), for example:

Struct MyDtype( a : atom ; b : Text [I] ; c: Number[J] )

This Struct has three members -- a with any atomic data type, b, which is text and may be an array of texts with index I, and c, which must be a number and may have index J.

The default Definition of a Struct is Self. Sometimes you may specify a more specialized definition -- see #Constructor customization below.

Instantiating a value

To create an instance of a Struct, you simply call the name of your struct as if it were a function from within an expression. In the previous example, that might be:

MyDtype( Category, Category_aliases, Category_IDs )

If any value passed to a constructor parameter has extra indexes (dimensions), i.e. other than those specified in each parameter, Array Abstraction kicks in and creates an array of struct instances over those extra indexes.

Accessing member values

Use the Arrow operator to access a member values of a Struct. E.g.,

inst -> b

where inst was obtained by calling the Struct.

The left-hand side of the arrow can be a single instance, an array of instances, or even Null. The right-hand side of the arrow is the name of a member value.

Member functions

You can define member functions to access values from a Struct, for example:

inst -> F(x, y)

The parameters must be passed by value (e.g., no Index parameters). Keep in mind that the instance is immutable so these functions can't usually modify the internal state of the instance (see Section #Immutability for exceptions).

You can define a Struct member function using a standard user-defined function. Open the Struct's diagram (as you would open up the diagram of a Module node) and drag a Function object into the diagram. In the Function's Object window, set its parameters ensuring that the first parameter is declared as Self using the Struct datatype and Atom qualifier, for example:

Function F( Self: MyDtype Atom; param1; param2)

It can have zero or more additional parameters. These are the ones passed to the function when it is called using the syntax inst → F(x,y). These parameters can have any data type qualifier, dimensionality declaration; however, for now they cannot handle evaluation mode qualifiers or Object class qualifiers.

You can also introduce global variables that are "private" to the Struct and its member functions by adding normal variable nodes to the diagram. Note that these are contained in the Struct definition, not in each instance. To have a value that varies with each instance, you must use a member value.

Constructor customization

The member values of an instance are usually the parameter names that appear in the Parameter attribute of the struct (its Constructor's parameter names). However, in some use cases the constructor may change the member values, such as by adding new members, transforming the named members, or even removing members. These changes must be performed in the Definition of the constructor.

When you first view the Object window of a Struct, the Definition might not be visible (because altering the member values is somewhat esoteric). If you don't see it, use the Attribute visibility popup to make it visible.

Inside the constructor (and only in the constructor) the members can be assigned. For example:

Struct MyDType( a : atom ; b : Text [I] ; c : Number [J] )
Definition: 
       Self -> creationTime := Today(true);
       Self -> c := c / Sum(C,  J);
       Self

The final value is always Self whether or not your Definition actually returns Self, although it is clearer to do. Notice that in this example, it adds a new member value, creationTime, and changes the member value c to the normalized value.

When you change the member values in the constructor, you should consider whether you will ever assign values of your Struct to a definition or other expression attribute, or whether instances will even need to be saved directly in the model file. These cases require a text representation that is valid expression and which results in an equivalent instance. In the event that you alter the members, you may need to add a custom #ExpressionText member function that prints out a constructor call expression.

Using an excess parameter in the constructor

An excess parameter is a catch-all parameter that allows the caller to include named parameters that aren't listed in the parameters. It can be either named or unnamed.

The constructor has an unnamed excess parameter when the last parameter is named "...". For example,

Struct MyDType2( a : atom ; b : Text[I] ; ... )

When declared in this fashion, a caller can specify parameters other than a and b, for example:

MyDType2( a:5, b:f"I={I}", c:14, d:=J^2 )

When an unnamed excess parameter is used in this way, the additional parameters are automatically added as members. Hence, in this example, MyDType2 has members ['a', 'b', 'c', 'd']. Because the excess parameter is unnamed, there is no way to refer to it in the constructor's definition.

To specify that the excess parameters not be added to the struct instance, you can use

Struct MyDType3( a : atom ; b : Text[I] ; ... : nonMember )

A named excess parameter is declared using a named that starts with "...", which again must be the last parameter in the declaration. For example,

Struct MyDType3( a : atom ; b : Text[I] ; ...kw )

Inside the constructor's definition, it can now refer to the local variable kw. The value of kw is itself a struct instance where each argument is a member. For example, when called as

MyDType3( a:5, b:f"{I}", y:43, z:'hello' )

The Definition of MyDType3 could reference kw->y or kw->z when called in this example; however, those would fail with an error when y or z is not specified.

Equality and comparison

Two struct instances are equal (i.e., s1=s2 is true) only when they are of the same Struct, have the same member names and have the same member values. In other words, s1=s2 is based on "deep equality".

Shallow equality tests whether two instances are the exact same instance (as opposed to whether the two instances are equal). The following can be used to test whether two instances are the exact same instance:

s1->_addr = s2->_addr

The special _addr member value returns an integer that is unique to each instance.

Inequality comparisons are also based on the "deep" member values, based on the internal member value ordering.

Basic properties about an instance

You can get a list of the member value names using:

inst -> _members

You can test whether it has a given member using:

inst -> Has('name')

If you have the name of a member value as text in a variable memberName, you can access its value using:

`->`( inst, memberName )

With this syntax, you can also specify a default value to use when the instance does not have the specified name by specifying the optional «defVal» parameter:

`->`( inst, memberName, defVal: Null )

Note that it wouldn't work to write inst -> memberName because that would look for a member value named "memberName".

You can get a handle to the instance's Struct object using

inst -> _type

You can get the name of the Struct (without a namespace prefix) from TypeOf(inst). You can detect that an atom is a struct instance using

TypeOf( inst, shallow:true ) = "Struct"

Special member functions

You can customize the behavior of your struct instances by adding special member functions (with reserved member function names). You add these as you would your own member functions to your Struct's diagram using the reserved name. Your implementation must match the required parameters for the special function.

Customizing how a value is printed

My default, an instance of your Struct will display in a result table cell or in the Typescript console as, e.g., «YourStructName». You can customize this by adding a member function named DisplayName. E.g.,

Function DisplayName( Self : MyDtype atom )
Definition: f"«{TypeOf(Self)} a={Self->a}»"

Because DisplayName occurs while individual cells are drawing on the screen, errors that occur while it is evaluating are not displayed. If an error occurs, it will silently fall back to the default display name.

When your instance is "printed" to an expression (e.g., as a result of an assignment), you need to ensure that the text is a valid expression that creates a logically equivalent instance. This happens by default unless you alter the members in your constructor (see #Constructor customization above). To control this, add a member function name ExpressionText(Self).

Value expansion (in a result table)

When a struct instance appears in the cell of a result table, it appears as a hyperlink. Double clicking on it expands the value. By default, it expands into the member values. You can change this by adding a member function named Expand(Self).

Your expansion code cannot simply create an array of your member values (assuming they have different dimensionalities) since this would combine their dimensions. Instead, you should wrap each non-atomic value in _HyperlinkedCellvalue. For example:

Function Expand( Self )
Definition: [ 'a' : Self->a, 
              'b' : _HyperlinkedCellValue( Self->b ), 
              'c' : _HyperlinkedCellValue( Self->c ) ]

You can also introduce a local index for the expansion.

After it expands, the result table displays a title showing the current "path" to the result view. Ideally this title is also a valid expression that produces the same result. When you customize the expansion, you may also need to customize the expansion title. You can do this by adding a member function

Function TitleForExpand(Self : Tuple atom ; subTitle : text atom ; memberNum : optional positive atom )

The «subTitle» is the title up to the point of this expansion, and «memberNum» exposes which cell number was expanded from the previous level.

Finalizers

A finalizer is a member function that is called when the struct instance value is released for last time.

You can add a customized finalizer, for example, to ensure that a resource gets cleaned up. An example use case would be when your Struct is a temporary file, with the finalizer ensuring that the temporary file is deleted when the instance is released. This is an example where the constructor would pick a unique file name that doesn't already exist, and the finalizer calls FileSystemDelete.

To add a finalizer, add a member function named Finalizer(Self).

Finalizers should have a low probability of an error occurring while the finalizer is evaluating. In the event that an error occurs, it is not possible for Analytica to report the error, so the error will occur silently. If you do not catch it (in a Try), your full finalizer code might not run.

Callable values

You can make an instance of your Struct "callable", such that you can call the instance using e.g.,

inst -> ( x, y )

To make it callable, create a member function named `()` -- make sure to include the backtics as part of the name, and no spaces between the parens. For example:

Function `()`( alpha, pwr )
Definition: (1-alpha) * Self->a + alpha * Sum( b^pwr, I )

If you do this, you can also use a Callable object class to hold your instance (or an array of instances) and then call it using the name of your callable as if it were a normal function (even though the logical function itself is computed), e.g.,

Callable F1 := MyDType( x,y,z )

And call it:

F1(0.3, 2)

Custom atomic subscripting

Your data type can support the syntax

inst -> [n]

by adding a member function named `[]`. It can have either one or two parameters (after Self). With one parameter, only inst -> [n] is supported (so that inst -> [m..n] would array abstract returning a list). With two parameters, where the second parameter is optional, it can include special handling of inst -> [m..n], for example by returning a new collection instance of the same type.

Function `[]`( Self : Tuple atom ; m,n : optional number atom )

In this example, if inst -> [m] is called, then the «n» parameter is omitted. If inst -> [m..n] is called, both parameters are provided. If inst -> [m..] is called, then «n» is INF. And if inst->[..n] is called, then «m» is omitted.

Operator overloading

At present, only these operators can be overloaded by adding a member function with the given name.

  • Function `#`( Self )

Overloading the `->` operator

It is possible to overload the right-arrow operator for a Struct, intercepting attempts to read a member value or to set a member value.

If you only want to intercept attempts to read member values, add the following member function in your struct:

Function `->`( Self : YourStruct atom ; member : Text atom ; defVal : optional named )

By sure to change YourStruct to the actual name of your struct. This will be called when code evaluates

inst -> member

on an instance inst of your struct. Your function can return values for members that don't actually exist, or deny members that might exist, or just intercept the read and then forward it in the usual way.

Exceptions: Member functions with names beginning with an underscore are never intercepted (such as the special inst->_members, inst->_type and inst->_addr virtual members). Also, calls to member local functions or member functions are never intercepted -- this intercepts member value access only.

Your implementation of the overload will often want to access the actual members of the class. It must not use the usual arrow operator for this because that would result in an infinite recursion. Instead, you can use the raw member access function, `->!`, which behaves the same same as `->` except that it cannot be overridden. This function can only be called using a function call syntax, it cannot be used as an infix operator.

Here is a contrived example that makes is look like the file contents is a member value. (It would be better to just add a member function for this, but it makes for an easy example).

Struct TextFile( filename : Text atom )

Function TextFile::`->`( Self : TextFile atom ; member : Text atom ; defVal : optional named )
Definition:
If TextLowerCase(member) = "contents" Then 
  ReadTextFile(filename)
Else
  `->!`( Self, member, defVal:defVal )

You can also use an override of `->` to intercept attempts to assign to a member, such as an assignment like

 inst -> val := newVal

A struct instance is itself immutable, so such assignments are not generally valid, but using an override you can make it appear that a member is mutable. This could be useful, for example, when using a struct to wrap an object from a different programming language, or in a web service, where members can be assigned. The overload could send the change through an API, keeping a familiar assignment syntax.

To add an overload that can also intercept member assignments (as well as reading member values), the member function should have this signature:

Function `->`( Self : YourStruct atom ; member : Text atom ; defVal : optional named ; rhs : optional named )

Inside the definition, use IsNotSpecified(rhs) to determine whether it is a read or an assignment.

Assignment

Sometimes a button's OnClick expression assigns a result value to a global variable. When you do so, it sets the definition of the global variable to an expression that results in the same value when evaluated. It is also an expression that can be saved with a model file.

In the case of a Struct, this means that the expression must be a call to the Struct's constructor with parameter values that result in a logically equivalent instance.

In the cases where you have customized constructor logic that alters what member values are actually stored, if you want assignment (and saving to a model file) to work successfully, you need to include the custom member function:

Function ToExpression( Self : MyStruct atom )

This can return the textual expression that results in an equivalent value, but in general, it can be difficult to get the textual expression correct. Hence, the recommended way to implement the function is to return an instance of the generic built-in _Struct with member names that correspond to your constructor parameter names.

Example

This example uses the Mutables library.

Struct S( x : nonMember )
Definition:  Self->mutable_val := Mutable(x)
 
Function ToExpression( Self : S atom )
Definition: _Struct( x: Self->mutable_val->val )

The _Struct returned has the parameter name x, which matches the parameter name in S's constructor with the value that should appear in the call to the constructor.

When you assign a value containing an instance of S to a global, the definition contains something like S( 'Foo' ) -- a valid call to the constructor.

Repeated parameters

When your constructor has a repeated parameter, you need to set the _Struct element to a list, where each item in the list corresponds to one argument passed to the parameter. If all of the items that should be passed to the repeated parameter have the same dimensionality and none are a scalar reference, then a list is fine.

Struct S2( people : ... Text atom nonMember )
Definition: Self->people_ids := PersonToId( people )

 

Function ToExpression( Self : S2 atom )
Definition: 
     Local people := IdToPerson( Self->people_ids );               { This is a list of text }
     _Struct( people : people )

A more nuanced case occurs when the items to the repeated parameter may have different dimensionalities. In this case, you need to take the reference of each item in the list so that its dimensions don't combine with the indexes of its sibling elements.

Struct S3( items : ... nonMember )
Definition: 
     Self->first := items[@=1];
     Local refd := (For xi := repeated items Do \xi); {refs to keep dims separate}
     Self->rest := refd[@=2..Size(items,listLen:true)]
 
Function ToExpression( Self : S3 atom )
Definition:  _Struct( items : [\Self->first, ...Self->rest ] )

To keep the dimensionalities separate for each item is Self->Rest, the constructor takes the reference of each repeated item. In ToExpression, these are already references so it simply forwards them into the list for «items».

Immutability

The Self members can be altered from within the Struct constructor, but cannot be altered once the fully-constructed instance has been returned by the constructor. From that point on, the members of a struct instance are never changed.

However, a struct can hold a Mutable or a captured local which actually makes it possible for the internal state of an instance to be changed. The mutable or the local's identity never changes, and changes to this type of internal state does not result in any downstream invalidation.

Generic Struct

A built-in struct named _Struct (with an underscore) can be used as a generic Struct that you can instantiate on arbitrary members without having to define your own Struct object. To instantiate, every parameter must be named, but can be whatever name you want, e.g.,

_Struct( date:Today(), name: 'Donald', address: '1600 Pennsylvania Avenue' )

Examples

Examples in this section demonstrate various aspects of a Struct and member functions.

Tuple

A Tuple is a ordered list of items, each item having a different dimensionality. It can be implemented as a Struct.

To create a tuple (after the Tuple Struct is defined), use:

Tuple( x, y, z )

with any number of values. The values passed can have different dimensionalities. The collection is held in a single cell (if you have an array of Tuples), and the dimensionalities of the items are kept separate.

Struct Tuple( items : ... ;
              direct : hidden optional named boolean atom )
Definition:
   If direct Then (
       For xi:= repeated items Do 
           If Not IsReference(xi) then 
               Error("When using direct:True to Tuple, all «items» must be references");
       Self->items := items
   ) Else (
       Self->items := (For xi := repeated items Do \xi)
   )
Function Tuple::Length( Self : Tuple atom )
Definition: 
   Size( Self->items, True )
Function Tuple::Expand ( Self : Tuple atom )
Definition:
   Local i:=f"[{1..size(Self->items)}]";
   For ii:=@i do (
       Local xi := #(Self->items[@=ii]);
       If Size(IndexesOf(xi))>0 then
           _HyperlinkedCellValue(xi)
       Else
            xi
   )
Function Tuple::`[]`(Self : Tuple atom ; m,n : optional number atom )
Definition:
   If IsNotSpecified(n) Then 
       #(Self->items[@=m,defVal:Null])
   Else (
       If IsNotSpecified(m) Then m := 1;
       If n>Size(Self->items) Then n := Size(Self->items);
       Tuple( ...Self->items[@=m..n], direct:True )
   )
Function Tuple::TitleForExpand(Self : Tuple  atom ; subTitle : text atom ; memberNum :  optional  positive atom )
Definition:
   If IsNotSpecified(memberNum) Then
       f"{subTitle}->"
   Else
       f"{subTitle}->[{memberNum:I}]"
Function Tuple::ToExpression( Self : Tuple atom )
Definition: _Struct( items: Self->items )


Reference

This example demonstrates how a reference could be implemented using Struct if it weren't already built into the Analytica language. Note that the keywords Ref and Reference are already in use, so we need to pick a different name.

Struct _Ref( r : [I] ; I : optional ... index = common )
Function _Ref::`#`( Self : _Ref atom )
Definition: Self->r
Function _Ref::Expand( Self : _Ref atom )
Definition: `#`(Self)
Function _Ref::TitleForExpand(Self : _Ref atom ; subTitle : text atom ; memberNum :  optional  positive atom )
Definition:
           f"#{subTitle}"

See also

Comments


You are not allowed to post comments.