문 (statement)과 표현식(expression)을 같은 의미라고 넘겨버리는 개발자가 많습니다. 하지만, 자바스크립트 (이하 JS)에서 두 용어는 다릅니다.
문은 문장, 표현식은 어구, 연산자는 구두점/접속사로 비유할 수 있습니다.
let a = 3 * 6;
let b = a;
b;
여기서 3 * 6은 표현식입니다. 2, 3번째 줄 역시 표현식입니다.
또한, 이 세 줄은 각각의 표현식이 포함된 문입니다.
1, 2번째 줄은 변수를 선언하는 문이므로 선언문이라고 합니다.
만약, let과 같이 변수를 선언하는 부분이 빠졌다면 할당 표현식이라고 합니다.
3번째 줄 같은 경우는 표현식 문이라고 합니다.
이를 확인할 확실한 방법은 브라우저 개발자 콘솔 창에 타이핑해보는 것입니다. 콘솔 창은 가장 최근에 실행된 문의 완료 값을 기본적으로 출력하게 되어 있기 때문입니다.
let b = a;
할당 표현식 b = a는 할당 이후의 값이 완료 값이지만, let 문 자체의 완료 값은 undefined입니다. 실제로 콘솔 창에 변수를 선언하면 출력값으로 undefined가 나오는 경우를 떠올릴 수 있을 것입니다.
let b;
if (true) {
b = 4 + 20;
}
콘솔 창에 실행하면 24가 나옵니다. 블록 내의 마지막 문의 완료 값이 24이기 때문에 if 블록의 완료 값도 24를 반환한 것입니다.
문의 완료 값을 포착하여 다른 변수에 할당하는 건 불가능하다고 생각하시면 됩니다.
대부분의 표현식에는 부수 효과가 없습니다.
let a = 2;
let b = a + 3;
표현식 a + 3 자체는 a 값을 바꾸는 등의 부수 효과가 전혀 없습니다. 다만, b에 값이 할당될 뿐입니다.
함수 호출 표현식은 부수 효과를 가진 표현식의 전형적인 예입니다.
function foo() {
a = a + 1;
}
let a = 1;
foo();
마지막에 foo 함수를 호출하면 이 문의 결과값은 undefined이지만, 부수 효과로 변수 a의 값이 1 증가합니다.
let a = 24;
let b = a++;
다음과 같은 단항 연산자도 대표적인 부수 효과를 가진 표현식입니다.
a++는 a의 현재 값인 24를 반환하고 a 값을 1만큼 증가시키기 때문입니다.
let a = 24, b;
b = (a++, a); // 25
만약, 1 증가시킨 a의 값을 할당받고 싶은 경우에는 문을 나열하는 식으로 가능합니다.
a++, a 표현식은 두 번째 a 표현식을 a++ 표현식에서 부수 효과가 발생한 이후에 평가하므로 b의 값은 25가 됩니다.
그리고 사실, 변수에 값을 할당하는 것도 부수 효과입니다.
let a;
a = 24;
a = 24 문의 실행 결과는 24이므로 24를 a에 할당하는 자체가 본질적으로 부수 효과에 해당됩니다.
delete 연산자의 결괏값은 유효한 연산일 경우 true, 아닐 경우 false입니다. 이 연산자의 부수 효과는 바로 프로퍼니 혹은 배열의 슬롯을 제거하는 것입니다.
let a, b, c;
a = b = c = 24;
c = 24 평가 결과는 24가 되고, b = 24 평가 결과 24가 되고, 결국 a = 24까지 가는 것입니다.
할당 연산자의 부수 효과를 잘 활용하면 다음과 같이 2개의 if문을 하나로 간단히 합칠 수 있습니다.
function vowels(str) {
let matches;
if (str && (matches = str.match(/[aeiou]/g)) {
return matches;
}
}
vowels("Hello World"); // ["e", "o", "o"]
str는 "Hello World"이고 matches = str.match() 문의 결과값은 3이므로 둘 다 falsy한 값이 아니므로 해당 조건문은 실행되게 됩니다.
foo: for (let i = 0; i < 4; i++) {
if (i === 3) {
break foo;
}
}
위의 코드는 foo라는 레이블에서 반복문을 수행될 때 특정 조건을 만족하면 foo라는 레이블 밖으로 나가라는 명령을 수행합니다.
레이블 루프/블록은 사용 빈도가 극히 드물고 로직의 흐름을 복잡하게 하는 원흉이 되어 가능한 한 피하는 게 상책입니다.
[] + {}; // "[object Object]"
{} + []; // 0
그러나 아랫 줄의 {}는 아무것도 하지 않는 빈 블록으로 해석되고 + []는 []를 0으로 강제변환하므로 0이 나오게 됩니다.
실제로 else if같은 건 없습니다.
else if () {
}
else {
if () {
}
}
else if는 else 블록 안의 if 문으로 파싱됩니다.
&&, || 같은 연산자들은 같이 사용되어도 엄연히 우선순위에 따라 실행순서가 결정됩니다.
false && true || true; // true
위의 결괏값은 true입니다. && 연산자가 먼저 실행되고 || 연산자가 실행되기 때문입니다.
&& 연산자가 || 연산자보다 우선순위가 높습니다.
&&, || 연산자는 좌측 피연산자의 평가 결과만으로 전체 결과가 이미 결정될 경우 우측 피연산자의 평가를 건너뜁니다.
예를 들어, a && b가 있을 때 a가 false면 b를 평가할 필요가 없기 때문에 건너뜁니다.
a || b가 있고 a가 true일 경우에도 마찬가지입니다.
&& > || > ? : 순으로 우선순위가 높습니다.
하지만 삼항 연산자는 특별하게도 우측에서 좌측으로 흐름이 전개됩니다. (=도 동일)
a ? b : c ? d : e
a ? b : (c ? d : e)
윗 줄의 연산은 아랫 줄과 동일합니다.
let a = 24;
let b = "foo";
let c = false;
let d = a && b || c? c || b ? a : c && b : a; // 24
// ((a && b) || c)? ((c || b) ? a : (c && b)) : a로 전개
||, &&, 삼항 연산자의 우선순위를 알면 위와 같이 ()를 통해 더 명확히 전개 순서를 나타낼 수도 있습니다.
연산자 우선순위와 ()를 감싸는 것을 적절히 섞어서 사용한다면 깔끔하면서도 혼동을 줄이는 코드를 작성할 수 있습니다.
ASI는 Automatic Semicolon Insertion으로 JS 엔진이 세미콜론이 누락됐을 경우 자동으로 ;을 삽입하는 것을 말합니다.
그래서 몇몇 개발자는 코드 줄 마지막에 ;을 빼고 어차피 에러가 발생하지 않으므로 빼는 것이 더 수고를 덜고 깔끔한 코드를 만들 수 있지 않냐고 생각할 수 있습니다.
하지만, 이것은 "프로그램이 돌아가기만 하면 그만이니 나는 최대한 파서를 깨뜨리는 프로그램을 작성하겠다"는 소리와 일치합니다. ASI가 파서 에러가 발생하기 전 에러를 정정해준 것이기 에러가 처음부터 없던 것은 아니라는 것입니다.
필요하다고 생각되는 곳이라면 어디든지 세미콜론을 사용하고, ASI가 어떻게든 뭔가를 해줄 거라는 생각은 최소화하는 것이 올바른 코딩 습관이라 할 수 있습니다.
JS 에러는 대부분 런타임 도중에 발생되지만 몇몇은 컴파일 시점에 발생하도록 문법적으로 정의되어 있습니다.
let a = /+foo/; // error
24 = a;
function foo(a, b, a) {} // fine
function foo(a, b, a) { "use strict" } // error
잘못된 정규표현식을 정의하거나 변수가 아닌 대상에 값을 할당하는 경우, 엄격 모드에서 함수를 정의할 때에 같은 이름의 인자가 들어가는 경우 컴파일 시점에 조기 에러를 던집니다.
a = 2; // Reference Error
let a;
let a 선언에 의해 초기화되기 전 a = 2 할당문이 변수 a에 접근하려고 합니다. 하지만 a는 아직 TDZ 내부에 있으므로 에러가 발생합니다.
재밌는 사실은 원래 typeof 연산자는 선언되지 않은 변수 앞에 붙여도 오류는 나지 않는데 TDZ 참조 시에는 이러한 안전장치가 사라집니다.
typeof a; // undefined
typeof b; // ReferenceError
let b;
a는 선언조차 되지 않기 때문에 undefined 처리가 되지만 b는 나중에 let b로 초기화되므로 typeof 에서 에러가 발생합니다.
디폴트 인자값을 선언하는 경우에도 TDZ 관련 에러가 발생할 수 있습니다.
let b = 2;
function foo(a = 24, b = a + b + 3) {
...
}
여기서, foo의 2번째 인자로 디폴트 인자를 선언할 때에 b는 TDZ에 남아 있는 b를 참조하려고 하기 때문에 에러를 발생시킵니다.
finally 절의 코드는 어떤 일이 있어도 반드시 실행되고 다른 코드로 넘어가기 전에 try 이후 부터 항상 실행됩니다. 어떤 의미에서 finally 절은 다른 블록 코드에 상관없이 필히 실행되어야 할 콜백 함수와 같다고 보면 됩니다.
function foo() {
try {
return 24;
}
finally {
console.log("Hello");
}
}
console.log(foo());
// Hello
// 24
return 42에서 foo 함수의 완료 값은 24로 세팅되고, try 절의 실행이 종료되면서 곧바로 finally 절로 넘어갑니다.
try 안에 throw가 있어도 비슷합니다.
function foo() {
try {
throw 24;
}
finally {
console.log("Hello");
}
console.log("접근 불가능");
}
console.log(foo());
// Hello
// Uncaught Exception: 24
즉, 이전에 try 블록에서 생성된 완료 값이 있어도 완전히 무효 처리됩니다.
switch에서 쓰이는 case는 === 알고리즘이 적용됩니다. 엄격한 동등 비교입니다.
느슨한 동등 비교를 하려면 꼼수를 부려야 하는데 추천하지 않는 방식입니다.