LET проти LET * у Common Lisp


80

Я розумію різницю між LET і LET * (паралельне та послідовне прив'язування), і як теоретичний матеріал це має цілком сенс. Але чи є якийсь випадок, коли ви коли-небудь насправді потребували LET? У всьому моєму коді Lisp, який я нещодавно розглядав, ви можете замінити кожен LET на LET * без змін.

Редагувати: Добре, я розумію, чому якийсь хлопець винайшов LET *, мабуть, як макрос, ще колись. Моє запитання полягає в тому, що зважаючи на те, що LET * існує, чи є причина LET залишатися поруч? Чи писали ви будь-який фактичний код Lisp, де LET * не буде працювати так добре, як звичайний LET?

Я не купую аргумент ефективності. По-перше, розпізнавання випадків, коли LET * можна скомпілювати у щось таке ефективне, як LET, просто не здається таким важким. По-друге, у специфікації CL є багато речей, які просто не здаються такими, що взагалі розроблені навколо ефективності. (Коли ви востаннє бачили LOOP із деклараціями типів? Це так важко зрозуміти, що я ніколи не бачив, щоб їх використовували.) До тестів Діка Габріеля наприкінці 1980-х, CL був відверто повільним.

Схоже, це ще один випадок зворотної сумісності: розумно, ніхто не хотів ризикувати зламати щось таке принципове, як LET. Що було моєю здогадкою, але приємно чути, що ні в кого немає тупо-простої справи, яку я пропустив, де LET зробив купу речей смішно простішими, ніж LET *.


паралель - поганий вибір слів; видно лише попередні прив'язки. паралельне прив'язування було б більше схоже на прив'язки Хаскелла "... де ...".
jrockway

Я не ставив за мету заплутати; Я вважаю, що це слова, вжиті в специфікації. :-)
Кен

12
Паралельність правильна. Це означає, що пов’язки оживають одночасно і не бачать одне одного і не тінять одне одного. Жодного разу не існує видимого для користувача середовища, яке включає деякі змінні, визначені в LET, але не інші.
Каз

Хаскели, де прив’язки більше схожі на летрек. Вони можуть бачити всі прив'язки на одному рівні сфери.
Edgar Klerks,

1
Запитання: "чи є випадок, коли letце потрібно?" трохи нагадує запитання "чи є випадок, коли потрібні функції з кількома аргументами?". let& let*не існують через якесь поняття ефективності, оскільки вони дозволяють людям передавати наміри іншим людям під час програмування.
tfb

Відповіді:


85

LETсам по собі не є справжнім примітивом у мові функціонального програмування , оскільки його можна замінити на LAMBDA. Подобається це:

(let ((a1 b1) (a2 b2) ... (an bn))
  (some-code a1 a2 ... an))

є подібним до

((lambda (a1 a2 ... an)
   (some-code a1 a2 ... an))
 b1 b2 ... bn)

Але

(let* ((a1 b1) (a2 b2) ... (an bn))
  (some-code a1 a2 ... an))

є подібним до

((lambda (a1)
    ((lambda (a2)
       ...
       ((lambda (an)
          (some-code a1 a2 ... an))
        bn))
      b2))
   b1)

Ви можете собі уявити, що це простіше. LETа ні LET*.

LETполегшує розуміння коду. Можна побачити купу прив’язок, і можна прочитати кожну прив’язку окремо, не потребуючи розуміння потоку «ефектів» (перев’язок) зверху вниз / ліворуч. Використовуючи LET*сигнали програмісту (тому, хто читає код), що прив’язки не є незалежними, але існує якийсь потік зверху вниз - що ускладнює ситуацію.

Common Lisp має правило, згідно з яким значення для прив'язок в LETобчислюються зліва направо. Як оцінюються значення для виклику функції - зліва направо. Отже, LETце концептуально простіший вираз, і його слід використовувати за замовчуванням.

Типи вLOOP ? Використовуються досить часто. Є кілька примітивних форм декларації типу, які легко запам’ятати. Приклад:

(LOOP FOR i FIXNUM BELOW (TRUNCATE n 2) do (something i))

Вище оголошує змінну iяк fixnum.

Річард П. Габріель опублікував свою книгу про орієнтири Ліспа в 1985 році, і на той час ці орієнтири також використовувались для нелістичних критеріїв Ліс. Сам Common Lisp був абсолютно новим у 1985 році - книга CLtL1, що описує мову, щойно була опублікована в 1984 році. Недарма впровадження на той час були не дуже оптимізованими. Впроваджені оптимізації були в основному такими ж (або менше), що й реалізації раніше (наприклад, MacLisp).

Але в LETпорівнянні LET*з основною відмінністю полягає в тому, що використання коду LETлегше зрозуміти людям, оскільки зобов’язуючі речення не залежать один від одного - тим більше, що погано користуватися перевагами зліва направо (не встановлюючи змінні як сторону ефект).


3
Ні ні! Лямбда не є справжнім примітивом, оскільки його можна замінити на LET, а лямбда нижчого рівня, яка просто надає API для переходу до значень аргументів: (low-level-lambda 2 (let ((x (car %args%)) (y (cadr args))) ...) :)
Каз

36

Вам не потрібно LET, але ви зазвичай цього хочете .

LET припускає, що ви просто виконуєте стандартне паралельне прив'язування, і нічого хитрого не відбувається. LET * накладає обмеження на компілятор і пропонує користувачеві, що існує причина, що потрібні послідовні прив'язки. Що стосується стилю , LET є кращим, коли вам не потрібні додаткові обмеження, накладені LET *.

Використання LET може бути більш ефективним, ніж LET * (залежно від компілятора, оптимізатора тощо):

  • паралельні прив'язки можуть виконуватися паралельно (але я не знаю, чи справді це робить якась система LISP, і форми ініціювання все одно повинні виконуватися послідовно)
  • паралельні прив'язки створюють єдине нове середовище (область дії) для всіх прив'язок. Послідовні прив'язки створюють нове вкладене середовище для кожного окремого прив'язки. Паралельні прив'язки використовують менше пам'яті і мають швидший пошук змінних .

(Наведені вище пункти маркерів стосуються Схеми, іншого діалекту LISP. Clisp може відрізнятися.)


2
Примітка: Дивіться цю відповідь (та / або пов’язаний розділ hyperspec), щоб трохи пояснити, чому ваша перша точка пульсу - скажімо, оманлива. У прив'язок відбуватися паралельно, але форми виконуються послідовно - згідно специфікації.
Lindes

6
паралельне виконання - це не те, про що стандарт Common Lisp піклується будь-яким чином. Швидший пошук змінних також є міфом.
Райнер Йосвіг

1
Різниця не лише важлива для компілятора. Я використовую let і let * як підказки собі про те, що відбувається. Коли я бачу let в своєму коді, я знаю, що прив'язки незалежні, а коли бачу let *, я знаю, що прив'язки залежать одне від одного. Але я це знаю лише тому, що обов’язково постійно використовую let і let *.
Єремія

28

Я привожу надумані приклади. Порівняйте результат цього:

(print (let ((c 1))
         (let ((c 2)
               (a (+ c 1)))
           a)))

з результатом запуску цього:

(print (let ((c 1))
         (let* ((c 2)
                (a (+ c 1)))
           a)))

3
Хочете зрозуміти, чому це так?
Джон МакАлілі,

4
@ John: у першому прикладі aприв'язка посилається на зовнішнє значення c. У другому прикладі, де let*дозволяє прив'язки посилатися на попередні прив'язки, aприв'язка посилається на внутрішнє значення c. Логан не бреше, що це надуманий приклад, і це навіть не прикидається корисним. Крім того, відступ нестандартний та оманливий. І в тому, aі в іншому, прив'язка повинна складати один пробіл, щоб вирівнюватися до c"s", а "тіло" внутрішнього letповинно знаходитись лише в двох пробілах від самого letсебе.
зем

