Programming/Javascript

[Javascript] You Don't Know JS (문법-2)

알로그 2020. 4. 5. 12:47
반응형

5.2 연산자 우선순위

앞서 설명했지만 자바스크립트에서 &&, || 연산자는 true/false를 반환하는 것이 아닌 피연산자 중 하나를 선택해서 반환한다.

var a = 42;
var b = "foo";

a && b;    // "foo"
a || b;    // 42

하지만 연산자 2개, 피연산자 3개면?

var a = 42;
var b = "foo";
var c = [1,2,3];

a && b || c; // ???
a || b && c; // ???

두 표현식의 결과를 이해하려면 연산자와 피연산자가 여러 개 있을 경우 어떤 규칙으로 처리되는지 알아야 한다.
이 규칙을 '연산자 우선순위'라고 한다.

앞서 살펴봤던 예제이다.

var a = 42, b;
b = ( a++, a );

a;    // 43
b;    // 43

괄호를 없애보자.

var a = 42, b;
b = a++, a;

a;    // 43
b;    // 42

위에서 b의 결과값이 다른 이유는 연산자가 = 연산자보다 우선순위가 낮기 때문에 (b = a++), a로 해석한다.

다수의 문을 연결할때 연산자로 ,를 사용할 때, 이 연산자의 우선순위가 최하위라는 사실을 인지하자.

다른예제를 살펴보자.

if (str && (matches = str.match( /[aeiou]/g ))) {
    // ..
}

&&가 =보다 우선순위가 높으므로 ( )로 묶어주지 않으면 표현식은 (str && matches)..로 처리된다.

좀 더 복잡한 예제를 살펴보자

var a = 42;
var b = "foo";
var c = false;

var d = a && b || c ? c || b ? a : c && b : a;

d;        // 42

a && b || c의 연산은 (a && b) || c 와 a && (b || c) 중 어느 쪽으로 해석될까?

true || false && false;        // true

(true || false) && false;    // false -- nope
true || (false && false);    // true -- winner, winner!

위에서 볼 수 있듯이 좌측에서 우측으로 우선순위로 실행되는 것이 아닌 && 는 언제나 ||보다 우선순위가 높다.

5.2.1 단락 평가

&&, || 연산자는 좌측 피연산자의 평가 결과만으로 전체 결과가 결정이 될 때, 우측 피연산자의 평가는 건너뛴다.
그래서 단락(Short Circuited)이란 말이 유래된 것이다.

예를 들어 a && b 에서 a가 falsy면 연산 결과는 이미 false이므로 b는 평가하지도 않는다.
마찬가지로 a || b에서 a가 truthy면, b를 평가하지 않는다.

단락평가는 아주 유용하고 자주 사용된다.

function doSomething(opts) {
    if (opts && opts.cool) {
        // ..
    }
}

opts && opts.cool에서 opts는 일종의 가드다.
만약 opts가 존재하지 않는다면 opts.cool은 에러일 수 밖에 없지만, 먼저 opts를 단락평가 해보고 그 결과가 실패면 opts.cool은 자동으로 건너뛰니 결과적으로 에러가 나지 않는다.

|| 단락평가도 마찬가지다.

function doSomething(opts) {
    if (opts.cache || primeCache()) {
        // ..
    }
}

5.2.2 끈끈한 우정

삼항연산자를 살펴보자.
? : 는 &&와 ||보다 우선순위가 높을까 아니며 낮을까?

a && b || c ? c || b ? a : c && b : a

a && b || (c ? c || (b ? a : c) && b : a)
(a && b || c) ? (c || b) ? a : (c && b) : a

아래 두 라인 중 어느쪽으로 처리될까?
정답은 아래 줄이며, &&와 ||는 ? :보다 각각 우선순위가 높다.

5.2.3 결합성

그럼 우선순위가 동일한 다수의 연산자라면 처리 순서는 어떻게 될까?
일반적으로 연산자는 좌측부터 그룹핑이 일어나는지 우측부터 그룹핑이 일어나는지에 따라 좌측결합성 또는 우측결합성을 가진다.

var a = foo() && bar();

foo() 함수를 호출하고 그 결과에 따라 bar() 함수 호출 여부를 결정한다.
만일 bar()가 foo() 앞에 있다면 전혀 다른 식으로 프로그램이 진행된다.
foo() 함수가 먼저 호출되는 것은 좌->우 순으로 처리되는 것이지 &&의 결합성과는 무관하다.

그러나 a && b && c와 같은 표현식에서는 암시적인 그룹핑이 발생한다.
&&는 좌측부터 결합하므로 (a && b) && c와 같다.

