Код: маленький и ненужный

4e962cca2b27840f31192e773997333b

Приветствую читателей!

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

Вдохновило меня на эту тему статья: Почему Banditypes — самая маленькая TS-библиотека для валидации схем. В ней автор пытается показать приемы для уменьшения размера кода. Мне стало любопытно, а что получится у меня на этом поприще? Я слышал ранее о Code Golf, но не пробовал себя в таком. И вот, собрав все знания JS в кулак, попытался максимально сократить код одной из задач.

И так, сокращать мы будем код из задачки с CodeWars: Simple assembler interpreter. Задачка достаточно простая, и если у вас есть желание, вы можете ее предварительно решить сами. Но это совершенно не обязательно, хотя может помочь лучше понять контекст происходящего в коде. Решение, скорее всего, будет крайне типовым, но мест для сжатия кода будет предостаточно, даже не прибегая к сильному изменению начального алгоритма. И еще одно требование: нам достаточно прохождения тестов на задаче. Однако в конце будет выделен отдельный случай, когда алгоритм рабочий, но из-за переполнения стека решение не пройдет. Поэтому имеется бонусная часть с теоретическим сжатием.

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

Теперь встречаем код, который мы будем уродовать c помощью сжатия:

function simple_assembler(program) {
    let pointer = 0;
    let memory = {};
    while (pointer < program.length) {
        const [command, arg1, arg2] = program[pointer].split(' ');
        if (command === 'mov') {
            const number = Number(arg2);
            memory[arg1] = !Number.isNaN(number) ? number : memory[arg2];
        } else if (command === 'inc') {
            memory[arg1] += 1;
        } else if (command === 'dec') {
            memory[arg1] -= 1;
        } else if (command === 'jnz') {
            const number = Number(arg1);
            const value = !Number.isNaN(number) ? number : memory[arg1];
            if (value) {
                pointer += Number(arg2);
                continue;
            }
        }
        pointer += 1;
    }
    return memory;
}

Как и говорил выше, код достаточно типовой (наибольшей опцией является только if vs switch или выполнение команд по хэшмапе). Теперь мы начнем потихоньку откидывать лишнее, разбирая отдельные моменты. Несмотря на желание для сокращения начать сразу с переменных, я этого делать сразу не стану, так как наглядность примеров кода слишком резко упадет. Поэтому отложим это сжатие по более поздний срок. А начнем с частей, которые можно безболезненно поменять.

const simple_assembler = program => { // 1
    let pointer = 0 // 2
    let memory = {}
    while (program[pointer]) { // 3
        const [command, arg1, arg2] = program[pointer].split(' ')
        if (command === 'mov') {
            const number = Number(arg2)
            memory[arg1] = !Number.isNaN(number) ? number : memory[arg2]
        } else if (command === 'inc') {
            memory[arg1] += 1
        } else if (command === 'dec') {
            memory[arg1] -= 1
        } else if (command === 'jnz') {
            const number = Number(arg1)
            const value = !Number.isNaN(number) ? number : memory[arg1]
            if (value) {
                pointer += Number(arg2)
                continue
            }
        }
        pointer += 1
    }
    return memory
}

Пока применим 3 сокращения, которые особо не влияют на читабельность кода.

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

  2. Избавимся во всех строках от ; . Лично мое мнение: ; и так не нужен в коде на JS/TS. Но это сугубо личное мнение каждого. И в данном случае можно спокойно их выкинуть, не потеряв ни в чем.

  3. pointer < program.length заменили на program[pointer]. Так как по своей сути они выполняют одну задачу. Можно спорить об оптимизации каждого из подходов, но нас интересует лишь сокращение, а не производительность решения.

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

const simple_assembler = program => {
    let pointer = 0
    let memory = {}
    while (program[pointer]) {
        let pointerChange = 1 // 1
        const [command, arg1, arg2] = program[pointer].split(' ')
        if (command === 'mov') {
            memory[arg1] = !Number.isNaN(Number(arg2)) ? Number(arg2) : memory[arg2] // 2
        } else if (command === 'inc') {
            memory[arg1] += 1
        } else if (command === 'dec') {
            memory[arg1] -= 1
        } else if (command === 'jnz') {
            if (!Number.isNaN(Number(arg1)) ? Number(arg1) : memory[arg1]) { // 3
                pointerChange = Number(arg2) // 4
            }
        }
        pointer += pointerChange // 5
    }
    return memory
}
  1. Самое странное изменение, но которое сильно поможет в сокращении в последствии — это добавление переменной, которая бы показывала, какой шаг сделаем в результате выполнения команды (по умолчанию 1), но в шаге (4) мы меняем его по условию и применяем непосредственно в шаге (5)

  2. Плохой совет: откидываем промежуточные константы и портим читабельность кода, аналогичное действие в (3)

