|
|
Перевод блога Эрика Липперта
-
В C# есть много правил, спроектированных для предотвращения некоторых обычных источников ошибок и поощрения хороших практик программирования. Так много, на самом деле, что частенько достаточно сложно разобраться, какое конкретно правило было нарушено. Я решил, что мог бы потратить некоторое время на обсуждение различных правил. Мы закончим головоломкой. Для начала, жизненно важно понимать разницу между областью видимости и пространством деклараций. Чтобы осежить вашу память о моей давней статье: область видимости сущности – это регион текста, в котором к сущности можно обращаться по её неквалифицированному имени. Пространство деклараций – это регион текста, в котором две вещи не могут иметь одинаковое имя (за исключением методов, различающихся сигнатурами). «Пространство деклараций локальных переменных» является конкретным вариантом пространств деклараций, используемым для объявления локальных переменных; пространства деклараций локальных переменных имеют особые правила по определению того, когда они перекрываются. Следующая вещь, которую вам надо понять, чтобы извлечь из этого какой-то смысл, это что такое «простое имя». Простое имя это всегда либо обычный идентификатор, как «x», или, в некоторых случаях, обычный идентификатор, за которым следует список типов-аргументов, как «Frob<int, string>». Компилятор трактует множество вещей как «простые имена»: объявления локальных переменных, параметры лямбд, и так далее, всегда имеют первую форму простого имени в своих объявлениях. Когда вы пишете «Console.WriteLine(x);», простыми именами являются «Console» и «x», но не «WriteLine». К общему замешательству, есть текстовые сущности, которые имеют форму простых имён, но не трактуются как простые имена компилятором. Возможно, мы поговорим о некоторых из этих ситуаций в будущих невероятных приключениях. Так что, без дальнейших церемоний, вот несколько относящихся к теме правил, которые часто путают. Особенно затруднительными люди находят правила 3 и 4. 1) Нельзя ссылаться на локальную переменную до её объявления. (Надеюсь, это выглядит разумно) 2) Нельзя иметь две локальных переменных с одним именем в одном пространстве декларации локальных переменных или во вложенных пространствах декларации локальных переменных 3) Область видимости локальных переменных простирается на весь блок, где размещено объявление. Это отличается от C++, где область видимости локальной переменной начинается только после места объявления. 4) Для каждого вхождения простого имени, будь то в объявлении или как части выражения, все использования этого простого имени внутри непосредственно включающего пространства декларации локальных переменных должны ссылаться на одну и ту же сущность. Цель всех этих правил – в предотвращении класса багов, в которых человек, читающий или поддерживающий код, обманывается в том, что ссылается на одну сущность посредством простого имени, но на деле случайно ссылается на совсем другую сущность. В частности, эти правила спроектированы для предотвращения неприятных сюрпризов при выполнении того, что должно быть безопасным рефакторингом. Рассмотрим мир, в котором у нас нет правил 3 и 4. В том мире, этот код будет разрешён: class C { int x; void M() { // 100 строк кода x = 20; // означает «this.x»; Console.WriteLine(x); // означает «this.x» // 100 строк кода int x = 10; Console.WriteLine(x); // означает «локальный x» } } Это затрудняет жизнь читателю кода, у которого есть разумное предположение, что обе строчки «Console.WriteLine(x)» на деле печатают содержимое одной и той же переменной. Но это особенно неприятно по отношению к программисту поддержки, который хочет применить обоснованный стандарт кодирования к этому фрагменту. «Локальные переменные объявляются в начале блока, в котором используются» - вполне обоснованный стандарт кодирования, принятый во многих компаниях. Но замена кода на class C { int x; void M() { int x; // 100 строк кода x = 20; // уже не означает «this.x»; Console.WriteLine(x); // уже не означает «this.x» // 100 строк кода x = 10; Console.WriteLine(x); // означает «local x» } } меняет смысл кода! Мы хотим порицать написание многосотстрочных методов, но затруднение и внесение ошибок в процесс их рефакторинга во что-то более понятное – не лучший способ достичь этой цели. Заметьте, что в оригинальной версии программы, правило 3 означает, что программа нарушает правило 1 – первое использование «x» трактуется как ссылка на локальную переменную до её объявления. Тот факт, что она нарушает правило 1 из-за правила 3 как раз и есть в точности то, что не даёт ей нарушить правило 4! Значение «x» непротиворечиво во всём блоке; он везде означает локальную переменную, которая, таким образом, иногда используется до объявления. Если бы мы вычеркнули правило 3, то это было бы нарушением правила 4, потому что тогда бы мы имели два противоречивых значения простого имени «x» в пределах одного блока. Теперь, эти правила не означают, что вы можете рефакторить тяп-ляп. Мы всё ещё можем сконструировать ситуации, в которых похожие рефакторинги ломаются. Например; class C { int x; void M() { { // 100 строк кода x = 20; // означает "this.x"; Console.WriteLine(x); // означает "this.x" } { // 100 строк кода int x = 10; Console.WriteLine(x); // означает "local x" } } } Это полностью легально. У нас одно и то же простое имя используется двумя разными способами в двух разных блоках, но непосредственно включающие блоки каждого использования не перекрываются. Локальная переменная видима во всём своём непосредственно включающем блоке, но блок не перекрывается с блоком выше. В этом случае, безопасно переносить объявление переменной в начало её блока, но небезопасно переносить его в начало внешнего блока; это изменит смысл «x» в первом блоке. Перемещение объявления вверх почти всегда безопасно; перемещение наружу не обязательно безопасно. Теперь, когда вы всё это знаете, вот головоломка для вас, головоломка, которую я понял полностью неправильно, когда впервые увидел: using System.Linq; class Program { static void Main() { int[] data = { 1, 2, 3, 1, 2, 1 }; foreach (var m in from m in data orderby m select m) System.Console.Write(m); } } Имя «m» тут явно используется несколько раз в разных смыслах. Легальна ли эта программа? Если да, то почему правила по запрету повторного использования простых имён не срабатывают? Если нет, то какое именно правило здесь нарушено? [Эрик в отпуске, пост был предварительно записан]
|
-
Вот утверждение, которое я прочитал вчера про сравнения объектов ссылочного типа в C#: Object.ReferenceEquals(x,y) возвращает true если, и только если, x и y ссылаются на один и тот же объект. Правда или нет? Моя жена Лея недавно купила Хонду Фит, спасибо имманентному отказу в соленоидах автоматической коробки передач её старой Хонды Цивик. Задние сиденья Фита складываются в плоскость. В эту штуку можно загнать ламу, или целую кучу хула-хупов, или всё что угодно. Это весьма удобно. Не то, что я называю мощным движком, но для быстрых поездок по городу она – то, что надо. Поскольку мы были женаты, когда она купила машину, и продолжаем состоять в браке, что моё – то её, и что её – то моё. Так что если x = Хонда Фит Эрика, а y = Хонда Фит Леи, то x и y «ссылочно эквивалентны». Эти две вещи ссылаются на один и тот же объект, тот блестящий чёрный объект, полный лам и хула-хупов, который стоит возле моего дома. Теперь, мы могли купить другую машину. Скажем, Форд Фокус. Но мы не купили. У нас в сумме ноль Фордов Фокусов. Предположим, я сказал, что x = Форд Фокус Эрика, а y = Форд Фокус Леи. Каков осмысленный способ охарактеризовать природу x и y? Должны ли мы сказать, что обе они ссылаются на один и тот же Форд Фокус, а именно на несуществующий Форд Фокус? Разум колеблется от неприемлемого и парадоксального предположения о том, что где-то существует Форд Фокус, который является несуществующим Фордом Фокус!(*) Скорее, верный способ охарактеризовать это в том, чтобы сказать, что ни x ни y не ссылаются ни на какой объект. Это «пустые ссылки» - ссылки, которые ни на что не ссылаются, а, скорее, имеют значение «отсутствия ссылаемого». И именно поэтому неверно говорить, что Object.ReferenceEquals(x,y) возвращает true если, и только если, x и y ссылаются на один и тот же объект. Если x и y не ссылаются на объекты, то они явно не ссылаются на один и тот же объект, потому что никто из них не ссылается на объект вообще. Корректный способ охарактеризовать поведение ссылочной эквивалентности в том, что Object.ReferenceEquals(x,y) возвращает true если, и только если, либо x и y ссылаются на один и тот же объект, либо и x и y – пустые ссылки. *********** (*) И я по-прежнему фанат «null object pattern». Жизнь полна этих маленьких противоречий. [Эрик на этой неделе в отпуске, пост был предварительно записан]
|
-
Опять это как-то случилось; люди не перестают записывать меня на видео и выкладывать в интернет. В этих записях вы узнаете, как я выгляжу при освещении сверху и сзади. Типа шпионски. Нам надо было выключить в комнате свет и держать фонарик у меня под лицом. Это было бы, вроде, в десять раз страшнее. В общем, если вам интересна моя болтовня про мою любимую возможность в C#4, ко- и контравариантность интерфейсов и делегатов, то вот тут два маленьких демо ролика: часть один, часть два. (Там, похоже, местами есть небольшие проблемы с синхронизацией звука, но на самом деле это не проблема; большая часть звука – голос за кадром.) Чарли был безумно занят сбором этих маленьких роликов; вот ещё немного из его недавних достижений, включая некоторые неплохие с моими коллегами Крисом и Сэмом, рассказывающими про все остальные намного более восхитительные возможности C# 4.0: динамический интероп, улучшение интеропа с Office, именованные и опциональные параметры, и так далее. Ссылки на все наши новые ролики здесь: http://blogs.msdn.com/charlie/archive/2009/10/19/community-convergence-lvi.aspx. Желаю весёлого и безопасного Хэллоуина – в этом году я поеду на Хэллоуинские вечеринки на маленьком острове, просто чтобы сменить темп. [Эрик на этой неделе в отпуске, пост был предварительно записан]
|
-
Предостережение: я не эксперт по многопоточному программированию. На самом деле, я бы даже не стал утверждать, что я в нём компетентен. За всю мою карьеру, необходимость написать код, который запускает второй рабочий поток, возникала, вероятно, менее полудюжины раз. Так что воспринимайте всё, что я пишу на эту тему, с некоторым скептицизмом. Вопрос, который мне часто задают: «потокобезопасен ли этот код?». Для ответа на этот вопрос, нам явно нужно знать, что означает «потокобезопасен». Но, есть кое-что, что я хочу прояснить перед тем, как мы погрузимся в это. Вопрос, который мне задают значительно реже – «Эрик, почему Мишель Пфайфер всегда так хорошо выглядит на фотографиях?» За помощью в ответе на этот животрепещущий вопрос я обратился к Википедии: «Фотогеничный субъект – это субъект, который обычно оказывается физически привлекательным или красивым на фотографиях.» Почему Мишель Пфайфер всегда так хорошо выглядит на фотографиях? Потому, что она фотогенична. Очевидно. Ну, хорошо, что мы разгадали эту тайну, но, похоже, я несколько отклонился от предмета обсуждения. Википедия столь же полезна в определении потокобезопасности: «Код потоково-безопасный, если он функционирует корректно при использовании из нескольких потоков одновременно.» Как и с фотогеничностью, это очевидно, провоцирует вопросы. Когда мы спрашиваем «является ли этот код потокобезопасным?», то на самом деле мы хотим узнать «является ли этот код корректным, будучи вызван определённым образом?» Так как же мы определим, корректен ли код? Мы, собственно, ничего здесь не объяснили. Википедия продолжает: «В частности, он должен обеспечивать корректный доступ нескольких потоков к разделяемым данным» Это выглядит честным; этот сценарий почти всегда и есть то, что люди имеют в виду, говоря о потокобезопасности. Но затем: «и обеспечивать доступ к фрагменту разделяемых данных только одному потоку в каждый момент времени.»† Теперь мы говорим о техниках обеспечения потокобезопасности, а не об определении того, что означает потокобезопасность. Блокировка данных, так, чтобы к ним мог обращаться только один поток за раз – это только одна из возможных техник обеспечения потокобезопасности; это само по себе не определение потокобезопасности.* Мой аргумент не в том, что определение неверно; как неформальное определение потокобезопасности это не слишком ужасно. Скорее, мой аргумент в том, что определение показывает что сама концепция абсолютно туманна и, в сущности, означает не более, чем «ведёт себя корректно в некоторых ситуациях». Так что, когда меня спрашивают «потокобезопасен ли этот код?», мне всегда приходится отступать и спрашивать «о каких конкретно многопоточных сценариях вы беспокоитесь?» и «какое конкретно поведение объекта корректно в каждом из этих сценариев?» Проблемы коммуникации возникают тогда, когда люди с различными ответами на эти вопросы пытаются общаться о потокобезопасности. Например, представьте, что я сказал вам, что у меня есть «потокобезопасная изменяемая очередь», которую вы можете использовать в своей программе. Вы затем радостно пишете следующий код, который выполняется в одном потоке, пока другой поток занят добавлением и удалением элементов из изменяемой очереди: if (!queue.IsEmpty) Console.WriteLine(queue.Peek()); Затем ваш код падает, когда Peek бросает QueueEmptyException. Что происходит? Я же сказал, что штуковина потокобезопасна, но ваш код всё равно падает в многопоточном сценарии. Когда я говорил «очередь потокобезопасна», я имел виду, что очередь поддерживает своё внутреннее состояние целостным, независимо от того, каков порядок отдельных операций, выполняемых в других потоках. Но я не имел в виду, что вы можете использовать мою очередь в произвольном сценарии, который требует поддержания логической целостности между несколькими последовательными операциями. Короче, моё мнение насчёт «корректного поведения» и ваше мнение на ту же тему разошлись, потому, что подразумеваемые нами сценарии были совершенно различны. Меня беспокоило только отсутствие сбоев, но вам важно иметь возможность логически рассуждать об информации, возвращаемой из каждого вызова метода. В этом примере мы с вами, вероятно, говорим о различных видах потокобезопасности. Потокобезопасность изменяемых структур данных обычно сводится к гарантии, что операции над разделяемыми данными всегда работают с самым свежим состоянием разделяемых данных по мере изменения, даже если это означает, что некоторая комбинация операций оказывается логически несогласованной, как в нашем примере выше. Потокобезопасность неизменяемых структур данных сводится к гарантии того, что использование данных во всех операциях логически согласовано, ценой того факта, что вы смотрите на неизменяемый отпечаток данных, который может оказаться устаревшим. Проблема здесь в том, что выбор, получать ли доступ к первому элементу, основан на «несвежих» данных. Проектирование полностью потокобезопасной изменяемой структуры данных в мире, где ничему не позволено быть устаревшим может быть весьма сложным. Подумайте, что бы вам пришлось сделать, чтобы операция «Peek» выше стала реально потокобезопасной. Вам бы потребовался новый метод: if (!queue.Peek(out first)) Console.WriteLine(first); «Потокобезопасно» ли это? Выглядит определённо лучше. Но что, если после Peek, другой поток опустошает очередь? Теперь вы не падаете, но вы значительно изменили поведение предыдущей программы. В предыдущей программе, если после проверки в другом потоке выполнилась операция, изменившая статус первого элемента, то вы либо падали, либо печатали свежий первый элемент очереди. Теперь вы печатаете устаревший первый элемент. Корректно ли это? Нет, если вы хотите всегда оперировать свежими данными! Но, минуточку – на самом деле, в предыдущей версии кода тоже была эта проблема. Что, если из очереди извлекли элемент в другом потоке после того, как завершился вызов Peek, но до того, как выполнился вызов Console.WriteLine? Опять же, вы бы вывели устаревшие данные. Что, если вы хотите гарантировать, что всегда печатаете свежие данные? Вот, что нужно вам на самом деле, чтобы сделать это потокобезопасным: queue.DoSomethingToHead(first=>{Console.WriteLine(first);}); Теперь автор очереди и пользователь очереди договорились о том, какие сценарии нужны, так что это и вправду потокобезопасно. Правильно? За исключением... в том делегате может быть что-то суперсложное. Что, если код делегата случайно вызывает событие, которое заставляет выполниться код в другом потоке, который в свою очередь запускает некую операцию с очередью, которая затем блокируется таким способом, что мы получили взаимоблокировку? Является ли взаимоблокировка «корректным поведением»? И если нет, то является ли этот метод истинно «безопасным»? Буэ. Теперь, я уверен, вы поняли, к чему я клоню. Как я указывал ранее, нет смысла говорить, что здание или кусок кода «безопасны» без некоторого описания того, от каких угроз применяемый механизм безопасности защищает, а от каких – нет. Аналогично, нет смысла говорить, что код потокобезопасен без некоторого описания того, какие виды нежелательного поведения предотвращаются применяемыми механизмами потокобезопасности, а какие – нет. «Потокобезопасность» - это ни больше, ни меньше, как контракт кода, как и любой другой контракт кода. Вы соглашаетесь общаться с объектом определённым образом, и он соглашается возвращать вам корректные результаты, если вы так делаете; выяснение того, каков именно этот образ, и что считать корректным результатом, представляет собой потенциально сложную задачу. ************ (†) В русскоязычной Википедии этот фрагмент определения отсутствует, есть в оригинале. – прим. перев. ************ (*) Да, я в курсе, что если я думаю, что что-то в Википедии неверно, то я могу это изменить. Есть две причины, по которым мне не стоит этого делать. Во-первых, как я уже заявил, я не эксперт в этой области; я оставляю экспертам разобраться между собой, что здесь будет правильно сказать. И во-вторых, смысл моего утверждения не в том, что страничка в Википедии неверна, а скорее в том, что она иллюстрирует неопределённость термина по его природе. – Эрик.
|
-
Пользователь: Недавно я обнаружил в C# странное поведение относительно деления на ноль чисел с плавающей запятой. Оно не бросает исключение, как целочисленное деление, а возвращает «бесконечность». С чего бы это? Эрик: Как я частенько говорил, мне трудно отвечать на вопросы «почему». Обычно моя первая попытка ответить на вопрос «почему» - «потому, что так гласит спецификация»; этот случай не исключение. Спецификация С# в секции 4.1.6 требует делать именно так. Но мы это делаем только потому, что так предписывает стандарт IEEE по арифметике с плавающей запятой. Мы хотим быть совместимыми с признанным промышленным стандартом. Подробности есть в стандарте IEEE №754-1985. Большая часть арифметики с плавающей запятой в наше время делается аппаратно, и большая часть железа совместима с этой спецификацией. Пользователь: А мне кажется, что деление на ноль – это баг, как на него ни посмотри! Эрик: Ну, поскольку это очевидно не совпадает с тем, как на это смотрели члены комитета по стандартизации IEEE в 1985 году, ваше утверждение, что это должно быть багом «как на него не посмотри», должно быть, некорректно. Пользователь: Хороший аргумент. А что послужило причиной такого решения? Эрик: Меня там не было; я в тот момент был занят игрой в Jumpman-а на моём Commodore 64. Но могу обоснованно предположить, что желательно, чтобы все возможные операции над всеми плавающими числами возвращали строго определённый плавающий результат. Математики назвали бы это свойством «замкнутости»; то есть, множество чисел с плавающей запятой «замкнуто» относительно всех операций. Положительная бесконечность выглядит разумным выбором для деления положительного числа на ноль. Она выглядит убедительно потому, конечно же, что предел 1 / x при x, стремящемся к нулю (сверху) - это «положительная бесконечность», так почему бы не положить 1/0 равным числу «положительная бесконечность»? Вообще-то, рассуждая как математик, я нахожу этот аргумент фальшивым. Нечто и предел этого нечто не обязаны иметь каких-то общих свойств; неправомерно рассуждать, что только потому, что, например, у последовательности есть некоторый предел, то факт о пределе является фактом о последовательности. Математически, «положительная бесконечность» (в смысле предела вещественнозначной функции; давайте оставим трансфинитные ординалы, гиперболическую геометрию, и весь этот прочий хлам за пределами этой дискуссии) вообще не число, и не должна трактоватьс я как таковое; скорее, это краткий способ сказать «предела не существует, поскольку последовательность расходится вверх». Когда мы делим на ноль, мы, в сущности, говорим «реши нам уравнение x * 0 = 1»; ответ на эту задачу не «положительная бесконечность», а «я не могу, потому что решения этого уравнения не существует». Это то же самое, что и просить решить уравнение «x + 1 = x» - сказать, что «x равен плюс бесконечности» не решение; решения вовсе нет. Но, с точки зрения практичного инженера, который использует числа с плавающей запятой для неточной аппроксимации идеальной арифметики, это выглядит полностью оправданным выбором. Пользователь: Но ведь в железе совершенно невозможно представить «бесконечность». Эрик: Это определённо возможно. У вас есть 32 бита в плавающем числе одинарной точности; это более четырёх миллиардов возможных чисел. Все битовые последовательности вида ?11111111??????????????????????? Зарезервированы для «нечисловых» значений. Это больше шестнадцати миллионов возможных NaN-комбинаций. Две из этих шестнадцати миллионов возможных NaN-последовательностей зарезервированы для обозначения положительной и отрицательной бесконечностей. Положительная бесконечность задаётся последовательностью 01111111100000000000000000000000, а отрицательная – 11111111100000000000000000000000. Пользователь: Все ли языки и приложения используют это соглашение деление-на-ноль-даёт-бесконечность? Эрик: Нет. Например, С# и Jscript используют, а VBScript – нет. VBScript даёт ошибку при попытке поделить на ноль. Пользователь: Как тогда реализаторы языков получают нужное поведение, если эта семантика реализуется аппаратно? Эрик: Есть две основных техники. Во-первых, многие чипы, реализующие этот стандарт, позволяют программисту сделать плавающее деление на ноль исключением, а не бесконечностью. У чипа 80x87, к примеру, можно использовать второй бит регистра контроля точности, чтобы определить, будет ли деление на ноль возвращать бесконечность, или бросать аппаратное исключение. Во-вторых, если вы хотите, чтобы это было программное исключение, а не аппаратное, то можно проверять второй бит регистра статуса после каждого деления; в нём записано, произошло ли только что событие деления на ноль. Эта стратегия используется в VBScript; после выполнения операции деления мы проверяем, не записана ли в регистре статуса операция деления на ноль; если так, то среда выполнения VBScript создаёт ошибку деления на ноль, и выполняется обычный процесс обработки ошибок VBScript, также, как и для любой другой ошибки. Похожие биты существуют и для других операций, которые возможно трактовать как исключения, вроде численного переполнения. Существование битов «аппаратных исключений» создаёт проблемы реализаторам современных языков, потому что теперь мы часто оказываемся в мире, где код, написанный на нескольких языках нескольких производителей, выполняется в одном процессе. Аппаратные управляющие биты являются «глобальным состоянием», и все мы знаем, как раздражает наличие глобального публичного состояния, по которому может топтаться произвольный код. Например: я, возможно, неправильно помню некоторые детали, но, по-моему, элементы управления, написанные на Delphi, устанавливают бит «переполнения вызывают исключение». То есть, авторы Delphi не использовали стратегию VBScript «попробуй это, дай ему закончиться, и проверь, не установлен ли бит переполнения в регистре статуса». Вместо этого они последовали стратегии «дай железу выбросить исключение, а потом поймай его». Это крайне неудачно. Когда VBScript вызывает элемент управления, написанный на Delphi, тот переключает бит для выброса исключений, но никогда не переключает его обратно. Если, при дальнейшем выполнении скрипта, программа на VBScript натыкается на переполнение, то мы получаем необработанное аппаратное исключение, потому что бит всё еще установлен, несмотря на то, что элемента управления из Delphi уже давно нет! Я починил это при помощи сохранения состояния регистра управления перед вызовом компонента, и восстановления после возврата управления. Это неидеально, но больше мы ничего не можем сделать. Пользователь: Весьма поучительно! Я передам эту информацию своим сослуживцам. Я бы с удовольствием увидел постинг в блоге на эту тему. Эрик: А вот и он!
|
-
Отсутствие доказательств – не доказательство отсутствия Сегодня – ещё два слегка неверных мифа о C#. Как вы, вероятно, знаете, C# требует, чтобы всем локальным переменным были явно присвоены значения перед тем, как из них читают, но предполагает, что всем полям экземпляра класса изначально присвоены их значения по умолчанию. Порой я слышу такое объяснение причин этого: «компилятор может легко доказать, что локальная переменная не проинициализирована, но значительно труднее доказать отсутствие инициализации для поля экземпляра. И, поскольку конструктор по умолчанию автоматически инициализирует все поля экземпляра значениями по умолчанию, то для полей проводить анализ не нужно». Оба утверждения слегка неверны. Первое утверждение неверно потому, что на самом деле компилятор не способен, и не пытается доказать, что локальная переменная неинициализирована. Доказательство этого (1) невозможно, и (2) не даёт нам никакой полезной информации для следующих действий. Невозможно оно потому, что доказательство наличия присваивания значения заданной переменной эквивалентно решению Задачи Останова: int x; if (/*тут условие, требующее решения задачи останова*/) x = 10; print(x); Если бы мы хотели доказать, что x неинициализирована, то нам бы во время компиляции пришлось бы доказать ложность условия. Наш компилятор не настолько изощрён! Но более глубокий момент тут в том, что мы не заинтересованы в уверенном доказательстве того, что x неинициализирована. Мы заинтересованы в уверенном доказательстве того, что x инициализирована! Если мы можем это уверенно доказать, то x «определённо инициализирована». Если мы не можем с уверенностью этого доказать, то x «не является определённо инициализированной». В «определённо неинициализирована» мы заинтересованы постольку, поскольку это более сильная версия утверждения «не определённо инициализирована». Если из x читают в момент, когда она «не определённо инициализирована», то это ошибка. Так что, мы пытаемся доказать, что x инициализирована, и неспособность доказать это в каждой точке, где происходит чтение x и приводит к порождению ошибки. Эта наспособность может быть вызвана как о честной ошибкой в вашей программе, так и чрезмерной консервативностью нашего анализатора потоков выполнения. Например: int x, y = 0; if (0 * y == 0) x = 10; print(x); Мы с вами знаем, что x определённо инициализирована, но в C# 3 компилятор осознанно недостаточно умён, чтобы это доказать. (Занятно, что он был достаточно умён в C# 2. Я сломал это, чтобы привести компилятор в соответствие со спецификацией; быть умнее в противоречие спецификации не всегда хорошо.) Этот пример снова демонстрирует, что мы не доказываем неинициализированность x; если бы мы это доказали, то наша доказывалка содержала бы ошибку, так как мы с вами знаем, что x определённо инициализирована. Скорее, нам не удаётся доказать, что x инициализирована. Это интересный вариант спора между скептиками и легковерами, который происходит следующим образом: скептик говорит «нет надёжных подтверждений существования йети, так что йети не существует». Легковер отвечает «отсутствие подтверждения не является подтверждением отсутствия; так что да, йети существуют». В обоих случаях, рассуждения с позиций отсутствия надёжных подтверждений редко приносят пользу! Но в нашем случае, именно потому, что нам не хватает надёжных подтверждений, мы и приходим к выводу, что наших знаний недостаточно, чтобы позволить вам читать x. (Принцип, позволяющий на основе отсутствия надёжного подтверждения сделать предварительное заключение о мифичности йети – «экстраординарные утверждения требуют экстраординарных подтверждений». Разумно предполагать, что экстраординарное утверждение ложно, до получения надёжного подтверждения. Когда чрезвычайно надёжное подтверждение находится для экстраординарного утверждения – например, экстраординарного утверждения о том, что само время замедляется, когда ты движешься быстрее, - то имеет смысл поверить в это экстраординарное утверждение. Чрезвычайно надёжные подтверждения были получены для теории относительности, но не для теории йети.) Второй миф в том, что конструктор по умолчанию инициализирует поля их значениями по умолчанию. Это можно опровергнуть несколькими аргументами. Во-первых, класс не обязан иметь конструктор по умолчанию, и тем не менее, его поля всегда обнаруживаются изначально проинициализированными. Если конструктора по умолчанию нет, то кто-то другой должен инициализировать поля. Во-вторых, даже если у класса есть конструктор по умолчанию, то нет гарантии, что он будет вызван. Может быть использован какой-то другой конструктор. В-третьих, инициализаторы полей класса выполняются до того, как выполнится тело любого конструктора, так что тело конструктора не может выполнять инициализацию, иначе мы бы затирали результаты инициализаторов полей. В-четвёртых, конструкторы могут вызвать другие конструкторы; если бы каждый из этих конструкторов инициализировал поля нулями, то это было бы расточительно; мы бы без необходимости переинициализировали уже обнулённые поля. На самом деле происходит то, что аллокатор памяти CLI гарантирует, что память, выделенная экземпляру класса, будет вся инициализирована нулями до того, как произойдёт вызов конструктора. К моменту, когда запускается конструктор, объект уже свежеобнулён и готов ко всему.
|
-
Большинство людей скажут, чт о разница между «(Alpha)bravo» и «bravo as Alpha» в том, что первое бросает исключение при неуспехе преобразования, а последнее возвращает null. Хоть это и правильно, и это самая очевидная разница, дело не только в этом. Здесь есть ловушки, которых нужно остерегаться. Во-первых, поскольку результатом оператора «as» может быть null, целевым может быть только такой тип, который допускает значение null: либо ссылочный тип, либо Nullable тип-значение. Нельзя сделать «as int», это не имеет никакого смысла. Если аргумент не int, то каким должно быть возвращаемое значение? Выражение «as» всегда возвращает указанный тип, так что он должен быть типом, допускающим null. Во-вторых, оператор приведения, как я уже обсуждал – странный зверёк. Он имеет два противоречивых смысла: «проверь, что этот объект действительно этого типа, брось исключение, если не так», и «этот объект не этого типа; найди мне эквивалентное значение, которое принадлежит этому типу». Второе значение оператора приведения не поддерживается оператором «as». Если вы написали short s = (short)123; int? i = s as int?; то вам не повезло. Оператор «as» не станет делать изменяющие представление преобразования из short в nullable int, как стал бы оператор приведения. Аналогично, если у вас есть класс Alpha и несвязанный с ним класс Bravo, с пользовательским оператором преобразования из Bravo в Alpha, то «(Alpha)bravo» применит это преобразование, а «bravo as Alpha» – нет. Оператор «as» учитывает только ссылочные преобразования, и упаковку/распаковку типов-значений. И, в-последних, сценарии использования этих двух операторов, конечно же, имеют поверхностное сходство, но весьма различны семантически. Приведение сообщает читателю «я уверен, что это преобразование законно, и я готов получить исключение при исполнении, если я ошибся» Оператор «as» сообщает «Я не знаю, можно ли провести это преобразование; мы дадим ему шанс, и посмотрим, что получилось».
|
-
Меня часто спрашивают «парни, вы добавили методы расширения в C# 3, так почему бы не добавить ещё и свойства расширения?» Хороший вопрос. Давайте, сначала я чуть поговорю о C# 3.0. Явно главной новостью в C# 3 был LINQ. В некотором смысле, у нас было всего три новых возможности в C# 3: - всё необходимое для LINQ – неявно типизированные переменные, анонимные типы, лямбда-выражения, методы расширения, инициализаторы объектов и коллекций, конструкторы запросов, деревья выражений, улучшенный вывод типов
- частичные методы
- автоматически реализуемые свойства
Последние два были крошечными по сравнению с пунктами из первой пачки. С точки зрения проектирования, синтаксис и семантика обеих возможностей прямолинейны. С точки зрения реализации, у нас уже были механизмы для устранения вызовов; частичные методы и условные методы – почти одно и то же за кулисами. Авто-свойства также были весьма прямолинейны в анализе и генерации кода. Нагрузка по тестировнию тоже была для них не особенно большой. Команда C# была «длинным шестом» в 2008 релизе Visual Studio и .NET Framework. Под этим я имею в виду то, что если бы вы взяли количество времени, нужно для выполнения работы, на которую подписалась каждая команда, с учётом численности персонала, всё такое, и сделали шесты пропорциональной этому длины для каждой команды в Developer Division, то шест C# оказался бы самым длинным. Что означает, что у любой другой команды в DevDiv был запас в расписании, но если бы мы отстали на день от своего расписания, то новая студия и CLR тоже были бы выпущены на день позже. Если бы любая другая команда отстала на день, ну, до тех пор, пока это не делало их шест длиннее нашего, у них всё ещё было бы всё в порядке. Внутри нашей команды, при разделе работы между разными её участниками, «длинным шестом» были задачи привязывания лямбд и вывода типов методов, отданные мне. Так что, в некотором смысле, каждый день моего опоздания одначал запаздывание выхода продукта на столько же дней. (Никакого давления!) К счастью, у нас тут превосходная команда, мы все очень хорошо помогали друг другу в течение этого релиза, подхватывая задачи друг друга при необходимости, и выполнили качественный релиз за долгое время. Моя мысль – в том, что если что-то не было либо необходимым для LINQ, либо маленьким, ортогональным, и легковыбрасываемым в случае необходимости (как остальные две), это выбрасывали немедленно. Ни в коем случае мы не собирались рисковать задержать выход всего продукта для какой-то штуки, которая была бы сразу и сложной и не необходимой. Конечно же, сразу было очевидно, что естественным спутником методов расширения являются свойства расширения. Менее очевидно, по какой-то причине, что события расширения, операторы расширения, конструкторы расширения (также известные, как «паттерн «фабрика»»), и так далее, тоже являются естественными спутниками. Но мы даже не рассматривали вопрос проектирования свойств расширения в C# 3; мы знали, что без них можно обойтись, и они добавят риск в и итак уже рискованное расписание, без привлекательного выигрыша. Ну, теперь мы дошли до C# 4. Как я люблю напоминать, ответ на все вопросы вида «почему в продукте X нет возможности Y?» один. Этопотому, что для того, чтобы в продукте была некоторая возможность, эта возможность должна быть - в первую очередь продумана
- нужна
- спроектирована
- специфицирована
- реализована
- протестирована
- задокументирована
- доставлена потребителям
Мы должны пройти каждый из этих пунктов, иначе – никаких новшеств. Когда мы начали работать над C# 4, мы составили список всех запросов, о которых мы только слышали. В нём были сотни возможностей. Как я описывал в прошлом году, мы рассортировали тот список по корзинкам «стоит сделать / неплохо бы / плохая идея». Свойства расширения были в корзинке «стоит сделать». Затем мы посмотрели на доступный нам бюджет – который измерялся не столько в долларах, сколько в доступных проектировщиках, разработчиках, тестерах, писателях, и менеджерах, умноженных на доступное время – и определили, что у нас хватит ресурсов на реализацию примерно половины вещей в корзинке «стоит сделать». Так что мы выбросили половину этого. Свойства расширения пережили это урезание. Затем мы спроектировали эту возможность. У нас были многочасовые споры о предполагаемом синтаксисе деклараций, способах «прямого» вызова методов чтения и записи как статических методов, и так далее. Мы придумали и согласовали приемлемый синтаксис, спроектировали семантику, написали черновик спецификации, и начали писать код и планы тестирования. К моменту, когда код дошёл до приличного состояния – еще не до конца протестирован, но работает и соответствует спецификации – мы стали устраивать собрания с командой WPF. Разработчики WPF были предполагаемыми основными потребителями свойств расширения. В WPF уже есть механизм, похожий на свойства расширения; было бы неплохо унифицировать этот механизм с нашим. К сожалению, рассмотрев хорошенько их реальные сценарии, мы прили к разочаровывающему выводу о том, что мы спроектировали не то; эта возможность на самом деле не решала их проблемы. Это, надо признать, провал процесса проектирования. Мы должны были поинтересоваться мнением основного потребителя намного раньше. Сделай мы это – и они могли бы либо повлиять на дизайн и получить что-то, пригодное для них, или бы мы выбросили эту возможность из плана без затрат на написание спецификации, кода, и планов тестирования. Но, когда оказываешься в неприятной ситуации, приходится принимать решение прекратить выбрасывать хорошие деньги на плохое. Вместо того, чтобы нести дополнительные расходы – на тестирование, документацию, поставку потребителям, а потом поддержку этой возможности до конца дней языка – в обмен на возможность, которая не удовлетворяет потребностей наших потребителей, мы выбросили эту возможность. К тому моменту мы не были уверены, что у нас осталось достаточно времени на перепроектирование возможности правильным образом. Так что, к сожалению, никаких свойств расширения в C# 4. Возможно, в гипотетической будущей версии C#.
|
-
Ещё один хороший вопрос со StackOverflow. Почему есть неявное преобразование из char в ushort, но только явное из ushort в char? Почему дизайнеры языка верят, что эти асимметричные правила имело смысл добавлять в язык? Ну, во-первых, очевидные вещи, которые бы не дали любому из преобразований быть неявным, тут неприменимы. Char реализован как беззнаковое 16-битное целое, которое представляет символ в кодировке UTF-16, так что его можно преобразовывать в ushort и обратно без потери точности, или, в данном случае, без изменения представления. Среда просто переходит от трактовки этого набора бит как символа к трактовке того же набора бит как ushort, или обратно. Так что можно позволить оба неявных преобразования. Но, только то, что что-то возможно не означает, что это хорошая идея. Явно дизайнеры языка думали, что неявное преобразование char в ushort - хорошая идея, но неявное преобразование из ushort в char – нет. (А, поскольку char в ushort - хорошая идея, то выглядит разумным, что char во что-угодно-во-что-преобразуется ushort тоже, так что char в int тоже можно.) В отличие от вас, парни, у меня в распоряжении есть оригинальные заметки от команды по проектированию языка. Копаясь в них, мы обнаруживаем некоторые интересные факты. Преобразовение из ushort в char упоминается в заметках от 14 апреля 1999, где поднимается вопрос, стоит ли разрешать преобразование из байта в char. В исходной пред-релизной версии C#, это было разрешено. Я слегка подредактировал заметки, чтобы сделать их ясными без понимания пред-релизных кодовых названий Майкрософт эпохи 1999. Я также добавил выделение в важных местах: [Комитет по проектированию языка] выбрал предоставление неявной конверсии из байтов в символы, поскольку область значений первых полностью покрывается вторыми. Тем не менее, прямо сейчас [авторы библиотеки среды исполнения] предоставляют только методы Write, которые принимают charы и intы, что означает, что байты печатаются как символы, поскольку этот метод оказывается наилучшим. Мы можем решить это либо предоставлением дополнительных методов Write, либо устранением неявного преобразования. Есть аргумент в пользу того, что последнее является правильным действием. В конце концов, байты на самом деле не символы. Да, тут может иметь место полезное отображение байтов в символы, но, фундаментально, 23 не обозначает то же самое, что и символ с ASCII кодом 23, в том же смысле, как байт 23 обозначает то же самое, что и длинное целое 23. Просить [разработчиков библиотеки] предоставить этот дополнительный метод просто из-за особенностей нашей системы типов кажется достаточно слабым. Заметки затем заканчиваются решением, что byte-в-char должно быть явным преобразованием, и целое-в-диапазоне-char тоже должно преобразовываться явно. Отметим, что заметки по дизайну языка не указывают, почему ushort-в-char тогда же было сделано тоже явным, но, как видите, применима та же логика. Передавая ushort в метод, перегруженный как M(int) и M(char), вы скорее всего хотите трактовать ushort как число, а не как символ. А ushort не является представлением символа в том же смысле, как представлением числа, так что выглядит разумным сделать это преобразование таким же явным. Решение преобразовывать char в ushort неявно было принято 17 сентября 1999 года; заметки по дизайну за этот день на эту тему гласят просто «char в ushort тоже разрешённое неявное преобразование”, и это всё. Никаких других проявлений того, что происходило в головах дизайнеров языка в тот день в заметках не засвидетельствовано. Тем не менее, мы можем сделать обоснованные предположения причин, по которым неявное преобразование char в ushort было рассмотрено как хорошая мысль. Ключевая идея здесь в том, что преобразование из числа в символ является «возможно сомнительным». Оно берёт что-то, что не факт, что планировалось быть символом, и решает трактовать его как символ. Это похоже на тот тип действий, который вы хотели бы явно объявлять при выполнении, а не случайно позволять себе. Но обратное гораздо менее сомнительно. В программировании на C есть давняя традиция трактовать символы как целые числа – для получения их фактических значений, или для выполнения с ними математических операций. Короче: вглядит разумным, что использование числа в качестве символа может быть случайностью и ошибкой, но также выглядит разумным и то, что использование символа в качестве числа намеренно и желательно. Эта асимметрия и отражена в правилах языка.
|
-
Вот любопытный фрагмент кода: object obj = "Int32"; string str1 = "Int32"; string str2 = typeof(int).Name; Console.WriteLine(obj == str1); // true Console.WriteLine(str1 == str2); // true Console.WriteLine(obj == str2); // false !? Конечно, если A равно B, и B равно C, то A равно C, это транзитивное своейство равенства. Похоже, что оно тут полностью нарушено. Ну, прежде всего, хотя транзитивность и желательна, это всего лишь одна из многих ситуаций, в которых равенство в C# нетранзитивно. Вы не должны полагаться на транзитивность в общем случае, хотя, конечно же, есть множество частных случаев, где она справедлива. В качестве упражнения, вы могли бы посмотреть, сколько других нетранзитивностей сможете вспомнить. (Случайно, один из вопросов, который мне задали на собеседовании при приёме в эту команду был про разработку производительного алгоритма для определения нетранзитивностей в упрощённой версии алгоритма «наилучшего метода».) Во-вторых, здесь происходит смешение двух разных видов равенства, которые случайно используют одинаковый синтаксис операторов. Мы смешиваем равенство по ссылке с равенством по значению. Объекты сравнивают по ссылке; в первом и третьем сравнении мы проверяем, что обе ссылыки указывают в точности на один и тот же объект. Во втором сравнении мы проверяем, одинаково ли содержимое двух строк, вне зависимости от того, представлены ли они одним объектом. Фактически, компилятор предупреждает вас о такой ситуации; такой код должен порождать предупреждение «возможно непреднамеренное сравнение ссылок». Это может потребовать чуть больше объяснений. В .NET вы можете иметь две строки с идентичным содержимым, но они будут разными объектами. Когда вы сравниваете эти строки как строки, они равны, но когда вы сравниваете их как объекты, они не равны. Это объясняет, почему второе сравнение истинно – это сравнение значений – и почему третье сравнение ложно – это сравнение ссылок. Но это не объясняет, почему первое и третье сравнения противоречат друг другу. Это результат маленькой оптимизации. Если у вас есть два идентичных строковых литерала в одной единице компиляции, то код, который мы генерируем, гарантирует, что только один объект строки создаётся CLR для всех экземпляров этого литерала в пределах сборки. Эта оптимизация называется «интернированием строк». String.Empty – не константа, это поле только-для-чтения в другой сборке. Поэтому оно не интернируется с пустой строкой в вашей сборке; это два разных объекта. Это объясняет, почему первое сравнение истинно: эти два литерала фактически превращаются в один и тот же объект строки. И это объясняет, почему третье сравнение ложно: литерал и вычисленное значение превращаются в разные объекты. Зная это, вы теперь можете сделать обоснованное предположение причины, по которой мы имеем это противоестественное поведение: object obj = ""; string str1 = ""; string str2 = String.Empty; Console.WriteLine(obj == str1); // true Console.WriteLine(str1 == str2); // true Console.WriteLine(obj == str2); // иногда true, иногда false?! Некоторые версии .NET автоматически интернируют пустую строку, некоторые – нет! Но почему, можете вы спросить, мы не выполняем эту оптимизацию во время выполнения для каждой строки? Почему бы нам не превращать агрессивно все равные по значеням строки в равные по ссылкам? Конечно же расточительно иметь две одинаковые строки, когда можно занять вдвое меньше памяти. Ответ – в том, что принцип ЛДНБ применим здесь в полной мере. То есть, Ланчей Даром Не Бывает. Интернирование имеет два положительных эффекта: оно уменьшает потребление памяти и уменьшает время сравнения двух строк. (Потому, что если все строки интернируются во время выполнения, то все сравнения строк могут быть дешёвыми сравнениями ссылок.) Но у этих положительных эффектов есть цена: выделение новой строки теперь требует от вас поиска по всем строкам в памяти для проверки, нет ли там уже такой. В нашей существующей оптимизации, цена невелика; во время компиляции нам известно, каковы строковые литералы в данной сборке и какие из них одинаковы. В предлагаемой оптимизации, эта цена платится во время выполнения, и может занимать весьма большую долю затрат времени на выделение строк. Чтобы снизить затраты времени, вам бы пришлось построить хеш-таблицу всех строк в памяти. Это означает либо частое вычисление хеш-кодов, что само по себе затратно по времени, либо их хранение где-то. Если мы выбираем последнее, то внезапно мы увеличиваем нагрузку на память для строк, которые не дублируются. То есть, наша оптимизация заставляет обычный сценарий – подавляющее большинство пар строк не совпадают друг с другом – требовать больше памяти, чтобы редкий сценарий мог её сэкономить. Это выглядит плохой сделкой; обычно вы хотите оптимизировать для более вероятного случая. Кроме того, у интернированных строк есть также и серьёзные проблемы с временем жизни. Когда их можно безопасно подвергнуть сборке мусора? Что, если новая копия строки создаётся в тот момент, когда старую удаляет сборщик в другом потоке? Самое безопасное – сделать интернированные строки бессмертными, что выглядит как утечка памяти. Утечки памяти плохо влияют на производительность, особенно когда ваша оптимизация – это попытка сэкономить память. ЛДНБ! Короче, в общем случае не стоит интернировать все строки. Тем не менее, это может оказаться стоящим в некоторых особых случаях. Например, если бы вы писали на C# компилятор, то вы бы скорее всего порождали в процессе выполнения большое количество одинаковых строк. Наш компилятор C# написан на C++, где мы написали нашу собственную прослойку интернирования строк, чтобы мы могли выполнять дешёвые ссылочные сравнения для всех строк в вашей программе. Весьма вероятно, что «int» будет встречаться десятки, сотни, или тысячи раз в одной программе; глупо выделять одну и ту же строку снова и снова. Если вы пишете на C# компилятор, или какое-то другое приложение, в котором вы чувствуете, что стоит постараться не дать тысячам идентичных строк потребить много памяти, то вы можете заставить среду исполнения интернировать вашу строку при помощи метода String.Intern. И, наоборот, если вы ненавидите интернирование слепой ненавистью, то можете заставить среду отключить всё интернирование строк в сборке при помощи атрибута CompilationRelaxation. В любом случае, вернёмся к вопросу транзитивности: равенство ссылок на объекты на самом деле транзитивно. Оно также симметрично (A==B подразумевает B==A) и рефлексивно (A==A), так что это отношение эквивалентности. Похожим образом, равенство значений строк транзитивно, симметрично и рефлексивно, поскольку оно использует прямолинейное сравнение «символ за символом». Но когда вы смешиваете их, то равенство перестаёт быть транзитивным. Это странно, но, надеюсь, теперь доступно для понимания.
|
-
Еще один интересный вопрос со StackOverflow: uint[] foo = new uint[10]; object bar = foo; Console.WriteLine("{0} {1} {2} {3}", foo is uint[], // True foo is int[], // False bar is uint[], // True bar is int[]); // True Что за ерунда тут происходит? Этот фрагмент кода иллюстрирует интересное, но неудачное противоречие между системой типов CLI и системой типов C#. В CLI есть концепция «совместимости по присваиванию». Если значение x известного типа S является «совместимым по присваиванию» с местом хранения y известного типа T, то вы можете записать x в y. Если нет, то попытка это сделать не является верифицируемым кодом и верификатор это запретит. Система типов CLI говорит, например, что подтипы ссылочного типа совместимы по присваиванию с супертипами ссылочного типа. Если у вас есть string, то её можно сохранить в переменной типа object, потому что оба типа – ссылочные, и string – подтип object. Но обратное неверно; супертипы не совместимы по присваиванию с подтипами. Вы не сможете засунуть что-то, известное как object, в переменную типа string без предварительного приведения типа. В сущности, «совместим по присваиванию» означает «имеет смысл засовывать эти конкретные биты в эту переменную». Присваивание исходных данных в переменную назначения должно «сохранять представление». Одно из правил CLI – в том, что «если X совместим по присваиванию с Y, то X[] совместим по присваиванию с Y[]». То есть, массивы ковариантны по отношению к совместимости по присваиванию. Как я уже обсуждал, это на самом деле сломанный вид ковариантности. Такого правила нет в C#. Правило ковариантности массивов в C# таково: «если X – ссылочный тип, неявно приводимый к ссылочному типу Y, то X[] неявно приводим к Y[]». Это слегка другое правило! В CLI, uint и int совместимы по присваиванию; так что uint[] и int[] тоже. Но в C#, преобразования между int и uint явные, а не неявные, и это типы-значения, а не ссылочные типы. Так что в C# запрещено конвертировать int[] в uint[]. Но это разрешено в CLI. Так что теперь мы стоим перед выбором. 1) Реализовать “is” так, чтобы, когда компилятор не смог статически определить результат, то он вставлял бы вызов метода, который проверяет все правила C# по проверке конвертируемости, сохраняющей представление. Это медленно, и в 99.9% случаев совпадает с результатом применения правил CLI. Но мы принимаем потерю производительности для 100% совместимости с правилами C#. 2) Реализовать “is” так, что когда компилятор не смог статически определить результат, то он полагался бы на невероятно быструю проверку совместимости по присваиванию из CLR, и жить с тем фактом, что она говорит, что uint[] это int[], несмотря на то, что это на самом деле не так в C#. Мы выбрали последнее. Не очень хорошо, что спецификации C# и CLI расходятся в этом мелком вопросе, но мы готовы жить с этим противоречием. Так что здесь происходит то, что в случаях «foo», компилятр статически может определить, каков будет результат в соответствии с правилами C#, и генерирует код для порождения «True» и «False». Но в случаях «bar», компилятор уже не знает точный тип того, что лежит в bar, так что он генерирует код, чтобы заставить CLR отвечать на вопрос, и CLR высказывает другое мнение.
|
-
Вот хороший вопрос со StackOverflow: Если у вас есть метод, принимающий «X», то вы должны передавать выражение типа X или что-то, приводимое к X. Скажем, выражение производного от X типа. Но если у вас есть метод, принимающий «ref X», то вы обязаны передавать ссылку на переменную типа X, точка. Почему это? Почему бы не разрешить типу варьироваться, как мы делаем для не-ref вызовов? Предположим, что у вас есть классы Животное, Млекопитающее, Рептилия, Жираф, Черепаха и Тигр, с очевидными отношениями наследования. Теперь предположим, что у вас есть метод void M(ref Млекопитающее m). M может как читать, так и писать m. Можно ли передать в M переменную типа Животное? Нет. Это было бы небезопасно. Такая переменная может ссылаться на Черепаху, но M предполагает, что там могут быть только Млекопитающие. Черепаха – не млекопитающее. Вывод 1: Ref-параметры нельзя делать «больше». (Животных больше, чем млекопитающих, так что переменная становится «больше» потому, что в неё входит больше разных существ) Можно ли передать в M переменную типа Жираф? Нет. M может записывать в m, и может захотеть записать туда экземпляр Тигра. Теперь вы засунули Тигра в переменную, которая имеет тип Жираф. Вывод 2: Ref-параметры нельзя делать «меньше». Теперь рассмотрим N(out Млекопитающее n). Можно ли передать в N переменную типа Жираф? Нет. Как и в нашем предыдущем примере, N может записывать в n, и N может захотеть записать туда Тигра. Вывод 3: Out-параметры нельзя делать «меньше». Можно ли передать в N переменную типа Животное? Хмм. Ну, почему бы и нет? N не может читать из n, он может туда только писать, верно? Вы записываете Тигра в переменную типа Животное и всё в порядке, так? Нет. Правило не в том, что «N может только записывать в n». Правила, вкратце, таковы: 1) N обязан записать в n перед тем, как выполнить нормальный возврат (если N бросает исключение, то с него взятки гладки) 2) N обязан записать что-то в n перед тем, как что-то оттуда прочитать. Это позволяет такую последовательность событий: - Объявляем поле x типа Животное.
- Передаём x как out-параметр в N
- N пишет Тигра в n, который является псевдонимом для x.
- В другом потоке, кто-то записывает в x Черепаху.
- N пытается читать содержимое n, и обнаруживает Черепаху в том, что, по его мнению, является переменной типа Млекопитающее.
Этот сценарий – использование многопоточности для записи в переменную, у которой есть псевдоним, – ужасен и вам ни в коем случае не стоит его реализовывать, но он возможен. UPDATE: Комментатор Павел Минаев верно подметил, что нет нужды в многопоточности для нанесения увечий. Мы можем заменить четвёртый шаг на - N делает вызов метода, который прямо или косвенно заставляет некоторый код записать в x Черепаху.
Независимо от того, каким образом может измениться содержимое переменной, мы явно хотим сделать нарушение системы типов незаконным. Вывод 4: Out-параметры нельзя делать «больше». Есть и другой аргумент в пользу этого вывода: «out» и «ref» на самом деле за кулисами совершенно одинаковы. CLR поддерживает только «ref»; «out» - это всего лишь «ref», для которого компилятор навязывает несколько другие правила насчёт того, когда рассматриваемая переменная подвергается определяющему присваиванию. Вот почему запрещено делать перегрузки метода, которые отличаются только out/ref-ностью параметров; CLR неспособен их различить! Так что правила типобезопасности для out вынуждены быть такими же, как и для ref. Окончательный вывод: Ни ref ни out параметры не позволяют варьировать типы аргументов в местах вызова. Иное бы нарушило верифицируемую безопасность типов.
|
-
К западу от здания Вы стоите в открытом поле к западу от белого здания с дощатой входной дверью. Тут маленький почтовый ящик. >открыть ящик В открытом маленьком почтовом ящике обнаружен листок. >взять листок Взят. >прочитать листок «ДОБРО ПОЖАЛОВАТЬ В ZORK! ZORK – игра, полная приключений, опасностей, и хитростей. В ней вы исследуете часть самой потрясающей территории, когда-либо доступной смертным. Ни один компьютер не должен быть её лишён!» И так в 1984 началось моё пожизненное увлечение «интерактивной литературой». Где-то в ящике для документов моего офисного шкафа у меня есть сотни нарисованных на миллиметровке вручную карт из квадратиков и линий, описывающих для меня план каждой из классических игр Infocom, где там каждый объект, и так далее. В 1984 мне было одиннадцать лет, и я наивно полагал себя прямо чертовски продвинутым программистом. Я уже написал к тому моменту несколько простых игр для Commodore PET (классического CBM 4032) в школе, добыл свой собственный Commodore 64 – благодаря маме – и обнаружил и исправил баг в программе, написанной профессионалами. Во как! Но я ни в жизнь не мог догадаться, каким образом разработчики в Infocom написали такую огромную, сложную игру, которая могла понимать предложения на английском, поддерживать согласованные положения иерархических объектов, которые взаимодействуют с окружающей средой (то есть, факел - в гробу, гроб – в лодке, лодка скользит вниз по течению), и так далее. Когда через несколько лет я узнал, как это всё работало, я просто обалдел. Это был один из тех моментов, когда вы вдруг ясно понимаете, что есть целый новый способ воспринимать компьютеры, как инструменты для решения задач. Как теперь хорошо известно, то, что сделали гении в Infocom, было разработкой и реализацией их собственной виртуальной машины, Z-машины. Затем они написали игры на байткоде этой виртуальной машины. Это даёт два огромных преимущества. Во-первых, абстрагируясь от реальной машины, вы можете написать вашу огромную сложную игру один раз, написать относительно маленькие и простые реализации Z-машины для какого хотите количества марок компьютеров, и внезапно вы получаете возможность написать-однажды-запускать-везде, значительно увеличивая количество платформ, для которых вы можете продавать. Большинство видеоигр того времени были написаны, скажем, на ассемблере Commodore 64, а потом переписаны на ассемблер Atari, и так далее; цена линейно масштабировалась относительно количества платформ. В подходе Infocom, затраты на каждую платформу были намного ниже. Во-вторых, ВМ может реализовывать то, что мы сейчас воспринимаем как страничная организация памяти. (Неизменяемый) код игры можно читать с диска по странице за один раз, исполнять, и затем выбрасывать. Единственное, что нужно держать в памяти, это относительно маленькое текущее состояние игры и, конечно, саму реализацию Z-машины. Но код может быть огромным, значительно больше доступной памяти. В те времена, когда доступной памяти было от 16 до 32 килобайт, это было значительным преимуществом. В наши дни, конечно, множество людей написали свои реализации Z-машины для собственного удовольствия. Я этого так и не сделал, но всегда хотел. После работы над столь многими языками с интерпретируемым байткодом за последние пятнадцать лет, это, вероятно, было бы достаточно несложным. Это был бы заметный объём работы, но об этом было бы занятно писать в блоге, и, я уверен, там было бы множество отличных возможностей проиллюстрировать изготовление настоящего интерпретатора байт-кода. Но, с другой стороны, у меня уже есть много того, чем можно занять своё свободное время, и склонность откусывать больше, чем я могу проглотить в один приём. Так что весьма удачно, что мне не нужно этого делать, потому что Майк Грегер делает именно это. Он уже написал несколько реализаций Z-машины на C# и пишет в блоге об этом процессе. Я с нетерпением предвкушаю чтение блога Майка и выяснение скрытых технических и исторических деталей, которые делают Z-машину столь интересной. Майк говорит мне, что у него трудности с поиском людей, кто в восторге и от C#, и от Z-машины; ладно, очевидно – засчитывай меня! Я уверен, что некоторые из моих читателей тоже заинтересованы и в том, и в другой.
|
-
Так же, как «fixed» и «into», «partial» используется в С# двумя похожими-но-разными способами. Задача частичного класса в том, чтобы позволить вам разбивать объявление класса на несколько частей, обычно расположенных в различных файлах. Мотиватором этой возможности был машинно-генерируемый код, который пользователю нужно было расширять путём прямых добавлений. Когда вы рисуете форму в дизайнере форм, дизайнер генерирует для вас класс, представляющий эту форму. Вы можете затем далее изменять этот класс, добавляя в него новый код. Если вы можете редактировать код, сгенерированный машиной, то возникает масса проблем. Что, если он будет сгенерирован повторно? Что, если машина использует сам код для хранения информации о дизайне формы, и ваши правки сбивают с толку парсер машины? Гораздо лучше просто поместить машинно-генерируемую половину в отдельный файл, сгенерировать комментарий «не трогайте это», и разместить пользовательский код в другом месте. Есть и другие применения частичных классов, не связанные с автоматически генерируемым кодом, но они относительно редки. Вот некоторые случаи, где я вижу применение частичных классов: - Если класс действительно большой, и реализует пачку интерфейсов, то, иногда, реализация каждого интерфейса в отдельном файле имеет смысл. Хотя, чаще это признак плохого кода, присущий классу, который пытается делать слишком много всего; разделение этой штуки на несколько классов может оказаться лучше.
- Иногда приятно выносить вложенные классы в отдельные файлы; единственный приличный способ этого добиться – это сделать содержащий их класс частичным.
- И так далее
Частичные методы – немножко другая история. Как и частичные классы, частичные методы – про склеивание множества объявлений одного и того же метода для улучшения сценариев, связанных с машинной генерацией кода. Но, несмотря на то, что высокоуровневые задачи этой возможности те же, детали весьма отличаются. Способ, которым работают частичные методы – это наличие двух объявлений, «латентного», и «актуального». У латентного объявления нет тела, как будто это абстрактный метод. У актуального – есть. Латентное объявление располагается на машинно-сгенерированной стороне частичного класса, актуальное объявление попадает в часть, написанную человеком. Если у нас есть актуальное объявление, то латентное полностью игнорируется. Но если актуального объявления нет, то все вызовы метода устраняются, как будто он был скомпилирован с условным атрибутом! И, фактически, латентное объявление также устраняется в момент порождения метаданных для сгенерированного класса; как будто его и не было. Причина для такого поведения – в том, что мы хотели сделать возможным такой сценарий: // Машинно-генерируемый код: partial class MyFoo { void ButtonClickEventHandler(/*всякое*/) { // вызвать пользовательский код чтобы посмотреть, не хочет ли он чего-нибудь сделать OnBeforeButtonClick(всякое); тра ля ля // опять вызвать пользовательский код OnAfterButtonClick(всякое); } partial void OnBeforeButtonClick(/*всякое*/); partial void OnAfterButtonClick(/*всякое*/); ... Пользователь будет модифицировать частичный класс; вставляя частичные методы, машинно-сгенерированная часть может повсеместно создать простые точки расширения, которые пользователь потом сможет реализовать. Но подумайте о негативных последствиях этого в мире без частичных методов. Пользователя заставляют реализовывать пустые методы. Если он этого не сделает, то получит ошибку. Потенциально там нужно реализовать сотни этих пустых методов, а это бесит. И каждый из этих методов генерирует нетривиальное количество метаданных, делая результирующую библиотеку больше, чем нужно. Дисковое пространство дёшево, но латентность сети заменила дисковое пространство в качестве фактора против больших сборок. Было бы здорово, если бы мы могли избавиться от этого груза метаданных. Частичные методы удовлетворяют требованиям. Пользователь может предоставлять реализации для методов по собственному выбору, и это становится моделью с «платой за фактическое использование»; вы получаете столько расходов на реализацию, сколько методов вы реализуете. Эта возможность во время процесса проектирования называлась «латентные и актуальные методы»; мы серьёзно рассматривали добавление новых ключевых слов «latent» и «actual». Но, поскольку эта возможность имеет смысл только в сценариях с частичными классами, то мы решили повторно использовать существующее контекстное ключевое слово «partial» и переименовали возможность. Надеюсь, что созвучие между двумя применениями «partial» помогает больше, чем тонкая разница вредит. СУПЕР ЭКСТРА БОНУС: Ещё немного частичности Мы рассматривали добавление третьего типа «частичности» в C# 4.0; эта возможность прошла через фазу проектирования, но была выброшена до реализации. (Если будет высокий спрос, то мы рассмотрим добавление её в гипотетических будущих версиях C#.) Иногда вы участвуете в сценарии машинной генерации кода типа такого: // сгенерировано машиной partial class C { int blah; ... И затем на той стороне, которую генерирует пользователь, вы хотите сделать что-то вроде: // User generated partial class C : ISerializable { И, о, чёрт, мне нужно пометить blah атрибутом NotSerialized, но я не могу редактировать текст blah потому, что когда его перегенерируют, это всё пропадёт. Идея этого нового типа частичности – в том, чтобы повторять объявление члена – поля, метода, вложенного типа, чего угодно – с атрибутами метаданных. Это как латентные/актуальные методы, только наоборот; «актуальная» часть – на машинно-генерируемой стороне, а «латентная» часть на стороне, генерируемой пользователем, нужна только для добавления метаданных: [NotSerialized] partial int blah; // на самом деле не объявляет поле Мне нравится такая возможность, но во время процесса проектирования я сопротивлялся, как мог, использованию ключевого слова «partial» в третьем значении, слегка отличающемся от первых двух. Добавление такой путаницы один раз казалось оправданным, но дважды? Это перевешивает. Так что мы сошлись на добавлении другого контекстного ключевого слова: [NotSerialized] existing int blah; // на самом деле не объявляет поле Оформление объявления при помощи «existing» значило бы «это не настоящее объявление, это упоминание существующего объявления; пожалуйста, проверьте, что такое объявление существует где-то еще в этом частичном классе, и добавьте эти метаданные к тому члену». Как я сказал, эта полезная возможность была выброшена из-за ограниченности ресурсов. Если у вас есть реально обалденные сценарии, которые бы она облегчила, то я был бы счастлив о них услышать; очевидно, я не могу делать никаких обещаний о будущих возможностях необъявленных, полностью гипотетических продуктов. Но реальные пользовательские сценарии являются сильным мотивирующим фактором в выделении бюджета для новых возможностей.
|
-
Быстрая заметка по метаблоггингу. Те из вас, кто комментирует этот блог (6700+ комментариев и продолжают поступать, спасибо вам) уже, вероятно, заметили, что теперь тут есть CAPTCHA, тот маленький тест «пожалуйста, докажите, что вы человек» перед тем, как отправить комментарий. Я понимаю причины. Сайты блогов MSDN и TechNet являются ценными целями для нежеланных коммерческих рекламодателей, для атакующих, которые хотят вынудить поисковые движки направить трафик на их сайты, и вандалов. У людей, которые занимаются безопасностью этого сайта, полно работы; мы уже испытывали некоторые достаточно серьёзные отказы в обслуживании из-за атак криворуких спамеров. Добавление CAPTCHA для проверки комментариев существенно снижает процент успешного спама в комментариях. Я не восторге от этого. Я нахожу решения в стиле CAPTCHA безвкусицей по нескольким причинам: - Добронравный комментатор – в точности тот персонаж, которого мы хотим поощрять – вынужден делать лишнюю работу. Это маленькое, но ненулевое препятствие к написанию комментариев
- Иногда будут происходить ошбки; предоставление компьютерам новых способов ежедневно напоминать нам, что мы неудачники, выглядит раздражающим
- Презумпция невиновности сменяется презумпцией вины; добронравный комментатор обязан доказывать свою невинность. Каждый раз, когда мне нужно заполнить CAPTCHA, я чувствую маленькое, но реальное оскорбление; я надёжный человек, так что уже доверяйте мне. Как однажды заметил Джоель Спольски, это как первой вещью при входе на станцию увидеть знак «КАТАНИЕ НА СКЕЙТАХ ЗАПРЕЩЕНО ПОПРОШАЙНИЧЕСТВО ЗАПРЕЩЕНО ТО ЗАПРЕЩЕНО СЁ ЗАПРЕЩЕНО ЭТО ТОЖЕ ЗАПРЕЩЕНО». Это негостеприимно. Это заставляет вас чувствовать вину и напоминает, что в мире есть зло.
- Есть проблемы с доступностью. Не все, кто пользуется компьютерами, имеют идеальное зрение, но это не делает их злобными роботами. Они заслуживают таких же шансов на внесение своего вклада, как и все остальные, и уже вынуждены преодолевать множество препятствий; не стоит подбрасывать им новых.
- И так далее
Так что, извините за это, комментаторы. Мне это нравится не больше, чем вам, но я не так уж много могу по этому поводу сделать; не я управляю серверами блогов. Единственная вещь, которой я управляю – это насколько фиолетовым будет цвет.
|
|
|
|