Когда-то давно (но уже после того, как я принял решение стать дотнетчиком) я случайно наткнулся на хабре на перевод статьи «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.
Глубоко копнул. Интересно. Как насчёт баг-репорт сабмитнуть?
НравитсяНравится
Если честно, я не уверен даже, что это баг.
Весь язык же такой: если есть выбор — упасть с ошибкой или сделать непонятно что, PHP сделает непонятно что. Broken by design.
Что же касается конкретно foreach — он работает практически на чём угодно. К примеру, для экземпляров класса, не реализующих никаких тематических интерфейсов, он перечислит открытые члены этого класса.
НравитсяНравится
Я про документацию. Данная штука явно заслуживает хотя-бы note.
НравитсяНравится
Ну вообще можно попробовать. Я, правда, не делал этого никогда. Есть пример, который я мог бы взять за основу?
И, кстати. У тебя в myopenid персоналии не настроены, или это у меня логинзу глючит? Я проверял его, вроде.
НравитсяНравится
Можно любую страницу с документацией глянуть, там где note имеется. Например, вот на этой странице: http://php.net/manual/en/function.unpack.php
Логинзу не глючит.
НравитсяНравится
Хотя не… глючит 🙂
НравитсяНравится
Занятно. Есть reproduce steps?
НравитсяНравится
Ага, по сути, основное отличие в том, что в шарпе foreach не дает работать по готовому енумератору. Только по тому, кто выдает енумератор. Пусть и можно обмануть, но только нарочно, а не случайно.
НравитсяНравится
И, что гораздо хуже, в документации вместо «осторожно, вы можете выстрелить себе в ногу!» написано «снимите пистолет с предохранителя, взведите курок и осторожно разместите оружие в поясной кобуре». И только чуть ниже «также вы можете носить пистолет, поставив его на предохранитель».
Александр прав, в документации следовало бы хотя бы упомянуть что-то вроде «Внимание! Если вы выберете первый вариант, то весьма вероятно, что рано или поздно пистолет самопроизвольно выстрелит, и, скорее всего, прострелит вам ногу»
То есть, я что хочу сказать. В самом языке — полбеды (тем более, что он весь такой). А беда — в документации, которая провоцирует писать опасный код ещё хлеще самого языка.
НравитсяНравится