Hello fellow data scientist.
Your approach is not ideal but let's first correct your code.
This is the fixed one:
type
Element[T] = ref ElementObj[T]
ElementObj*[T] = object
parent: string
data: T
type
Row*[T] = object
columns: seq[string]
elements: seq[Element[T]]
proc newElement*[T](parent: string, data: T): Element[T] =
new(result)
result.parent = parent
result.data = data
var element = newElement("index", 12)
echo element.parent
echo element.data
# The above compiles, when including the below it does not
proc newRow*(T: typedesc): Row[T] =
result.columns = @[]
result.elements = @[]
var row = newRow(int)
# Output:
# - index
# - 12
Run
Elements and Rows need the T marker for generics. Also if you have generic
sequence say `seq[Element[T]]` you can hold either `seq[Element[int]]` or
`seq[Element[float]]` but not both.
Why? Because at a low-level different types take different memory spaces.
Python supports heterogeneous lists because each elements is hidden behind a
pointer (32-bit size on 32 bit arch 64 bits on modern arch), we say that the
types are `boxed`.
This is a form of `type erasure`
Here is an example:
import typetraits
type
Element = ref object of RootObj
ElementString = ref object of Element
data: string
ElementInt = ref object of Element
data: int
type
Row*[T] = object
columns: seq[string]
elements: seq[Element]
proc newElement*[T](parent: string, data: T): Element =
when data is string:
result = ElementString(data: data)
elif data is int:
result = ElementInt(data: data)
else:
{.fatal: "Unsupported type: " & T.name .}
proc initRow*(): Row =
result.columns = @[]
result.elements = @[]
method `$`(x: Element): string {.base.} =
raise newException(ValueError, "Overload me!")
method `$`(x: ElementString): string =
x.data
method `$`(x: ElementInt): string =
$x.data
let element = newElement("index", 12)
let row = initRow()
echo element
echo row
# Output:
# - 12
# - (columns: @[], elements: @[])
Run
The other form of type erasure in Nim is through object variants, also called
tagged unions:
import typetraits
type
ElementKind = enum
ekString, ekint
Element = object
case kind: ElementKind
of ekString:
sData: string
of ekInt:
iData: int
type
Row*
= object
columns: seq[string]
elements: seq[Element]
proc initElement*[T](parent: string, data: T): Element =
when data is string:
result = Element(kind: ekString, sData: data)
elif data is int:
result = Element(kind: ekInt, iData: data)
else:
{.fatal: "Unsupported type: " & T.name .}
proc initRow*(): Row =
result.columns = @[]
result.elements = @[]
let element = initElement("index", 12)
let row = initRow()
echo element
echo row
# Output:
# - (kind: ekint, iData: 12)
# - (columns: @[], elements: @[])
Run
Now on the differences:
As you can see the first kind (Boxing) uses ref and inheritance, ref means that
data is allocated on the heap. Allocation is **very** expensive when done in a
loop. The advantage is that, if you write a library that uses inheritance your
types can be extended by the users.
For the second kind, object variants, this is allocated on stack so much faster
but cannot be user extended without forking a library. Also while you always
have to use a case statement to check the "tag"/"kind", branch predictors
nowadays are very good at that and this is much less costly than memory
accesses. The main issue is that ergonomically wise the fields are not named
the same but you can always write a proc data wrapper that selects the proper
field.
Now in conclusion, the json module already did half the work for you, just
reuse the [JsonNode type](https://nim-lang.org/docs/json.html#JsonNode). Also
be sure to check [NimData](https://github.com/bluenote10/NimData).