ИНФОРМАТИКА И ИНФОРМАЦИОННЫЕ ТЕХНОЛОГИИ В СРЕДНЕЙ И ВЫСШЕЙ ШКОЛЕ
УДК 004 (07) ББК Ч426.32
К.Ю. Поляков, Е.А. Еремин
ОБ ОСОБЕННОСТЯХ ФОРМИРОВАНИЯ ПОНЯТИЯ ПЕРЕМЕННОЙ В ШКОЛЬНОМ КУРСЕ ИНФОРМАТИКИ
Современные языки программирования становятся все сложнее, что неизбежно ведет к появлению методических трудностей в их преподавании. В статье рассмотрены вопросы, связанные с формированием у учеников фундаментального понятия переменной. На примере сравнения языков Паскаль и Питон показано, что классических представлений, принятых в Паскале, оказывается недостаточно при изучении переменных в более позднем языке Питон. Подробно описаны механизмы работы с переменными в обоих языках программирования; выделены наиболее важные идеи, о которых необходимо рассказывать при изучении курса информатики в школе.
Ключевые слова: переменная, программирование, адрес, память, указатель, обучение, информатика, методика преподавания.
1. Введение
Переменная - это одно из важнейших понятий при изучении любого языка программирования. На первый взгляд может показаться, что оно простое и не нуждается в разработке какой -то специальной методики изложения. Однако внимательный анализ опыта преподавания показывает, что для студентов, сталкивающихся с переменными во вводных курсах программирования, формирование эффективных представлений о переменных оказывается весьма сложной задачей. В частности, как следует из описанных в [13] экспериментов, только 40-50 % от общего числа студентов при выполнении анализа коротких фрагментов программ демонстрируют полностью правильное понимание работы с переменными.
В известной статье [9], посвященной анализу трудностей в изучении основ программирования, описан ряд заблуждений, которые могут возникать у учеников в случае непродуманного изложения материала. В частности, негативные эффекты часто являются следствием чрезмерного увлечения примитивными бытовыми аналогиями без глубоких объяснений. Когда ученикам представляют переменную в виде коробочки или выдвижного ящичка, где снаружи наклеена этикетка с именем, есть опасность, что их живое воображение сформирует неправильный вывод о сохранении в переменной полного списка всех когда-либо присвоенных ей значений или приведет к ложному убеждению о возможности одновременного сохранения в таком контейнере нескольких значений переменной. Более того, образ ящичка у некоторых учеников неожиданным образом формирует уверенность, что если в переменную еще не помещено никакое значение,
© Поляков К.Ю., Еремин Е.А., 2019
то оно равно нулю, поскольку пустая емкость воспринимается как разновидность нулевого значения.
В то же время обходиться в обучении совсем без моделей абстрактных понятий тоже не лучший вариант. Даже если учитель не предложил на уроке никакой модели, она все равно стихийно формируется в сознании учащихся [9]. Пока все идет хорошо, это не имеет особого значения. Но при анализе возникших ошибок некорректность внутренней ментальной модели часто проявляет себя. Сошлемся здесь также на мнение М. Бен-Ари, который, проанализировав роль ментальных моделей в усвоении учебного материала, пришел к выводу, что при объяснении абстрактных понятий обязательно требуется сформулировать подходящую модель на один уровень ниже, чем вы учите сейчас [7]. Применительно к нашему случаю это означает, что модель устройства переменной при объяснении материала необходима.
Значение рассматриваемой проблемы в настоящее время существенно возрастает, поскольку типы и структура переменных становятся все более сложными, и, следовательно, правильно их себе представить становится все труднее. В частности, для переменных, которые являются объектами, возникают дополнительные специфические проблемы при усвоении материала [12].
Можно привести и еще один факт, подтверждающий потребность в совершенствовании методики формирования рассматриваемого понятия. Для облегчения усвоения применения переменных финские преподаватели предложили показывать новичкам все основные роли переменных, которые встречаются в программах [15]. Оказалось, что для описания почти 99 % случаев использования переменных в классическом языке вроде Паскаля достаточно всего десяти ролей. В то же время для более современного языка Питон список ролей пришлось усовершенствовать [14], что подтверждает изменение концепций использования переменных в новых языках.
В данной статье предлагается вводить понятие переменной на базе некоторых наиболее важных сведений о реальном размещении данных в памяти компьютера. Сначала приводятся подробные сведения по данной теме для того, чтобы сформировать у учителя некоторую целостную картину по данному вопросу. Затем отбираются наиболее важные идеи, и строится упрощенное описание для учеников. В завершение на примерах показано, как предложенная модель позволяет лучше понять те или иные особенности использования переменных в современных языках программирования, таких как, например, Питон.
Реализация переменных в разных языках программирования может иметь свои особенности. Авторы не ставили себе целью написать профессиональный обзор устройства трансляторов, а попытались отобрать и сформулировать наиболее важные и типичные сведения о хранении переменных на примере двух языков: классического Паскаля и современного языка Питон (Python, по-английски правильнее читать «Пайтон»). Надеемся, что понимание описанных принципов будет полезно и при изучении других языков.
2. Хранение данных в памяти компьютера
2.1. Устройство памяти
После появления в 1946 г. «пионерской» работы по архитектуре вычислительных устройств [8], ставшей впоследствии классической, память всех машин строилась и сейчас продолжает строиться по единому принципу, который часто называют принципом адресности. Суть
его заключается в том, что память состоит из отдельных ячеек, каждая из которых имеет свой уникальный порядковый номер, называемый адресом1. Идея оказалась настолько удачной, что никакие последующие революционные изменения технологий производства компьютеров до сих пор не сумели вызвать к жизни что-либо лучшее.
Единственное усовершенствование принципа адресности коснулось размера ячеек памяти. Первоначально компьютеры (тогда их называли ЭВМ - электронно-вычислительные машины) использовались исключительно для вычислений, иначе говоря, обрабатывали только числа. Естественно, что ради точности вычислений ячейки под числа делались длинными - более 30 двоичных разрядов. Следующим видом обрабатываемых данных стали символы . Для них большие ячейки памяти оказались плохо подходящими: для кодирования одного символа в то время требовалось всего 7-8 бит, следовательно, в одну «числовую» ячейку входило не менее 4-5 символов. Поэтому, начиная с третьего поколения ЭВМ, размер одной ячейки памяти был выбран равным 8 двоичным разрядам. Байт, равный 8 битам, стал при этом аппаратно реализуемой ячейкой памяти, имеющей уникальный адрес, а все объемы информации начали измеряться в пропорциональных байту величинах.
После перехода к байтовому разделению памяти стало удобно хранить символы. Но то, что хорошо для символов, не совсем подходит для чисел: каждое из них теперь занимает несколько последовательно расположенных в памяти байтов. Чтобы ускорить чтение, инженерам пришлось научить процессор извлекать байты из памяти не только по одному, но и одновременно по несколько, начиная с заданного адреса.
Таким образом, при обращении к памяти компьютера, строго говоря, необходимо указать не только адрес нужных данных, но и количество байтов в них. При дальнейшем изложении, даже если ради упрощения рассуждений где-то будет умалчиваться о последней подробности, все равно будет подразумеваться необходимость чтения одного или нескольких байтов в зависимости от размера данных.
2.2. Об адресации данных в памяти
В программе, написанной для процессора, должна быть предусмотрена возможность как-то указывать, где находятся обрабатываемые каждой командой данные. Команды современного процессора могут включать в себя константы, содержимое ячеек ОЗУ, а также значения регистров (собственных ячеек памяти процессора). Вне зависимости от конкретных деталей, любые участвующие в операции данные принято называть операндами, а способы описания операндов в команде - методами адресации.
Методы адресации у процессоров довольно разнообразны (наглядным примером может служить четко организованная и гибкая система адресации компьютеров семейств PDP [3]). Картина дополнительно осложняется тем, что названия методов в разных изданиях не всегда одинаково переводятся на русский язык. К счастью, для целей данной статьи не требуется детально разбираться в особенностях адресации - достаточно четко понять всего один важный принцип, излагаемый далее.
1 Заметим, что в цитируемой нами работе использовалось труднопереводимое название memory location-number; термин адрес появился позднее.
2 Изначально символы предназначались для того, чтобы добавлять поясняющие подписи к напечатанным числам; до текстовых процессоров тогда было еще очень и очень далеко!
Существуют два разных пути указания местоположения операнда в команде [5]. Допустим, что нам требуется задать в операции сложения одно из слагаемых. Тогда, во-первых, можно с помощью любого существующего в процессоре метода адресации указать на само значение слагаемого. Но если слагаемое находится в ОЗУ, то есть и другой способ: задать в команде расположение не самого нужного нам значения, а его адреса. В последнем случае извлечение операнда будет происходить в два этапа: сначала прочитать двоичный код, который является адресом операнда, а затем, используя полученный адрес, извлечь из памяти непосредственное значение операнда. Такой усложненный механизм чтения данных называется косвенным. Его сложность компенсируется тем, что он дает программисту дополнительные возможности: поскольку здесь адрес не входит в команду, его легче изменять и можно создавать более гибкие структуры данных.
Заметим, что в принципе косвенный метод адресации может быть построен от любого сколь угодно сложного метода адресации. Например, если в команде задается константа, то при косвенной адресации будет указываться адрес этой константы в ОЗУ. Если обрабатываемая величина задается в регистре, то можно вместо этого записать ее в некоторый адрес ОЗУ, а сам адрес для организации косвенного обращения занести в регистр. Наконец, даже если данные уже и так лежат в некоторой ячейке ОЗУ, строго говоря, можно занести этот адрес в другую ячейку, а адрес последней включить в команду. Такую косвенность принято называть двойной: команда достает код из входящего в нее адреса ОЗУ, а затем с помощью этого нового адреса читаются данные (помните стихотворение про синицу, которая ворует пшеницу в доме, который построил Джек?)
Рассмотрим простейший пример. В процессорах семейства Intel среди прочих имеются регистры AX и BX. Пусть в рассматриваемый момент времени AX = 5, а BX = 400. Примем также, что в ячейке с адресом 400 сейчас хранится число 2. Тогда команда, записанная на языке ассемблера в виде ADD AX,BX, просуммирует содержимое регистров и получит ответ 405. Но если к тем же самым исходным данным применить команду ADD AX,[BX], то вычисления пойдут по-другому. Наличие скобок говорит о том, что адресация косвенная, следовательно, в BX находится не само число, а его адрес. Поэтому сначала из ячейки с адресом 400 будет прочитано лежащее там число 2, а затем в результате суммирования получится ответ 7.
Итак, в конструкцию процессора заложено два механизма извлечения данных из памяти.
1. В команде задано местоположение собственно данных, назовем этот вариант операнд-значение.
2. В команде задано местоположение адреса данных - операнд-адрес, т.е. косвенная адресация данных. На основе косвенной адресации построен особый тип переменных под названием указатели, о котором будет подробно рассказано далее.
2.3. Связь переменных с адресами памяти
Главная идея размещения переменных в памяти заключается в следующем. В тексте программы на языке высокого уровня программист дает всем величинам символические имена (в технической литературе их часто называют идентификаторами). В результате запись формул для вычислений приобретает, например, такой достаточно естественный для восприятия вид:
sred : = (a+b+c)/3.
Транслятор с языка высокого уровня разбирает текст программы и выделяет каждой переменной некоторую область памяти, связывая тем самым имя переменной с определенным адресом памяти. Установленное соответствие заносится в специальную таблицу (будем называть ее таблицей имен), пользуясь которой транслятор фактически заменяет любое встретившееся в программе имя обращением к соответствующим адресам ОЗУ. Заметим попутно, что в этой же таблице может храниться некоторая дополнительная информация о переменных, например, их тип.
Как известно, существуют трансляторы двух основных типов: компиляторы и интерпретаторы. Компилятор анализирует всю программу целиком и строит исполняемый файл - программу в машинных кодах. Интерпретатор же разбирает программу по частям, немедленно выполняя те действия, которые записаны в только что расшифрованной части программы. Существуют также некоторые смешанные схемы трансляции, когда текст программы преобразуется компилятором в программу на некотором промежуточном языке (например, в Java и Python такая программа называется байт-кодом), а затем полученная программа выполняется интерпретатором.
Таблица имен используется при любом способе трансляции. Принципиальное отличие состоит в том, что интерпретатор работает с ней постоянно, а компилятору после окончания трансляции таблица становится ненужной.
3. Виды переменных и их размещение в памяти
В современных языках программирования используются разные виды переменных. Уточним, что речь сейчас идет не о типах данных (числовых, символьных, логических и других переменных), а о способах размещения переменных в памяти компьютера. Чтобы в дальнейшем избежать путаницы, будем далее четко различать термины тип и вид переменной.
Понимание механизмов выделения памяти под переменные важно для преподавания, ибо оно позволит нам увидеть наиболее существенные черты хранения значений переменных и сформировать максимально простую модель, которую можно использовать при обучении школьников основам программирования.
Подчеркнем, что материал данного раздела статьи гораздо шире, чем требуется знать среднестатистическому школьнику. Подробное изложение предназначено для того, чтобы сформировать у учителя правильное представление о современных способах организации переменных в памяти, например, на случай вопросов учеников, серьезно интересующихся программированием. Упрощенная модель для рассказа на уроке будет описана в разделе 5.
3.1. Статические и динамические переменные
В классических языках программирования (вспомним для примера Паскаль) простейшие переменные представляют собой область памяти определенного размера: переменная каждого типа данных занимает фиксированное количество байтов. При выделении памяти под переменную транслятор запоминает адрес байта, соответствующего началу этой области ОЗУ. В дальнейшем, когда нужно прочитать или записать значение переменной, компьютер просто читает (записывает) нужное количество байтов с заданного адреса. Никакой информации об имени переменной, ее типе или размере в ОЗУ рядом со значением не хранится.
Такой способ выделения памяти под переменные принято называть статическим. Его характерные особенности таковы:
• переменные создаются транслятором в момент обработки их описаний;
• выделенная под переменную память не меняет своего назначения при работе программы (иначе говоря, переменные программы «жестко привязаны» к определенным ячейкам памяти);
• изменение значения переменной сводится к записи в отведенную область памяти нового значения (старое при этом стирается).
В ходе трансляции строится специальная таблица, в которой запоминаются имена всех переменных и их адреса. Пользуясь этой таблицей, транслятор производит замену каждого встретившегося в программе имени на соответствующий адрес ОЗУ. Если имя переменной в таблице отсутствует, это признак ошибки (переменная не была предварительно описана или имя было набрано неверно). Заметим, что после окончания компиляции таблица становится ненужной.
Для статических массивов известной размерности, состоящих из однотипных элементов, картина качественно не меняется: значения элементов массива, скажем, числа, размещаются в памяти друг за другом. Адрес начала нужного значения вычисляется по несложной формуле, смысл которой состоит в пропуске необходимого числа байтов ОЗУ, в которых лежат все предыдущие элементы массива.
Заметим, что строковые переменные не слишком хорошо сочетаются со статическим распределением памяти. Конечно, можно выделять под каждую такую переменную фиксированное число байтов (например, 256, как делали по умолчанию классические компиляторы Паскаля). Но это неудобно по двум причинам: во-первых, возникает неестественное ограничение на допустимую длину строки, а во-вторых, в случае коротких строк память расходуется неэкономно.
Итак, возникает желание иметь какой-то другой способ выделения памяти. Он существует и называется динамическим. Его особенности следующие:
• переменные создаются при выполнении программы (а не при трансляции, как было в статическом случае!);
• под значения динамических переменных отведена специальная область памяти (ее часто называют heap - куча); работающая программа находит в ней свободное место и резервирует его под значение создаваемой переменной;
• адрес выбранной области заносится в переменную; таким образом, в ней хранится не само значение (как в статическом случае), а его адрес в ОЗУ; саму переменную, в которой хранится адрес, принято называть указателем;
• если размер памяти, требуемой для хранения значения динамической переменной, не меняется, то новое значение, как вариант, может помещаться в те же самые ячейки ОЗУ;
• если же размер значения динамической переменной меняется (например, в строку помещают текст большей длины), то остается единственная возможность: сначала создать новое значение, а затем указатель заменить на адрес этого значения; память со старым значением автоматически будет освобождена3;
3 Свободные области памяти, разбросанные между используемыми значениями, порождают техническую проблему, решение которой часто называют «сборкой мусора» (garbage collection); во многих языках такая процедура реализована.
• подчеркнем, что хотя местоположение значения динамической переменной может меняться в процессе выполнения программы, положение самих переменных-указателей остается в ОЗУ неизменным.
Очевидно, что способ доступа к переменным через указатели сложнее, а значит, он работает медленнее. Но, с другой стороны, он более гибкий и позволяет обслуживать переменные, длина значений у которых в ходе программы изменяется.
В современных языках программирования, например, в Питоне, идея динамических переменных получает дальнейшее развитие. В частности, в этом языке большинство переменных (в том числе все простые!) реализуется динамически, что приводит к существованию у переменных некоторых принципиально новых свойств. Конкретные детали организации переменных в Питоне будут изложены в разделе 4.
3.2. Глобальные и локальные переменные
По мере увеличения объема программы ее обозримость ухудшается, что ведет к росту количества ошибок. Уже в классических языках программирования было предложено выносить фрагменты, выполняющие некоторое законченное действие, в особые самостоятельные блоки: процедуры или функции (будем в дальнейшем для краткости объединять обе этих конструкции термином подпрограммы). В современном программировании они используются очень широко. Дополнительно появилась возможность размещать наборы функций в отдельных файлах, называемых модулями.
Каждая процедура или функция, по сути, представляет собой небольшую автономную программу, получающую извне значения переменных-аргументов и возвращающую после их обработки значения переменных-результатов. Кроме того, как и в любой программе, в процедурах и функциях могут быть описаны собственные рабочие переменные.
Таким образом, возникает еще одно разделение переменных: на глобальные (доступные всем подпрограммам) и локальные (описанные в подпрограммах). Свойства этих двух видов переменных существенно отличаются. Глобальные переменные существуют в течение всего времени работы программы; их значения доступны (часто говорят «видны») из любой процедуры или функции. Локальные же переменные создаются в процессе работы подпрограммы, а при выходе из нее исчезают; очевидно, что вне процедуры ее переменные не видны.
В классических языках программирования локальные переменные организованы в памяти компьютера принципиально иначе, чем глобальные (реализация в Питоне требует отдельного обсуждения). Главная и не совсем очевидная причина появления этой разницы состоит в том, чтобы обеспечить возможность подпрограмме корректно вызывать другие подпрограммы и особенно саму себя. Последний случай в программировании настолько востребован, что даже имеет специальное название - рекурсия. Рекурсия - это тема для самостоятельной статьи. Здесь же для нас важно, что если локальную переменную «привязать» к определенному адресу, то повторный вызов этой процедуры будет невозможным. В самом деле, при такой организации памяти локальные переменные при вложенном вызове подпрограмм просто совпадут; в результате одна из них потеряет свои значения. Поэтому для локальных переменных (а также для аргументов и результатов) используется особая организация памяти, гарантирующая корректное выполнение вложенных подпрограмм. Такая организация называется стеком.
Итак, мы видим, что организация в памяти локальных переменных может иметь определенные особенности. В рамках дальнейшего обсуждения мы не будем их рассматривать.
3.3. Сложные типы данных
Для простых типов данных (например, числовых), когда требуемый размер памяти известен заранее, чтение значения переменной не требует никаких дополнительных данных. В этом случае достаточно указать адрес переменной в памяти и прочитать стандартное число байтов. Для более сложных типов понять картину труднее.
Рассмотрим в качестве примера тип STRING в классическом Паскале4. Его значением является строка символов, каждый из которых занимает один байт. Главная проблема заключается в том, что длина строки заранее неизвестна и компилятор языка программирования «не знает», сколько выделить памяти под строковую переменную. Классическое решение проблемы было таким: по умолчанию под каждую строковую переменную отводилось 256 байт. Немедленно возникал вопрос: какая часть строки реально занята. Например, если присвоить переменной значение «ДОМ», то из всей отведенной памяти занято будет всего 3 байта. Было принято правило, что реально используемая длина строки хранилась в нулевом байте отведенной памяти. Поэтому нумерация символов начиналась с единицы, а длина строки не могла превышать 255 байт. Для экономии памяти было разрешено создавать более короткие строки. Например, описание VAR s: STRING[5]; создавало в памяти строку из 5, а не из 255 символов.
Описанный пример показывает, что для сложных данных в памяти может храниться не только само значение, но и некоторые дополнительные сведения о нем, в частности, его размер.
Существуют и еще более сложные структуры данных. В частности, в Delphi и Visual BASIC имеется особый тип данных VARIANT, который может в разных участках программы помещать в одну и ту же переменную значения разного типа: например, сначала INTEGER, а затем STRING. В классическом Паскале такое вообще невозможно.
Рассмотрим кратко, как устроен это необычный тип данных в Delphi. Каждая переменная типа VARIANT состоит из 16 байт, причем первая их половина хранит служебную информацию, а вторая - значение переменной или ссылку на это значение. Самые первые 2 байта переменной содержат целое число, характеризующее тип значения, которое в данный момент лежит в переменной. Полный список возможных типов содержит около 20 разных значений, начиная с целых и вещественных чисел и кончая символьными строками и даже массивами. Например, для числа типа INTEGER характеристическое значение равняется 3, для вещественного числа типа DOUBLE - 5, а для STRING - 100i6 = 256i0.
Таким образом, внутри выделенной под переменную области ОЗУ кроме собственно значения (или ссылки на него) есть еще служебная информация, в том числе и о типе текущего значения. Благодаря такому устройству в переменную можно поместить значение любого допустимого типа. Но и это еще не все. Возможность изменять тип значения позволяет в нужных случаях делать это автоматически. Например, если в результате арифметического действия ответ перестает помещаться в 2-байтовое целое, можно перейти к 4-байтовому. А когда «ассорти-
4 В современных версиях Delphi строки реализованы динамически, поэтому там приведенное далее описание к типу STRING неприменимо; для совместимости с прежними версиями Паскаля предусмотрен тип SHORTSTRING, совпадающий с классическим.
мент» целых типов закончится, то вообще преобразовать результат в вещественное число. Предусмотрены и более неожиданные преобразования типов. Например, если V1 и V2 типа VARIANT имеют значения V1=12.5, V2='12', то V1+V2 будет равно 24.5, потому что строка '12' перед сложением будет автоматически преобразована в число.
Любопытно, что переменные типа VARIANT нельзя вводить с клавиатуры, поскольку компьютер не всегда способен однозначно определить, какого именно типа набранное значение (сравните со вводом в ячейки электронных таблиц).
Вершиной сложности структур данных на сегодняшний день является объект, в котором объединены не только данные, но и методы работы с ними (реализованные в виде процедур и функций). Рассмотрение принципов хранения объектов слишком далеко уведет нас от обсуждаемого материала, поэтому отметим только, что служебная информация о распределении памяти внутри объекта значительно больше и сложнее, чем для обычных переменных. Вся эта информация размещается вместе с данными в некотором выделенном под объект участке ОЗУ, а ссылка на этот участок выполняется с помощью адреса начала этой области.
4. Переменные в языке Питон
В Питоне при обсуждении переменных вводится новое понятие - изменяемое (англ. mutable) или неизменяемое (англ. immutable) значение. В первом случае переменная работает по классическому механизму, и новое значение записывается в ОЗУ на место старого. Так, например, устроен тип данных список, с помощью которого в Питоне моделируется массив. Во втором случае новое значение создается в свободной области памяти, а затем в указатель записывается новый адрес. Иными словами, хотя конкретное значение в памяти изменять нельзя, но зато можно переопределить ссылку на новое значение, хранимое в другом месте ОЗУ. Так работает большая часть типов в Питоне, в том числе все простые типы, в частности, числа и строки. В данном разделе мы будем обсуждать именно такие переменные.
Переменная, создаваемая динамически, может иметь любой тип. Чтобы при выполнении программы можно было определять, к какому типу принадлежит текущее значение переменной, информацию об этом помещают рядом со значением [6]. Таким образом, каждому значению переменной в памяти предшествует код ее типа, что позволяет правильно распознавать и обрабатывать это значение в программе.
Поскольку теперь значение переменной может иметь произвольный тип, предварительное объявление типов становится не только бесполезным, но даже невозможным: переменная в каждый момент работы программы имеет тот тип, который записан вместе с ее значением.
Кроме того, в области памяти, отведенной под переменную и служебные данные о ней, хранится еще одно дополнительное число [4]. Понять его назначение можно на следующем простом примере. В Питоне можно присваивать одно и то же значение сразу нескольким переменным. Например, х = y = z = 300.
После выполнения этой строки все переменные получат одно и то же значение. Задумаемся над вопросом, будут ли указатели в х, y и z одинаковыми, или же каждый из них будет показывать на «свое собственное» значение (вспомним, что при статическом методе организации переменных в памяти действительно будут лежать три числа, равные 300). Оказывается [4, 6], в Питоне данная строка программы выполняется так: сначала создается числовое значение
300, а затем ссылка на него помещается во все три переменные. Если теперь поменять значения переменных, например, так
х = 1, у = 2, г = 3,
то все указатели будут переопределены на новое значение, а «утратившее силу» значение 300 станет ненужным.
Если подумать еще немного, то возникает следующий вопрос: а как программа узнает, в какой момент начальное значение перестанет быть нужным? В самом деле, после присвоения нового значения х ссылки в у и г еще остаются! Оказывается, решение проблемы очень простое: около каждого значения в памяти хранится счетчик количества ссылок на него. При изменении значения переменной ссылка переопределяется на новое значение, а из счетчика старого вычитается единица. В тот момент, когда счетчик обратится в ноль (в нашем примере это произойдет только после изменения указателя на переменную г), значение больше не используется и его можно удалить из памяти5.
Итак, анализ показывает, что переменные в языках Паскаль и Питон существенно отличаются. В Паскале в переменной всегда хранится значение строго определенного типа. В Питоне же тип переменной не фиксируется, и ей могут присваиваться значения произвольного типа (независимо от того, какое значение в переменной хранилось ранее, и даже существовала ли до этого данная переменная). Кроме того, в отличие от Паскаля, в Питоне большинство переменных (в том числе все простые) динамические. Так что в переменной всегда располагается не само значение, а ссылка на область ОЗУ, где находятся это значение и код его типа, а также счетчик ссылок на данное значение.
С одной стороны, возможность присвоить переменной значение любого типа дает программисту возможность не тратить время на описание переменных. А с другой - менее жесткий контроль транслятора может пропускать специфические ошибки, которые не всегда удается быстро найти. В частности, при работе с программой на Питоне следует соблюдать следующие предосторожности:
• при написании и при наборе текста программы тщательно следить за правильностью имен переменных, иначе вместо одной переменной можно получить две разных6 (см. пример 1 в разделе 6);
• при написании программы очень внимательно контролировать типы присваиваемых значений и применяемые к ним операции (см. пример 2);
• во избежание путаницы без необходимости не изменять по ходу программы тип переменной.
Совместное хранение в переменных значения и его типа позволяют интерпретатору Питона обеспечивать более гибкую обработку данных. Рассмотрим это на примере целых чисел [11]. Пока значение числа невелико, оно хранится и обрабатывается обычным образом. Но (в последних версиях Питона) как только значение превысит допустимое для целых хотя бы на единицу, число автоматически преобразуется в новый формат: к его области хранения добавля-
5 Точно такой же механизм используется во всех операционных системах семейства Unix, где файл может принадлежать нескольким каталогам одновременно.
6 Такого рода ошибки уже встречались в практике программирования на языке BASIC, где тоже не обязательно объявлять переменные.
ется еще несколько байтов, и тем самым верхняя граница числового диапазона отодвигается. Переменная при этом получает другой тип, который обрабатывается по собственным правилам арифметики, заложенным в Питон-машину. Если и добавочных байтов становится недостаточно, то область хранения числа снова будет увеличена и т.д. Таким образом, количество битов для хранения числа все время автоматически добавляется и переполнения не происходит. Получается, что величина целых чисел в Питоне ограничена только объемом свободной памяти.
Заметим, что указанный механизм автоматического расширения диапазона представления целых чисел в Питоне на вещественные числа не распространяется (по крайней мере, пока).
5. Что рассказывать школьникам
5.1. Основные идеи
Проведенный нами анализ показывает, что понятие переменных со времени возникновения первых классических языков программирования существенным образом расширилось. Способы хранения переменных в ОЗУ также значительно модифицировались. Сформулируем наиболее важные выводы, полученные из нашего анализа.
1. Классическое представление о переменной как об области памяти фиксированного размера с постоянным адресом устарело. В некоторых современных языках, например, в языке Питон, подобных переменных нет вообще.
2. В современных языках программирования существует два вида переменных: фиксированной длины (например, целое число с заданным диапазоном значений) и переменной длины (например, строки).
3. Память для хранения переменных может выделяться как статически (компилятором во время трансляции программы в машинный код), так и динамически (во время выполнения программы). Для переменных, длина которых изменятся во время работы программы, память обычно выделяется динамически.
4. Существуют два основных способа доступа к значению переменной: прямой и косвенный. При прямом обращении в переменной хранится непосредственно ее значение. При косвенном обращении переменная является указателем, т.е. в ней хранится ссылка - адрес той области ОЗУ, где находится нужное значение.
5. Статический способ размещения переменных в памяти проще, операции с переменными выполняются быстрее. Но он подходит только для переменных, размер которых фиксирован и заранее известен.
6. Для обращения к переменным, память под которые выделена динамически, всегда используется косвенная адресация, т.е. обращение через указатель. Такой способ доступа сложнее и медленнее. Зато он более гибкий: позволяет работать с данными изменяющейся длины и даже менять тип значений, хранимых в переменной.
7. Изменение значения динамической переменной происходит следующим образом. Сначала в ОЗУ размещается новое значение, затем адрес этой области заносится в указатель,
который используется для обращения к данным. При этом предыдущее значение указателя сти-
7
рается, и ссылка на «старое» значение переменной в памяти теряется .
8. Если в языке поддерживаются и статические, и динамические переменные (например, в Паскале), то возможен компромиссный вариант: переменная фиксированной длины создается динамически, а затем работа с ней идет как с обычной переменной. Иными словами, адрес ОЗУ при записи новых значений не изменяется.
9. В современных языках программирования при необходимости вместе со значением переменной хранится служебная информация о ней, например, размер переменной.
5.2. Классическая переменная
Рассмотрим для начала простейший случай организации переменных, как это делается в классических языках типа Паскаля или Си. Если использовать введенную ранее в разделе 3 терминологию, речь здесь идет о глобальных статических переменных простейших типов данных, значения которых имеют постоянный размер области хранения. В этом случае в памяти хранится только значение переменной без всякой служебной информации о ней (см. рисунок, изображение а).
A0
значение
a
A1
данные о значении
значение
b
Рис. Классическая переменная (а) и переменная в языке Питон (б)
Как работают такие переменные? Пусть в программе на языке Паскаль объявлена целочисленная переменная X. При этом транслятор заносит имя X в таблицу имен переменных и связывает это имя с некоторым адресом A0 в ОЗУ. Теперь оператор X: = 2000 будет заменен одной или несколькими командами, которые записывают двоичный код числа 2000 в память, начиная с адреса A0. Размер записываемого значения равен размеру переменной типа INTEGER. Значение, хранившееся ранее в этой части ОЗУ, как обычно будет стерто.
В любой части программы можно читать и изменять значение переменной X, обращаясь к ОЗУ по адресу A0.
Таким образом, в рассматриваемом случае переменная есть просто фиксированная область памяти, где непосредственно хранится значение переменной.
В языках программирования, в которых нет автоматического «сборщика мусора» (например, в C и C++), программист должен сам освободить область памяти, в которой хранилось предыдущее значение динамической переменной.
5.3. Переменная в Питоне
Переменная в Питоне устроена значительно сложнее. Рассмотрим, как работает в этом языке тот же самый пример присвоения переменной X начального значения X = 2000. Будем предполагать, что ранее с переменной X никакие действия не производились, а значит, она еще не была создана. В этом случае Питон-машина выполнит следующие основные действия (см. рисунок, изображение б).
1. В области памяти, где хранятся значения для переменных, будет найден свободный участок, достаточный для размещения значения и сведений о нем (адрес AI на изображении б).
2. В найденную область будет помещено целочисленное значение 2000 и служебные данные о нем (в частности, что это значение именно типа int - данный факт определяется непосредственно по записи числа)8.
3. Интерпретатор Питона (Питон-машина) всегда ищет переменные по имени9, поэтому прежде всего он просмотрит список уже существующих переменных. В рассматриваемом нами случае переменой X там еще не будет. Тогда в таблицу переменных (в языке Питон она организована в виде словаря) добавится новая запись вида {'X' : A}, т.е. очередная пара {<имя> : <адрес>}, где A - это адрес места, зарезервированного под ссылку на значение переменной. В дальнейшем, когда поле адреса заполнится, будет легко извлечь из памяти значение требуемой переменной.
4. Во вторую часть записи словаря заносится значение адреса A1.
Тем, кому описанная схема кажется сложной, дополнительно сообщим, что на самом деле она, напротив, была упрощена (см., например, описание [10]).
Чтобы теперь прочитать из памяти значение переменной X, интерпретатор Питона будет действовать так. В словаре он найдет имя переменной X, извлечет соответствующий ему адрес A1, а затем, используя его в качестве начального адреса ОЗУ, добавит необходимое смещение и извлечет из памяти число 2000.
При повторной записи в переменную X нового значения цепочка действий очень похожа на ту, которая была описана выше для присвоения самого первого значения. Разница только в том, что в пункте 3 переменная X найдется среди уже имеющихся, так что расширять словарь не потребуется. Заметим, что при изменении значения X в пункте 4, прежде чем заменить указатель новым значением, у старого «счетчик использований» (см. раздел 4) будет уменьшен на единицу. Это позднее позволит системе в случае нулевого счетчика освободить память, убрав такое неиспользуемое значение.
6. Несколько практических примеров
Практика показывает, что именно понимание механизмов работы программы отличает хорошего специалиста от посредственного ремесленника. Причем пока все идет хорошо, эти различия не так заметны. Зато при возникновении ошибки умение проанализировать, как именно компьютер выполняет отлеживаемую программу, приносит неоценимую пользу и существенную экономию времени [7, 9].
8 Если в памяти уже существует нужное для присвоения значение 2000, то пункты 1 и 2 выполняться не будут.
9 Строго говоря, компилятор текста программы в байт-код мог бы в качестве альтернативы организовать более короткий доступ к значениям: без промежуточного поиска по строковому имени.
Для языка Питон, рассматриваемого в данной статье в качестве образца современного языка программирования, понимание внутреннего устройства переменных становится еще актуальнее по следующей причине. В связи с отсутствием четких описаний переменных, свойственных, скажем, Паскалю, компилятор Питона не имеет возможности заранее жестко контролировать текст программы на предмет типа присваиваемых переменным значений или применяемых к ним операций. В результате полученная программа может работать, но совсем не так, как задумал ее автор, а некоторым альтернативным способом. Нахождение ошибок в таких ситуациях часто отнимает много времени.
Ниже приводится несколько максимально простых примеров, подтверждающих этот тезис. Показано, что из-за ошибок или неточностей результат работы программы может неожиданно отличаться от того, что задумал программист. Пример 1
Невнимательный школьник набрал следующую программу.
temp = 0
tem = temp + 1
х = 10 * temp
print(x)
При ее наборе он поторопился и сделал ошибку во второй строке, написав в левой части оператора имя переменной tem вместо temp. Программа все равно работает, только выдает неправильный ответ (0 вместо 10). Ошибки такого рода замечают далеко не все, но даже если ученик догадался, что ответ неправильный, найти такую простую, казалось бы, ошибку в тексте на экране удается не каждому. Пример 2
А теперь продемонстрируем пример, когда ошибка возникает из-за неаккуратного обращения с типами данных. И, к нашему сожалению, транслятор, не имея описаний типов переменных, не способен заметить ее вовремя.
Вот небольшой фрагмент программы: a = 64 b = a/2 c = chr(b).
Казалось бы, здесь все просто: число 64 делится пополам, а получившееся значение 32 с помощью функции chr() преобразуется в символ (ожидается, что в пробел). Однако программа не работает, выдавая в последней строке сообщение о несоответствии типа аргумента функции. Но истинная причина ошибки находится в предыдущей строке: там к целым числам применена операция «вещественного» деления; результат такой операции всегда является вещественным числом, даже если ответом является целое значение. В итоге компьютер, «не подозревая ничего плохого», запишет в переменную b вещественное значение, и только при попытке преобразовать вещественное число в символ ошибка станет фатальной и выполнение программы прекратится. Замените операцию делением нацело (b = a // 2) и все заработает правильно. Отметим, что в языке со строгими описаниями типов вроде Паскаля такая ошибка была бы обнаружена на этапе трансляции.
Пример 3
У начинающих осваивать Питон часто возникают ошибки, связанные с тем, что любая вводимая с клавиатуры информация является строкой. Если же требуется ввести число, то строку необходимо дополнительно преобразовывать. Вот пример типичной ошибки. а = трШ;() Ь = трШ;0 с = а + Ь рпП;(с) х = с / 2
Пусть мы ввели 21 и 35. Но это пока не числа, а строки из цифр. Следовательно, результат сложения строк даст результат 2135 (а не 56, как было бы для чисел!) Более того, сумма по-прежнему остается строкой, а значит, при попытке вычислить х путем деления будет ошибка. Пример 4
Не зная, как работает механизм ссылок, часто бывает трудно понять получаемый программой результат. Рассмотрим такой пример. А = [1, 2, 3, 4] В = А А[0] = 0 рпп^А, В)
В приведенной выше программе ссылки на массивы А и В одинаковы. Следовательно, если поменять значение в одном из них, то это автоматически означает такое же изменение в другом. В итоге элемент В[0] тоже станет равным нулю.
Заметим, что так получается только для массивов. Для простых переменных все работает по другой схеме, которая была описана ранее. А = 1 В = А А = 0 рпп^А, В)
Здесь В сохранит свое значение 1 (см. раздел 4). В чем принципиальная разница между этими двумя случаями?
Напомним, что простые переменные являются неизменяемыми; для них присваиваемое значение создается в новом месте памяти и ссылка в переменной (во втором случае в переменной А) соответствующим образом изменяется. В результате А и В становятся разными.
Переменные, хранящие ссылку на списки, напротив, изменяемы. Хотя их значения состоят из заменяемых ссылок, но сама ссылка на весь список остается постоянной. В итоге в первом случае при присвоении нового значения А[0] ссылки, хранящиеся в переменных А и В, не обновляются, а значит, В[0] будет по-прежнему совпадать с А[0].
7. Заключение
Таким образом, классические представления о переменных, заложенные в язык Паскаль, в настоящее время далеко не всегда позволяют понять, как ведут себя переменные в современ-
ных языках, таких как Питон. Конкретные примеры, подтверждающие это положение, приведены в разделе 6.
Для правильного и глубокого понимания поведения переменных необходимо дать школьникам некоторую модель этого понятия. В данной статье предлагается при рассмотрении проблемы уделять внимание в первую очередь особенностям хранения переменных в памяти.
Авторы считают, что даже в рамках преподавания вводного курса программирования учителя должны четко осознать суть понятия переменной. Это важно как для корректного изложения основных теоретических идей, так и для ответов на практические вопросы, неизбежно возникающие при поиске и исправлении ошибок в программах. В связи с этим очень полезны эксперименты по изучению идей хранения переменных, подобные описанным, например, в публикациях [1, 2, 4, 10, 11].
Список цитируемых источников
1. Еремин Е.А. Изучение средствами Delphi способов хранения в компьютерной памяти различных данных // Информатика («Первое сентября»). - 2010. - № 19. - С. 4-23.
2. Еремин Е.А. Представление информации в ЭВМ средствами Turbo Pascal // Информатика и образование. - 1999. - № 3. - С. 47-58.
3. Лин В. PDP-11 и VAX-11. Архитектура ЭВМ и программирование на языке ассемблера. - М.: Радио и связь, 1989.
4. Романов С. Использование памяти в Python [Электронный ресурс]. - URL: https://habr.com/ru/post/193890/ (дата обращения: 31.03.2019).
5. Столлингс В. Структурная организация и архитектура компьютерных систем. -М.: Вильямс, 2002.
6. Харрисон М. Как устроен Python. Гид для разработчиков, программистов и интересующихся. - СПб.: Питер, 2019.
7. Ben-Ari M. Constructivism in Computer Science Education // Journal of Computers in Mathematics and Science Teaching. - 2001. - Vol. 20, № 1. - P. 45-73.
8. Burks A.W., Goldstine H.H., von Neumann J. Preliminary Discussion of the Logical Design of an Electronic Computing Instrument. - 1946 [Электронный ресурс]. - URL:
http://research.microsoft.com/users/gbell/Computer_Structures_Readings_and_Examples/00000112.h
tm (дата обращения: 31.03.2019). [Русский перевод: Беркс А., Голдстейн Г., Нейман Дж. Предварительное рассмотрение логической конструкции электронного вычислительного устройства // Кибернетический сборник. - М.: Мир, 1964. - Вып. 9. - С. 7-67].
9. Du Boulay B. Some Difficulties of Learning to Program // Journal of Educational Computing Research. - 1986. - Vol. 2, № 1. - P. 57-73.
10. Goldsborough P. Disassembling Python Bytecode [Электронный ресурс]. - URL: http://www.goldsborough.me/python/low-level/2016/10/04/00-31-30-disassembling_python_bytecode/ (дата обращения: 31.03.2019).
11. Golubin A. Python Internals: Arbitrary-precision Integer Implementation [Электронный ресурс]. - URL: https://rushter.com/blog/python-integer-implementation/ (дата обращения: 31.03.19).
12. Holland S., Griffiths R., Woodman M. Avoiding Object Misconceptions // Proc. of the 28th SIGCSE Technical Symposium on CS Education. - 1997. - Vol. 29, № 1. - P. 131-134.
13. Jimoyiannis A. Using SOLO Taxonomy to Explore Students' Mental Models of the Programming Variable and the Assignment Statement // Themes in Science and Technology Education. -2011. - Vol. 4, № 2. - P. 53-74.
14. Nikula U., Sajaniemi J., Tedre M., Wray S. Python and Roles of Variables in Introductory Programming: Experiences from Three Educational Institutions // Journal of Information Technology Education. - 2007. - Vol. 6. - P. 199-214.
15. Sajaniemi J. An Empirical Analysis of Roles of Variables in Novice-level Procedural Programs // Proc. of IEEE Symposia on Human Centric Computing Languages and Environments. - 2002. - P. 37-39.