3
Ця відповідь дає важливе розуміння. Можна було б спеціально використовувати , letколи один хоче , щоб уникнути мають вторинні прив'язки ( з допомогою яких я просто маю в виду не перший) , відносяться до перших обов'язковим, але ви дійсно хочете , щоб тінь попередньої прив'язки - використовуючи попереднє значення його для ініціалізації одного з ваші вторинні прив’язки.
Lindes

2
Хоча відступ вимкнено (і я не можу його редагувати), це найкращий приклад поки. Коли я дивлюсь на (нехай ...), я знаю, що жодне з прив’язок не збирається нарощувати одне одне, і їх можна розглядати окремо. Коли я дивлюсь на (нехай * ...), я завжди підходжу з обережністю і дуже уважно розглядаю, які прив'язки використовуються повторно. Тільки з цієї причини має сенс завжди використовувати (нехай), якщо вкрай не потрібно вкладання.
andrew

1
(6 років потому ...) чи було неправильним та оманливим відступом задумом, задуманим як зачіпка ? Я схильний редагувати це, щоб це виправити ... Чи не слід?
Will Ness

11

У LISP часто виникає бажання використовувати найслабші можливі конструкції. Деякі посібники стилю підкажуть вам використовувати, =а не eqlтоді, коли ви знаєте, що порівнювані елементи є числовими, наприклад. Ідея часто полягає в тому, щоб вказати, що ви маєте на увазі, а не ефективно програмувати комп’ютер.

Однак можуть бути фактичні покращення ефективності, якщо ви говорите лише те, що ви маєте на увазі, і не використовуєте сильніші конструкції. Якщо у вас є ініціалізації за допомогою LET, вони можуть виконуватися паралельно, тоді як LET*ініціалізації повинні виконуватися послідовно. Я не знаю, чи дійсно будь-які реалізації це зроблять, але деякі цілком можуть в майбутньому.


2
Влучне зауваження. Хоча оскільки Lisp є такою мовою високого рівня, це просто змушує мене дивуватися, чому "найслабші можливі конструкції" - це такий бажаний стиль у країні Lisp. Ви не бачите програмістів Perl, які кажуть "ну, нам тут не потрібно використовувати регулярний вираз ..." :-)
Кен

1
Не знаю, але є певна перевага стилю. Проти цього дещо виступають люди (такі як я), яким подобається використовувати ту саму форму якомога більше (я майже ніколи не пишу setq замість setf). Це може бути пов’язано з ідеєю сказати те, що ви маєте на увазі.
Девід Торнлі,

=Оператор НЕ є ні сильніше , ні слабкіше eql. Це слабший тест, оскільки 0дорівнює 0.0. Але це також сильніше, оскільки нечислові аргументи відхиляються.
Каз

2
Принцип, на який ви посилаєтесь, полягає у використанні найсильнішого примітиву, а не найслабшого . Наприклад, якщо речі, що порівнюються, є символами, використовуйте eq. Або якщо ви знаєте, що призначаєте місце символічному використанню setq. Однак цей принцип також відкидається моїми багатьма програмістами Lisp, які просто хочуть високого рівня мови без передчасної оптимізації.
Каз

1
насправді, CLHS говорить " прив'язки [виконуються] паралельно", але "вирази init-form-1 , init-form-2 тощо, [обчислюються] у [конкретно, зліва направо (або зверху -вниз)] замовлення ". Отже, значення потрібно обчислювати послідовно (прив’язки встановлюються після того, як всі значення були розраховані). Це також має сенс, оскільки RPLACD-подібна структурна мутація є частиною мови, і при справжньому паралелізмі вона стала б недетермінованою.
Will Ness

9

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

(defun foo (a b)
  (let ((a (max a b))
        (b (min a b)))
    ; here we know b is not larger
    ...)
  ; we can use the original identities of a and b here
  ; (perhaps to determine the order of the results)
  ...)

