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

104

Я прочитал эту строчку в книге:

Доказано, что невозможно создать компилятор, который действительно может определить, изменит ли функция C ++ значение определенной переменной.

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

Почему невозможно построить такой компилятор?

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

Игрок в крикет
источник
24
Первое, что приходит мне в голову, - это библиотеки динамической компоновки. Если я компилирую код на своей машине, а вы компилируете код на своей машине, и мы связываем их во время выполнения , как ваш компилятор может узнать, изменил ли я переменные или нет?
Mooing Duck
4
@MooingDuck Именно это. В более широком смысле, компилятор не компилирует функцию по отдельности, а компилирует ее как часть более широкой картины, которая не может полностью входить в сферу действия компилятора.
called2voyage 01
3
«невозможно» может быть преувеличением - «вычислительно невыполнимый» (как в NP-hard) может быть лучшей характеристикой, но ученику немного сложнее понять. Представьте себе связанный список или другую абстрактную структуру данных. Если я вызываю функцию, которая изменяет один узел в этом списке / дереве / чем угодно, как может компилятор когда-либо надеяться доказать, какой именно узел был изменен (и, возможно, что более важно, какие из них не изменились), без полного моделирования программы с помощью ожидаемый ввод, при этом не требуется 3 дня для компиляции одного исходного файла ...
twalberg 01
36
@twalberg Impossible - это не преувеличение, проблема с остановкой применяется здесь, как объясняют несколько ответов. Просто невозможно полностью алгоритмически проанализировать общую программу.
Fiktik 01
5
@twalberg Компиляторы, которые компилируют только часть допустимых программ, не очень полезны.
Калеб

Ответы:

139

Почему невозможно построить такой компилятор?

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

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

Вот простой пример:

void foo() {
    if (bar() == 0) this->a = 1;
}

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

Калеб
источник
48
@mrsoltys, квантовые компьютеры "только" экспоненциально быстрее для некоторых задач, они не могут решить неразрешимые проблемы.
zch 01
8
@mrsoltys Эти экспоненциально сложные алгоритмы (например, факторизация) идеально подходят для квантовых компьютеров, но проблема остановки - это логическая дилемма, ее невозможно вычислить, независимо от того, какой у вас «компьютер».
user1032613 01
7
@mrsoltys, просто чтобы быть умницей, да, это изменится. К сожалению, это будет означать, что алгоритм как завершен, так и все еще работает, к сожалению, вы не можете сказать, какой из них, не наблюдая напрямую, посредством чего вы влияете на фактическое состояние.
Натан Эрнст
9
@ ThorbjørnRavnAndersen: Хорошо, предположим, я выполняю программу. Как точно определить, прекратится ли он?
ruakh
8
@ ThorbjørnRavnAndersen Но если вы действительно выполняете программу, и она не завершается (например, бесконечный цикл), вы никогда не узнаете, что она не завершается ... вы просто продолжаете выполнять еще один шаг, потому что это может быть последний ...
MaxAxeHax
124

Представьте, что такой компилятор существует. Также предположим, что для удобства он предоставляет библиотечную функцию, которая возвращает 1, если переданная функция изменяет заданную переменную, и 0, если функция не изменяет. Тогда что должна печатать эта программа?

int variable = 0;

void f() {
    if (modifies_variable(f, variable)) {
        /* do nothing */
    } else {
        /* modify variable */
        variable = 1;
    }
}

int main(int argc, char **argv) {
    if (modifies_variable(f, variable)) {
        printf("Modifies variable\n");
    } else {
        printf("Does not modify variable\n");
    }

    return 0;
}
orlp
источник
12
Ницца! Парадокс « Я лжец», написанный программистом.
Krumelur 01
28
На самом деле это просто хорошая адаптация известного доказательства неразрешимости проблемы остановки .
Konstantin Weitz
10
В этом конкретном случае "modify_variable" должен возвращать истину: существует по крайней мере один путь выполнения, в котором переменная действительно изменена. И этот путь выполнения достигается после вызова внешней недетерминированной функции, поэтому вся функция недетерминирована. По этим двум причинам компилятор должен занять пезимистическую точку зрения и решить, что он действительно изменяет переменную. Если путь к изменению переменной достигается после того, как детерминированное сравнение (проверяемое компилятором) дает ложь (т.е. «1 == 1»), то компилятор может с уверенностью сказать, что такая функция никогда не изменяет переменную
Джо Пинеда,
6
@JoePineda: Вопрос в том, fизменяет ли переменную, а не может ли она изменить переменную. Это правильный ответ.
Neil G
4
@JoePineda: ничто не мешает мне скопировать / вставить код modifies_variableиз источника компилятора, полностью аннулируя ваш аргумент. (предполагая открытый исходный код, но суть должна быть ясна)
orlp
60

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

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

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

Итак, то, что кажется неожиданным заявлением о C ++, на самом деле является тривиальным заявлением обо всех языках.

BlueRaja - Дэнни Пфлугхофт
источник
5
Это лучший ответ, imho, важно проводить различие.
UncleZeiv 02
«банально невозможно»?
Кип
2
@Kip «тривиально невозможно решить», вероятно, означает «невозможно решить, и доказательство тривиально».
fredoverflow
28

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

void maybe(int& val) {
    cout << "Should I change value? [Y/N] >";
    string reply;
    cin >> reply;
    if (reply == "Y") {
        val = 42;
    }
}
dasblinkenlight
источник
«Конечно, можно создать компилятор, который проверяет, может ли функция C ++ изменять значение определенной переменной» Нет, это не так. См. Ответ Калеба. Чтобы компилятор знал, может ли foo () изменить a, он должен знать, может ли bar () вернуть 0. И нет вычислимой функции, которая могла бы сообщить все возможные возвращаемые значения любой вычислимой функции. Таким образом, существуют пути кода, так что компилятор не сможет определить, будут ли они когда-либо достигнуты. Если переменная изменяется только в пути кода, который не может быть достигнут, она не изменится, но компилятор не обнаружит ее
Мартин Эпс, 01
12
@MartinEpsz Под «может» я имею в виду «разрешено изменять», а не «возможно изменить». Я полагаю, что это то, что OP имел в виду, говоря о constпроверках -ness.
dasblinkenlight 01
@dasblinkenlight Я должен согласиться с тем, что я считаю, что OP, возможно, имел в виду первое, «разрешено изменение» или «может или не может измениться» по сравнению с «определенно не изменится». Конечно, я не могу представить себе сценарий, при котором это могло бы стать проблемой. Вы даже можете изменить компилятор так, чтобы он просто отвечал «может измениться» для любой функции, содержащей либо идентификатор, либо вызов функции, которая имеет атрибут ответа «может измениться». Тем не менее, C и C ++ - ужасные языки, чтобы пробовать это, поскольку они имеют такое расплывчатое определение вещей. Я думаю, что именно поэтому константность вообще будет проблемой в C ++.
DDS
@MartinEpsz: «И нет вычислимой функции, которая могла бы сообщить все возможные возвращаемые значения любой вычислимой функции». Я считаю, что проверка «всех возможных возвращаемых значений» - неправильный подход. Существуют математические системы (maxima, mathlab), которые могут решать уравнения, а значит, имеет смысл применить аналогичный подход к функциям. Т.е. рассматривать его как уравнение с несколькими неизвестными. Проблемы: управление потоком + побочные эффекты => неразрешимые ситуации. ИМО, без них (функциональный язык, без присваивания / побочных эффектов) можно было бы предсказать, какой путь пойдет программа
SigTerm 02
16

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

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

int y;

int main(int argc, char *argv[]) {
   if (argc > 2) y++;
}

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

Ларш
источник
7

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

Что невозможно знать в общем случае.

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

Однако вы должны использовать const, избегать глобальных переменных, предпочитать ссылки на указатели, избегать повторного использования переменных для несвязанных задач и т. Д., Что облегчит жизнь компилятору при выполнении агрессивных оптимизаций.

Крисс
источник
1
Если я правильно помню, в этом весь смысл функционального программирования, верно? Используя только чисто детерминированные функции без побочных эффектов, компиляторы могут выполнять агрессивную оптимизацию, предварительное выполнение, последующее выполнение, мемоизацию и даже выполнение во время компиляции. Дело в том, что я думаю, что многие из игнорируют отвечающими (или путать о) является то , что это действительно возможно для хорошо себя подмножество всех программ . И нет, это подмножество нетривиально и неинтересно, на самом деле оно очень полезно. Но в абсолютном общем случае это действительно невозможно.
Джо Пинеда
Перегрузка - это концепция времени компиляции. Вы, наверное, имели в виду «переопределенный метод».
fredoverflow
@FredOverflow: да, я имею в виду переопределение. Перегрузка действительно является концепцией времени компиляции. Спасибо, что заметили это (конечно, если реализация исходит из другой единицы компиляции, у компилятора могут возникнуть проблемы с ее анализом, но я не это имел в виду). Я исправлю ответ.
kriss
6

Есть несколько способов объяснить это, одна из которых - проблема остановки :

В теории вычислимости проблема остановки может быть сформулирована следующим образом: «Учитывая описание произвольной компьютерной программы, решите, завершит ли программа выполнение или продолжит выполнение бесконечно». Это эквивалентно проблеме принятия решения при наличии программы и ввода, остановится ли программа в конечном итоге при запуске с этим вводом или будет работать вечно.

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

Если я напишу программу, которая выглядит так:

do tons of complex stuff
if (condition on result of complex stuff)
{
    change value of x
}
else
{
    do not change value of x
}

Изменится ли стоимость x? Чтобы определить это, вам сначала нужно определить, do tons of complex stuffвызывает ли деталь условие срабатывания или, что более важно, останавливается ли оно. Этого компилятор сделать не может.

Тимоти Шилдс
источник
6

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

Представьте, что компилятор может определить, изменила ли функция значение переменной. Тогда он определенно сможет определить, изменяет ли следующая функция значение y или нет, предполагая, что значение x можно отслеживать во всех вызовах остальной части программы:

foo(int x){
   if(x)
       y=1;
}

Теперь для любой понравившейся программы давайте перепишем ее так:

int y;
main(){
    int x;
    ...
    run the program normally
    ...
    foo(x);
}

Обратите внимание, что если и только если наша программа изменяет значение y, она затем завершается - foo () - это последнее, что она делает перед завершением. Это означает, что мы решили проблему остановки!

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

Джон Дусетт
источник
Я не уверен, что понимаю ваши рассуждения о том, почему наша программа завершается, если она меняет значение y. Мне кажется, он foo()быстро возвращается, а затем main()уходит. (Кроме того, вы звоните foo()без аргументов ... это часть моего замешательства.)
LarsH 02
1
@LarsH: Если модифицированная программа завершается, последней вызванной ею функцией была функция f. Если y был изменен, вызывается f (другие операторы не могут изменить y, поскольку он был введен только модификацией). Следовательно, если y был изменен, программа завершается.
MSalters 02
4

Как только функция вызывает другую функцию, источник которой компилятор не «видит», она либо должна предположить, что переменная изменена, либо что-то может пойти не так, как описано ниже. Например, скажем, у нас есть это в "foo.cpp":

 void foo(int& x)
 {
    ifstream f("f.dat", ifstream::binary);
    f.read((char *)&x, sizeof(x));
 }

и у нас есть это в bar.cpp:

void bar(int& x)
{
  foo(x);
}

Как компилятор может «знать», что xне меняется (или, точнее, изменяется) bar?

Я уверен, что мы можем придумать что-то более сложное, если это будет недостаточно сложно.

Матс Петерссон
источник
Компилятор может знать, что x не изменяется в bar, если bar x передается как pass-by-reference-to-const, верно?
Игрок в крикет,
Да, но если я добавлю const_castв foo, он все равно внесет xизменения - я нарушил бы контракт, в котором говорится, что вы не должны изменять constпеременные, но поскольку вы можете преобразовать что угодно в "more const" и const_castсуществует, Разработчики языка наверняка имели в виду, что иногда есть веские причины полагать, что constценности могут нуждаться в изменении.
Mats Petersson
@MatsPetersson: Я считаю, что если вы используете const_cast, вы сможете сохранить все ломающиеся части, потому что компилятор может это компенсировать, но не обязан.
Zan Lynx
@ZanLynx: Да, я уверен, что это правильно. Но в то же время приведение типов действительно существует, а это означает, что у кого-то, кто разработал язык, была какая-то идея, что «нам это может понадобиться в какой-то момент» - что означает, что он не предназначен для того, чтобы вообще ничего не делать.
Mats Petersson
1

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

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