Теперь у нас на руках программа, в которой так и чешутся руки взять и отрезать самое главное: обертки в Number:

const simple_assembler = program => {
    let pointer = 0
    let memory = {}
    while (program[pointer]) {
        let pointerChange = 1
        const [command, arg1, arg2] = program[pointer].split(' ')
        if (command === 'mov') {
            memory[arg1] = !Number.isNaN(+arg2) ? +arg2 : memory[arg2] // 1
        } else if (command === 'inc') {
            memory[arg1] += 1
        } else if (command === 'dec') {
            memory[arg1] -= 1
        } else if (command === 'jnz') {
            if (!Number.isNaN(+arg1) ? +arg1 : memory[arg1]) { // 2
                pointerChange = +arg2
            }
        }
        pointer += pointerChange
    }
    return memory
}
  1. И вот Number(x) превращается в краткий +x.

  2. Аналогично действие с пунктом 1

Примечание: Лично я большой противник унарного + как замены Number. Несмотря на то, что унарный + — это оператор, единственной задачей которого является преобразование типов данных к числу. Да, он краток и лаконичен, однако его использование делает преобразование типов неоднообразным. Если есть желание похолливарить, то добро пожаловать в комментарии.

Теперь провернем вот такие момент:

const simple_assembler = program => {
    let pointer = 0
    let memory = {}
    while (program[pointer]) {
        let pointerChange = 1
        const [command, arg1, arg2] = program[pointer].split(' ')
        if (command === 'mov') {
            memory[arg1] = +arg2 === +arg2 ? +arg2 : memory[arg2] // 1
        } else if (command === 'inc') {
            memory[arg1] += 1
        } else if (command === 'dec') {
            memory[arg1] -= 1
        } else if (command === 'jnz') {
            if (+arg1 === +arg1 ? +arg1 : memory[arg1]) { // 2
                pointerChange = +arg2
            }
        }
        pointer += pointerChange
    }
    return memory
}

В (1) и (2) изменении мы развернули операцию !Number.isNaN(x) в x === x. Если кому-то не очевидно изменение, то разберем его подробнее: Во-первых нужно вспомнить уникальную особенность NaN, которая будет справедлива для многих языков, а не только JS. сравнение NaN с NaN возвращает всегда false. Таким образом, проверка x !== xаналогичен Number.isNaN(x), а так как мы проверяли на неравенство с NaN, то нам достаточно поменять неравенство на равенство, и мы получим наше преобразование. Однако это нам досталось ценой меньшей ясности в коде.

Теперь настало время более тяжелой артиллерии:

const simple_assembler = program => {
    let pointer = 0
    let memory = {}
    while (program[pointer]) {
        let pointerChange = 1
        const [[command], arg1, arg2] = program[pointer].split(' ') // 1
        if (command === 'm') { // 2
            memory[arg1] = +arg2 === +arg2 ? +arg2 : memory[arg2]
        } // 3
        if (command === 'i') { // 4 
            memory[arg1] += 1
        } // 5
        if (command === 'd') { // 6
            memory[arg1] -= 1
        } // 7
        if ((command === 'j') && (+arg1 === +arg1 ? +arg1 : memory[arg1])) { // 8
            pointerChange = +arg2
        }
        pointer += pointerChange
    }
    return memory
}
  1. Мы тут злоупотребляем особенностями постановки задачи и всегда ожидаем валидных данных. Таким образом, нас не сильно волнует то, что нам может прилететь не валидная команда, и по данным 4ем командам мы можем позволить себе сокращение и проверять только лишь 1ую букву в названии команды. Сделано это с помощью вложенной деструктуризации. Таким же образом происходит сокращение в пунктах (2), (4), (6) и (8). Итого: добавив 2 символа мы выкидываем 8 символов.

  1. Просто выкидываем все else, оставляя только if. Таким же образом поступаем в (5) и (7) пункта. Именно поэтому, я не откидывал условие проверки в последнем else до этого, хотя это вполне естественное желание.

  1. Тут мы еще склеили 2 вложенных if-а через &&, чтобы разобрать условие стало еще сложнее, однако от лишнего «тяжеловесного» if мы избавились.