결합 방향이 좌/우측 어느 쪽이냐에 따라 완전히 다르게 작동하는 연산자도 있다.
우선 삼항연산자가 그렇다.

첫째줄은 두번째줄과 세번째줄 둘중 어떤식으로 동작할까?

a ? b : c ? d : e;
a ? b : (c ? d : e) //정답
(a ? b : c) ? d : e 

&&, ||와 달리 우측부터 결합하므로 결과가 완전히 달라진다.

다음 조합도 그렇다.

true ? false : true ? true : true;        // false

true ? false : (true ? true : true);    // false
(true ? false : true) ? true : true;    // true

결과 값은 같은데 미묘한 차이가 숨겨진 조합도 있다.

true ? false : true ? true : false;        // false

true ? false : (true ? true : false);    // false
(true ? false : true) ? true : false;    // false

결과가 같아서 그룹핑이 문제되지 않아보이지만 실상은 문제가 될 수 있다.

var a = true, b = false, c = true, d = true, e = false;

a ? b : (c ? d : e); // false, evaluates only `a` and `b`
(a ? b : c) ? d : e; // false, evaluates `a`, `b` AND `e`

=도 우측 결합성 연산자 중 하나다.

var a, b, c;

a = b = c = 42;

a = (b = (c = 42))처럼 해석한다.

var a = 42;
var b = "foo";
var c = false;

var d = a && b || c ? c || b ? a : c && b : a;

d;        // 42
//((a && b) || c) ? ((c || b) ? a : (c && b)) : a

5.2.4 분명히 하자

그렇다면 이 모든 연산자 우선순위/결합성을 코드를 볼 사람이 모조라 다 알고 있다는 전제하에 코딩을 해야 할까?
임의로 처리 순서나 바인딩을 다르게 가져갈때만 ( )로 손수 그룹핑을 하는게 맞을까?

4장 강제변환과 마찬가지로 정답이 있는 것이 아니니 왈가왈부 할 수 없다.
책의 필자는 우선순위/결합성과 ()로 감싸주는 두 방법을 적절히 사용하는 것이다.

예를 들어, (a && b && c)는 그 자체로 최선이므로 굳이 ((a && b) && c)와 같이 쓰지 않을 것이다.
반면 두 개의 ? : 조건 연산자가 체이닝된 코드가 있다면 ()로 그룹핑하여 의도한 로직을 밝힐 것이다.

결론은 우선순위/결합성을 적절히 활용하여 짧고 깔끔한 코드를 작성하되 혼동을 줄이고 분명히 밝혀야 할 부분은 ()로 감싸주길 바란다.

5.3 세미콜론 자동삽입

ASI(Automatic Semicolon Insertion)은 자바스크립트 프로그램의 세미콜론(;)이 누락된 곳에 엔진이 자동으로 ;을 삽입하는 것을 말한다.

코딩시 ;를 생략해도 실행되는 이유는 모두 ASI 덕분이다.
ASI는 새 줄(행바꿈)에만 적용되고 줄 중간에 삽입되는 경우는 없다.

var a = 42, b
c;

각 라인마다 세미콜론이 삽입된다.

또한 아래와 같이 do ... while문에 세미콜론을 생략했더라도 ASI는 세미콜론을 자동으로 삽입해준다.

var a = 42;

do {
    // ..
} while (a)    // <-- ; expected here!
a;

문 블록에서는 ;는 필수가 아니다.

var a = 42;

while (a) {
    // ..
} // <-- no ; expected here
a;

ASI는 주로 break, continue, return, yield 키워드가 있는 곳에서 활약한다.

function foo(a) {
    if (!a) return
    a *= 2;
    // ..
}

return 문 다음 라인으로 넘어갈 일이 없으니 return 문 뒤에 ; 을 삽입한다.

function foo(a) {
    if (!a) return
    a *= 2;
    // ..
}

return 다음에 새 줄/행바꿈 문자만 있는 경우는 제외된다. (176쪽 하단 참조)

function foo(a) {
    return (
        a * 2 + 3 / 12
    );
}

5.3.1 에러 정정

자바스크립트 커뮤니티에서 ASI에 전적으로 의존해야 하는지에 대해 논란거리이다.

찬성측은 ASI가 보다 간결하게 작성하게 도와주는 유용한 도구라고 얘기하고,
반대측에서는 개발자가 의도하지 않은 ;들이 삽입되면서 의미가 달라지거나 초보자들이 실수할 가능성이 높다고 말한다.

필자는 아래와 같이 얘기한다.
ASI는 에러 정정 루틴이라고 명세되어 있으며 구체적으로 파서 에러이다.
파서에러는 프로그램을 잘못 코딩했기 때문에 나는 것이므로 프로그램 작성자가 정말 잘못 짠 코드가 있다는 증거이다.

