Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/php/classes.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,9 +266,9 @@ Property default values are applied both for the normal `new ClassName()` form a

An `array`-typed (or untyped) property may take an associative literal default such as `['a' => 1]`. The property is then stored as an associative array, so string-key reads and writes (`$this->data['a']`, `$this->data[$key]`) type-check and run like any other associative array. A positional literal default (`[1, 2, 3]`) keeps integer-keyed list storage.

A **nullable or union** array property takes either literal too — `public ?array $x = [1, 2];` and `public ?array $x = ['k' => 1];` both initialize, as do the `mixed` and `array|string` spellings. The value is boxed the way any other `mixed` payload is, so the slot can later hold `null` or a non-array without changing representation.
The elements may themselves be array literals, to any depth and in either spelling: `public array $grid = [[1, 2], [3, 4]];`, `public array $conf = ['db' => ['host' => 'localhost']];`, and mixtures such as `[[1], 2]` all initialize without running any code. Each nested container is allocated as part of the object's initialization and owned by the one enclosing it, so the whole tree is released exactly once with the object, and two instances never share storage — writing through `$a->grid[0][] = 9` leaves a second instance's default untouched, as in PHP.

A default whose ELEMENTS are themselves array literals (`public array $x = [[1], [2]];`) is not supported yet and reports a compile error; assign it in the constructor instead. The same literal is accepted everywhere else — as a local, a parameter default, or a class constant.
A **nullable or union** array property takes either literal too — `public ?array $x = [1, 2];` and `public ?array $x = ['k' => 1];` both initialize, as do the `mixed` and `array|string` spellings. The value is boxed the way any other `mixed` payload is, so the slot can later hold `null` or a non-array without changing representation.

