Каковы препятствия на пути к пониманию указателей и что можно сделать, чтобы их преодолеть?

Почему указатели являются таким главным фактором путаницы для многих новых и даже старых студентов, изучающих C или C++? Существуют ли какие-либо инструменты или мыслительные процессы, которые помогли вам понять, как указатели работают на уровне переменной, функции и за ее пределами?

Какие хорошие практические приемы можно сделать, чтобы довести кого-то до уровня «Ага, я понял», не увязая в общей концепции? По сути, тренируйте похожие сценарии.

Ответов (25)

Решение

Указатели - это концепция, которая для многих поначалу может сбивать с толку, в частности, когда дело доходит до копирования значений указателя и обращения к одному и тому же блоку памяти.

Я обнаружил, что лучшая аналогия - рассматривать указатель как лист бумаги с домашним адресом на нем, а блок памяти, на который он ссылается, - как реальный дом. Таким образом, можно легко объяснить все виды операций.

Я добавил немного кода Delphi ниже и несколько комментариев, где это необходимо. Я выбрал Delphi, поскольку другой мой основной язык программирования, C#, не проявляет таких вещей, как утечки памяти, таким же образом.

Если вы хотите изучить только высокоуровневую концепцию указателей, вам следует игнорировать части, помеченные как «Структура памяти» в объяснении ниже. Они предназначены для того, чтобы дать примеры того, как могла бы выглядеть память после операций, но они носят более низкоуровневый характер. Однако, чтобы точно объяснить, как на самом деле работает переполнение буфера, мне было важно добавить эти диаграммы.

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


Предположим, что используемый ниже класс THouse выглядит так:

type
    THouse = class
    private
        FName : array[0..9] of Char;
    public
        constructor Create(name: PChar);
    end;

Когда вы инициализируете домашний объект, имя, данное конструктору, копируется в личное поле FName. Есть причина, по которой он определяется как массив фиксированного размера.

В памяти будут некоторые накладные расходы, связанные с выделением дома, я проиллюстрирую это ниже следующим образом:

--- [ttttNNNNNNNNNN] ---
     ^ ^
     | |
     | + - массив FName
     |
     + - накладные расходы

Область «tttt» является накладной, обычно ее больше для различных типов сред выполнения и языков, например, 8 или 12 байт. Крайне важно, чтобы любые значения, хранящиеся в этой области, никогда не изменялись ничем, кроме распределителя памяти или основных системных процедур, иначе вы рискуете привести к сбою программы.


Выделить память

Найдите предпринимателя, который построит ваш дом, и сообщите его адрес. В отличие от реального мира, распределение памяти не может быть сказано, где выделить, но найдет подходящее место с достаточным пространством и сообщит адрес в выделенную память.

Другими словами, место выберет предприниматель.

THouse.Create('My house');

Схема памяти:

--- [ttttNNNNNNNNNN] ---
    1234 Мой дом

Сохраните переменную с адресом

Напишите адрес вашего нового дома на листе бумаги. Этот документ послужит вам ссылкой на ваш дом. Без этого листка бумаги вы потерялись и не можете найти дом, если вы уже не в нем.

var
    h: THouse;
begin
    h := THouse.Create('My house');
    ...

Схема памяти:

    час
    v
--- [ttttNNNNNNNNNN] ---
    1234 Мой дом

Копировать значение указателя

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

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

var
    h1, h2: THouse;
begin
    h1 := THouse.Create('My house');
    h2 := h1; // copies the address, not the house
    ...
    h1
    v
--- [ttttNNNNNNNNNN] ---
    1234 Мой дом
    ^
    h2

Освобождение памяти

Снесите дом. Затем вы можете позже повторно использовать бумагу для нового адреса, если хотите, или очистить ее, чтобы забыть адрес дома, который больше не существует.

var
    h: THouse;
begin
    h := THouse.Create('My house');
    ...
    h.Free;
    h := nil;

Здесь я сначала построю дом и узнаю его адрес. Затем я что-то делаю с домом (использую ... код, оставленный в качестве упражнения для читателя), а затем освобождаю его. Наконец, я очищаю адрес своей переменной.

Схема памяти:

    ч <- +
    v + - до бесплатного
--- [ttttNNNNNNNNNN] --- |
    1234 Мой дом <- +

    h (теперь никуда не указывает) <- +
                                + - после бесплатного
---------------------- | (обратите внимание, память может все еще
    xx34 Мой дом <- + содержат некоторые данные)