필요하다고 생각되는 곳이라면 어디든지 세미콜론을 사용하고, ASI가 어떻게 해줄 것이라는 가정은 최소화하자.

5.4 에러

자바스크립트는 TypeError, ReferenceError, SyntaxError 뿐만 아니라 일부 에러는 컴파일 시점에 발생하도록 문법적으로 정의한다.

특히 조기 에러(Early Error)를 발생시키기도 한다.
a= . 또는 구문상 오류는 아니지만 허용되지 않는 것들이 정의되어 있다.

정규표현식 리터럴 내부의 구문 예이다.
구문상 아무 문제가 없지만 올바르지 않은 정규표현식은 조기 에러를 던진다.

var a = /+foo/;        // Error!

또 다른 예이다.

var a;
42 = a;        // Error!

ES5 엄격모드는 조기 에러가 더 많다. (함수 인자명 중복)

function foo(a,b,a) { }                    // just fine
function bar(a,b,a) { "use strict"; }    // Error!

동일한 이름의 프로퍼티가 있는 객체 리터럴도 마찬가지다.

(function(){
    "use strict";

    var a = {
        b: 42,
        b: 43
    };            // Error!
})();

5.4.1 너무 이른 변수 사용

ES6에서 임시 데드 존(TDZ) 개념을 도입했다.
TDZ는 아직 초기화를 하지 않아서 변수를 참조할 수 없는 코드 영역이다.
ES6 let 블록 스코핑이 대표적인 예다.

{
    a = 2;        // ReferenceError!
    let a;
}

let a 선언에 의해 초기화 되기 전 변수 a에 접근하고자 하지만 a는 TDZ 내부에 있으므로 에러가 난다.

앞장에서 배웠던 것처럼 typeof 연산자는 선언되지 않은 변수 앞에 붙여도 오류가 발생하지 않는데 TDZ 참조시에는 오류가 발생한다.

{
    typeof a;    // undefined
    typeof b;    // ReferenceError! (TDZ)
    let b;
}

5.5 함수인자

TDZ 관련 에러는 ES6 디폴트 인자 값에서도 볼 수 있다.

var b = 3;

function foo( a = 42, b = a + b + 5 ) {
    // ..
}

두 번째 할당문에서 좌변 b는 아직 TDZ에 남아 있는 b를 참조하려고 하기 때문에 에러를 던진다.
함수 인자의 디폴트 값은 마치 하나씩 좌->우 순서로 let 선언을 한 것과 동일하게 처리된다.

ES6 디폴트 인자값은 함수에 인자를 넘기지 않거나 undefined를 전달했을 때 적용된다.

function foo( a = 42, b = a + 1 ) {
    console.log( a, b );
}

foo();                    // 42 43
foo( undefined );        // 42 43
foo( 5 );                // 5 6
foo( void 0, 7 );        // 42 7
foo( null );            // null 1

ES6 디폴트 인자 입장에서 보면 인자 값이 없거나 undefined 값을 받거나 동일하다.
하지만 차이점을 볼 수 있는 예제도 있다.

function foo( a = 42, b = a + 1 ) {
    console.log(
        arguments.length, a, b,
        arguments[0], arguments[1]
    );
}

foo();                    // 0 42 43 undefined undefined
foo( 10 );                // 1 10 11 10 undefined
foo( 10, undefined );    // 2 10 11 10 undefined
foo( 10, null );        // 2 10 null 10 null

아무런 인자를 넘기지 않았을 때 디폴트 인자 값이 a, b에 각각 적용되었지만 arguments 배열에는 원소가 하나도 없다.

arguments 배열은 이제 비권장 요소지만 완전히 나쁜것은 아니다.

5.6 try finally

finally 절의 코드는 반드시 실행되고 다른 코드로 넘어가기 전에 try 이후부터 항상 실행된다.
만약 try절에 return 문이 있다면 finally 이전에 실행될까? 이후에 실행될까?

function foo() {
    try {
        return 42;
    }
    finally {
        console.log( "Hello" );
    }

    console.log( "never runs" );
}

console.log( foo() );
// Hello
// 42

finally 이후에 실행되는 것을 확인할 수 있다.

try 안에 throw가 있어도 비슷하다.

function foo() {
    try {
        throw 42;
    }
    finally {
        console.log( "Hello" );
    }

    console.log( "never runs" );
}

console.log( foo() );
// Hello
// Uncaught Exception: 42

만약 finally 절에서 예외가 던져지면 이전의 실행결과는 모두 무시된다.

