Skip to content

Instantly share code, notes, and snippets.

@PhilipWitte
Last active August 29, 2015 14:20
Show Gist options
  • Save PhilipWitte/0ca58b9655a6f3fd8ff0 to your computer and use it in GitHub Desktop.
Save PhilipWitte/0ca58b9655a6f3fd8ff0 to your computer and use it in GitHub Desktop.
import jsonstream
let
js = JsonStream() # Compile-time Error: empty params not allowed
js = new JsonStream # Compile-time Error: empty params not allowed
js = JsonStream("some/path.data") # Works: calls 'create' implicitly
something(js) # won't get here unless you properly construct the JsonStream (or go out of your way)
# vs the current way...
let
js = JsonStream() # no compile-time error, but this doesn't properly setup or register instance
js = JsonStream( # no compile-time error, but still doesn't properly register instance
path: "some/path.data",
json: parseFile("some/path.data")
)
something(js) # we get a run-time error here, cause the instance 'fs' isn't setup correctly
import json
type
JsonStream* = object
path: string
json: JObject
var
allStreams = newSeq[JsonStream]()
proc create*(js:var JsonStream, path:string) =
## creates and sets up a JsonStream
js.path = path
js.json = parseFile(path)
allStreams.add(js) # add instance to a private global list
proc something(js:JsonStream) =
## do something complicated with a JsonStream
# if we can't ensure the type is properly constructed we need to do more checks, like...
assert js.path != nil
assert js.json != nil
assert allStreams.contains(js)
# otherwise it's harder to call this function, so we can often just assume it's properly setup
...

Comparison

Pros and Cons of this method vs restricting Object Constructors from being used outside of their type's module

Key

  • 'Object Constructor' refers to TypeName(...), written shorter as T()
  • 'Named Constructor' refers to newT(...), initT(...), etc.. but also T.new(...)

Object Constructors

Premise

  • T() is the most direct syntactical way to describe the creation of a type T (even if that's type conversion).

  • T() is the only pattern where the return type is known "at a glance" because it is the type name.

  • Due to it's direct relationship T() will be the most obvious and attractive syntax to the largest number of people.

  • Many, if not most types need only one or few obvious and api dictated ways to construct a valid object.

Pros

  • It doesn't break existing code! Adding in the option to overload 'n lock object construction to a specific parameter interface could prevent code from using parameterless constructors, but only after those constructors are written. Simply adding the feature would not break existing code.

  • Extending object constructor syntax does not remove anything. Named constructors, in all there forms, would still be available and encouraged for some situations and as a way to distinguish between distinct constructors sharing parametric interface, or as just to be explict.

  • T() syntax remains a useful pattern available for every type. When no create() proc is define, T() does exactly what it does today, eg, it gives you a 'default' object. create makes this same pattern useful for the many situation where you need objects to be constructed procedurally in order to correctly setup their state [1]. This removes mental overhead required to properly construct any arbitrary type (plus, invalid construction is caught at compile-time, and constructor parameters can be assessed and suggested by the compiler when someone does not provide a valid set [2]).

  • T() is the only pattern in which generic code can construct an object given only the type name and a list of parameters. Named constructors either need to follow a specific idiom to work, eg, newT(): T or new(t:type T): T. Types which do not follow this interface, or who's interface is unknown, are not as useful from generic code compared to my proposed constructor syntax.

  • T()'s allocation is type-specific and largely decoupled from the user's concern. Eg, a ref object will be allocated on the heap while a regular object will be allocated on the stack. This behavior saves the end-user from having to both think about, or explicitly call new in order to instantiate a variable. By default create would be triggered after default allocation, but combined with a {.allocator.} pragma create could be used as a mechanism to transparently customize a type's allocation [3]. Compare this to named constructors where allocation is idiomatically explicit, eg, newT() and initT() both return a T only in different ways. Conflating allocation with construction (by default, which explicit ability to state otherwise) is more inline with Nim's "useful defaults" philosophy.

Cons

  • The create procedure is decoupled from the T() call. This could arguably make tracing through code more difficult, though I don't see how this would be any worse than the existing destroy system. Nor do I think, given this would be a well documented and common place rule, that create would be a lasting source of confusion.

  • The importing rules could be a source of confusion, or even a source of bugs (depending on how they're handled). Eg, from module import Foo, someProc imports the type Foo and a proc someProc but not the constructor. The solution is to either always import create along with the Foo or to simply make a warning/error for this scenario. More input is needed here.

References

[1]

type
  Foo = object
  Bar = object
  Baz = object

proc create(f:var Foo) = # setup Foo properly
proc create(b:var Bar, s:string) = # setup Bar properly

let
  foo = Foo()      # invokes 'create(foo)' implicitly
  bar = Bar("abc") # invokes 'create(bar, "abc")' implicitly
  baz = Baz()      # no 'create' provided, but still useful for allocation

[2]

type Foo = object
  ...

proc create(f:var Foo, path:string) =
  ...

let
 f = Foo() # Error: invalid parameters, possible options: 'Foo(path:string)'

[3]

type
  Foo = ref object
    x, y: int
  
  Bar = ref object
    x, y: int
  
  Baz = ref object


proc create(f:var Foo) =
  ## prepares a Foo on the heap for use
  # note: 'f' is conveniently already allocated for this pattern, which helps
  # since it's common we only want to focus on initialization, not allocation.
  f.x = 123
  f.y = 456


var barCache = MemoryCache[Bar]()

proc create(b:var Bar) {.allocator.} =
  ## prepares a Bar in a memory cache for use
  b = barCache.borrowInstance()
  b.x = 123
  b.y = 456

proc destroy(b:var Bar) =
  ## returns a Bar to the memory cache for reuse later
  barCache.returnInstance(b)


let
  # These are syntactically identical as they conceptually do the same thing,
  # eg, they introduce a new instance of a type into the program. However, they
  # do somewhat different things 'under the hood'.
  f = Foo() # default allocation + create
  b = Bar() # create + cached allocation
  z = Baz() # default allocation
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment