Чем лучше я узнаю PHP, тем больше я люблю C#. Издание второе, переработанное и дополненное.

Когда-то давно (но уже после того, как я принял решение стать дотнетчиком) я случайно наткнулся на хабре на перевод статьи «PHP: a fractal of bad design», и лишь тогда осознал всю глубину той бездны, из которой намеревался выбраться. Пока я работал с PHP, мне каким-то образом (чудом, не иначе) удавалось избежать большего количества мин и граблей. Но недавно мне довелось ещё раз убедиться, что я принял верное решение.

Взгляните на условия данной задачки:

class Foo implements Iterator, ArrayAccess, Countable {…}

$foo = new Foo(); 
$i = 0; 
// В $foo кладётся 10 элементов; по сути, Foo — обёртка над массивом 
foreach ( $foo as $x ) foreach ( $foo as $y ) $i++; 

echo $i; //чему равно $i ?

В самом деле, чему же оно равно? Иными словами, сколько раз будет выполнено тело внутреннего цикла foreach?

Любой здравомыслящий человек, не ожидающий никаких подвохов, уверенно (ну, или не очень 😉 даст ответ 100. Логично и ожидаемо, правда? Когда этот вопрос задали мне, я, помня о некоторых особенностях поведения PHP, уверенно ответил, что не возьмусь предсказывать результат, руководствуясь лишь логикой и здравым смыслом. Я догадывался, что ответ может быть равен 0, 1, 10, 50, 100… В зависимости от того, кто и в какую часть тела укусил дизайнера языка накануне. Я не ошибся в своих ожиданиях: результат оказался равен 10, в то время как вложенные итерации по собственно массиву дали ожидаемые 100. Почему?

Чтобы ответить на этот в высшей степени логичный вопрос, попробуем разобраться,  как именно foreach в PHP работает с классами, реализующими интерфейс Iterator. Для этого скопипастим, чуток подправим и запустим пример из официальной документации PHP по итераторам.

class MyIterator implements Iterator
{
    private $var = array();

    public function __construct($array)
    {
        if (is_array($array)) {
            $this->var = $array;
        }
    }

    public function rewind()
    {
        echo "rewind() Перемотка в началоn";
        reset($this->var);
    }

    public function current()
    {
        $var = current($this->var);
        echo "current() Запрос текущего: $varn";
        return $var;
    }

    public function key() 
    {
        $var = key($this->var);
        echo "key() Получение ключа: $varn";
        return $var;
    }

    public function next() 
    {
        $var = next($this->var);
        echo "next() Следующий: $varn";
        return $var;
    }

    public function valid()
    {
        $key = key($this->var);
        $var = ($key !== NULL && $key !== FALSE);
        echo "valid() Валидация: $varn";
        return $var;
    }

}

$values = array(10,50,100);
$it = new MyIterator($values);
$counter = 0;

foreach ($it as $a => $b) {
    foreach($it as $k => $v) {
        print "Текущие значения: внешний $a → $b, внутренний $k → $vn";
        $counter++;
    }
}

echo "Итого: $counter";

Там, кстати, из зала подсказывают, что всё не так просто и вообще, ахтунг минен. Ну, посмотрим: запустим это дело, учитывая, что элементов не 10, а 3 для краткости. Получим:

rewind() Перемотка в начало
valid() Валидация: 1
current() Запрос текущего: 10
key() Получение ключа: 0
rewind() Перемотка в начало
valid() Валидация: 1
current() Запрос текущего: 10
key() Получение ключа: 0
Текущие значения: внешний 0 → 10, внутренний 0 → 10
next() Следующий: 50
valid() Валидация: 1
current() Запрос текущего: 50
key() Получение ключа: 1
Текущие значения: внешний 0 → 10, внутренний 1 → 50
next() Следующий: 100
valid() Валидация: 1
current() Запрос текущего: 100
key() Получение ключа: 2
Текущие значения: внешний 0 → 10, внутренний 2 → 100
next() Следующий: 
valid() Валидация: 
next() Следующий: 
valid() Валидация: 
Итого: 3

Упс. Итератор нереентерабелен. Что ж, это всё объясняет, кроме одного: с массивами-то почему всё в порядке? Заглянем в исходники:

switch (zend_iterator_unwrap(array, &iter TSRMLS_CC)) {
	default:
	case ZEND_ITER_INVALID:
		zend_error(E_WARNING, "Invalid argument supplied for foreach()");
		ZEND_VM_JMP(EX(op_array)->opcodes+opline->op2.u.opline_num);

	case ZEND_ITER_PLAIN_OBJECT: {
		char *class_name, *prop_name;
		zend_object *zobj = zend_objects_get_address(array TSRMLS_CC);

		fe_ht = HASH_OF(array);
		zend_hash_set_pointer(fe_ht, &EX_T(opline->op1.u.var).fe.fe_pos);
		do {
			if (zend_hash_get_current_data(fe_ht, (void **) &value)==FAILURE) {
				/* reached end of iteration */
				ZEND_VM_JMP(EX(op_array)->opcodes+opline->op2.u.opline_num);
			}
			key_type = zend_hash_get_current_key_ex(fe_ht, &str_key, &str_key_len, &int_key, 0, NULL);

			zend_hash_move_forward(fe_ht);
		} while (key_type == HASH_KEY_NON_EXISTANT ||
		         (key_type != HASH_KEY_IS_LONG &&
		          zend_check_property_access(zobj, str_key, str_key_len-1 TSRMLS_CC) != SUCCESS));
		zend_hash_get_pointer(fe_ht, &EX_T(opline->op1.u.var).fe.fe_pos);
		if (use_key && key_type != HASH_KEY_IS_LONG) {
			zend_unmangle_property_name(str_key, str_key_len-1, &class_name, &prop_name);
			str_key_len = strlen(prop_name);
			str_key = estrndup(prop_name, str_key_len);
			str_key_len++;
		}
		break;
	}

	case ZEND_ITER_PLAIN_ARRAY:
		fe_ht = HASH_OF(array);
		zend_hash_set_pointer(fe_ht, &EX_T(opline->op1.u.var).fe.fe_pos);
		if (zend_hash_get_current_data(fe_ht, (void **) &value)==FAILURE) {
			/* reached end of iteration */
			ZEND_VM_JMP(EX(op_array)->opcodes+opline->op2.u.opline_num);
		}
		if (use_key) {
			key_type = zend_hash_get_current_key_ex(fe_ht, &str_key, &str_key_len, &int_key, 1, NULL);
		}
		zend_hash_move_forward(fe_ht);
		zend_hash_get_pointer(fe_ht, &EX_T(opline->op1.u.var).fe.fe_pos);
		break;
	case ZEND_ITER_OBJECT:
		/* !iter happens from exception */
		if (iter && ++iter->index > 0) {
			/* This could cause an endless loop if index becomes zero again.
			 * In case that ever happens we need an additional flag. */
			iter->funcs->move_forward(iter TSRMLS_CC);
			if (EG(exception)) {
				Z_DELREF_P(array);
				zval_ptr_dtor(&array);
				ZEND_VM_NEXT_OPCODE();
			}
		}
		/* If index is zero we come from FE_RESET and checked valid() already. */
		if (!iter || (iter->index > 0 && iter->funcs->valid(iter TSRMLS_CC) == FAILURE)) {
			/* reached end of iteration */
			if (EG(exception)) {
				Z_DELREF_P(array);
				zval_ptr_dtor(&array);
				ZEND_VM_NEXT_OPCODE();
			}
			ZEND_VM_JMP(EX(op_array)->opcodes+opline->op2.u.opline_num);
		}
		iter->funcs->get_current_data(iter, &value TSRMLS_CC);
		if (EG(exception)) {
			Z_DELREF_P(array);
			zval_ptr_dtor(&array);
			ZEND_VM_NEXT_OPCODE();
		}
		if (!value) {
			/* failure in get_current_data */
			ZEND_VM_JMP(EX(op_array)->opcodes+opline->op2.u.opline_num);
		}
		if (use_key) {
			if (iter->funcs->get_current_key) {
				key_type = iter->funcs->get_current_key(iter, &str_key, &str_key_len, &int_key TSRMLS_CC);
				if (EG(exception)) {
					Z_DELREF_P(array);
					zval_ptr_dtor(&array);
					ZEND_VM_NEXT_OPCODE();
				}
			} else {
				key_type = HASH_KEY_IS_LONG;
				int_key = iter->index;
			}
		}
		break;
}

То есть реализации процесса итерации для разных типов коллекций как бы разные. Ну, наверное, у них были причины так сделать.

Как быть? Ну, та же документация подсказывает, что есть ещё такая штука, как интерфейс IteratorAggregate, и единственная обязанность реализовавшего его класса — возвращать итератор функцией getIterator():

class MyCollection implements IteratorAggregate
{
    private $var = array();

    public function __construct($array)
    {
        if (is_array($array)) {
            $this->var = $array;
        }
    }

    public function getIterator() {
        return new MyIterator($this->var);
    }
}

$values = array(10,50,100);
$it = new MyCollection($values);

$counter = 0;

foreach ($it as $a => $b) {
    foreach($it as $k => $v) {
        print "Текущие значения: внешний $a → $b, внутренний $k → $vn";
        $counter++;
    }
}

И в этом случае всё в порядке:
[spoiler name=»Результат»]

rewind() Перемотка в начало
valid() Валидация: 1
current() Запрос текущего: 10
key() Получение ключа: 0
rewind() Перемотка в начало
valid() Валидация: 1
current() Запрос текущего: 10
key() Получение ключа: 0
Текущие значения: внешний 0 → 10, внутренний 0 → 10
next() Следующий: 50
valid() Валидация: 1
current() Запрос текущего: 50
key() Получение ключа: 1
Текущие значения: внешний 0 → 10, внутренний 1 → 50
next() Следующий: 100
valid() Валидация: 1
current() Запрос текущего: 100
key() Получение ключа: 2
Текущие значения: внешний 0 → 10, внутренний 2 → 100
next() Следующий: 
valid() Валидация: 
next() Следующий: 50
valid() Валидация: 1
current() Запрос текущего: 50
key() Получение ключа: 1
rewind() Перемотка в начало
valid() Валидация: 1
current() Запрос текущего: 10
key() Получение ключа: 0
Текущие значения: внешний 1 → 50, внутренний 0 → 10
next() Следующий: 50
valid() Валидация: 1
current() Запрос текущего: 50
Итого: 9
key() Получение ключа: 1
Текущие значения: внешний 1 → 50, внутренний 1 → 50
next() Следующий: 100
valid() Валидация: 1
current() Запрос текущего: 100
key() Получение ключа: 2
Текущие значения: внешний 1 → 50, внутренний 2 → 100
next() Следующий: 
valid() Валидация: 
next() Следующий: 100
valid() Валидация: 1
current() Запрос текущего: 100
key() Получение ключа: 2
rewind() Перемотка в начало
valid() Валидация: 1
current() Запрос текущего: 10
key() Получение ключа: 0
Текущие значения: внешний 2 → 100, внутренний 0 → 10
next() Следующий: 50
valid() Валидация: 1
current() Запрос текущего: 50
key() Получение ключа: 1
Текущие значения: внешний 2 → 100, внутренний 1 → 50
next() Следующий: 100
valid() Валидация: 1
current() Запрос текущего: 100
key() Получение ключа: 2
Текущие значения: внешний 2 → 100, внутренний 2 → 100
next() Следующий: 
valid() Валидация: 
next() Следующий: 
valid() Валидация: 
Итого: 9

[/spoiler]

Теперь попробуем сделать то же самое в C# и посмотреть, что из этого получится. Перепишем наш первый пример на C#:

    class Program
    {
        internal class MyIterator : IEnumerator
        {
            private int[] _collection;
            private int _curIndex;
            private int _curValue;

            public MyIterator(int[] values)
            {
                _collection = values;
                _curIndex = -1;
                _curValue = default(int);
            }

            public bool MoveNext()
            {
                Console.WriteLine("MoveNext()");
                if (++_curIndex >= _collection.Length)
                {
                    return false;
                }
                _curValue = _collection[_curIndex];
                return true;
            }

            public void Reset()
            {
                Console.WriteLine("Reset()");
                _curIndex = -1;
            }

            public int Current { get { Console.WriteLine("Current get"); return _curValue; } }

            object IEnumerator.Current { get { return Current; } }

            void IDisposable.Dispose() { }
        }

        static void Main(string[] args)
        {
            int counter = 0;
            int[] data = new int[3] { 10, 50, 100};
            MyIterator it = new MyIterator(data);
            foreach (int value in it)
            {
                foreach (int anotherValue in it)
                {
                    counter++;
                    Console.WriteLine("Внешнее значение {0}, внутреннее — {1}", value, anotherValue);
                }
            }
            Console.WriteLine("Итого: {0}", counter);
            Console.ReadKey();
        }
    }

Но… Нам не удастся запустить и даже откомпилировать программу: компилятор выдаст пару ошибок:

…Program.cs(53,13): error CS1579: foreach statement cannot operate on variables of type 'ConsoleApplication1.Program.MyIterator' because 'ConsoleApplication1.Program.MyIterator' does not contain a public definition for 'GetEnumerator'
…Program.cs(55,17): error CS1579: foreach statement cannot operate on variables of type 'ConsoleApplication1.Program.MyIterator' because 'ConsoleApplication1.Program.MyIterator' does not contain a public definition for 'GetEnumerator'

…и категорически откажется работать дальше.
Упс. Кажется, нам только что помешали выстрелить себе в ногу. Идём читать примеры из документации. Хм, там сразу предлагают сделать ещё один класс, реализующий GetEnumerator(), и использовать его для генерации итераторов. Как-то так:

            public int Current { get { Console.WriteLine("Current get"); return _curValue; } }

            object IEnumerator.Current { get { return Current; } }

            void IDisposable.Dispose() { }
        }

        internal class MyCollection : IEnumerable
        {
            private int[] _collection;

            public MyCollection(int[] values)
            {
                _collection = values;
            }

            public IEnumerator GetEnumerator()
            {
                return new MyIterator(_collection);
            }

            IEnumerator IEnumerable.GetEnumerator()
            {
                return (IEnumerator)GetEnumerator();
            }
        }

        static void Main(string[] args)
        {
            int counter = 0;
            int[] data = new int[] { 10, 50, 100};
            MyCollection it = new MyCollection(data);
            foreach (int value in it)
            {
                foreach (int anotherValue in it)
                {
                    counter++;
                    Console.WriteLine("Внешнее значение {0}, внутреннее — {1}", value, anotherValue);
                }
            }
            Console.WriteLine("Итого: {0}", counter);
            Console.ReadKey();
        }
    }

Компилируется, запускается, работает.
[spoiler name=»Результат»]

MoveNext()
Current get
MoveNext()
Current get
Внешнее значение 10, внутреннее - 10
MoveNext()
Current get
Внешнее значение 10, внутреннее - 50
MoveNext()
Current get
Внешнее значение 10, внутреннее - 100
MoveNext()
MoveNext()
Current get
MoveNext()
Current get
Внешнее значение 50, внутреннее - 10
MoveNext()
Current get
Внешнее значение 50, внутреннее - 50
MoveNext()
Current get
Внешнее значение 50, внутреннее - 100
MoveNext()
MoveNext()
Current get
MoveNext()
Current get
Внешнее значение 100, внутреннее - 10
MoveNext()
Current get
Внешнее значение 100, внутреннее - 50
MoveNext()
Current get
Внешнее значение 100, внутреннее - 100
MoveNext()
MoveNext()
Итого: 9

[/spoiler]
…причём, работает правильно.

А всё-таки, если очень-очень хочется, есть ли принципиальная возможность выстрелить себе в ногу? Оказывается, есть:

            public int Current { get { Console.WriteLine("Current get"); return _curValue; } }

            object IEnumerator.Current { get { return Current; } }

            void IDisposable.Dispose() { }

            // что-то типа утиной типизации, прикольно.
            public IEnumerator GetEnumerator()
            {
                return this;
            }
        }

        static void Main(string[] args)
        {
            int counter = 0;
            int[] data = new int[] { 10, 50, 100};
            MyIterator it = new MyIterator(data);
            foreach (int value in it)
            {
                foreach (int anotherValue in it)
                {
                    counter++;
                    Console.WriteLine("Внешнее значение {0}, внутреннее — {1}", value, anotherValue);
                }
            }
            Console.WriteLine("Итого: {0}", counter);
            Console.ReadKey();
        }
    }

То есть тут мы выкинули второй класс, а вместо него реализовали метод GetEnumerator, возвращающий ссылку на сам объект. Случайно и неосознанно этого, кажется, не сделаешь. Теперь обработка итераций завязана на поля единственного экземпляра класса, что делает перебор нереентерабельным:

MoveNext()
Current get
MoveNext()
Current get
Внешнее значение 10, внутреннее - 50
MoveNext()
Current get
Внешнее значение 10, внутреннее - 100
MoveNext()
MoveNext()
Итого: 2

Резюмируя вышеизложенное: и в том, и в другом языке есть техническая возможность как выстрелить себе в ногу, так и избежать этой ловушки. Но по какой-то неведомой причине язык PHP устроен таким образом, что провоцирует писать потенциально опасный код: в данном случае это возможность сделать foreach по объекту, реализующему Iterator (хотя разумнее было бы разрешить перечисление только для классов, реализующих IteratorAggregate); ситуация усугубляется тем, что в документации приведён пример того, как делать не надо, к тому же до примера того, как делать надо. В самой документации нет ни слова о реентерабельности перечислений, никаких предупреждений, ничего. Хоть бы написали что-то вроде «Вы знаете, что будет больно и, скорее всего, вы всю жизнь будете хромать?», что ли…
И это лишь одна из тех вещей, за которые я не люблю PHP.

9 thoughts on “Чем лучше я узнаю PHP, тем больше я люблю C#. Издание второе, переработанное и дополненное.

  1. Ага, по сути, основное отличие в том, что в шарпе foreach не дает работать по готовому енумератору. Только по тому, кто выдает енумератор. Пусть и можно обмануть, но только нарочно, а не случайно.

    Нравится

    • И, что гораздо хуже, в документации вместо «осторожно, вы можете выстрелить себе в ногу!» написано «снимите пистолет с предохранителя, взведите курок и осторожно разместите оружие в поясной кобуре». И только чуть ниже «также вы можете носить пистолет, поставив его на предохранитель».
      Александр прав, в документации следовало бы хотя бы упомянуть что-то вроде «Внимание! Если вы выберете первый вариант, то весьма вероятно, что рано или поздно пистолет самопроизвольно выстрелит, и, скорее всего, прострелит вам ногу»

      То есть, я что хочу сказать. В самом языке — полбеды (тем более, что он весь такой). А беда — в документации, которая провоцирует писать опасный код ещё хлеще самого языка.

      Нравится

Добавить комментарий

Заполните поля или щелкните по значку, чтобы оставить свой комментарий:

Логотип WordPress.com

Для комментария используется ваша учётная запись WordPress.com. Выход /  Изменить )

Google photo

Для комментария используется ваша учётная запись Google. Выход /  Изменить )

Фотография Twitter

Для комментария используется ваша учётная запись Twitter. Выход /  Изменить )

Фотография Facebook

Для комментария используется ваша учётная запись Facebook. Выход /  Изменить )

Connecting to %s