diff --git a/CMakeLists.txt b/CMakeLists.txt index 6ccbede..34a8a27 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -90,3 +90,4 @@ set_property(TEST frozen_bridge_segfault.frank PROPERTY WILL_FAIL true) set_property(TEST unresolved_field.frank PROPERTY WILL_FAIL true) set_property(TEST unresolved_global.frank PROPERTY WILL_FAIL true) set_property(TEST unresolved_name.frank PROPERTY WILL_FAIL true) +set_property(TEST func_no_return.frank PROPERTY WILL_FAIL true) diff --git a/README.md b/README.md index 9c6cb80..9e15927 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,10 @@ model inspired by [behaviour-oriented concurrency](https://doi.org/10.1145/36228 To this end, FrankenScript programs generate a file called `mermaid.md` with a Mermaid diagram per line in the source program showing the object and region graph of the program at that program point. +This is a legend for the diagrams that FrankenScript generates: + +![](./docs/frankenscript-legend.png) + ## Pre-requisites This project is C++20 based and uses CMake as the build system. We also recommend installing Ninja to speed up the build process. @@ -49,3 +53,4 @@ You can run in interactive mode by running: ``` Which will keep overwritting the `mermaid.md` file with the new heap state after each step. + diff --git a/docs/frankenscript-legend.png b/docs/frankenscript-legend.png new file mode 100644 index 0000000..1fdabeb Binary files /dev/null and b/docs/frankenscript-legend.png differ diff --git a/examples/01-objects.frank b/examples/01-objects.frank new file mode 100644 index 0000000..21b5069 --- /dev/null +++ b/examples/01-objects.frank @@ -0,0 +1,43 @@ +# This file shows how different objects can be created and used in FrankenScript +# +# The following line creates a dictionary and assigns it to the variable `a`. +# The variable name is on the arrow from the frame to the object. The object +# displays the memory address and the reference count of the object. +a = {} + +# Fields of dictionaries can be assigned in two ways: +a["field1"] = {} # 1 +a.field2 = {} # 2 + +# Fields can be accessed the same way +b = a.field1 +c = a["field1"] + +# FrankenScipt uses reference counting. Values are deallocated when the reference +# count hits 0. This can be used to remove unused elements from the diagram. +a = None +b = None +c = None + +# FrankenScript also provides strings, created by double quotes: +d = "This is a string" + +# All objects in FrankenScript are dictionaries. The language uses prototypes +# to share functionality across objects and to identify the type of object. +# The diagram currently shows the prototype for frame objects (called `[Frame]), +# and the prototype for strings (called `[String]`). +# +# Prototypes can be accessed via the `__proto__` field. +e = d.__proto__ + +# The prototype can also be written to. This creates an object `g` with the +# prototype `f` +f = {} +f.field = {} +g = {} +g.__proto__ = f + +# If a field is not found on an object the prototype will be checked for the +# field. In this example, a reference to `f.field` is returned since `g` doesn't +# have a field called `field` but the prototype has one. +h = g.field diff --git a/examples/02-freezing.frank b/examples/02-freezing.frank new file mode 100644 index 0000000..9d64148 --- /dev/null +++ b/examples/02-freezing.frank @@ -0,0 +1,31 @@ +# Freezing describes the action of making an object immutable. +# +# Objects in FrankenScript are mutable by default. +x = {} + +# Immutable objects are ice blue and not contained by a specific region +freeze(x) + +# Assigning to a field of `x` afterward will throw an error +# x.field = {} # ERROR + +# Calling `freeze(x)` freezes the object `x` points to but not the variable +# itself, so x can be reassigned +x = {} +x.f = {} # Mutation of x + +# Freezing is deep, meaning that all objects reachable from a +# frozen object will also be frozen. This is needed for safe sharing +# across concurrent readers. See §2.1 for more details +freeze(x) + +# The immutable status in Lungfish refers to the observable state. +# This means that the reference count can remain mutable as long as +# it is not observable in the program. Notice how this new reference +# increases the reference count. See §5.3.5 for more details. +xx = x + +# Setting all references to an immutable object to `None` +# will deallocate it. +xx = None +x = None diff --git a/examples/03-regions1.frank b/examples/03-regions1.frank new file mode 100644 index 0000000..ce5f400 --- /dev/null +++ b/examples/03-regions1.frank @@ -0,0 +1,50 @@ +# Lungfish uses regions to track and enforce ownership dynamically. +# The previous examples only worked in the local region drawn in light +# green. This example shows how new regions can be created and used. +# +# The `Region()` constructor creates a new region and returns its bridge +# object. The new region is drawn in yellow. Any object in the yellow +# rectangle belongs to the region. The trapezoid shape denotes the +# bridge object. It displays the following information about the region: +# - LRC: The number of incoming references from the local region. This +# counter tracks references to all objects in the region not +# just the bridge object. See §3.1 for more details. +# - SBRC: The number of open subregions. +# - RC: The reference count of the bridge object. +r = Region() + +# Any objects reachable from the bridge or an object in the region +# will automatically be part of the region. Notice how the new dictionaries +# are members of the region, indicated by them being in the same yellow box. +r.field = {} +r.field.data = {} + +# Objects inside a region have no topological restrictions. As such we can create +# cycles, like this: +r.field.bridge = r +r.field.data.parent = r.field + +# All objects are by created in the local region. +x = {} +x.data = {} + +# An object in the local region is moved into a region, when an object in the +# region references it. All reachable objects from the moved object are also +# moved into the region. Figure 7 in the paper shows the individual steps of +# this process. +r.x = x + +# Moving the value of `x` into the region increased the LRC since the variable `x` +# is a reference from the local frame into the region. Reassigning `x` to another +# value will decrement the LRC again. This is done by a write barrier, discussed in +# §3.2 of the paper. +x = None + +# References to frozen objects are unrestricted. Table 1 in the paper provides a +# good overview of what references are allowed. +r.x.data = None + +# When a region has no incoming references it and all contained objects can +# be deallocated as a unit. This even allows the collection of topologies +# with cycles. +r = None diff --git a/examples/04-regions2.frank b/examples/04-regions2.frank new file mode 100644 index 0000000..a1cf623 --- /dev/null +++ b/examples/04-regions2.frank @@ -0,0 +1,52 @@ +# 03-regions1.frank covered how new regions can be created. This +# example covers how ownership is enforced with regions. +r1 = Region() +r1.data = {} +r2 = Region() + +# Region 1 owns an object with the name `data`. All objects can at most have +# one owner. Creating a reference from one region to an object in another region +# will result in an error +# r2.data = r1.data # Error + +# However, creating a single reference to a bridge object of another region +# is allowed. This reference is called the owning reference, indicated by +# the solid orange arrow. The region of the referenced bridge object is now +# a subregion of the first region. +r1.data.r2 = r2 + +# Attempts to add additional owning references will result in an error: +# r1.new = r2 # Error + +# Regions are arranged in a forest. It is not allowed to create a cycle +# between regions. Attempting to make `r1` a subregion of `r2` which is +# currently a subregion of `r1` will result in an error: +# r2.r1 = r1 # Error + +# Region 2 is currently considered open as it has an incoming reference from +# the local region. This is indicated by the LRC not being zero. The parent +# region `r1` has a SBRC of 1 indicating that a subregion of it is open. +# +# Removing the local reference into `r2` will close the region and also decrement +# the SBRC of the parent region. +r2 = None + +# Creating a reference into the subregion will open it again. +r2 = r1.data.r2 + +# Subregions can be unparented again by overwriting the owning reference. +# This is possible since there can only be one owing reference at a time. +r1.data.r2 = None + +# Region 2 can now take ownership of `r1`. +r2.r1 = r1 + +# Regions can also be frozen to allow multiple regions to reference them. +# The bridge object of the frozen region will remain, but it will lose the +# `[RegionObject]` prototype. +freeze(r1) + +# The previous bridge object referenced by `r1` can now be referenced by other +# regions. +r3 = Region() +r3.r1 = r1 diff --git a/examples/05-regions3.frank b/examples/05-regions3.frank new file mode 100644 index 0000000..b391931 --- /dev/null +++ b/examples/05-regions3.frank @@ -0,0 +1,42 @@ +# 04-regions2.frank covered how ownership is enfored with regions. +# This example covers some built-in functions for regions. + +r1 = Region() +r1.data1 = {} +r1.data2 = {} + +# The `is_closed()` function can be used to check if a region +# is open. Region 1 is currently considered open since it has +# an incoming reference from the local region. +res = is_closed(r1) + +# Region 2 is created to be the new owner of Region 1. +r2 = Region() +r2.child = r1 + +# The `close()` function can be used to set all incoming local +# references to None. +close(r1) + +# We can also check the status with `is_closed`. +res = is_closed(r2.child) + +# A subregion can be merged into its parent region, with the +# `merge()` function. The bridge object of the subregion loses +# the `[RegionObject]` prototype, since it is no longer the bridge +# object of a region. +merge(r2.child, r2) + +# Regions can also be dissolved, thereby moving all contained +# objects back into the local region. +# +# This function differs syntactically from the paper which +# uses `merge(r2)`. +dissolve(r2) + +# This can be used to reconstruct regions. This example uses +# a for loop: +r1 = Region() +for key, value in r2.child: + r1[key] = value +r2.child = r1 diff --git a/examples/06-cowns.frank b/examples/06-cowns.frank new file mode 100644 index 0000000..cc06a69 --- /dev/null +++ b/examples/06-cowns.frank @@ -0,0 +1,53 @@ +# This is the sixth example and assumes the knowledge of the +# previous examples. +# +# Lungfish uses concurrent owners, called Cowns, to coordinate +# concurrent access at runtime. Cowns and their states are +# introduced in §2.3.1 of the paper. +# +# Cowns can store immutable objects, regions and other cowns. +# The following will create a cown pointing to the built-in +# frozen value `None`. +c1 = Cown(None) + +# The cown has the status "Released" which means that a concurrent +# unit can aquire it and access its data. Attempting to access +# the data in this state will result in an error. +# ref = c1.value # Error + +# Regions are used to track ownership of mutable data. This can be +# combined with cowns to safely share data. This created an open region: +r1 = Region() +r1.data = {} +data = r1.data + +# A cown created from an open region has the status "Pending Release". +# This status allows access to the contained value. However, it will +# switch to the "Released" status when the region is closed +c2 = Cown(r1) +close(c2.value) + +# Cowns can be safely shared across threads since they dynamically enforce +# ownership and concurrent coordination at runtime. They are therefore allowed +# to be referenced by frozen objects. The freeze will not affect the cown +# or the contained values. +x = {} +x.cown = c2 +freeze(x) + +# Cowns can also be referenced from one or multiple regions like this: +r2 = Region() +r2.cown = c2 +r3 = Region() +r3.cown = c2 + +# Regions referencing a cown can still be closed and sent. This example +# uses the `move` keyword to perform a destructive read and close the +# region as part of the cown creation. The cown is therefore created in +# the "Released" status +c3 = Cown(move r2) + +# Cowns can also point to other cowns. The second cown is also +# in the "Released" state since the given cown is allowed to +# have local references. +c4 = Cown(c2) diff --git a/examples/07-expressions.frank b/examples/07-expressions.frank new file mode 100644 index 0000000..d27c5a3 --- /dev/null +++ b/examples/07-expressions.frank @@ -0,0 +1,63 @@ +# The previous examples introduced objects, freezing, regions and cowns. +# This example is a showcase of most expressions that FrankenScript +# supports. It can be used as a reference for writing your own scripts. +# +# A list of built-in function can be found in the `docs` folder. Here is +# a link to the rendered version on GitHub: +# + +# This is how you construct the objects discussed in example 01 +a = {} +x = "a new string" +x = Region() +x = Cown(None) + +# FrankenScript has a few built-in and frozen objects: +x = True # The boolean value `true` +x = False # The boolean value `false` +x = None # The null value, similar to Python's None + +# FrankenScript supports functions with arguments and return values. +def id(x): + return x + +# A function can be called like this. Each function call adds a new frame +# to the diagram. The frame holds all variables known to the current scope. +# The frame is deleted when the function returns. +id(a) + +# Function objects are hidden from the mermaid to make it cleaner. But they're +# still normal objects that can be used in assignments like this: +a.id = id + +# This can be used to simulate method calls. Calling a function on stored in a +# field will pass the object in as the first argument. +a = a.id() # a is passed in as the first argument + +# The move keyword can be used for destructive reads. The previous location +# is reassigned to None. +b = move a + +# FrankenScript has two comparison operators. These check for object identity: +res = b == None +res = b != None + +# Boolean values can be used in if statements: +if res: + pass() # A built-in function for empty blocks +else: + unreachable() # A built-in function for unreachable branches + +# The else block is optional: +if res: + pass() + +# Boolean expressions can also be used in while loops: +while res == True: + res = False + +# For loops can be used to iterate over all fields of an object. +a = {} +a.field = "value" +for key, value in b: + pass() diff --git a/src/lang/interpreter.cc b/src/lang/interpreter.cc index 999e42a..375e09c 100644 --- a/src/lang/interpreter.cc +++ b/src/lang/interpreter.cc @@ -242,7 +242,10 @@ namespace verona::interpreter if (node == StoreFrame) { - assert(frame()->get_stack_size() >= 1 && "the stack is too small"); + if (frame()->get_stack_size() < 1) + { + rt::ui::error("Interpreter: The stack is too small"); + } auto v = frame()->stack_pop("value to store"); std::string field{node->location().view()}; auto v2 = rt::set(frame()->object(), field, v); @@ -252,7 +255,10 @@ namespace verona::interpreter if (node == SwapFrame) { - assert(frame()->get_stack_size() >= 1 && "the stack is too small"); + if (frame()->get_stack_size() < 1) + { + rt::ui::error("Interpreter: The stack is too small"); + } auto new_var = frame()->stack_pop("swap value"); std::string field{node->location().view()}; @@ -265,7 +271,10 @@ namespace verona::interpreter if (node == LoadField) { - assert(frame()->get_stack_size() >= 2 && "the stack is too small"); + if (frame()->get_stack_size() < 2) + { + rt::ui::error("Interpreter: The stack is too small"); + } auto k = frame()->stack_pop("lookup-key"); auto v = frame()->stack_pop("lookup-value"); @@ -293,7 +302,10 @@ namespace verona::interpreter if (node == StoreField) { - assert(frame()->get_stack_size() >= 3 && "the stack is too small"); + if (frame()->get_stack_size() < 3) + { + rt::ui::error("Interpreter: The stack is too small"); + } auto v = frame()->stack_pop("value to store"); auto k = frame()->stack_pop("lookup-key"); auto v2 = frame()->stack_pop("lookup-value"); @@ -307,7 +319,10 @@ namespace verona::interpreter if (node == SwapField) { - assert(frame()->get_stack_size() >= 3 && "the stack is too small"); + if (frame()->get_stack_size() < 3) + { + rt::ui::error("Interpreter: The stack is too small"); + } auto new_var = frame()->stack_pop("swap value"); auto key = frame()->stack_pop("lookup-key"); auto obj = frame()->stack_pop("lookup-value"); @@ -432,9 +447,11 @@ namespace verona::interpreter // solution would require more effort and would be messier auto dup_idx = std::stoul(std::string(node->location().view())); auto stack_size = frame()->get_stack_size(); - assert( - dup_idx < stack_size && - "the stack is too small for this duplication"); + if (dup_idx > stack_size) + { + rt::ui::error( + "Interpreter: the stack is too small for this duplication"); + } auto var = frame()->stack_get(stack_size - dup_idx - 1); frame()->stack_push(var, "duplicated value"); diff --git a/src/rt/core.h b/src/rt/core.h index d0d8a1c..58c10b5 100644 --- a/src/rt/core.h +++ b/src/rt/core.h @@ -166,8 +166,10 @@ namespace rt::core std::string value; public: - StringObject(std::string value_) - : objects::DynObject(stringPrototypeObject()), value(value_) + StringObject( + std::string value_, + objects::Region* region = rt::objects::get_local_region()) + : objects::DynObject(stringPrototypeObject(), region), value(value_) {} std::string get_name() override @@ -190,13 +192,15 @@ namespace rt::core inline StringObject* trueObject() { - static StringObject* val = new StringObject("True"); + static StringObject* val = + new StringObject("True", objects::immutable_region); return val; } inline StringObject* falseObject() { - static StringObject* val = new StringObject("False"); + static StringObject* val = + new StringObject("False", objects::immutable_region); return val; } diff --git a/src/rt/objects/dyn_object.h b/src/rt/objects/dyn_object.h index 9e4c126..41853e4 100644 --- a/src/rt/objects/dyn_object.h +++ b/src/rt/objects/dyn_object.h @@ -271,6 +271,12 @@ namespace rt::objects [[nodiscard]] virtual DynObject* set(std::string name, DynObject* value) { assert_modifiable(); + + if (name == PrototypeField) + { + return set_prototype(value); + } + DynObject* old = fields[name]; fields[name] = value; return old; diff --git a/tests/regressions/func_no_return.frank b/tests/regressions/func_no_return.frank new file mode 100644 index 0000000..2127842 --- /dev/null +++ b/tests/regressions/func_no_return.frank @@ -0,0 +1,8 @@ +def new(proto): + fresh_obj = {} + fresh_obj.__proto__ = proto + +Obj = {} # Modelling the class as a prototype object + +x = new(Obj) +y = new(Obj)