Свисающие указатели

Вы говорите своему предпринимателю, чтобы он разрушил дом, но забываете стереть адрес со своего листа бумаги. Позже, когда вы посмотрите на листок бумаги, вы забудете, что дома больше нет, и отправитесь его посетить, но безуспешно (см. Также часть о недействительной ссылке ниже).

var
    h: THouse;
begin
    h := THouse.Create('My house');
    ...
    h.Free;
    ... // forgot to clear h here
    h.OpenFrontDoor; // will most likely fail

Использование h после вызова .Free может сработать, но это чистая удача. Скорее всего, он выйдет из строя у клиента во время критической операции.

    ч <- +
    v + - до бесплатного
--- [ttttNNNNNNNNNN] --- |
    1234 Мой дом <- +

    ч <- +
    v + - после бесплатного
---------------------- |
    xx34 Мой дом <- +

Как видите, h по-прежнему указывает на остатки данных в памяти, но, поскольку они могут быть неполными, их использование по-прежнему может не сработать.


Утечка памяти

Вы теряете листок бумаги и не можете найти дом. Однако дом все еще где-то стоит, и когда вы позже захотите построить новый дом, вы не сможете повторно использовать это место.

var
    h: THouse;
begin
    h := THouse.Create('My house');
    h := THouse.Create('My house'); // uh-oh, what happened to our first house?
    ...
    h.Free;
    h := nil;

Здесь мы перезаписали содержимое h переменной адресом нового дома, но старый все еще стоит ... где-то. После этого кода нет возможности добраться до этого дома, и он останется стоять. Другими словами, выделенная память будет оставаться выделенной до закрытия приложения, после чего операционная система отключит ее.

Схема памяти после первого выделения:

    час
    v
--- [ttttNNNNNNNNNN] ---
    1234 Мой дом

Схема памяти после второго выделения:

                       час
                       v
--- [ttttNNNNNNNNNN] --- [ttttNNNNNNNNNN]
    1234Мой дом 5678Мой дом

Более распространенный способ получить этот метод - просто забыть освободить что-то, вместо того, чтобы перезаписывать это, как указано выше. В терминах Delphi это произойдет следующим образом:

procedure OpenTheFrontDoorOfANewHouse;
var
    h: THouse;
begin
    h := THouse.Create('My house');
    h.OpenFrontDoor;
    // uh-oh, no .Free here, where does the address go?
end;

После выполнения этого метода в наших переменных нет места, где адрес дома существует, но дом все еще существует.

Схема памяти:

    ч <- +
    v + - перед потерей указателя
--- [ttttNNNNNNNNNN] --- |
    1234 Мой дом <- +

    h (теперь никуда не указывает) <- +
                                + - после потери указателя
--- [ttttNNNNNNNNNN] --- |
    1234 Мой дом <- +

Как видите, старые данные остаются в памяти нетронутыми и не будут повторно использоваться распределителем памяти. Распределитель отслеживает, какие области памяти были использованы, и не будет использовать их повторно, пока вы не освободите их.


Освобождение памяти, но сохранение (теперь недействительной) ссылки

Снесите дом, сотрите один из листков бумаги, но у вас также есть еще один лист бумаги со старым адресом, когда вы пойдете по адресу, вы не найдете дома, но вы можете найти что-то похожее на руины одного.

Возможно, вы даже найдете дом, но это не тот дом, которому изначально был дан адрес, и поэтому любые попытки использовать его так, как будто он принадлежит вам, могут потерпеть неудачу.

Иногда вы даже можете обнаружить, что на соседнем адресе стоит довольно большой дом, занимающий три адреса (Main Street 1-3), и ваш адрес идет в середину дома. Любые попытки рассматривать эту часть большого трехадресного дома как отдельный маленький дом также могут потерпеть неудачу.

var
    h1, h2: THouse;
begin
    h1 := THouse.Create('My house');
    h2 := h1; // copies the address, not the house
    ...
    h1.Free;
    h1 := nil;
    h2.OpenFrontDoor; // uh-oh, what happened to our house?

Здесь дом был снесен по ссылке h1, и хотя h1 тоже был расчищен, h2 до сих пор сохранился старый, устаревший адрес. Доступ к дому, который больше не существует, может работать, а может и не работать.

Это разновидность висячего указателя выше. См. Схему его памяти.


Переполнение буфера

Вы перемещаете в дом больше вещей, чем можете вместить, и попадаете в соседний дом или двор. Когда позже хозяин соседнего дома придет домой, он найдет все, что сочтет своим.

