Duplicate array keys (Notice: member variable "a" returned from __sleep() multiple times)
Asked Answered
R

3

8

The title may seem a bit silly but I'm totally serious with this. Today at work I came across a weird PHP behaviour which I could not explain. Luckily this behaviour is fixed in PHP 7.4, so it seems that someone stumbled upon that, too.

I made a small example to illustrate what went wrong:

<?php

class A {
    private $a = 'This is $a from A';

    public $b = 'This is $b from A';

    public function __sleep(): array
    {
        var_dump(array_keys(get_object_vars($this)));

        return [];
    }
}

class B extends A
{
    public $a = 'This is $a from B';
}

$b = new B;

serialize($b);

Run this code here: https://3v4l.org/DBt3o

Heres a bit explanation of what is going on here. We have to classes A and B which both share a property $a. Careful readers noticed, that the property $a has two different visibilities (public, private). Nothing fancy so far. The magic happens in the __sleep method which gets magically called when we serialize our instance. We want to have all object variables which we get with get_object_vars reduce this to only the keys with array_keys and output everything with var_dump.

I would expect something like this (this happens since PHP 7.4 and is my expected output):

array(2) {
  [0]=>
  string(1) "b"
  [1]=>
  string(1) "a"
}

But what I get is this:

array(3) {
  [0]=>
  string(1) "a"
  [1]=>
  string(1) "b"
  [2]=>
  string(1) "a"
}

How could it be, that PHP delivers an array with two completely identical keys? Who is able to explain what happens here internally because in plain PHP I'm not able to generate an array with two completely identical keys? Or do I miss something obvious here?

My coworkers did not want to believe me at first but none of them had a good explanation of this after they understood what is happening here.

I really would love to see a good explanation.

Ruction answered 5/11, 2019 at 19:5 Comment(2)
It's interesting if you change the line to var_dump(array_keys((array)$this));Dysentery
I gave an answer but have since removed it because I now think that given this excerpt from the PHP manual "Gets the accessible non-static properties of the given object according to scope. " this is a straightforward bug. I say this because the private ancestor property $a is not "accessible" to B. I assumed that this result may be because you refer to the $this in A::__sleep and it was thus showing the full scope of all, however having moved it to B::__sleep, the behaviour remains identical.Addlepated
R
6

I couldn't find a report for the bug in the question but interestingly it seems this commit addresses the same thing:

If we are in a scope where the shadowed private property is visible, the shadowing public property should not be visible.

The test code is well-written, with a simple change we could have it here:

class Test
{
    private $prop = "Test";

    function run()
    {
        return get_object_vars($this);
    }
}

class Test2 extends Test
{
    public $prop = "Test2";
}

$props = (new Test2)->run();

Calling var_dump() on $props shows:

array(2) {
  ["prop"]=>
  string(5) "Test2"
  ["prop"]=>
  string(4) "Test"
}

Back to your question:

How could it be, that PHP delivers an array with two completely identical keys? Who is able to explain what happens here internally because in plain PHP I'm not able to generate an array with two completely identical keys?

Yes, you are not able to have an array with two identical keys:

var_dump(array_flip(array_flip($props)));

results in:

array(1) {
  ["prop"]=>
  string(4) "Test"
}

but let me not agree with you on two completely identical keys as these two elements with identical key names aren't stored with identical keys internally within a hashtable. That is, those are stored as unique integers except on potential collisions and as this has been occurring internally the restriction on user inputs was ignored.

Radarscope answered 8/11, 2019 at 0:1 Comment(0)
S
3

After messing with this a bit, it looks like this doesn't depend on __sleep().

Apparently this was always the case in earlier versions of PHP 7 (but apparently not in PHP 5). This smaller example shows the same behavior.

class A {
    private $a = 'This is $a from A';

    public function showProperties() { return get_object_vars($this); }
}

class B extends A
{
    public $a = 'This is $a from B';
}

$b = new B;
var_dump($b->showProperties());

Output from PHP 7.0 - 7.3

array(2) {
  ["a"]=>
  string(17) "This is $a from B"
  ["a"]=>
  string(17) "This is $a from A"
}

I think the private $a in the parent is a different property than the public $a in the child. When you change the visibility in B you're not changing the visibility of the $a in A, you're really making a new property with the same name. If you var_dump the object itself you can see both properties.

It shouldn't have much effect though, since you wouldn't be able to access the private property from the parent class in the child class, even though you can see it exists in those earlier PHP 7 versions.

Subtangent answered 5/11, 2019 at 19:44 Comment(4)
Shouldn't be possible that associative array (hash table) is in this state. Accessible is only one of them, yet the size is 2.Infield
@Infield I agree. It's really weird looking.Shaw
Also, accessing index a returns the second one This is $a from A.Preceding
@Preceding I noticed that too. That part makes sense I suppose, since you'll end up with the last value when you write an array literal with duplicate keys.Shaw
I
0

My couple cents.

I don't know about coworkers, but I did not believe and thought this is a joke.

For the explanation - definitely the issue is under "get_object_vars" variable since it's returning duplicated associative array. Should be two different hash table values for the same key (which is not possible, but the only explanation comes). I was not able to find any links to internal get_object_vars() implementation (even though PHP is based on open source so it's possible to get code and debug somehow). Also I am thinking (unsuccessfully so far) on the way to see array representation in memory including hash table. On the other hand, I was able to use PHP "legal" functions and do some tricks with array.

This is my attempt to test some functionality with that associative array. Below is the output. No explanation required - you can see everything and try the same code yourself, so only some comments.

  1. My environment is php 7.2.12 x86 (32 bit) - well... yeah, shame on me

  2. I get rid of "magic" and serialization, left only stuff bringing issue.

  3. Completed some refactoring on classes A and B as well as function call.

  4. The $key under class A must be private, otherwise no miracle.

  5. Part testing vars - nothing interesting except for the main issue.

  6. Part testing copy_vars - array was copied with duplicate!! New key was added successfully.

  7. Part testing iteration and new_vars - iteration went through duplicate without a problem, but new array did not accept duplicate, last key accepted.

  8. Testing replacement - replacement completed on second key, duplicate stay.

  9. Testing ksort - array did not change, duplicate was not recognized

  10. Testing asort - after changing values and running asort, I was able to change the order and swap duplicate keys. Now first key becomes second and the new key is the one when we call array by key or assign a key. As a result I was able to change both keys!! Before I thought that duplicate key is kind of invisible one, now it's clear that the last key works when we reference or assign the key.

  11. Conversion to stdClass object - no way! Only last key accepted!

  12. Testing for unset - good work! Last key removed, but the first key is in charge and the only one key left, no duplicates.

  13. Internal representation test - this is a subject to add some other functions and see the source of duplication. I am thinking on that now.

The result output is below the code.

<?php

class A {
    private $key = 'This is $a from A';

    protected function funcA() {
        $vars = get_object_vars($this);

        return $vars;
    }
}

class B extends A
{
    public $key = 'This is $a from B';

    public function funcB() {
        return $this->funcA();
    }
}

$b = new B();

$vars = $b->funcB();

echo "testing vars:\n\n\n";

var_dump($vars);
var_dump($vars['key']);
var_dump(array_keys($vars));

echo "\n\n\ntesting copy_vars:\n\n\n";

$copy_vars = $vars;
$copy_vars['new_key'] = 'this is a new key';

var_dump($vars);
var_dump($copy_vars);

echo "\n\n\ntesting iteration and new_vars:\n\n\n";

$new_vars = [];
foreach($vars as $key => $val) {
    echo "adding '$key', '$val'\n";
    $new_vars[$key] = $val;
}

var_dump($new_vars);

echo "\n\n\ntesting replace key (for copy):\n\n\n";

var_dump($copy_vars);
$copy_vars['key'] = 'new key';
var_dump($copy_vars);

echo "\n\n\ntesting key sort (for copy):\n\n\n";