Перед- вважаючи bбув більше, якби ми використовували let*, ми б випадково встановили aі bдо того ж значенню.


1
Якщо вам не знадобляться значення x і y пізніше всередині зовнішнього let, це можна зробити простіше (і чіткіше) за допомогою: (rotatef x y)- непоганої ідеї, але все одно це здається розтяжкою.
Кен

Це правда. Це може бути корисніше, якби x та y були спеціальними змінними.
Семюель Едвін Уорд,

9

Основна відмінність загального списку між LET і LET * полягає в тому, що символи в LET пов'язані паралельно, а в LET * - послідовно. Використання LET не дозволяє паралельно виконувати init-форми, а також не дозволяє змінювати порядок init-форм. Причина в тому, що Common Lisp дозволяє функціям мати побічні ефекти. Отже, порядок оцінки є важливим і завжди зліва направо у формі. Таким чином, у LET спочатку оцінюються init-форми, зліва направо, потім паралельно створюються прив'язки, зліва направо . У LET * форма init обчислюється, а потім прив'язується до символу послідовно, зліва направо.

CLHS: Спеціальний оператор LET, LET *


2
Здається, ця відповідь, мабуть, черпає енергію з того, що є відповіддю на цю відповідь ? Крім того, відповідно до специфікації, прив’язки, як кажуть, виконуються паралельно паралельно LET, навіть якщо ви правильно називаєте, що init-форми виконуються послідовно. Чи має це якусь практичну різницю в будь-якому з існуючих реалізацій, я не знаю.
Lindes

8

я йду один крок далі і використовувати прив'язку , яка уніфікує let, let*, multiple-value-bind, і destructuring-bindт.д., і це навіть розширюється.

загалом мені подобається використовувати "найслабшу конструкцію", але не з let& друзями, оскільки вони просто видають шум коду (попередження про суб'єктивність! не потрібно намагатися переконати мене у протилежному ...)


Ох, акуратно. Я зараз піду пограти з BIND. Дякую за посилання!
Кен

4

Імовірно, використання letкомпілятора має більшу гнучкість для впорядкування коду, можливо, для покращення простору або швидкості.

Стилістично використання паралельних прив’язок показує намір зв’язати їх між собою; це іноді використовується для збереження динамічних прив'язок:

(let ((*PRINT-LEVEL* *PRINT-LEVEL*)
      (*PRINT-LENGTH* *PRINT-LENGTH*))
  (call-functions that muck with the above dynamic variables)) 

У 90% коду не має значення, використовуєте ви LETчи LET*. Отже, якщо ви використовуєте *, ви додаєте непотрібний гліф. Якби LET*паралельний сполучний елемент LETбув послідовним, програмісти все одно використовували б LETі витягували лише, LET*коли бажали паралельного прив’язки. Це, ймовірно, буде LET*рідкісним
Каз

насправді, CLHS визначає порядок оцінки давайте init-form s.
Will Ness

4
(let ((list (cdr list))
      (pivot (car list)))
  ;quicksort
 )

Звичайно, це спрацювало б:

(let* ((rest (cdr list))
       (pivot (car list)))
  ;quicksort
 )

І це:

(let* ((pivot (car list))
       (list (cdr list)))
  ;quicksort
 )

Але важлива думка.


2

ОП запитує "коли-небудь насправді потрібно було ЛЕТ"?

Коли був створений Common Lisp, було наявне завантаження існуючого коду Lisp на різних діалектах. Короткий зміст, який прийняли люди, що розробляли Common Lisp, полягав у створенні діалекту Lisp, який би забезпечив спільну мову. Їм "потрібно" зробити спрощеним і привабливішим перенесення існуючого коду на Common Lisp. Залишення LET або LET * поза мовою могло б послужити деяким іншим чеснотам, але це проігнорувало б цю ключову мету.

Я використовую LET, а не LET *, оскільки він розповідає читачеві про те, як розгортається потік даних. У моєму коді, принаймні, якщо ви бачите LET *, ви знаєте, що значення, прив'язані достроково, будуть використані для подальшого прив'язки. Чи мені "потрібно" це робити, ні; але я думаю, що це корисно. Тим не менш, я рідко читав код, який за замовчуванням має значення LET *, і поява LET сигналізує про те, що автор справді цього хотів. Тобто, наприклад, поміняти місцями значення двох вар.

(let ((good bad)
     (bad good)
...)

Існує дискусійний сценарій, який наближається до "фактичної потреби". Це виникає з макросами. Цей макрос:

(defmacro M1 (a b c)
 `(let ((a ,a)
        (b ,b)
        (c ,c))
    (f a b c)))

працює краще, ніж

(defmacro M2 (a b c)
  `(let* ((a ,a)
          (b ,b)
          (c ,c))
    (f a b c)))

оскільки (M2 cba) не спрацює. Але ці макроси досить неакуратні з різних причин; так що це підриває аргумент "фактична потреба".


1

На додаток до відповіді Райнера Йосвіга , і з пуристичної, або з теоретичної точки зору. Нехай & Нехай * представляють дві парадигми програмування; функціональний та послідовний відповідно.

Що стосується того, чому я повинен продовжувати використовувати Let * замість Let, ну, ти отримуєш задоволення від того, що я повертаюся додому і думаю чисто функціональною мовою, на відміну від послідовної мови, де я проводжу більшу частину свого дня, працюючи :)


1

З Дозвольте використовувати паралельне прив'язування,

(setq my-pi 3.1415)

(let ((my-pi 3) (old-pi my-pi))
     (list my-pi old-pi))
=> (3 3.1415)

А з послідовним прив'язуванням Let *,

(setq my-pi 3.1415)

(let* ((my-pi 3) (old-pi my-pi))
     (list my-pi old-pi))
=> (3 3)

Так, саме так вони визначені. Але коли вам знадобиться перший? Ви фактично не пишете програму, яка повинна змінювати значення pi в певному порядку, я припускаю. :-)
Кен