По этой причине я выбрал массив фиксированного размера. Чтобы подготовить почву, предположим, что второй дом, который мы выделяем, по какой-то причине будет помещен перед первым в памяти. Другими словами, второй дом будет иметь более низкий адрес, чем первый. Кроме того, они расположены рядом друг с другом.

Таким образом, этот код:

var
    h1, h2: THouse;
begin
    h1 := THouse.Create('My house');
    h2 := THouse.Create('My other house somewhere');
                         ^-----------------------^
                          longer than 10 characters
                         0123456789 <-- 10 characters

Схема памяти после первого выделения:

                        h1
                        v
----------------------- [ttttNNNNNNNNNN]
                        5678 Мой дом

Схема памяти после второго выделения:

    h2 h1
    vv
--- [ttttNNNNNNNNNN] ---- [ttttNNNNNNNNNN]
    1234 Мой другой дом где-нибудь
                        ^ --- + - ^
                            |
                            + - перезаписано

Часть, которая чаще всего вызывает сбой, - это когда вы перезаписываете важные части сохраненных данных, которые действительно не должны изменяться случайным образом. Например, может не быть проблемой, что части имени h1-house были изменены с точки зрения сбоя программы, но перезапись служебных данных объекта, скорее всего, приведет к сбою, когда вы попытаетесь использовать сломанный объект, как и перезапись ссылок, хранящихся на других объектах в объекте.


Связанные списки

Когда вы следуете за адресом на листе бумаги, вы попадаете в дом, и в этом доме есть еще один лист бумаги с новым адресом, для следующего дома в цепочке и так далее.

var
    h1, h2: THouse;
begin
    h1 := THouse.Create('Home');
    h2 := THouse.Create('Cabin');
    h1.NextHouse := h2;

Здесь мы создаем ссылку из нашего домашнего дома в нашу хижину. Мы можем проследить цепочку до тех пор, пока у дома не будет NextHouse ссылки, а это значит, что это последний дом. Чтобы посетить все наши дома, мы могли бы использовать следующий код:

var
    h1, h2: THouse;
    h: THouse;
begin
    h1 := THouse.Create('Home');
    h2 := THouse.Create('Cabin');
    h1.NextHouse := h2;
    ...
    h := h1;
    while h <> nil do
    begin
        h.LockAllDoors;
        h.CloseAllWindows;
        h := h.NextHouse;
    end;

Макет памяти (добавлен NextHouse как ссылка в объекте, отмеченный четырьмя LLLL на диаграмме ниже):

    h1 h2
    vv
--- [ttttNNNNNNNNNNLLLL] ---- [ttttNNNNNNNNNNLLLL]
    1234Дом + 5678Кают +
                   | ^ |
                   + -------- + * (без ссылки)

В общих чертах, что такое адрес памяти?

Адрес памяти в общих чертах - это просто число. Если вы думаете о памяти как о большом массиве байтов, самый первый байт имеет адрес 0, следующий - адрес 1 и так далее. Это упрощено, но достаточно хорошо.

Итак, эта схема памяти:

    h1 h2
    vv
--- [ttttNNNNNNNNNN] --- [ttttNNNNNNNNNN]
    1234Мой дом 5678Мой дом

Могут быть эти два адреса (крайний левый - адрес 0):

  • h1 = 4
  • h2 = 23

Это означает, что наш связанный список выше может выглядеть так:

    h1 (= 4) h2 (= 28)
    vv
--- [ttttNNNNNNNNNNLLLL] ---- [ttttNNNNNNNNNNLLLL]
    1234 Дом 0028 5678 Кабина 0000
                   | ^ |
                   + -------- + * (без ссылки)

Обычно адрес, который «никуда не указывает», сохраняется как нулевой адрес.


В общих чертах, что такое указатель?

Указатель - это просто переменная, содержащая адрес памяти. Обычно вы можете попросить язык программирования дать вам свой номер, но большинство языков программирования и сред выполнения пытаются скрыть тот факт, что под ним стоит номер, просто потому, что само число на самом деле не имеет для вас никакого значения. Лучше всего представить указатель как черный ящик, т.е. вы на самом деле не знаете и не заботитесь о том, как это на самом деле реализовано, до тех пор, пока оно работает.

Я не думаю, что указатели как концепция особенно сложны - ментальные модели большинства студентов сопоставляются с чем-то вроде этого, и некоторые быстрые наброски блоков могут помочь.

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