Продолжаем воевать с тяжеловесными условиями. Следующее сокращение кода:

const simple_assembler = program => {
    let pointer = 0
    let memory = {}
    while (program[pointer]) {
        let pointerChange = 1
        const [[command], arg1, arg2] = program[pointer].split(' ')
        if (command === 'm') memory[arg1] = +arg2 === +arg2 ? +arg2 : memory[arg2] // 1
        if (command === 'i') memory[arg1] += 1 // 2
        if (command === 'd') memory[arg1] -= 1 // 3
        if ((command === 'j') && (+arg1 === +arg1 ? +arg1 : memory[arg1])) pointerChange = +arg2 // 4
        pointer += pointerChange
    }
    return memory
}

(1…4) Тут мы провернули одно из самых любимых сокращений разработчиков на всех языках: откидывание скобок и лишней строки, ради сокращения. Тема опять же холливарная, но я в своем коде такое крайне не люблю (больше действий читать 1 строку и если нужно добавить еще 1 действие, то редактировать «дольше»).

Но раз мы взялись за уменьшение условий, то пойдем дальше и применим один из самых грязных трюков в JS:

const simple_assembler = program => {
    let pointer = 0
    let memory = {}
    while (program[pointer]) {
        let pointerChange = 1
        const [[command], arg1, arg2] = program[pointer].split(' ')
        ;(command === 'm') && (memory[arg1] = +arg2 === +arg2 ? +arg2 : memory[arg2]) // 1
        ;(command === 'i') && (memory[arg1] += 1) // 2
        ;(command === 'd') && (memory[arg1] -= 1) // 3
        ;(command === 'j') && (+arg1 === +arg1 ? +arg1 : memory[arg1]) && (pointerChange = +arg2) // 4
        pointer += pointerChange
    }
    return memory
}

Тут во всех пунктах одно и тоже изменение: мы сокращаем if-ы до злоупотребления возможностями &&. К сожалению, такое сокращение я вижу порой даже в серьезных проектах и от достаточно сильных программистов. Надеюсь, они рады сэкономленными парой байтов кода и мы вместе с ними. Так же обратим внимание, что у нас добавились уродливые ; вначале строчек, это из особенностей обработки выражений на JS и, конечно, мы исправим это «недоразумение» позже.

Следующее изменение — это то, что я оттягивал так долго, как мог и это:

const simple_assembler = p => {
    let i = 0
    let m = {}
    while (p[i]) {
        let c = 1
        const [[w], f, s] = p[i].split(' ')
        ;(w === 'm') && (m[f] = +s === +s ? +s : m[s])
        ;(w === 'i') && (m[f] += 1)
        ;(w === 'd') && (m[f] -= 1)
        ;(w === 'j') && (+f === +f ? +f : m[f]) && (c = +s)
        i += c
    }
    return m
}

Мы оставили только односимвольные переменные, что очень сильно дополнительно ударило по читабельности кода. Теперь без контекста понять, что же тут происходит уже достаточно сложно. Поэтому в реальном коде такого стараемся всеми силами избегать (даже i/j по возможности заменяем на более уместное наименование).

Ну и раз уж начали неприятные изменения, добавим больше ошибку новичков, чем осознанное сокращение:

let simple_assembler=p=>{ // 1
    let i=0,m={} // 2
    while(p[i]){
        let c=1,[[w],f,s]=p[i].split(' ') // 3
        ;(w=='m')&&(m[f]=+s==+s?+s:m[s]) // 4
        ;(w=='i')&&(m[f]+=1) // 5
        ;(w=='d')&&(m[f]-=1) // 6
        ;(w=='j')&&(+f==+f?+f:m[f])&&(c=+s) // 7
        i+=c
    }
    return m
}
  1. Мы убрали все окружающие скобки. И теперь код превратился совсем в кашу.

  2. Мы склеили в (2) и (3) пунктах многострочные определения переменных в 1 строку через , и это тоже добавляет проблем с читабельностью. Радует, что в реальных проектах сейчас такое почти не встречается. Дополнительно заменяем const на let, так как это немного короче.

  3. (3) (4) (5) (6) (7) мы заменили === на ==, так как проверка на тип нас здесь не особо интересует.

Теперь избавимся от неприятных ; :

let simple_assembler=p=>{ // 1
    let i=0,m={} // 2
    while(p[i]){
        let c=1,[[w],f,s]=p[i].split(' ') // 3
        w=='m'&&(m[f]=+s==+s?+s:m[s]) // 4
        w=='i'&&(m[f]+=1) // 5
        w=='d'&&(m[f]-=1) // 6
        w=='j'&&(+f==+f?+f:m[f])&&(c=+s) // 7
        i+=c
    }
    return m
}

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