1

letОператор вводить єдину середу для всіх прив'язок , які він визначає. let*, принаймні концептуально (і якщо ми на мить ігноруємо декларації) вводить кілька середовищ:

Тобто:

(let* (a b c) ...)

це як:

(let (a) (let (b) (let (c) ...)))

Отже, в певному сенсі letє більш примітивним, тоді як let*є синтаксичним цукром для написання каскаду let-s.

Але не зважайте на це. (І далі я дам обґрунтування нижче, чому нам слід "не хвилювати"). Справа в тому, що є два оператори, і в "99%" коду не має значення, якого з них ви використовуєте. Причина віддати перевагу letбільш let*просто , що він не має *бовталися в кінці.

Всякий раз, коли у вас є два оператори, і один має *бовтанку на його назві, використовуйте одного без, *якщо він працює в такій ситуації, щоб ваш код був менш потворним.

Це все.

З огляду на це, я підозрюю, що якби letі let*обмінялися їх значеннями, це, швидше за все, було б рідше використовувати, let*ніж зараз. Поведінка послідовного прив'язки не заважатиме більшості коду, який цього не вимагає: рідко let*доводиться використовувати його для запиту паралельної поведінки (і такі ситуації також можна виправити, перейменувавши змінні, щоб уникнути тінізації).

Тепер щодо обіцяної дискусії. Незважаючи на те, що let*концептуально вводить декілька середовищ, скласти let*конструкцію дуже просто, щоб було створено єдине середовище. Так що , хоча (принаймні , якщо ми будемо ігнорувати заяви ANSI CL), є алгебраїчне рівність , що один let*відповідає кільком вкладеним let-s, що робить letвиглядати більш примітивними, немає ніяких причин , щоб фактично розширити let*в letскомпілювати його, і навіть погана ідея.

Ще одне: зауважте, що Common Lisp lambdaнасправді використовує let*подібну семантику! Приклад:

(lambda (x &optional (y x) (z (+1 y)) ...)

тут xinit-форма для yдоступу до попереднього xпараметра, і аналогічно (+1 y)посилається на попередній yнеобов'язковий. У цій області мови простежується чітка перевага послідовної видимості в палітурці. Було б менш корисно, якби форми в a lambdaне бачили параметрів; необов’язковий параметр не міг бути стислим за замовчуванням з точки зору значення попередніх параметрів.


0

При цьому letвсі вирази, що ініціалізують змінну, бачать абсолютно те саме лексичне середовище: те, що оточує let. Якщо випадково ці вирази фіксують лексичні закриття, усі вони можуть мати спільний об’єкт середовища.

При цьому let*кожен ініціалізуючий вираз знаходиться в іншому середовищі. Для кожного наступного виразу середовище повинно бути розширене, щоб створити нове. Принаймні в абстрактній семантиці, якщо закриття фіксується, вони мають різні об'єкти навколишнього середовища.

let*Повинно бути добре оптимізовано , щоб згорнути непотрібні розширення середовища для того , щоб підходять в якості заміни для повсякденної let. Повинен бути компілятор, який працює, форми якого отримують доступ до чого, а потім перетворює всі незалежні у більші комбіновані let.

(Це справедливо, навіть якщо let*це просто макрооператор, який випромінює каскадні letформи; оптимізація проводиться на цих каскадних lets).

Ви не можете реалізувати let*як єдиний наївний елемент letіз прихованими присвоєннями змінних для ініціалізації, оскільки буде виявлено відсутність належного масштабування:

(let* ((a (+ 2 b))  ;; b is visible in surrounding env
       (b (+ 3 a)))
  forms)

Якщо це перетвориться на

(let (a b)
  (setf a (+ 2 b)
        b (+ 3 a))
  forms)

в цьому випадку це не спрацює; внутрішнє bзатінює зовнішнє, bтому в підсумку ми додаємо 2 до nil. Таке перетворення можна здійснити, якщо ми перейменуємо всі ці змінні. Потім довкілля добре вирівнюється

(let (#:g01 #:g02)
  (setf #:g01 (+ 2 b) ;; outer b, no problem
        #:g02 (+ 3 #:g01))
  alpha-renamed-forms) ;; a and b replaced by #:g01 and #:g02

Для цього нам потрібно розглянути підтримку налагодження; якщо програміст вступає в цей лексичний обсяг за допомогою налагоджувача, чи хочемо ми, щоб вони мали справу із #:g01замість a.

Отже, в основному, let*це складна конструкція, яка повинна бути оптимізована добре для роботи, а також letу випадках, коли вона може зменшитись до let.

Це саме по собі не виправдовує користь letбільш let*. Припустимо, у нас хороший компілятор; чому не використовувати let*весь час?

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

Це міркування не найкраще стосується letversus let*, оскільки let*це явно не абстракція вищого рівня щодо let. Вони приблизно на "рівному рівні". За допомогою let*ви можете ввести помилку, яка вирішується простим переходом на let. І навпаки . let*насправді є лише м’яким синтаксичним цукром для візуально руйнуючогося letгніздування, а не значною новою абстракцією.


-1

Я здебільшого використовую LET, за винятком випадків, коли мені конкретно потрібен LET *, але іноді я пишу код, який явно потребує LET, як правило, коли виконую різні (як правило, складні) дефолти. На жаль, у мене немає під рукою жодного зручного прикладу коду.


-1

кому хочеться знову переписати letf vs letf *? кількість дзвінків із захистом від розмотування?

простіше оптимізувати послідовні прив'язки.

може це впливає на env ?

дозволяє продовження з динамічним ступенем?

іноді (нехай (xyz) (setq z 0 y 1 x (+ (setq x 1) (prog1 (+ xy) (setq x (1- x))))) (значення ()))

[Я думаю, що це працює] суть в тому, що простіший час від часу легше читати.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.