function foo() {
    try {
        return 42;
    }
    finally {
        throw "Oops!";
    }

    console.log( "never runs" );
}

console.log( foo() );
// Uncaught Exception: Oops!

continue와 break 같은 비선형 제어문 역시 비슷하게 동작한다.

for (var i=0; i<10; i++) {
    try {
        continue;
    }
    finally {
        console.log( i );
    }
}
// 0 1 2 3 4 5 6 7 8 9

finally 절의 return은 그 이전에 실행된 try 나 catch 절의 return을 덮어쓰는 능력을 갖고 있는데, 반드시 명시적으로 return 문을 써야한다.

function foo() {
    try {
        return 42;
    }
    finally {
        // no `return ..` here, so no override
    }
}

function bar() {
    try {
        return 42;
    }
    finally {
        // override previous `return 42`
        return;
    }
}

function baz() {
    try {
        return 42;
    }
    finally {
        // override previous `return 42`
        return "Hello";
    }
}

foo();    // 42
bar();    // undefined
baz();    // "Hello"

레이블 break와 finally가 만나면?

function foo() {
    bar: {
        try {
            return 42;
        }
        finally {
            // break out of `bar` labeled block
            break bar;
        }
    }

    console.log( "Crazy" );

    return "Hello";
}

console.log( foo() );
// Crazy
// Hello

return을 취소해버리는 finally + 레이블 break 코드는 골치 아픈 코드를 양산할 뿐이다.
이런 코드는 피하자.

5.7 switch

switch 문을 간략히 살펴보자.

switch (a) {
    case 2:
        // do something
        break;
    case 42:
        // do another thing
        break;
    default:
        // fallback to here
}

switch에는 여러분이 몰랐을 기벽이 있다.
첫째, switch 표현식과 case 표현식 간의 매칭 과정은 === 알고리즘과 같다.

그러나 강제변환이 일어나는 동등비교(==)를 이용하고 싶다면 switch문에 꼼수를 부려야한다.

var a = "42";

switch (true) {
    case a == 10:
        console.log( "10 or '10'" );
        break;
    case a == 42:
        console.log( "42 or '42'" );
        break;
    default:
        // never gets here
}
// 42 or '42'

case절에 표현식이 있으니 실행상 문제는 없고, 즉 switch 표현식의 결과 true와 case 표현식의 결과를 엄격하게 매칭한다. (true === true)

==를 써도 switch문은 엄격하게 매치한다.
표현식에 &&나 ||같은 논리연산자를 사용할 때 문제가 될 수 있다.

var a = "hello world";
var b = 10;

switch (true) {
    case (a || b == 10):
        // never gets here
        break;
    default:
        console.log( "Oops" );
}
// Oops

(a || b == 10)은 true가 아닌 'hello wolrd' 이므로 매치가 되지 않는다.
끝으로 default절은 선택사항이며 꼭 마지막에 쓸 필요는 없다.

break가 없으면 계속 실행되니 주의하자.

var a = 10;

switch (a) {
    case 1:
    case 2:
        // never gets here
    default:
        console.log( "default" );
    case 3:
        console.log( "3" );
        break;
    case 4:
        console.log( "4" );
}
// default
// 3

이런식의 코드는 가능하지만 합리적이지 않고 이해하기 어렵다.
이런 로직을 피할 수 없는 상황이라면 왜 그래야 하는지 의문을 가져보고 정말 불가피하다면 의도가 명백히 드러나도록 주석을 충분히 작성하자.

5.8 정리하기

  • 자바스크립트 연산자는 그 우선순위와 결합성이 분명히 정해져있다.
  • ASI는 자동 스크립트 엔진에 내장된 '파서 에러 감지 시스템'으로 필요한 세미콜론이 코드에서 누락된 경우 파서 에러가 나면 자동으로 코드를 삽입해보고 코드 실행에 문제가 없도록 도와준다.
  • 에러는 몇 가지 유형이 있지만 크게 '조기 에러'와 '런타임 에러'로 분류된다.
  • arguments 배열은 조심하지 않으면 쿠멍난 추상화에서 비롯된 갖가지 함정에 빠질 수 있다. 가급적 사용하지 말되, 사용할 경우 arguments의 원소와 이에 대응하는 명명된 인자를 동시에 사용하지 말자.
  • try에 붙는 finally 절에는 실행 처리 순서면에서 레이블 블록과 함께 사용하면 혼란을 가중시킬 수 있으니 조심하자.
  • switch는 if else if 문을 대체하는 훌륭한 수단이지만 트릭들이 있으므로 조심해서 사용해야 한다.
반응형