Теперь учтем, что CodeWars не использует строгий режим (strict mode) при запуске тестов. Это нам дает еще одну возможность откинуть «бесполезный код»:

simple_assembler=p=>{ // 1
    i=0,m={} // 2
    while(p[i]){
        c=1,[[w],f,s]=p[i].split(' ') // 3
        w=='m'&&(m[f]=+s==+s?+s:m[s])
        w=='i'&&(m[f]+=1)
        w=='d'&&(m[f]-=1)
        w=='j'&&(+f==+f?+f:m[f])&&(c=+s)
        i+=c
    }
    return m
}

Так как JS в нестрогом режиме позволяет использовать даже необъявленные переменные, то мы с большим удовольствием воспользуемся такой возможностью. Это происходит в (1) (2) и (3) пунктах.

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

simple_assembler=p=>{i=0,m={}
while(p[i]){c=1,[[w],f,s]=p[i].split(' ')
w=='j'&&(+f==+f?+f:m[f])&&(c=+s) // 1
w=='m'&&(m[f]=+s==+s?+s:m[s])
w=='i'&&(m[f]+=1)
w=='d'&&(m[f]-=1)
i+=c}return m}

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

И так, теперь мы попытаемся убрать еще несколько символов за счет склеивания все в 1 выражение.

simple_assembler=p=>{i=0,m={}
while(p[i]){c=1,[[w],f,s]=p[i].split(' ')
w=='j'?(+f==+f?+f:m[f])&&(c=+s):w=='m'?(m[f]=+s==+s?+s:m[s]):w=='i'?(m[f]+=1):(m[f]-=1)
i+=c}return m}

Тут мы заменили последовательность проверок на подобие if else при помощи ? : . Сейчас лучше взять паузу и просмотреть, во что это превратилось. Можно заметить, что благодаря этому мы смогли выкинуть последнюю проверку на команду декремента. И такое изменение позволило нам выкинуть 11 символов (осталось 174).

Теперь совершим достаточно логичное преобразование в данном случае:

simple_assembler=p=>{i=0,m={}
while(p[i]){c=1,[[w],f,s]=p[i].split(' ')
w=='j'?(+f?+f:m[f])&&(c=+s):w=='m'?(m[f]=+s==+s?+s:m[s]):m[f]+=w=='i'?1:-1
//        ^-1                                            ^-2
i+=c}return m}
  1. Тут мы для сокращения воспользуемся тем фактом, что прыжок по условию не может быть на 0 символов (так как в этом случае мы останемся на том же месте). Поэтому мы отсекаем этот вариант, и он позволяет отсечь проверку на 0. В итоге либо это число больше 0, либо имя регистра. Поэтому мы просто делаем проверку на +f и нам этого достаточно.

  2. Обращаем внимание, что инкремент и декремент — это 2 стороны одной монеты и преобразуем 2 изменения в 1 с выбором значения.

Итого: 161 (-13 символов)

Развиваем прошлые изменения:

simple_assembler=p=>{i=0,m={}
while(p[i]){c=1,[[w],f,s]=p[i].split(' ')
w=='j'?(+f||m[f])&&(c=+s):m[f]=w=='m'?(+s==+s?+s:m[s]):m[f]+(w=='i'?1:-1)
//        ^-1                 ^-2                           ^-3
i+=c}return m}
  1. Теперь настало время злоупотребить возможностью ||. И мы сразу возвращаем при возможности преобразованное к числу выражение, иначе забираем значение из регистра.

  2. Расширяем замеченное до этого, что инкремент и декремент — это одно и тоже. Но присваивание — это тоже изменение. Поэтому преобразовываю выражение так, чтобы мы в итоге получали в m[f] новое значение.

  3. Тут мы немного меняем значение, чтобы оно работало с преобразованием из пункта (2).

Итого: 160 (-1 символ). Немного, но нужно для дальнейших преобразований

simple_assembler=p=>{i=0,m={}
while(p[i]){c=1,[[w],f,s]=p[i].split(' ')
w=='j'?c=+f||m[f]?+s:1:m[f]=w=='m'?(+s==+s?+s:m[s]):m[f]+(w=='i'||-1)
//     ^-1                                               ^-2           
i+=c}return m}
  1. Делаем интересное изменение, которое изменяет с в любом случае, но если условие не выполняется, я возвращаю значение на 1.

  2. Меняем тернарный оператор w=='i'?1:-1 на w=='i'||-1. Тут используется трюк как с ||, так и неявное преобразование к числу выражения w=='i'.