Крумелур
источник
2
Одна вещь, которую я хотел бы поддерживать в языках, - это различие между эфемерными, возвращаемыми и устойчивыми ссылками (или указателями). Эфемерные ссылки могут быть скопированы только в другие эфемерные ссылки, возвратные могут быть скопированы в эфемерные или возвратные, а постоянные могут быть скопированы любым способом. Возвращаемое значение функции будет ограничено наиболее строгим из аргументов, которые передаются как «возвращаемые» параметры. Я считаю досадным, что во многих языках при передаче ссылки нет ничего, что указывало бы, как долго она может использоваться.
supercat 01
Это, безусловно, было бы полезно. Конечно, для этого есть шаблоны, но в C ++ (и многих других языках) всегда можно «схитрить».
Krumelur 01
Основным преимуществом .NET над Java является то, что он имеет концепцию эфемерной ссылки, но, к сожалению, у объектов нет возможности отображать свойства в виде эфемерных ссылок (то, что я действительно хотел бы видеть, было бы средством какой код, использующий свойство, будет передавать эфемерную ссылку на код (вместе с временными переменными), который должен использоваться для управления объектом.
supercat 01
1

Чтобы сделать вопрос более конкретным, я предполагаю, что автор книги мог иметь в виду следующий набор ограничений:

  1. Предположим, что компилятор изучает поведение конкретной функции в отношении константы переменной. Для корректности компилятор должен предположить (из-за псевдонима, как объяснено ниже), если функция вызвала другую функцию, переменная будет изменена, поэтому предположение № 1 применимо только к фрагментам кода, которые не вызывают вызовов функций.
  2. Предположим, что переменная не изменяется в результате асинхронного или одновременного действия.
  3. Предположим, что компилятор только определяет, можно ли изменить переменную, но не будет ли она изменена. Другими словами, компилятор выполняет только статический анализ.
  4. Предположим, что компилятор учитывает только правильно работающий код (не учитывая переполнения / недоработки массива, неверные указатели и т. Д.)

В контексте разработки компилятора, я думаю, что предположения 1,3,4 имеют смысл с точки зрения автора компилятора в контексте правильности генерации кода и / или оптимизации кода. Предположение 2 имеет смысл при отсутствии ключевого слова volatile. И эти предположения также фокусируют вопрос достаточно, чтобы сделать оценку предлагаемого ответа гораздо более окончательной :-)

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

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

Χpẘ
источник
0

Даже если переменная объявлена const, это не значит, что какой-то плохо написанный код может ее перезаписать.

//   g++ -o foo foo.cc

#include <iostream>
void const_func(const int&a, int* b)
{
   b[0] = 2;
   b[1] = 2;
}

int main() {
   int a = 1;
   int b = 3;

   std::cout << a << std::endl;
   const_func(a,&b);
   std::cout << a << std::endl;
}

вывод:

1
2
Марк Лаката
источник
Это происходит потому, что aи bявляются переменными стека и b[1]просто находятся в том же месте памяти, что и a.
Марк Лаката
1
-1. Undefined Behavior снимает все ограничения на поведение компилятора.
MSalters 02
Не уверен в голосовании против. Это просто пример, который относится к исходному вопросу OP о том, почему компилятор не может выяснить, действительно constли что- то действительно, если все помечено const. Это потому, что неопределенное поведение является частью C / C ++. Я пытался найти другой способ ответить на его вопрос, вместо того, чтобы упоминать проблему остановки или вмешательство человека.
Марк Лаката 02
0

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

Как я уже сказал, эта книга пытается сказать: «Давайте заставим бесконечное количество обезьян написать все мыслимые функции C ++, которые когда-либо могли быть написаны. Будут случаи, когда мы выберем переменную, которая (какую-то конкретную функцию, которую написали обезьяны) использует, мы не можем понять, изменит ли функция эту переменную ".

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

Эту функцию легко проанализировать так:

static int global;

void foo()
{
}

"foo" явно не изменяет "global". Он вообще ничего не меняет, и компилятор может с этим легко справиться.

Эту функцию нельзя так анализировать:

static int global;

int foo()
{
    if ((rand() % 100) > 50)
    {
        global = 1;
    }
    return 1;

Поскольку действия "foo" зависят от значения, которое может изменяться во время выполнения , во время компиляции явно невозможно определить , изменит ли он "global".

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

Эль Зорко
источник
то, что вы говорите, верно, но даже для очень простых программ, о которых все известно во время компиляции, вы не сможете ничего доказать, даже то, что программа остановится. Это проблема остановки. Например, вы можете написать программу на основе Hailstone Sequences en.wikipedia.org/wiki/Collatz_conjecture и заставить ее возвращать истину, если она сходится к единице. Компиляторы не смогут этого сделать (так как во многих случаях произойдет переполнение), и даже математики не знают, правда это или нет.
Крисс
Если вы имеете в виду «Есть некоторые очень простые выглядящие программы , для которых вы не можете ничего доказать» Я полностью согласен. Но классическое доказательство Тьюринга проблемы остановки по существу полагается на способность самой программы определить, останавливается ли она, чтобы создать противоречие. Поскольку это математика, а не реализация. Конечно, есть программы, в которых во время компиляции можно статически определить, будет ли изменена конкретная переменная и остановится ли программа. Возможно, это невозможно доказать математически, но в некоторых случаях это практически достижимо.
El Zorko