Я думаю, это может быть проблема с синтаксисом. Синтаксис C/C++ для указателей кажется непоследовательным и более сложным, чем должен быть.

По иронии судьбы, что действительно помогло мне понять указатели, так это то, что я столкнулся с концепцией итератора в стандартной библиотеке шаблонов С ++ . Это иронично, потому что я могу только предположить, что итераторы были задуманы как обобщение указателя.

Иногда вы просто не можете видеть лес, пока не научитесь игнорировать деревья.

Причина, по которой это так сложно понять, заключается не в том, что это сложная концепция, а в том, что синтаксис непоследователен .

   int *mypointer;

Сначала вы узнали, что самая левая часть создания переменной определяет тип переменной. Объявление указателя работает иначе в C и C++. Вместо этого они говорят, что переменная указывает на тип слева. В этом случае: *mypointer указывает на int.

Я не полностью понимал указатели, пока не попытался использовать их в C# (с небезопасным), они работают точно так же, но с логичным и последовательным синтаксисом. Указатель - это сам тип. Здесь mypointer - это указатель на int.

  int* mypointer;

Даже не заставляйте меня начинать с указателей функций ...

Я мог работать с указателями, когда знал только C++. Я как бы знал, что делать в некоторых случаях, а чего не делать методом проб / ошибок. Но то, что дало мне полное понимание, - это ассемблер. Если вы выполняете серьезную отладку на уровне инструкций с помощью написанной вами программы на языке ассемблера, вы должны понимать многие вещи.

Каждый новичок в C/C++ сталкивается с одной и той же проблемой, и эта проблема возникает не потому, что «указатели трудно выучить», а «кто и как это объясняет». Некоторые учащиеся собирают это словесно, некоторые визуально, и лучший способ объяснить это - использовать « обучающий » пример (подходит как для словесного, так и для визуального примера).

Где «локомотив» - это указатель, который ничего не может удерживать, а «вагон» - это то, что «локомотив» пытается тянуть (или указывать). После этого вы можете классифицировать сам «вагон», может ли он содержать животных, растения или людей (или их комбинацию).

Мне нравилось объяснять это в терминах массивов и индексов - люди могут быть не знакомы с указателями, но обычно они знают, что такое индекс.

Итак, я говорю: представьте, что ОЗУ - это массив (а у вас всего 10 байт ОЗУ):

unsigned char RAM[10] = { 10, 14, 4, 3, 2, 1, 20, 19, 50, 9 };

Тогда указатель на переменную на самом деле является просто индексом (первым байтом) этой переменной в ОЗУ.

Итак, если у вас есть указатель / индекс unsigned char index = 2, тогда значение, очевидно, является третьим элементом или числом 4. Указатель на указатель - это то место, где вы берете это число и используете его как сам индекс, например RAM[RAM[index]] .

Я бы нарисовал массив в списке бумаги и просто использовал бы его, чтобы показать такие вещи, как множество указателей, указывающих на одну и ту же память, арифметику указателей, указатель на указатель и т. Д.

Я подумал, что добавлю аналогию к этому списку, который я нашел очень полезным при объяснении указателей (в свое время) в качестве репетитора по информатике; сначала давайте:


Подготовьте почву :

Рассмотрим стоянку на 3 места, эти места пронумерованы:

-------------------
|     |     |     |
|  1  |  2  |  3  |
|     |     |     |

В некотором смысле это похоже на ячейки памяти, они последовательные и смежные ... вроде как массив. Сейчас в них нет машин, так что это как пустой массив ( parking_lot[3] = {0} ).


Добавьте данные

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

   1     2     3
-------------------
| o=o | o=o | o=o |
| |B| | |R| | |G| |
| o-o | o-o | o-o |

Эти машины все тот же тип (автомобиль) , так что один способ думать о том , что наши машины какие - то данные (скажем int ) , но они имеют разные значения ( blue, red, green, которые могут быть цвета enum )


Введите указатель

Теперь, если я отведу вас на эту стоянку и попрошу найти мне синюю машину, вы протянете один палец и укажете им на синюю машину в точке 1. Это все равно, что взять указатель и назначить его адресу в памяти. ( int *finger = parking_lot )

Ваш палец (указатель) не является ответом на мой вопрос. Взгляд на ваш палец ничего мне не говорит, но если я посмотрю туда, куда указывает ваш палец (разыменование указателя), я смогу найти машину (данные), которую искал.


Переназначение указателя

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

Указатель физически не изменился, это все еще ваш палец, изменились только данные, которые он мне показывал. (адрес "парковочного места")


Двойные указатели (или указатель на указатель)

Это также работает с более чем одним указателем. Я могу спросить, где указатель, который указывает на красную машину, и вы можете другой рукой указать пальцем на первый палец. (это похоже int **finger_two = &finger )

Теперь, если я хочу знать, где находится синяя машина, я могу проследить направление первого пальца ко второму пальцу, к машине (данным).


Висячий указатель

Теперь предположим, что вы чувствуете себя очень похожим на статую, и вы хотите бесконечно держать руку, указывая на красную машину. Что, если эта красная машина уедет?

   1     2     3
-------------------
| o=o |     | o=o |
| |B| |     | |G| |
| o-o |     | o-o |

Ваш указатель все еще указывает , где красный автомобиль не был , но не более. Допустим, туда подъезжает новая машина ... Оранжевая машина. Теперь, если я снова спрошу вас: «Где красная машина?», Вы все еще указываете туда, но теперь вы ошибаетесь. Это не красная машина, это оранжевая.


Указатель арифметики

Итак, вы все еще указываете на второе парковочное место (сейчас занято оранжевой машиной)

   1     2     3
-------------------
| o=o | o=o | o=o |
| |B| | |O| | |G| |
| o-o | o-o | o-o |

Что ж, у меня теперь новый вопрос ... Я хочу знать цвет машины на следующем парковочном месте. Вы видите, что указываете на точку 2, поэтому вы просто добавляете 1, и вы указываете на следующую точку. ( finger+1 ), теперь, поскольку я хотел узнать, какие данные были там, вы должны проверить это место (а не только палец), чтобы вы могли уважать указатель ( *(finger+1) ), чтобы увидеть, что там присутствует зеленая машина (данные в этом месте )

Причина, по которой указатели, кажется, сбивают с толку многих людей, заключается в том, что они в основном не имеют или почти не имеют знаний в области компьютерной архитектуры. Поскольку многие, похоже, не имеют представления о том, как на самом деле реализованы компьютеры (машина), работа на C/C++ кажется чуждой.

Упражнение состоит в том, чтобы попросить их реализовать простую виртуальную машину на основе байт-кода (на любом языке, который они выберут, python отлично подходит для этого) с набором инструкций, ориентированным на операции с указателями (загрузка, сохранение, прямая / косвенная адресация). Затем попросите их написать простые программы для этого набора инструкций.

Все, что требует немного большего, чем простое добавление, будет включать указатели, и они обязательно это получат.

Почему указатели являются таким главным фактором путаницы для многих новых и даже старых студентов, изучающих язык C/C++?

Концепция заполнителя для значения - переменных - отображается на то, чему нас учат в школе - алгебру. Не существует параллели, которую вы могли бы провести, не понимая, как физически размещена память в компьютере, и никто не думает об этом, пока не будет иметь дело с низкоуровневыми вещами - на уровне связи C/C++ / байтов. .

Существуют ли какие-либо инструменты или мыслительные процессы, которые помогли вам понять, как указатели работают на уровне переменной, функции и за ее пределами?

Адреса ящики. Я помню, когда я учился программировать BASIC на микрокомпьютерах, были эти милые книжки с играми, и иногда приходилось указывать значения по определенным адресам. У них было изображение группы коробок, постепенно помеченных цифрами 0, 1, 2 ... и было объяснено, что только одна маленькая вещь (байт) могла поместиться в эти коробки, а их было много - некоторые компьютеры было аж 65535! Они были рядом друг с другом, и у всех был адрес.

Какие хорошие практические приемы можно сделать, чтобы довести кого-нибудь до уровня «Ага, я понял», не увязнув в общей концепции? По сути, тренируйте похожие сценарии.

Для дрели? Сделайте структуру:

struct {
char a;
char b;
char c;
char d;
} mystruct;
mystruct.a = 'r';
mystruct.b = 's';
mystruct.c = 't';
mystruct.d = 'u';

char* my_pointer;
my_pointer = &mystruct.b;
cout << 'Start: my_pointer = ' << *my_pointer << endl;
my_pointer++;
cout << 'After: my_pointer = ' << *my_pointer << endl;
my_pointer = &mystruct.a;
cout << 'Then: my_pointer = ' << *my_pointer << endl;
my_pointer = my_pointer + 3;
cout << 'End: my_pointer = ' << *my_pointer << endl;

Тот же пример, что и выше, за исключением C:

// Same example as above, except in C:
struct {
    char a;
    char b;
    char c;
    char d;
} mystruct;

mystruct.a = 'r';
mystruct.b = 's';
mystruct.c = 't';
mystruct.d = 'u';

char* my_pointer;
my_pointer = &mystruct.b;

printf("Start: my_pointer = %c\n", *my_pointer);
my_pointer++;
printf("After: my_pointer = %c\n", *my_pointer);
my_pointer = &mystruct.a;
printf("Then: my_pointer = %c\n", *my_pointer);
my_pointer = my_pointer + 3;
printf("End: my_pointer = %c\n", *my_pointer);

Выход:

Start: my_pointer = s
After: my_pointer = t
Then: my_pointer = r
End: my_pointer = u

Возможно, это объясняет некоторые основы на примере?

Я думаю, что основная причина, по которой у людей возникают проблемы с этим, заключается в том, что этому обычно не преподают интересно и увлекательно. Я бы хотел, чтобы лектор набрал 10 добровольцев из толпы и дал им по 1-метровой линейке каждый, заставил их стоять в определенной конфигурации и использовать линейки, чтобы указывать друг на друга. Затем покажите арифметику указателя, перемещая людей (и куда они указывают свои линейки). Это был бы простой, но эффективный (и, прежде всего, запоминающийся) способ продемонстрировать концепции, не слишком увязнув в механике.

Как только вы дойдете до C и C++, некоторым людям станет труднее. Я не уверен, что это потому, что они, наконец, применяют теорию, которую они не понимают должным образом, или потому, что манипуляции с указателями по своей сути сложнее в этих языках. Я не могу так хорошо вспомнить свой собственный переход, но я знал указатели в Паскале, а затем перешел на C и полностью потерялся.

Я не понимаю, что сбивает с толку в указателях. Они указывают на место в памяти, то есть хранят адрес памяти. В C/C++ вы можете указать тип, на который указывает указатель. Например:

int* my_int_pointer;

Говорит, что my_int_pointer содержит адрес места, содержащего int.

Проблема с указателями заключается в том, что они указывают на место в памяти, поэтому легко уйти в какое-то место, где вы не должны находиться. В качестве доказательства посмотрите на многочисленные дыры в безопасности в приложениях C/C++ от переполнения буфера (увеличение указателя за выделенную границу).

Пример учебника с хорошим набором диаграмм очень помогает в понимании указателей .

Джоэл Спольски делает несколько хороших замечаний по поводу понимания указателей в своей статье Guerrilla Guide to Interviewing :

Почему-то кажется, что большинство людей рождаются без той части мозга, которая понимает указатели. Это вопрос способностей, а не навыков - это требует сложной формы двояко-косвенного мышления, которое некоторые люди просто не могут сделать.

На моем первом занятии по компьютерным наукам мы выполнили следующее упражнение. Конечно, это был лекционный зал, в котором было около 200 студентов ...

Профессор пишет на доске: int john;

Джон встает

Профессор пишет: int *sally = &john;

Салли встает, показывает на Джона.

Профессор: int *bill = sally;

Билл встает, указывает на Джона

Профессор: int sam;

Сэм встает

Профессор: bill = &sam;

Билл теперь указывает на Сэма.

Думаю, вы уловили идею. Думаю, мы потратили на это около часа, пока не познакомились с основами присвоения указателей.

Не думаю, что сами указатели сбивают с толку. Большинство людей могут понять эту концепцию. Теперь о том, сколько указателей вы можете придумать или сколько уровней косвенности вам удобно. Не нужно слишком много людей, чтобы довести людей до крайности. Тот факт, что они могут быть случайно изменены из-за ошибок в вашей программе, также может затруднить их отладку, когда что-то идет не так в вашем коде.

Сложности указателей выходят за рамки того, чему мы можем легко научить. Когда учащиеся указывают друг на друга, и использование листов бумаги с адресами домов - это отличные инструменты для обучения. Они отлично справляются с введением основных понятий. Действительно, изучение основных концепций жизненно важно для успешного использования указателей. Однако в производственном коде часто встречаются гораздо более сложные сценарии, чем могут инкапсулировать эти простые демонстрации.

Я был связан с системами, в которых у нас были структуры, указывающие на другие структуры, указывающие на другие структуры. Некоторые из этих структур также содержат встроенные структуры (а не указатели на дополнительные структуры). Вот где указатели действительно сбивают с толку. Если у вас есть несколько уровней косвенного обращения, и вы начинаете с такого кода:

widget->wazzle.fizzle = fazzle.foozle->wazzle;

он может очень быстро запутаться (представьте намного больше линий и, возможно, больше уровней). Добавьте массивы указателей и указатели от узла к узлу (деревья, связанные списки), и это станет еще хуже. Я видел, как некоторые действительно хорошие разработчики терялись, когда начинали работать над такими системами, даже те разработчики, которые действительно хорошо понимали основы.

Сложные структуры указателей также не обязательно указывают на плохое кодирование (хотя могут). Композиция - жизненно важная часть хорошего объектно-ориентированного программирования, и в языках с необработанными указателями она неизбежно приведет к многоуровневому косвенному обращению. Кроме того, системам часто необходимо использовать сторонние библиотеки со структурами, которые не соответствуют друг другу по стилю или технике. В таких ситуациях, естественно, возникают сложности (хотя, конечно, мы должны бороться с этим как можно больше).

Я думаю, что лучшее, что колледжи могут сделать, чтобы помочь студентам изучить указатели, - это использовать хорошие демонстрации в сочетании с проектами, требующими использования указателей. Один сложный проект сделает больше для понимания указателя, чем тысяча демонстраций. Демонстрации могут дать вам поверхностное понимание, но чтобы глубоко понять указатели, вы должны действительно их использовать.

Я нашел "Учебник по указателям и массивам в C" Теда Дженсена отличным ресурсом для изучения указателей. Он разделен на 10 уроков, начиная с объяснения того, что такое указатели (и для чего они нужны), и заканчивая указателями на функции. http://web.archive.org/web/20181011221220/http://home.netcom.com:80/~tjensen/ptr/cpoint.htm

Двигаясь дальше, Beej's Guide to Network Programming учит API сокетов Unix, из которого вы можете начать делать действительно забавные вещи. http://beej.us/guide/bgnet/

Аналогия, которую я нашел полезной для объяснения указателей, - это гиперссылки. Большинство людей могут понять, что ссылка на веб-странице «указывает» на другую страницу в Интернете, и если вы можете скопировать и вставить эту гиперссылку, они обе будут указывать на одну и ту же исходную веб-страницу. Если вы перейдете и отредактируете исходную страницу, а затем перейдите по любой из этих ссылок (указателей), вы получите новую обновленную страницу.

Мне нравится аналогия с домашним адресом, но я всегда думал об адресе самого почтового ящика. Таким образом вы можете визуализировать концепцию разыменования указателя (открытие почтового ящика).

Например, следуя связному списку: 1) начните с бумаги с адреса 2) Перейдите к адресу, указанному на бумаге 3) Откройте почтовый ящик, чтобы найти новый лист бумаги со следующим адресом на нем.

В линейно связанном списке в последнем почтовом ящике ничего нет (конец списка). В круговом связном списке последний почтовый ящик имеет адрес первого почтового ящика в нем.

Обратите внимание, что на шаге 3 происходит разыменование и когда вы ошибаетесь или ошибаетесь, если адрес недействителен. Предполагая, что вы можете подойти к почтовому ящику с недействительным адресом, представьте, что там есть черная дыра или что-то такое, что выворачивает мир наизнанку :)

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

http://arjay.bc.ca/Modula-2/Text/Ch15/Ch15.8.html#15.8.5 говорит об этом немного более связно, чем я. :-)

I think that what makes pointers tricky to learn is that until pointers you're comfortable with the idea that "at this memory location is a set of bits that represent an int, a double, a character, whatever".

When you first see a pointer, you don't really get what's at that memory location. "What do you mean, it holds an address?"

Я не согласен с утверждением, что «вы их либо получаете, либо нет».

Их становится легче понять, когда вы начинаете находить для них реальное применение (например, не передавать большие структуры в функции).

Проблема с указателями не в концепции. Дело в исполнении и языке. Дополнительная путаница возникает, когда учителя полагают, что сложна КОНЦЕПЦИЯ указателей, а не жаргон или запутанный беспорядок, который C и C++ вносят в эту концепцию. Так много усилий затрачивается на объяснение концепции (как в принятом ответе на этот вопрос), и это в значительной степени просто напрасно тратится на кого-то вроде меня, потому что я уже все это понимаю. Это просто объясняет неправильную часть проблемы.