Итого: 156 (-4 символа)

simple_assembler=p=>{i=0,m={}
while(p[i]){c=1,[[w],f,s]=p[i].split` ` // 1
w=='j'?c=+f||m[f]?+s:1:m[f]=w=='m'?+s==+s?+s:m[s]:m[f]+(w=='i'||-1)
//                                 ^-2        
i+=c}return m}
  1. Тут мы сделали крайне любопытное преобразование вызова функции к шаблонной строке с тегом. Чтобы лучше понять этот трюк, можно ознакомиться с данным материалом.

  2. Благодаря прошлым изменениям мы можем спокойно откинуть скобки. Конфликта в порядке действий не возникнет.

Итого: 152 (и еще -4 символа)

simple_assembler=p=>{i=0,m={}
while(p[i]){[[w],f,s]=p[i].split` ` // 1
i+=w=='j'?+f||m[f]?+s:1:(m[f]=w=='m'?+s==+s?+s:m[s]:m[f]+(w=='i'||-1),1)
//^-2                   ^-3                                          ^-4
}return m}
  1. Откинута лишняя переменная с. Далее расписано как это достигнуто.

  2. Мы считаем значение изменение i в выражении всегда и (4) пункт позволяет установить значение по умолчанию.

  3. Новые вынужденные скобки.

  4. Злоупотребление оператором ,, который я бы очень не хотел встретить в реальном проекте.

Итого: 149 (-3)

simple_assembler=p=>{i=0,m={}
while(p[i])[[w],f,s]=p[i].split` `,i+=w=='j'?+f||m[f]?+s:1:(m[f]=w=='m'?+s==s?+s:m[s]:m[f]+(w=='i'||-1),1)
//                                                                          ^-1
return m}

Теперь осталось достаточно простое преобразование: привести тело цикла к одному выражению тоже с помощью , и благодаря этому откинуть фигурные скобки вокруг while. Так же замечаем, что +s==+s излишне громоздок. Так как == будет делать автоматическое преобразование типа, то второй + излишен и его можно опустить. Итого: +s==s

Итого: 146 (-3)

И на данный момент это самое большое, что я смог выжать из кода так, чтобы он прошел все тесты на CodeWars. Однако мне этого было мало, и я рискнул на авантюру, преобразовав цикл к рекурсии, чтобы избавиться от «монструозных» while/return.

Бонусная часть

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

simple_assembler=p=>(m={},(x=i=>p[([[w],f,s]=p[i].split` `,i+=w!='j'?(m[f]=w=='m'?+s==s?+s:m[s]:m[f]+(w=='i'||-1),1):+f||m[f]?+s:1),i]?x(i):m)(0))
//                  ^-1    ^-2                                                                                                      ^-3 ^-4    ^-5
  1. Преобразовав функцию к рекурсивной, удается избавить ее от цикла и сразу возвращать значение. Так же приходит применить IIFE и вызвать функцию от 0-вой строчки (5).

  2. Нам все-таки нужно для рекурсии использовать какое-то имя функции, поэтому ее присваиваем к x. Так же для удобства работы со скобками я перевернул 1 из условий.

  3. Возвращаем для проверки получившийся i. Пришлось докинуть скобок для правильной работы оператора ,.

  4. Тут совершаем интересный прием: либо вызываем функцию рекурсивно с новым i, либо возвращает объект памяти и завершает рекурсию.

Итого: 146 (Не смотря на некоторые сокращения, из коробки данный способ дает столько же в размере)

simple_assembler=p=>(m={},x=i=>p[[[w],f,s]=p[i].split` `,i+=w!='j'?(m[f]=v=='m'?+s==s?+s:m[s]:m[f]+(w=='i'||-1),1):+f||m[f]?+s:1]?x(i):m)(0)
//                       ^-1     ^-2

Тут выполнено выбрасывание излишних скобочек

  1. Лишняя обертка. Без нее все работает нормально.

  2. Замечаем, что в индекс мы прокидываем i, который и так вычисляется в теле, поэтому спокойно выкидываем лишние скобки и ,1.

Итого: 140 (-6)

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

Так же оставлю ссылку, где можно увидеть еще больше приемов для «сжатия» кода.

© Habrahabr.ru