var_dump($copy_vars);
ksort($copy_vars);
var_dump($copy_vars);

echo "\n\n\ntesting asort (for copy):\n\n\n";

$copy_vars['key'] = "A - first";
var_dump($copy_vars);
asort($copy_vars);
var_dump($copy_vars);
$copy_vars['key'] = "Z - last";
var_dump($copy_vars);

echo "\n\n\ntesting object conversion (for copy):\n\n\n";

var_dump($copy_vars);
$object = json_decode(json_encode($copy_vars), FALSE);
var_dump($object);


echo "\n\n\ntesting unset (for copy):\n\n\n";

var_dump($copy_vars);
unset($copy_vars['key']);
var_dump($copy_vars);


echo "\n\n\ntesting inernal representation:\n\n\n";

debug_zval_dump($vars);

Output now:

testing vars:


array(2) {
  ["key"]=>
  string(17) "This is $a from B"
  ["key"]=>
  string(17) "This is $a from A"
}
string(17) "This is $a from A"
array(2) {
  [0]=>
  string(3) "key"
  [1]=>
  string(3) "key"
}



testing copy_vars:


array(2) {
  ["key"]=>
  string(17) "This is $a from B"
  ["key"]=>
  string(17) "This is $a from A"
}
array(3) {
  ["key"]=>
  string(17) "This is $a from B"
  ["key"]=>
  string(17) "This is $a from A"
  ["new_key"]=>
  string(17) "this is a new key"
}



testing iteration and new_vars:


adding 'key', 'This is $a from B'
adding 'key', 'This is $a from A'
array(1) {
  ["key"]=>
  string(17) "This is $a from A"
}



testing replace key (for copy):


array(3) {
  ["key"]=>
  string(17) "This is $a from B"
  ["key"]=>
  string(17) "This is $a from A"
  ["new_key"]=>
  string(17) "this is a new key"
}
array(3) {
  ["key"]=>
  string(17) "This is $a from B"
  ["key"]=>
  string(7) "new key"
  ["new_key"]=>
  string(17) "this is a new key"
}



testing key sort (for copy):


array(3) {
  ["key"]=>
  string(17) "This is $a from B"
  ["key"]=>
  string(7) "new key"
  ["new_key"]=>
  string(17) "this is a new key"
}
array(3) {
  ["key"]=>
  string(17) "This is $a from B"
  ["key"]=>
  string(7) "new key"
  ["new_key"]=>
  string(17) "this is a new key"
}



testing asort (for copy):


array(3) {
  ["key"]=>
  string(17) "This is $a from B"
  ["key"]=>
  string(9) "A - first"
  ["new_key"]=>
  string(17) "this is a new key"
}
array(3) {
  ["key"]=>
  string(9) "A - first"
  ["key"]=>
  string(17) "This is $a from B"
  ["new_key"]=>
  string(17) "this is a new key"
}
array(3) {
  ["key"]=>
  string(9) "A - first"
  ["key"]=>
  string(8) "Z - last"
  ["new_key"]=>
  string(17) "this is a new key"
}



testing object conversion (for copy):


array(3) {
  ["key"]=>
  string(9) "A - first"
  ["key"]=>
  string(8) "Z - last"
  ["new_key"]=>
  string(17) "this is a new key"
}
object(stdClass)#2 (2) {
  ["key"]=>
  string(8) "Z - last"
  ["new_key"]=>
  string(17) "this is a new key"
}



testing unset (for copy):


array(3) {
  ["key"]=>
  string(9) "A - first"
  ["key"]=>
  string(8) "Z - last"
  ["new_key"]=>
  string(17) "this is a new key"
}
array(2) {
  ["key"]=>
  string(9) "A - first"
  ["new_key"]=>
  string(17) "this is a new key"
}



testing inernal representation:


array(2) refcount(2){
  ["key"]=>
  string(17) "This is $a from B" refcount(2)
  ["key"]=>
  string(17) "This is $a from A" refcount(4)
}
Inkwell answered 8/11, 2019 at 8:49 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.