```php
<?php
Expand Down
20 changes: 20 additions & 0 deletions examples/classes/main.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,26 @@ public function show() {
$b->inc();
echo "a=" . $a->get() . " b=" . $b->get() . "\n";

// A property default may be an array literal whose elements are array literals,
// to any depth and in either spelling. Each nested container belongs to the
// object holding it, so two instances never share one.
class Grid {
public array $rows = [[1, 2], [3, 4]];
public array $conf = ['db' => ['host' => 'localhost', 'port' => 5432]];

public function cell(int $row, int $col): int {
return $this->rows[$row][$col];
}
}

$g = new Grid();
echo "cell(1,0)=" . $g->cell(1, 0) . "\n";
echo "host=" . $g->conf['db']['host'] . ":" . $g->conf['db']['port'] . "\n";

$g->rows[0][] = 9;
$fresh = new Grid();
echo "written=" . count($g->rows[0]) . " fresh=" . count($fresh->rows[0]) . "\n";

// One declaration can introduce several properties or constants, separated by commas. The type
// and every modifier belong to the whole list; each name carries its own initializer.
class Viewport
Expand Down
248 changes: 232 additions & 16 deletions src/codegen/literal_defaults.rs

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions tests/codegen/objects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ mod constructor_promotion;
mod static_properties;
#[path = "objects/untyped_property_defaults.rs"]
mod untyped_property_defaults;
#[path = "objects/nested_array_property_defaults.rs"]
mod nested_array_property_defaults;
#[path = "objects/nested_arrays.rs"]
mod nested_arrays;
#[path = "objects/nullable_dispatch.rs"]
Expand Down
157 changes: 157 additions & 0 deletions tests/codegen/objects/nested_array_property_defaults.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
//! Purpose:
//! Integration tests for property defaults whose ELEMENTS are themselves array literals:
//! `public array $x = [[1], [2]];` and its keyed, nullable, mixed and static spellings. Every
//! one of these was refused at compile time until the literal-default form became recursive,
//! even though the same literal was already accepted as a local, a parameter default and a
//! class constant.
//!
//! Called from:
//! - `cargo test` through Rust's test harness.
//!
//! Key details:
//! - Inline PHP fixtures are compiled to native binaries and assertions compare stdout.
//! - Expected values are real `LC_ALL=C php` 8.5 output for the same fixtures.
//! - Ownership of the nested containers is covered separately under `runtime_gc/`.

use super::*;

/// The issue's headline shape: an indexed default whose elements are indexed literals.
#[test]
fn test_indexed_property_default_with_indexed_literal_elements() {
let out = compile_and_run(
r#"<?php
class C { public array $x = [[1], [2]]; }
$c = new C();
echo count($c->x), "|", $c->x[0][0], "|", $c->x[1][0];
"#,
);
assert_eq!(out, "2|1|2");
}

/// The same default on the nullable and `mixed` slots, which box the outer container.
///
/// The issue's table lists all three, and notes that a plain `array` property failed exactly
/// as a `?array` or `mixed` one did -- it was never about the slot.
#[test]
fn test_nested_literal_default_on_nullable_and_mixed_slots() {
let out = compile_and_run(
r#"<?php
class N { public ?array $x = [[1], [2]]; }
class M { public mixed $x = [[1], [2]]; }
$n = new N();
$m = new M();
echo count($n->x), $n->x[1][0], "|", count($m->x), $m->x[1][0];
"#,
);
assert_eq!(out, "22|22");
}

/// The keyed spellings, on the plain `array` slot and on the boxed ones.
///
/// `public ?array $x = ["k" => 1];` was refused on its own too -- a keyed literal had no boxed
/// form at all, only the positional one -- so the keyed nullable row of the issue's table was
/// two independent gaps, not one.
#[test]
fn test_keyed_property_defaults_including_nested_and_boxed() {
let out = compile_and_run(
r#"<?php
class A { public array $x = ["k" => [1]]; }
class B { public ?array $x = ["k" => 1]; }
class D { public mixed $x = ["k" => [1]]; }
class E { public array $x = ["k" => ["j" => 7]]; }
$a = new A();
$b = new B();
$d = new D();
$e = new E();
echo $a->x["k"][0], "|", $b->x["k"], "|", $d->x["k"][0], "|", $e->x["k"]["j"];
"#,
);
assert_eq!(out, "1|1|1|7");
}

/// Depth beyond one level, and elements that mix containers with scalars.
///
/// The recursion has no depth limit of its own, and an element list does not have to be
/// uniform: `[[1], 2]` is an array element beside an int element in the same literal.
#[test]
fn test_nested_property_defaults_nest_deeply_and_mix_element_kinds() {
let out = compile_and_run(
r#"<?php
class C {
public array $deep = [[[1]]];
public array $mixedKinds = [[1], 2];
public array $scalars = [[1, "s", 2.5, null, true]];
public array $empty = [[]];
}
$c = new C();
echo $c->deep[0][0][0], "|", count($c->mixedKinds), $c->mixedKinds[1], "|",
count($c->scalars[0]), $c->scalars[0][1], "|", count($c->empty[0]);
"#,
);
assert_eq!(out, "1|22|5s|0");
}

/// A static property takes the same nested default.
///
/// Static and instance defaults go through separate emitters (`block_emit` and
/// `property_defaults`), so a fix applied to one does not reach the other.
#[test]
fn test_static_property_default_with_nested_literal_elements() {
let out = compile_and_run(
r#"<?php
class S { public static array $s = [[1], [2]]; }
echo count(S::$s), "|", S::$s[1][0];
"#,
);
assert_eq!(out, "2|2");
}

/// A STATIC property with a KEYED default on a boxed slot takes the `BoxedAssocArray` path.
///
/// Raised in review: the static fixture above uses a positional default on a plain `array`
/// slot, which never reaches the keyed boxing emitter in `block_emit`. That emitter is separate
/// from the instance one in `property_defaults`, and separate again from the positional
/// `BoxedArray` arm beside it, so nothing here covered it. The flat keyed spelling is included
/// because it was refused on its own before this change, independently of any nesting.
#[test]
fn test_static_keyed_defaults_on_boxed_slots() {
let out = compile_and_run(
r#"<?php
class S {
public static ?array $flat = ["k" => 1];
public static mixed $nested = ["k" => [1, 2]];
public static ?array $deep = ["a" => ["b" => "c"]];
}
echo S::$flat["k"], "|",
count(S::$nested["k"]), S::$nested["k"][1], "|",
S::$deep["a"]["b"];
"#,
);
assert_eq!(out, "1|22|c");
}

/// Two instances hold separate storage, and a copy outlives the object it came from.
///
/// Each nested container is allocated per object, so a write through one instance's default
/// must not reach another's. PHP's value semantics say the same, and the copy taken out of a
/// destroyed object has to stay readable -- which it cannot if the object's release freed a
/// child the copy still holds.
#[test]
fn test_nested_property_defaults_do_not_share_storage_between_instances() {
let out = compile_and_run(
r#"<?php
class A { public array $x = [[1], [2]]; }
$a = new A();
$b = new A();
$a->x[0][] = 99;
echo count($a->x[0]), count($b->x[0]), "|";
$c = new A();
$inner = $c->x[0];
unset($c);
$inner[] = 7;
$d = new A();
echo $inner[0], count($inner), count($d->x[0]);
"#,
);
assert_eq!(out, "21|121");
}
4 changes: 3 additions & 1 deletion tests/codegen/runtime_gc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
//! - `cargo test` through Rust's test harness.
//!
//! Key details:
//! - Submodules group focused fixtures for basics, regressions, stack args, copy-on-write and cycle handling, growth, related suites, resource scope-cleanup, by-reference builtin arguments that name a property, static property, or container element, calls that OMIT an optional by-reference argument (whose caller-side cell nothing reads back), the reference a `foreach` loop holds on an object source, the container an array literal allocates when it defaults a boxed property, and read-modify-write stores into a typed static property or a property array element.
//! - Submodules group focused fixtures for basics, regressions, stack args, copy-on-write and cycle handling, growth, related suites, resource scope-cleanup, by-reference builtin arguments that name a property, static property, or container element, calls that OMIT an optional by-reference argument (whose caller-side cell nothing reads back), the reference a `foreach` loop holds on an object source, the containers an array literal allocates when it defaults a property, boxed or nested, and read-modify-write stores into a typed static property or a property array element.

#[path = "runtime_gc/basics.rs"]
mod basics;
Expand Down Expand Up @@ -41,6 +41,8 @@ mod by_ref_place_args;
mod omitted_by_ref_default_args;
#[path = "runtime_gc/foreach_object_source.rs"]
mod foreach_object_source;
#[path = "runtime_gc/nested_property_defaults.rs"]
mod nested_property_defaults;
#[path = "runtime_gc/spread_promotion.rs"]
mod spread_promotion;
#[path = "runtime_gc/stack_args.rs"]
Expand Down
120 changes: 120 additions & 0 deletions tests/codegen/runtime_gc/nested_property_defaults.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
//! Purpose:
//! Heap-debug coverage for property defaults whose elements are themselves array literals.
//! Each nested container is allocated during the object's initialization and handed to the
//! container enclosing it, so the whole tree has to be owned by its root and released exactly
//! once with the object.
//!
//! Called from:
//! - `cargo test` through Rust's test harness.
//!
//! Key details:
//! - Each fixture runs under `--heap-debug` and asserts `leak summary: clean`. The two ways to
//! get the transfer wrong land on opposite sides of that assertion: retaining the child
//! without releasing the builder's reference leaks the whole subtree once per object, and
//! releasing it without the retain frees a child the object still points at, which shows up
//! as corrupted reads or a double free rather than as a leak -- so the fixtures read the
//! values back as well.
//! - The loops allocate hundreds of objects, so a per-object leak cannot hide in heap slack.
//! - Expected stdout values are real `LC_ALL=C php` 8.5 output for the same fixtures.

use crate::support::compile_and_run_with_heap_debug;

/// Asserts the program printed `expected` and left a clean heap under heap debug.
fn assert_clean(out: crate::support::ProgramOutput, expected: &str) {
assert_eq!(out.stdout, expected, "stderr: {}", out.stderr);
assert!(
out.stderr.contains("HEAP DEBUG: leak summary: clean"),
"expected clean heap, got: {}",
out.stderr
);
}

/// An indexed default holding indexed literals releases its whole tree with the object.
#[test]
fn test_indexed_nested_property_default_releases_with_the_object() {
let out = compile_and_run_with_heap_debug(
r#"<?php
class A { public array $x = [[1], [2]]; }
$t = 0;
for ($i = 0; $i < 300; $i++) {
$a = new A();
$t += count($a->x) + $a->x[1][0];
unset($a);
}
echo $t;
"#,
);
assert_clean(out, "1200");
}

/// The keyed and boxed spellings release too, including a tree that mixes both.
///
/// The hash path transfers ownership differently from the indexed one -- `__rt_hash_set` takes
/// the value it stores rather than retaining it, where the indexed path boxes into a Mixed cell
/// and releases the builder's reference -- so a single fixture cannot cover both.
#[test]
fn test_keyed_and_boxed_nested_property_defaults_release_with_the_object() {
let out = compile_and_run_with_heap_debug(
r#"<?php
class B { public mixed $x = ["a" => [1, 2], "b" => ["c" => "d"], "e" => 3]; }
class C { public array $x = ["k" => ["j" => 7]]; }
$t = 0;
for ($i = 0; $i < 300; $i++) {
$b = new B();
$t += count($b->x) + $b->x["a"][1];
unset($b);
$c = new C();
$t += $c->x["k"]["j"];
unset($c);
}
echo $t;
"#,
);
assert_clean(out, "3600");
}

/// A deep tree with string, float and null leaves stays balanced.
///
/// Strings are the leaf that can go wrong independently: they are persisted rather than
/// refcounted like a container, so a tree carrying both has to get two ownership rules right
/// at once.
#[test]
fn test_deep_nested_property_default_with_mixed_leaves_releases_cleanly() {
let out = compile_and_run_with_heap_debug(
r#"<?php
class D { public array $x = [[[1, "s", 2.5, null, true]]]; }
$t = 0;
for ($i = 0; $i < 300; $i++) {
$d = new D();
$t += count($d->x[0][0]);
unset($d);
}
echo $t;
"#,
);
assert_clean(out, "1500");
}

/// A copy taken out of the default outlives the object it came from.
///
/// This is the over-release side of the contract: if the object's release freed a child the
/// copy still holds, reading the copy afterwards reads freed memory. The loop repeats it so a
/// recycled block would come back with someone else's contents.
#[test]
fn test_copy_of_a_nested_default_survives_its_object() {
let out = compile_and_run_with_heap_debug(
r#"<?php
class A { public array $x = [[1], [2]]; }
$t = 0;
for ($i = 0; $i < 300; $i++) {
$a = new A();
$inner = $a->x[0];
unset($a);
$t += $inner[0] + count($inner);
unset($inner);
}
echo $t;
"#,
);
assert_clean(out, "600");
}
Loading