Values, Identity, Equality, and Collections
Canonical source:
docs/guide/05-values-identity-equality-and-collections.mdat locked revisione99b712ecf658a527b1fe82c8beba2c1286d8dc9. This rendered page is non-normative; the applicable Protos specification remains authoritative.
Status: Non-normative programming guide
Normative owners:
spec/semantics/VALUES_AND_COLLECTIONS.md,spec/semantics/OBJECT_MODEL.md,spec/semantics/CALLABLES.md, andspec/PROTOS_GRAMMAR.md
The previous chapters established that Protos uses one object model, ordinary lookup, ordinary Closures, and ordinary protocols even for control flow. Values and collections follow the same principle.
The central idea is:
Every Protos value participates in the object model, but not every value has the same identity law or the same storage protocol.
Understanding that distinction prevents several common mistakes:
nullmeans a real value, not a failed lookup;===asks about semantic identity, not customizable equality;==is behavioral and may be customized;- postfix
receiver[index]is an indexing protocol, while primary[items...]is ordinary-Array(...)construction sugar; MapandIdentityMapintentionally use different key laws;- Core collections and Standard Library algorithms are different layers.
null is a value, not missing state
Section titled “null is a value, not missing state”Core has one canonical null value.
x: nullmeans that the binding x exists and contains null.
That is different from a missing lookup:
missingNameA failed lookup signals an Error. It does not silently produce null.
This distinction is useful whenever absence is part of application data:
result: nullThe program can deliberately store and return null while still relying on
lookup failure to expose missing names and slots.
Canonical Booleans are values too
Section titled “Canonical Booleans are values too”Core has exactly two semantic Boolean values:
truefalseThey are canonical singleton values.
There is no standard Boolean prototype whose descendants automatically become
Booleans. Delegating to true, false, or an object that exposes similarly
named selectors does not grant Boolean membership.
This matters for every standard operation that requires a Boolean result:
==<<=>>=andorwhileThe standard contract requires canonical true or canonical false; there is
no truthiness conversion.
Identity is semantic
Section titled “Identity is semantic”The primitive identity operator is:
a === b=== is not a user-overridable protocol.
For an ordinary identity-bearing object, identity means that both expressions denote the same semantic individual:
a: { value: 1 }b: { value: 1 }c: a
a === b // falsea === c // trueThe fact that two objects currently have the same visible slots does not make them the same object.
Identity is semantic rather than a JVM-address, allocation, wrapper, or cache
property. An implementation can change representation without changing the
answer to ===.
Some Core values use value identity
Section titled “Some Core values use value identity”Core v0.1 has a closed set of value-identity families.
The semantic value itself determines identity for:
- Number values;
- String values;
- canonical
true; - canonical
false; - canonical
null.
For example:
1 === 1"hello" === "hello"("hel" + "lo") === "hello"true === truenull === nullThose results do not depend on whether the implementation interns, boxes, caches, or reconstructs the values.
The set is deliberately closed. Properties such as these do not grant value identity to another object:
immutableclosedfrozenstructurally equalinterned by an implementationbacked by shared host storageArrays, Maps, IdentityMaps, Bytes, Closures, Futures, Errors, execution contexts, module instances, and ordinary objects remain identity-bearing unless a normative rule explicitly says otherwise.
Delegation does not transfer value identity
Section titled “Delegation does not transfer value identity”An ordinary object can delegate to a value object without becoming that value family.
Conceptually:
wrapped: (42) { label: "answer"}wrapped remains an ordinary identity-bearing object. Its parent relationship
does not make:
wrapped === 42true.
This is another example of Protos separating delegation from classification. Inheritance of behavior is not conversion of semantic identity.
Strings are immutable value objects
Section titled “Strings are immutable value objects”String operations do not mutate a String in place.
When an operation produces different text, the result is another String value:
a: "hel"b: a + "lo"
b === "hello"Because Strings use value identity, an implementation may share or intern their
storage freely without exposing that choice through ===.
Every semantic String value receives its standard behavior through the standard
String delegation path. An ordinary object that merely delegates to String
is still not itself a semantic String value.
Numbers are value-identity values
Section titled “Numbers are value-identity values”Numbers follow the same identity principle:
x: 40 + 2
x === 42The identity observation is about the semantic numeric value, not about a particular host numeric object.
Different numeric representations may still belong to more specific numeric
protocols, but the programmer should not reason about === using JVM boxing,
cache ranges, addresses, or allocation behavior.
The detailed numeric model remains owned by the normative specification. The important Programming Guide rule here is that Number identity is value-based.
=== and == answer different questions
Section titled “=== and == answer different questions”The two operators are intentionally different.
=== non-overridable semantic identity
== customizable semantic equality protocolFor ordinary objects:
left: { value: 1 }right: { value: 1 }
left === rightis false unless both names denote the same semantic individual.
left == right, however, depends on the selected equality behavior.
A type-like prototype may define equality in terms of application state:
Point: { ==: (other) => { (x == other.x) && (y == other.y) }}That changes equality behavior. It does not change identity.
Equality results are strictly Boolean
Section titled “Equality results are strictly Boolean”A == implementation must normally return exactly:
trueor:
falseReturning an arbitrary object, null, or a Future is not a truthy/falsy
equality result. It violates the equality-result contract and signals Error at
the applicable standard boundary.
The same strict Boolean-result principle applies to the standard ordering operators:
< <= > >=This makes comparison code predictable because callers never have to guess how an arbitrary object should be coerced to a Boolean.
Hashes are not identity
Section titled “Hashes are not identity”Hashing supports keyed protocols, but a hash integer is never proof of identity.
Two distinct objects may legally have the same hash.
So this reasoning is invalid:
hash(a) == hash(b)thereforea === bLikewise, an identity hash is not required to be globally unique.
Identity is decided by identity semantics. Equality is decided by equality semantics. Hashing is an indexing aid used by protocols such as Map.
Keep those three concepts separate.
Postfix bracket syntax is an ordinary indexing protocol
Section titled “Postfix bracket syntax is an ordinary indexing protocol”Indexed read:
receiver[index]means the indexing operation:
receiver.at(index)Indexed write:
receiver[index] = valuemeans:
receiver.atPut(index, value)The syntax does not bypass lookup and does not impose one universal collection model.
Any object can participate by implementing the corresponding protocol.
Indexed access is not slot access
Section titled “Indexed access is not slot access”This distinction is fundamental:
object.nameuses slot/member lookup.
This:
object["name"]uses the at indexing protocol.
They are not automatically equivalent.
An object can even support both with unrelated state:
map.description: "users"map["description"] = someUserThe first operation addresses a slot. The second addresses indexed contents.
Adding a slot does not make an object indexable, and implementing at does not
turn indexed entries into slots.
: does not create indexed entries
Section titled “: does not create indexed entries”Slot creation uses ::
object.name: valueThere is no indexed counterpart:
object[index]: valueThat form is invalid.
Indexed mutation is expressed through atPut, directly or through [] =.
Whether atPut inserts, replaces, rejects, extends, or requires an existing
position belongs to the receiver’s protocol.
This lets Array and Map use the same bracket syntax while retaining different invariants.
Array construction is ordinary invocation
Section titled “Array construction is ordinary invocation”The standard prelude Array object acts as a factory through ordinary call
semantics:
Array()Array(10)Array(10, 20, 30)Protos also provides bracket construction syntax:
[][10][10, 20, 30]This is syntax sugar, not another collection mechanism. The lowering is exact:
[] -> Array()[10] -> Array(10)[10, 20, 30] -> Array(10, 20, 30)Spread is the existing call spread:
middle: [20, 30]values: [10, ...middle, 40]and nested/postfix forms compose normally:
nested: [[10], 20]first: nested[0][0]Array remains an ordinary lookup. Bracket construction does not secretly
capture the standard-prelude factory:
Array: (a, b) => a * 100 + b
[4, 2] // 402So [a, b] behaves exactly like Array(a, b) under the binding visible at that
point. With the ordinary standard-prelude Array, each successful construction
creates a fresh open Array identity whose indexed elements are the exact supplied
values.
A very important rule is:
[3]and equivalently:
Array(3)mean:
one elementindex 0 -> 3They do not mean “allocate an Array of length 3”.
Core has no special numeric-length Array constructor, holes, comprehensions, repetition/fill form, or generic collection-literal conversion.
Arrays are shallow containers
Section titled “Arrays are shallow containers”Array construction keeps the exact supplied objects:
item: { value: 1 }items: Array(item)
items[0] === itemThe element is not cloned or frozen merely because it was inserted into an Array.
This shallow behavior is consistent with ordinary Protos reference semantics.
Standard Array indexing is positional
Section titled “Standard Array indexing is positional”For the standard Array protocol:
items.at(index)items.atPut(index, value)items.size()the index is an Integer position.
Standard reads and writes require a valid in-range position. atPut replaces
the indexed value at that position; it is not a generic “grow to arbitrary
index” operation.
Bracket syntax can express the same protocol:
x: items[0]items[0] = replacementAgain, indexed state is distinct from the Array object’s ordinary slots.
Collection state and slot state remain orthogonal
Section titled “Collection state and slot state remain orthogonal”Object openness and collection mutation are related by each protocol’s own
rules, not by a blanket interpretation of [] as slot mutation.
For example, the standard Array protocol can replace an existing indexed
element through atPut when that Array state permits the operation even though
indexed assignment is not slot assignment.
Freezing prevents mutation of mutable standard collection state.
The practical rule is:
Ask which protocol owns the state being changed.
For:
array.label = "x"the answer is the slot model.
For:
array[0] = "x"the answer is the Array indexed-state protocol.
Array.each iterates a shallow snapshot
Section titled “Array.each iterates a shallow snapshot”The standard Array.each callback traversal observes a shallow pre-callback
snapshot of the indexed elements.
Conceptually:
items.each((element) => { consume(element)})means that the traversal set is established before user callbacks begin.
The snapshot is shallow: its elements are the same semantic element objects, not deep copies.
This makes mutation during callback execution easier to reason about: a callback may cause later state changes, but those changes do not retroactively redefine which element references were captured for that traversal.
The callback itself uses ordinary polymorphic invocation.
%{...} defines a Map’s initial associations
Section titled “%{...} defines a Map’s initial associations”For the common equality-keyed case, Protos has concise populated Map construction:
m: %{ "language": "Protos" "version": 1}Entries may also share a line with ;:
m: %{ "language": "Protos"; "version": 1 }This syntax creates a standard Map and defines its initial associations. It is important not to read it as hidden repeated indexed assignment.
Construction first looks up ordinary Map. The selected call behavior must be
the canonical standard Map factory behavior, either directly or inherited
through ordinary delegation.
That means ordinary prototype specialization works:
MyMap: Map { label: "custom"}
Map: MyMap
m: %{ "answer": 42 }The resulting Map is fresh standard Map state whose parent is MyMap.
A nearer custom call override is different. It does not become construction
authority merely because it is named Map or happens to return an indexable
object. %{...} rejects that factory before executing the custom call and
before evaluating entries.
For every entry, Protos evaluates the key and then the value. Only after both finish normally does it define the initial association.
Initial definition uses normal Map hashing and directed equality, but it does
not send atPut. An atPut override on an otherwise eligible Map prototype
therefore cannot intercept initial construction.
Keys remain ordinary expressions:
key: "language"
m: %{ key: "Protos"}The key above is the value "language". A bare key name is not converted
implicitly to a String.
Duplicate/equal initial keys are errors:
%{ "version": 1 "version": 2}does not mean “last value wins”. The second initial definition conflicts with the first under the normal Map key-selection law and signals Error.
This differs deliberately from later indexed mutation:
m: %{ "version": 1 }m["version"] = 2The second line is ordinary indexed assignment and therefore ordinary atPut.
Standard Map mutation may replace the existing value there.
Map construction is a primary expression, so postfix indexing works directly:
%{ "answer": 42 }["answer"]IdentityMap deliberately stays explicit:
identity: IdentityMap()It has a different key law and receives no %{...} syntax. There is also no
generic %Factory{...} form.
Map construction and structural Map.match(subject) are separate operations
with separate semantics. %{...} constructs a Map value; it is not pattern
syntax.
Map is keyed by equality
Section titled “Map is keyed by equality”The standard Map family uses the key’s ordinary equality/hash protocols.
A typical Map:
m: Map()
m["language"] = "Protos"m["version"] = 1m["version"] = 2
m["language"]m["version"]m.size()Updating an already-matching key replaces its value rather than creating a second entry.
Map preserves insertion order for its keyed traversal.
The key law is behavioral: finding a matching key involves the keyed hash/equality protocol rather than semantic object identity alone.
Map and custom equality compose
Section titled “Map and custom equality compose”Because Map uses ordinary equality behavior, application-defined key types can participate.
If a key customizes equality, its hashing behavior must be designed coherently for keyed use. Map is not required to ignore hashing and linearly test every possible object.
This is a useful power but also a responsibility: mutable equality/hash state is a poor Map key design because changing the properties used by those protocols can undermine later lookup.
For identity-based keyed state, use IdentityMap instead.
IdentityMap is keyed by === semantics
Section titled “IdentityMap is keyed by === semantics”IdentityMap deliberately uses a different law.
Consider two different objects with the same visible contents:
left: { value: 1 }right: { value: 1 }
left === right // falseAn IdentityMap treats them as distinct keys:
m: IdentityMap()
m[left] = "left"m[right] = "right"
m.size() // 2It does not call user-defined == to decide that those ordinary objects are
the same key.
The operation relies on semantic identity plus identity hashing.
Value-identity keys still collapse by value in IdentityMap
Section titled “Value-identity keys still collapse by value in IdentityMap”IdentityMap does not mean “physical allocation map”.
Because Numbers and Strings use value identity:
a: "hel" + "lo"b: "hello"
a === bis true.
Those values therefore denote the same semantic IdentityMap key.
This is exactly why the distinction between semantic identity and physical representation matters.
Map and IdentityMap solve different problems
Section titled “Map and IdentityMap solve different problems”Use Map when key sameness should follow semantic equality:
"same customer number""same path value""same application key"Use IdentityMap when key sameness should follow semantic identity:
this exact mutable objectthis exact Closurethis exact identity-bearing capability/valueNeither is the “faster version” of the other at the language level. They expose different semantics.
Map mutation has key-specific rules
Section titled “Map mutation has key-specific rules”The standard Map operations include:
at(key)atPut(key, value)containsKey(key)remove(key)size()each(callback)An absent at signals Error rather than returning null.
That keeps “missing key” distinct from:
m[key] = nullwhere the key exists and deliberately stores null.
Replacing an existing entry and adding a new key are semantically distinct. Adding/removing keyed structure requires the appropriate open mutable state; frozen state rejects mutation.
Map iteration observes insertion order
Section titled “Map iteration observes insertion order”Map traversal preserves insertion order.
The standard each callback receives the key and value:
m.each((key, value) => { consume(key, value)})Like other standard collection callbacks, invocation is ordinary protocol invocation rather than a second callable universe.
The traversal uses a keyed snapshot so callback effects do not rewrite the already-selected traversal sequence underneath the current iteration.
IdentityMap exposes the analogous keyed operations while using its identity key law.
Bytes is another identity-bearing collection
Section titled “Bytes is another identity-bearing collection”Core Bytes represents indexed byte state.
It is not in the closed value-identity set, so a Bytes object remains an identity-bearing collection even when two Bytes currently contain the same byte sequence.
That is different from String:
String immutable value identity
Bytes identity-bearing indexed byte stateEncoding and byte-I/O behavior are covered in later guide material. The important point here is that “sequence-like” does not imply value identity.
Core collections and Standard Library algorithms are different layers
Section titled “Core collections and Standard Library algorithms are different layers”Core provides the semantic collection mechanisms such as:
ArrayMapIdentityMapBytesThe Standard Library builds higher-level behavior using those mechanisms.
For example:
std:collections/Arrayprovides ordinary Protos algorithms such as mapping, filtering, indexed search,
reduction, and sorting over Core Arrays, plus isolated-parallel variants of
them composed from closure.parallel(...) and Futures (see chapter 09).
Those algorithms do not replace the Core Array family or introduce a second
runtime Array representation.
Set and IdentitySet are Standard Library abstractions
Section titled “Set and IdentitySet are Standard Library abstractions”Current Standard Library modules include:
std:collections/Setstd:collections/IdentitySetThey deliberately reuse existing keyed semantics:
Set built on Map membership follows equality-key semantics
IdentitySet built on IdentityMap membership follows identity-key semanticsThere is no separate Core Set runtime family required merely because set operations are useful.
This is a good example of the Protos design principle “mechanisms over institutions”: higher-level collection abstractions can be written in ordinary Protos when Core already supplies the necessary semantic foundation.
Equality choice propagates into collection choice
Section titled “Equality choice propagates into collection choice”A useful design sequence is:
What does "same" mean for my domain? | +-- semantic equality --> == | | | +--> Map / Set | +-- semantic identity --> === | +--> IdentityMap / IdentitySetChoose the identity/equality law first. Then choose the collection whose key semantics match it.
Do not choose Map versus IdentityMap based only on familiarity or presumed performance.
Practical rules to remember
Section titled “Practical rules to remember”- Treat
nullas an actual stored value; missing lookup is an Error. - Remember that canonical
trueandfalseare the only semantic Booleans. - Use
===for non-overridable semantic identity. - Use
==for customizable semantic equality. - Keep identity, equality, and hashes conceptually separate.
- Remember the closed value-identity set: Numbers, Strings,
true,false, andnull. - Do not infer value identity from immutability, freezing, structural equality, or host interning.
- Read
object[index]asobject.at(index), never as dynamic slot lookup. - Remember that
Array(3)creates one element containing3, not three empty positions. - Use Map when keys follow equality and IdentityMap when keys follow identity.
- Distinguish an absent Map key from a present key whose value is
null. - Treat Set/IdentitySet and Array algorithms as Standard Library abstractions built on Core mechanisms rather than new Core runtime families.
Run the executable lessons
Section titled “Run the executable lessons”The current tutorial material includes direct examples for keyed collections:
../../protos/tutorials/05-collections/01-maps.protosdemonstrates equality-keyed Map creation, insertion, replacement, lookup, and size;../../protos/tutorials/05-collections/02-identity-maps.protosdemonstrates distinct identity-bearing objects as separate IdentityMap keys.
The current Standard Library collection sources are also ordinary Protos and are useful as larger examples:
../../protos/lib/collections/Array.protosimplements higher-level Array algorithms over the Core Array surface;../../protos/lib/collections/Set.protosbuilds equality-based set behavior over Map;../../protos/lib/collections/IdentitySet.protosbuilds identity-based set behavior over IdentityMap.
These files demonstrate current runnable behavior. The specification remains the normative authority.
Normative references
Section titled “Normative references”For exact behavior, consult:
../../spec/semantics/VALUES_AND_COLLECTIONS.mdfornull, Booleans, identity/equality, indexed access, Array, Map, IdentityMap, Bytes, and standard collection/value protocols;../../spec/semantics/OBJECT_MODEL.mdfor open/closed/frozen object state, delegation, slots, and semantic-family receiver domains;../../spec/semantics/CALLABLES.mdfor ordinary factory specialization and collection callback invocation;../../spec/PROTOS_GRAMMAR.mdfor literal, equality/comparison, bracket-indexing, and assignment syntax;02-objects-delegation-and-composition.mdfor the object-state rules that collections remain orthogonal to;04-control-flow-through-protocols.mdfor strict canonical Boolean decisions and lazy callback composition.
Those documents define the language. This chapter supplies a programmer-facing model for choosing the right identity, equality, and collection semantics.