Чтобы дать вам представление о моем происхождении, я прекрасно понимаю указатели и могу грамотно их использовать на языке ассемблера. Потому что на языке ассемблера они не называются указателями. Они называются адресами. Когда дело доходит до программирования и использования указателей в C, я делаю много ошибок и очень запутываюсь. Я до сих пор не разобрался. Позвольте привести пример.

Когда api говорит:

int doIt(char *buffer )
//*buffer is a pointer to the buffer

чего он хочет?

он может захотеть:

число, представляющее адрес буфера

(Чтобы дать ему это, я говорю doIt(mybuffer), или doIt(*myBuffer) ?)

число, представляющее адрес адреса в буфере

(это doIt(&mybuffer) или doIt(mybuffer) или doIt(*mybuffer) ?)

число, представляющее адрес к адресу к адресу к буферу

(может быть doIt(&mybuffer) . или это doIt(&&mybuffer) ? или даже doIt(&&&mybuffer) )

и так далее, и используемый язык не делает это таким ясным, потому что он включает слова «указатель» и «ссылка», которые для меня не имеют такого большого значения и ясности, как «x содержит адрес y» и « для этой функции требуется адрес y ". Ответ также зависит от того, что это за чертов «mybuffer» вначале и что doI собирается с ним делать. Язык не поддерживает уровни вложенности, встречающиеся на практике. Например, когда мне нужно передать «указатель» на функцию, которая создает новый буфер, и она изменяет указатель так, чтобы он указывал на новое местоположение буфера. Действительно ли ему нужен указатель или указатель на указатель, чтобы он знал, куда идти, чтобы изменить содержимое указателя. В большинстве случаев мне просто нужно угадывать, что имеется в виду под "

"Указатель" просто слишком перегружен. Указатель - это адрес значения? или это переменная, которая содержит адрес значения. Когда функции нужен указатель, нужен ли ей адрес, который хранится в переменной-указателе, или ей нужен адрес переменной-указателя? Я запутался.

Путаница возникает из-за того, что несколько уровней абстракции смешиваются в концепции «указателя». Программистов не смущают обычные ссылки в Java / Python, но указатели отличаются тем, что раскрывают характеристики базовой архитектуры памяти.

Четкое разделение уровней абстракции - хороший принцип, а указатели этого не делают.

Причина, по которой мне поначалу было трудно понять указатели, заключалась в том, что многие объяснения содержат много чуши о передаче по ссылке. Все это только запутывает проблему. Когда вы используете параметр-указатель, вы все равно передаете его по значению; но значение оказывается адресом, а не, скажем, int.

Кто-то уже связался с этим руководством, но я могу выделить момент, когда я начал понимать указатели:

Учебник по указателям и массивам в C: Глава 3 - Указатели и строки

int puts(const char *s);

На данный момент игнорируйте const.параметр, переданный в puts()это указатель, то есть значение указателя (поскольку все параметры в C передаются по значению), а значение указателя - это адрес, на который он указывает, или просто , адрес. Таким образом, когда мы пишем, puts(strA);как мы видели, мы передаем адрес strA [0].

В тот момент, когда я прочитал эти слова, облака разошлись, и луч солнечного света окутал меня указательным пониманием.

Даже если вы разработчик VB .NET или C# (как и я) и никогда не используете небезопасный код, все равно стоит понимать, как работают указатели, иначе вы не поймете, как работают ссылки на объекты. Тогда у вас будет распространенное, но ошибочное представление о том, что передача ссылки на объект методу копирует объект.

Я думаю, что основным препятствием для понимания указателей являются плохие учителя.

Почти всех учат лжи об указателях: что они - не что иное, как адреса памяти или что они позволяют указывать на произвольные местоположения .

И конечно, что они трудные для понимания, опасные и полу-магические.

Ничего из этого не соответствует действительности. Указатели на самом деле являются довольно простыми концепциями, если вы придерживаетесь того, что говорит о них язык C++, и не наделяете их атрибутами, которые «обычно» работают на практике, но, тем не менее, не гарантируются языком. , и поэтому не являются частью фактической концепции указателя.

Несколько месяцев назад я попытался написать объяснение этого в этом блоге - надеюсь, это кому-то поможет.

(Обратите внимание, прежде чем кто-либо станет педантичным по отношению ко мне, да, стандарт C++ действительно говорит, что указатели представляют адреса памяти. Но он не говорит, что «указатели - это адреса памяти, а не что иное, как адреса памяти, и их можно использовать или рассматривать как взаимозаменяемые с памятью. адресов ". Различие важно)