EMPL IR
Access nodes — `MemberAccessNode`, `IndexAccessNode`
Access nodes — MemberAccessNode, IndexAccessNode
The IR has two property/index access node kinds covering all forms of "look something up":
MemberAccessNode — obj.member
IndexAccessNode — obj[index]
Both are used in read position (e.g. console.log(obj.field)) and in write position (e.g. obj.field = 42). The read/write context is determined by the parent node, not the access node itself.
MemberAccessNode
// MPL_Compiler/core/ir/EMPLNode.h:363
class MemberAccessNode : public EMPLNode {
public:
EMPLNodePtr object;
std::string memberName;
MemberAccessNode() : EMPLNode(NodeKind::MemberAccess) {}
void accept(EMPLVisitor& v) override { v.visit(*this); }
};
| Field | Type | Source | Description |
|---|---|---|---|
kind |
NodeKind::MemberAccess |
EMPLNode.h:368 |
Discriminator. |
object |
EMPLNodePtr |
EMPLNode.h:365 |
The receiver expression (obj in obj.field). Required. |
memberName |
std::string |
EMPLNode.h:366 |
The member identifier (the part after the dot). Required. |
Source mapping
| Source | Field config |
|---|---|
obj.x |
object=Identifier("obj"), memberName="x" |
obj.x.y |
object=MemberAccess(obj, "x"), memberName="y" |
arr.length |
object=Identifier("arr"), memberName="length" |
obj["x"] |
object=Identifier("obj"), memberName="x" (canonicalized — see "Bracketed member access" below) |
obj?.x (optional chaining, JS / TS) |
object=Identifier("obj"), memberName="x", metadata["optional"]="true" |
self.field (Python) |
object=Identifier("self"), memberName="field" |
@x (Rust / PHP / Ruby instance var) |
object=Identifier("this"), memberName="x" (with metadata["instance_var"]="true") |
obj->field (C / C++ pointer) |
object=Identifier("obj"), memberName="field" (with metadata["pointer_deref"]="true" for C backend) |
obj::ns (C++ namespace) |
object=Identifier("obj"), memberName="ns" (with metadata["namespace"]="true") |
Validation rules
| Rule | Constraint |
|---|---|
| V1 | object MUST be non-null. |
| V1 | memberName MUST be non-empty. |
Backend lowering (C++)
// obj.x → obj.x
// obj?.x → (obj) ? obj->x : mpl_undefined() // optional chaining
// obj->field → obj->field
// obj::ns → obj::ns
// @x (Rust instance) → obj.x // no special lowering
IndexAccessNode
// MPL_Compiler/core/ir/EMPLNode.h:373
class IndexAccessNode : public EMPLNode {
public:
EMPLNodePtr object;
EMPLNodePtr index;
IndexAccessNode() : EMPLNode(NodeKind::IndexAccess) {}
void accept(EMPLVisitor& v) override { v.visit(*this); }
};
| Field | Type | Source | Description |
|---|---|---|---|
kind |
NodeKind::IndexAccess |
EMPLNode.h:378 |
Discriminator. |
object |
EMPLNodePtr |
EMPLNode.h:375 |
The receiver expression (obj in obj[index]). Required. |
index |
EMPLNodePtr |
EMPLNode.h:376 |
The index expression. Required. |
Source mapping
| Source | Field config |
|---|---|
arr[0] |
object=Identifier("arr"), index=Literal(0) |
arr[i] |
object=Identifier("arr"), index=Identifier("i") |
arr[i][j] |
object=IndexAccess(arr, i), index=Identifier("j") |
obj["x"] (computed key) |
object=Identifier("obj"), index=Literal("x") |
obj[k] (dynamic key) |
object=Identifier("obj"), index=Identifier("k") |
obj?.[k] (optional chaining) |
object=Identifier("obj"), index=Identifier("k"), metadata["optional"]="true" |
arr.at(i) (JS / Python) |
This is a method call, NOT an IndexAccessNode. Emitted as MethodCallNode("at", arr, i). |
arr[:3] (Python slice) |
object=Identifier("arr"), index=SliceNode(0, 3) — SliceNode is currently BinaryOpNode(":", …) with metadata["slice"]="true"; planned: dedicated SliceNode |
Validation rules
| Rule | Constraint |
|---|---|
| V1 | object MUST be non-null. |
| V1 | index MUST be non-null. |
Backend lowering (C++)
// arr[0] → arr[0]
// obj[k] → obj[k]
// obj?.[k] → (obj) ? mpl_get(obj, k) : mpl_undefined()
// obj["x"] → obj["x"]
For arrays of unknown type, the backend emits mpl_at(obj, index) (defined in empl.h) which dispatches to the appropriate operator[] for mpl_value, std::vector, or string.
Bracketed member access vs. IndexAccessNode
In JavaScript and Python, obj["x"] and obj.x are semantically equivalent when x is a static identifier. The IR canonicalizes both to MemberAccessNode (if the key is a string literal). For dynamic keys, the IR uses IndexAccessNode.
| Source | IR node |
|---|---|
obj.x |
MemberAccessNode(obj, "x") |
obj["x"] |
MemberAccessNode(obj, "x") (canonicalized — same as obj.x) |
obj[x] |
IndexAccessNode(obj, x) |
obj[42] |
IndexAccessNode(obj, Literal(42)) |
Canonicalizer rule (implicit, in frontend): emit MemberAccessNode for static string keys, IndexAccessNode otherwise.
Read vs. write context
MemberAccessNode and IndexAccessNode are inherently directionless — they don't know whether they are on the left or right side of an assignment. The parent node determines context:
| Parent | Interpretation |
|---|---|
AssignmentNode::target |
Write context — assigning to the accessed property/element. |
BinaryOpNode (right side) |
Read context — value of the property/element. |
FunctionCallNode::arguments[i] |
Read context — passing the property/element as an argument. |
MethodCallNode::object |
Read context — using as a method-call receiver. |
ReturnNode::value |
Read context — returning the property/element. |
For C++ backend lowering, the context matters: obj.x = 5 lowers to obj.x = 5, but arr[i] = 5 lowers to arr[i] = 5 AND may require arr to be mutable (the backend emits an error if the access path goes through a const binding).
Special forms (metadata keys)
The IR uses metadata to carry language-specific hints that backends consume:
| Key | Value | Meaning |
|---|---|---|
optional |
"true" |
Optional chaining (?., ?.[, ?.()). |
instance_var |
"true" |
Source-language instance-variable syntax (Rust @x, PHP $this->x, Ruby @x). |
pointer_deref |
"true" |
C / C++ pointer dereference (->). |
namespace |
"true" |
C++ namespace (::). |
deref |
"true" |
C++ unary * dereference. |
slice |
"true" |
Indicates a slice expression (the operator field of the surrounding BinaryOpNode carries ":"). |
These keys are NOT standardized across all backends — each backend documents which keys it consumes. The C++ backend consumes all of the above; the Python backend (planned) ignores most.
See also
nodes/operators.md—BinaryOpNodewithop="in"(membership test)nodes/calls.md—MethodCallNode(member-as-method)nodes/literals.md—LiteralNode(typicalindexfor array indexing)validator.md— full rule set
Detailed examples by source language
JavaScript — property access patterns
const obj = { x: 10, y: 20, nested: { z: 30 } };
// Read
console.log(obj.x); // → MemberAccess(obj, "x")
console.log(obj["x"]); // → MemberAccess(obj, "x") (canonicalized)
console.log(obj.nested.z); // → MemberAccess(MemberAccess(obj, "nested"), "z")
console.log(obj?.x); // → MemberAccess(obj, "x") with metadata["optional"]="true"
console.log(obj?.nested?.z); // → chained optional MemberAccess
console.log(obj[dynamicKey]); // → IndexAccess(obj, dynamicKey)
console.log(obj?.[dynamicKey]); // → IndexAccess(obj, dynamicKey) with optional
// Write
obj.x = 100; // → Assignment(MemberAccess(obj, "x"), 100)
obj["x"] = 100; // → Assignment(MemberAccess(obj, "x"), 100) (canonicalized)
obj[dynamicKey] = 100; // → Assignment(IndexAccess(obj, dynamicKey), 100)
// Computed property names
const key = "x";
console.log(obj[key]); // → IndexAccess(obj, Identifier("key"))
// Delete
delete obj.x; // → UnaryOp("delete", MemberAccess(obj, "x"))
delete obj[dynamicKey]; // → UnaryOp("delete", IndexAccess(obj, dynamicKey))
Lowered IR for obj.nested.z:
auto innerAccess = std::make_shared<mpl::ir::MemberAccessNode>();
innerAccess->object = std::make_shared<mpl::ir::IdentifierNode>("obj");
innerAccess->memberName = "nested";
auto outerAccess = std::make_shared<mpl::ir::MemberAccessNode>();
outerAccess->object = innerAccess;
outerAccess->memberName = "z";
Python — attribute access
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(1, 2)
print(p.x) # → MemberAccess(Identifier("p"), "x")
print(p.distance) # → MemberAccess(Identifier("p"), "distance")
The Python frontend translates p.x to MemberAccessNode. Python's __dict__ access (rare in source) is emitted as MemberAccessNode with metadata["dunder_access"]="true".
C++ pointer / reference semantics
struct Point { int x, y; };
Point* pp = new Point{1, 2};
int a = pp->x; // → MemberAccess(Identifier("pp"), "x") with metadata["pointer_deref"]="true"
int b = (*pp).x; // → MemberAccess(Dereference(Identifier("pp")), "x")
int c = pp[0].x; // → MemberAccess(IndexAccess(Identifier("pp"), 0), "x")
The C++ backend lowers:
pp->x→pp->x(*pp).x→(*pp).xpp[0].x→pp.operator[](0).x(ifoperator[]is overloaded) orpp[0].xfor arrays
Ruby — instance variables
class Counter
def initialize
@count = 0 # instance variable
end
def increment
@count += 1
end
end
c = Counter.new
puts c.instance_variable_get(:@count) # → MemberAccess with metadata["instance_var"]="true"
The Ruby frontend translates @count to MemberAccessNode { object = Identifier("this"), memberName = "count", metadata["instance_var"] = "true" }.
Optional chaining patterns
JS optional chaining has four syntactic forms, all lowered to MemberAccessNode / IndexAccessNode / MethodCallNode with metadata["optional"] = "true":
| Source | Lowered to |
|---|---|
obj?.x |
MemberAccessNode { object, "x", metadata["optional"]="true" } |
obj?.() |
FunctionCallNode { "", [obj], metadata["optional"]="true" } (planned; currently emits MethodCallNode with empty name) |
obj?.[k] |
IndexAccessNode { object, k, metadata["optional"]="true" } |
obj?.m() |
MethodCallNode { object, "m", [], metadata["optional"]="true" } |
The C++ backend emits:
// obj?.x → (obj) ? obj->x : mpl_undefined()
auto _tmp = obj;
auto _result = _tmp ? _tmp->x : mpl_value(__mpl_undefined__);
For chained optional access (obj?.x?.y), the backend short-circuits at the first undefined.
Read vs. write context — extended examples
Assignment targets
obj.x = 5; // → Assignment(target=MemberAccess(obj, "x"), value=5)
arr[i] = 5; // → Assignment(target=IndexAccess(arr, i), value=5)
obj.x.y = 5; // → Assignment(target=MemberAccess(MemberAccess(obj, "x"), "y"), value=5)
obj?.[k] = 5; // → Assignment(target=IndexAccess(obj, k, optional=true), value=5)
The C++ backend emits the access as the LHS of an assignment. For obj.x = 5, the LHS is obj.x and the RHS is 5.
Compound assignment
obj.count += 1; // → Assignment(target=MemberAccess(obj, "count"), op="+=", value=1)
arr[i] *= 2; // → Assignment(target=IndexAccess(arr, i), op="*=", value=2)
The C++ backend emits obj.count += 1; and arr[i] *= 2; directly.
Destructuring assignment
({ x, y } = obj); // → Assignment(target=DestructuringPattern({x, y}), value=Identifier("obj"))
For destructuring assignments, the target is a DestructuringPatternNode (see variable-decl.md).
Member access on smart pointers
The C++ backend distinguishes between:
| Source pattern | IR | C++ lowered form |
|---|---|---|
ptr.x (raw pointer) |
MemberAccessNode { metadata["pointer_deref"]="true" } |
ptr->x |
ptr.x (smart pointer) |
MemberAccessNode |
ptr->x (smart pointers have operator->) |
ptr.x (value / reference) |
MemberAccessNode |
ptr.x |
obj.x (boxed value) |
MemberAccessNode |
mpl_get(obj, "x") |
The C++ backend reads metadata["receiver_kind"] (one of "raw", "smart", "value", "boxed") when present to disambiguate. If absent, the backend uses std::is_pointer / std::is_class_with_arrow_operator heuristics.
Index access on different container types
| Container | IR IndexAccessNode |
C++ lowered form |
|---|---|---|
std::vector<T> |
object = std::vector, index = int |
vec[i] |
std::array<T, N> |
same | arr[i] |
std::map<K, V> |
object = std::map, index = K |
m[k] |
std::unordered_map<K, V> |
same | m[k] |
| JS-style array (boxed) | object = mpl_object, index = any |
mpl_at(obj, idx) |
| String | object = std::string, index = int |
s[i] |
| Tuple | object = std::tuple, index = int |
std::get<i>(t) |
| Optional | object = std::optional, index = any |
(*obj).second or similar (rare) |
For tuples with a constant integer index, the C++ backend may substitute std::get<i> at compile time. For dynamic indices, it emits std::get<i> with a runtime check.
Slice notation
Python-style slicing (arr[1:4], arr[1:], arr[:4], arr[::2]) is currently represented as a BinaryOpNode with op=":" and metadata["slice"]="true" on the surrounding node. The C++ backend emits calls to mpl_slice(arr, start, stop, step). A dedicated SliceNode is planned for v4 to clean this up.
// arr[1:4]
auto slice = std::make_shared<mpl::ir::BinaryOpNode>();
slice->op = ":";
slice->left = mpl::ir::LiteralNode::makeInt(1);
slice->right = mpl::ir::LiteralNode::makeInt(4);
slice->metadata["slice"] = "true";
auto indexAccess = std::make_shared<mpl::ir::IndexAccessNode>();
indexAccess->object = std::make_shared<mpl::ir::IdentifierNode>("arr");
indexAccess->index = slice;
The C++ backend emits mpl_slice(arr, 1, 4, 1).
See also (post-summary)
nodes/operators.md—BinaryOpNodenodes/calls.md—MethodCallNodenodes/literals.md—LiteralNodevalidator.md— rules V1