표현식과 제어 구조
표현식과 제어 구조 (Expressions and Control Structures)
Solidity의 제어 구조, 함수 호출(내부/외부), new로 컨트랙트 생성, 표현식 평가 순서, 할당, 스코프, checked/unchecked 산술, 그리고 예외 처리(assert/require/revert/try-catch)까지 아우르는 핵심 문법 문서예요. 함수와 상태를 다루는 기본 규칙을 차근차근 이해할 수 있어요.
출처: 문서
본문
제어 구조 (Control Structures)
중괄호 언어에서 알려진 대부분의 제어 구조가 Solidity에 있어요. C나 JavaScript에서 알려진 일반적인 의미를 가진 if, else, while, do, for, break, continue, return이 있어요.
Solidity는 또한 try/catch-문의 형태로 예외 처리를 지원하지만, 외부 함수 호출과 컨트랙트 생성 호출에 대해서만 그래요. 에러는 revert 문을 사용해 만들 수 있어요.
조건부에서는 괄호를 생략할 수 없지만, 단일 문 본문 주변의 중괄호는 생략할 수 있어요. C와 JavaScript처럼 비-불리언에서 불리언 타입으로의 타입 변환이 없다는 점에 주의하세요. 그래서 if (1) { ... }는 유효한 Solidity가 아니에요.
함수 호출 (Function Calls)
내부 함수 호출 (Internal Function Calls)
현재 컨트랙트의 함수는 이 터무니없는 예시에서 보듯 직접("내부적으로"), 또한 재귀적으로 호출될 수 있어요:
open in Remix
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.22 <0.9.0;
// This will report a warning
contract C {
function g(uint a) public pure returns (uint ret) { return a + f(); }
function f() internal pure returns (uint ret) { return g(7) + f(); }
}
이런 함수 호출은 EVM 안에서 단순 점프로 변환돼요. 이는 현재 메모리가 지워지지 않는다는 효과가 있어요. 즉 내부적으로 호출되는 함수에 메모리 참조를 전달하는 것은 매우 효율적이에요. 같은 컨트랙트 인스턴스의 함수만 내부적으로 호출될 수 있어요. 과도한 재귀는 여전히 피해야 해요. 모든 내부 함수 호출은 최소한 하나의 스택 슬롯을 사용하고 1024개만 사용할 수 있기 때문이에요.
외부 함수 호출 (External Function Calls)
함수는 또한 this.g(8);와 c.g(2); 표기법으로 호출될 수 있어요. 여기서 c는 컨트랙트 인스턴스이고 g는 c에 속한 함수예요. 어느 방식으로든 g를 호출하면 "외부적으로" 호출되어, 점프가 아니라 메시지 호출을 사용해요.
this에 대한 함수 호출은 생성자에서 사용할 수 없다는 점을 주의해요. 실제 컨트랙트가 아직 만들어지지 않았기 때문이에요. 다른 컨트랙트의 함수는 외부적으로 호출되어야 해요. 외부 호출의 경우 모든 함수 인자를 메모리로 복사해야 해요.
참고 (Note)
한 컨트랙트에서 다른 컨트랙트로의 함수 호출은 자체 트랜잭션을 만들지 않아요. 전체 트랜잭션의 일부로서의 메시지 호출이에요.
다른 컨트랙트의 함수를 호출할 때, 특별한 옵션 {value: 10, gas: 10000}으로 호출과 함께 보내는 Wei 또는 가스의 양을 지정할 수 있어요. 가스 비용은 미래에 바뀔 수 있으므로 가스 값을 명시적으로 지정하는 것은 권장되지 않는다는 점에 주의해요. 컨트랙트에 보낸 어떤 Wei든 그 컨트랙트의 총 잔액에 더해져요:
open in Remix
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.2 <0.9.0;
contract InfoFeed {
function info() public payable returns (uint ret) { return 42; }
}
contract Consumer {
InfoFeed feed;
function setFeed(InfoFeed addr) public { feed = addr; }
function callFeed() public { feed.info{value: 10, gas: 800}(); }
}
info 함수에 payable 수정자를 사용해야 해요. 그렇지 않으면 value 옵션을 사용할 수 없기 때문이에요.
경고 (Warning)
feed.info{value: 10, gas: 800}은 함수 호출과 함께 보내는 값과 가스의 양을 로컬에서 설정할 뿐이고, 끝의 괄호가 실제 호출을 수행한다는 점에 주의하세요. 그래서feed.info{value: 10, gas: 800}은 함수를 호출하지 않고 value와 gas 설정은 손실돼요. 오직feed.info{value: 10, gas: 800}()만 함수 호출을 수행해요.
경고 (Warning)
EVM은 존재하지 않는 컨트랙트에 대한 호출을 항상 성공으로 간주하기 때문에, Solidity는
extcodesizeopcode로 호출하려는 컨트랙트가 실제로 존재하는지(코드를 포함하는지) 확인하고 존재하지 않으면 예외를 일으켜요. 이 검사는 호출 후 반환 데이터가 디코딩되면 건너뛰어지는데, ABI 디코더가 존재하지 않는 컨트랙트의 경우를 잡아내기 때문이에요. 이 검사는 컨트랙트 인스턴스가 아니라 주소에 대해 동작하는 저수준 호출의 경우 수행되지 않아요.
경고 (Warning)
사전 컴파일된 컨트랙트에 대한 고수준 호출을 사용할 때 주의하세요. 컴파일러가 위 로직에 따라 그것들을 존재하지 않는 것으로 간주하는데, 실제로는 코드를 실행하고 데이터를 반환할 수 있기 때문이에요.
참고 (Note)
버전 0.8.10부터, 컴파일러는 반환 데이터가 기대되면 고수준 외부 호출에서
extcodesize를 확인하지 않아요. 빈 코드는 데이터를 반환할 수 없고 ABI 디코더가 revert할 것이기 때문이에요. 결과적으로 이는 고수준 외부 호출이 사전 컴파일된 컨트랙트에 접근할 수 있게 해줘요. 주소와 연관된 코드가 없어도 데이터를 반환할 수 있기 때문이에요. 자세한 내용은 사전 컴파일된 컨트랙트와 저수준 호출에 대해 읽어보세요.
함수 호출은 또한 호출된 컨트랙트 자체가 예외를 던지거나 가스가 부족해지면 예외를 일으켜요.
경고 (Warning)
다른 컨트랙트와의 어떤 상호작용도 잠재적 위험을 부과해요, 특히 컨트랙트의 소스 코드가 미리 알려지지 않았다면 더 그래요. 현재 컨트랙트는 호출된 컨트랙트에 제어권을 넘겨주고, 그것은 잠재적으로 거의 무엇이든 할 수 있어요. 호출된 컨트랙트가 알려진 부모 컨트랙트를 상속해도, 상속 컨트랙트는 올바른 인터페이스를 가지기만 하면 돼요. 그러나 컨트랙트의 구현은 완전히 임의적일 수 있고 따라서 위험을 제기해요. 게다가 첫 호출이 반환되기 전에 여러분의 시스템의 다른 컨트랙트로, 심지어 호출 컨트랙트로 다시 호출할 경우도 준비하세요. 이는 호출된 컨트랙트가 그 함수들을 통해 호출 컨트랙트의 상태 변수를 바꿀 수 있다는 뜻이에요. 컨트랙트가 재진입 공격에 취약하지 않도록, 예를 들어 외부 함수에 대한 호출이 컨트랙트의 상태 변수에 대한 어떤 변경 후에 일어나도록 함수를 작성하세요.
참고 (Note)
Solidity 0.6.2 이전에는 value와 gas를 지정하는 권장 방법은
f.value(x).gas(g)()였어요. 이것은 Solidity 0.6.2에서 비권장됐고 Solidity 0.7.0부터 더 이상 불가능해요.
이름 있는 매개변수를 가진 함수 호출 (Function Calls with Named Parameters)
함수 호출 인자는 { }로 묶으면 이름으로, 어떤 순서로든 줄 수 있어요. 다음 예시에서 볼 수 있어요. 인자 목록은 함수 선언의 매개변수 목록과 이름으로 일치해야 하지만 순서는 임의일 수 있어요.
open in Remix
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.0 <0.9.0;
contract C {
mapping(uint => uint) data;
function f() public {
set({value: 2, key: 3});
}
function set(uint key, uint value) public {
data[key] = value;
}
}
함수 정의에서 이름 생략 (Omitted Names in Function Definitions)
함수 선언에서 매개변수와 반환 값의 이름을 생략할 수 있어요. 이름이 생략된 항목은 여전히 스택에 존재하지만, 이름으로 접근할 수 없어요. 생략된 반환 값 이름은 return 문의 사용으로 여전히 호출자에게 값을 반환할 수 있어요.
open in Remix
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.22 <0.9.0;
contract C {
// omitted name for parameter
function func(uint k, uint) public pure returns(uint) {
return k;
}
}
new로 컨트랙트 생성 (Creating Contracts via new)
컨트랙트는 new 키워드로 다른 컨트랙트를 만들 수 있어요. 생성되는 컨트랙트의 전체 코드가 생성 컨트랙트를 컴파일할 때 알려져야 해서, 재귀적 생성-의존성은 불가능해요.
open in Remix
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract D {
uint public x;
constructor(uint a) payable {
x = a;
}
}
contract C {
D d = new D(4); // will be executed as part of C's constructor
function createD(uint arg) public {
D newD = new D(arg);
newD.x();
}
function createAndEndowD(uint arg, uint amount) public payable {
// Send ether along with the creation
D newD = new D{value: amount}(arg);
newD.x();
}
}
예시에서 보듯, value 옵션을 사용해 D의 인스턴스를 만들면서 Ether를 보내는 것은 가능하지만 가스의 양을 제한하는 것은 불가능해요. 생성이 실패하면(스택 부족, 잔액 부족 또는 다른 문제로) 예외가 던져져요.
소금을 넣은 컨트랙트 생성 / create2 (Salted contract creations / create2)
컨트랙트를 만들 때 컨트랙트의 주소는 생성 컨트랙트의 주소와 각 컨트랙트 생성마다 증가하는 카운터에서 계산돼요. 옵션 salt(bytes32 값)를 지정하면 컨트랙트 생성이 새 컨트랙트의 주소를 알아내는 다른 메커니즘을 사용해요. 생성 컨트랙트의 주소, 주어진 salt 값, 생성된 컨트랙트의 (생성) 바이트코드, 생성자 인자에서 주소를 계산해요.
특히 카운터("nonce")는 사용되지 않아요. 이는 컨트랙트 생성에서 더 큰 유연성을 허용해요. 새 컨트랙트의 주소를 그것이 생성되기 전에 파생할 수 있어요. 게다가 생성 컨트랙트가 그 사이에 다른 컨트랙트를 만들더라도 이 주소에 의존할 수 있어요. 주요 사용 사례는 분쟁이 있을 때만 만들어질 필요가 있는, 오프체인 상호작용의 심판 역할을 하는 컨트랙트예요.
open in Remix
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract D {
uint public x;
constructor(uint a) {
x = a;
}
}
contract C {
function createDSalted(bytes32 salt, uint arg) public {
// This complicated expression just tells you how the address
// can be pre-computed. It is just there for illustration.
// You actually only need ``new D{salt: salt}(arg)``.
address predictedAddress = address(uint160(uint(keccak256(abi.encodePacked(
bytes1(0xff),
address(this),
salt,
keccak256(abi.encodePacked(
type(D).creationCode,
abi.encode(arg)
))
)))));
D d = new D{salt: salt}(arg);
require(address(d) == predictedAddress);
}
}
경고 (Warning)
salt 생성과 관련된 몇 가지 특이점이 있어요. 컨트랙트는 파괴된 후 같은 주소에서 다시 생성될 수 있어요. 그런데도 그 새로 생성된 컨트랙트는 생성 바이트코드가 같았어도(그렇지 않으면 주소가 바뀌므로 이것은 요구사항) 다른 배포 바이트코드를 가질 수 있어요. 이는 생성자가 두 생성 사이에 바뀌었을 수 있는 외부 상태를 조회해 그것을 배포 바이트코드에 통합할 수 있기 때문이에요.
표현식 평가 순서 (Order of Evaluation of Expressions)
표현식의 평가 순서는 지정되지 않아요(더 공식적으로, 표현식 트리의 한 노드의 자식들이 평가되는 순서는 지정되지 않지만, 그것들은 당연히 노드 자체보다 먼저 평가돼요). 문이 순서대로 실행되고 불리언 표현식에 대해 단락 평가(short-circuiting)가 수행된다는 것만 보장돼요.
할당 (Assignment)
구조 분해 할당과 여러 값 반환 (Destructuring Assignments and Returning Multiple Values)
Solidity는 내부적으로 튜플 타입을 허용해요. 즉 컴파일 타임에 개수가 상수인, 잠재적으로 다른 타입의 객체 목록이에요. 이 튜플들은 동시에 여러 값을 반환하는 데 사용될 수 있어요. 이들은 새로 선언된 변수나 기존 변수(또는 일반적으로 LValues)에 할당될 수 있어요. 튜플은 Solidity에서 적절한 타입이 아니고, 표현식의 구문적 그룹을 형성하는 데만 사용될 수 있어요.
open in Remix
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.5.0 <0.9.0;
contract C {
uint index;
function f() public pure returns (uint, bool, uint) {
return (7, true, 2);
}
function g() public {
// Variables declared with type and assigned from the returned tuple,
// not all elements have to be specified (but the number must match).
(uint x, , uint y) = f();
// Common trick to swap values -- does not work for non-value storage types.
(x, y) = (y, x);
// Components can be left out (also for variable declarations).
(index, , ) = f(); // Sets the index to 7
}
}
변수 선언과 비-선언 할당을 섞는 것은 불가능해요. 즉 다음은 유효하지 않아요: (x, uint y) = (1, 2);
참고 (Note)
버전 0.5.0 이전에는 더 작은 크기의 튜플에 할당하는 것이 가능했어요(왼쪽 또는 오른쪽 중 어느 쪽이 비어 있든 채우면서). 이제는 허용되지 않으므로 양쪽 모두 같은 수의 구성 요소를 가져야 해요.
경고 (Warning)
참조 타입이 관련되면 동시에 여러 변수에 할당할 때 주의하세요. 예상치 못한 복사 동작을 일으킬 수 있기 때문이에요.
배열과 구조체의 복잡성 (Complications for Arrays and Structs)
할당의 의미는 배열과 구조체 같은 비-값 타입(bytes와 string 포함)에 대해 더 복잡해요. 자세한 내용은 데이터 위치와 할당 동작(Data location and assignment behavior)을 참고해요.
아래 예시에서 g(x)에 대한 호출은 x에 영향을 주지 않아요. 스토리지 값의 독립적인 복사본을 메모리에 만들기 때문이에요. 그러나 h(x)는 복사본이 아니라 참조만 전달되므로 x를 성공적으로 수정해요.
open in Remix
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.22 <0.9.0;
contract C {
uint[20] x;
function f() public {
g(x);
h(x);
}
function g(uint[20] memory y) internal pure {
y[2] = 3;
}
function h(uint[20] storage y) internal {
y[3] = 4;
}
}
스코프와 선언 (Scoping and Declarations)
선언된 변수는 바이트 표현이 모두 0인 초기 기본 값을 가져요. 변수의 "기본 값(default values)"은 타입이 무엇이든 그 타입의 전형적인 "제로 상태"예요. 예를 들어 bool의 기본 값은 false예요. uint나 int 타입의 기본 값은 0이에요. 정적 크기 배열과 bytes1부터 bytes32까지는 각 개별 요소가 그 타입에 해당하는 기본 값으로 초기화돼요. 동적 크기 배열, bytes, string의 경우 기본 값은 빈 배열이나 빈 문자열이에요. enum 타입의 경우 기본 값은 첫 번째 멤버예요.
Solidity의 스코프는 C99(및 많은 다른 언어)의 널리 퍼진 스코프 규칙을 따라요. 변수는 선언 직후 지점부터 그 선언을 포함하는 가장 작은 { }-블록의 끝까지 보여요. 이 규칙의 예외로, for-loop의 초기화 부분에서 선언된 변수는 for-loop의 끝까지만 보여요.
매개변수 같은 변수(함수 매개변수, 수정자 매개변수, catch 매개변수 등)는 뒤따르는 코드 블록 안에서 보여요. 함수와 수정자 매개변수의 경우 함수/수정자의 본문이고, catch 매개변수의 경우 catch 블록이에요.
코드 블록 밖에서 선언된 변수와 다른 항목(예: 함수, 컨트랙트, 사용자 정의 타입)은 선언되기 전에도 보여요. 이는 상태 변수를 선언되기 전에 사용하고 함수를 재귀적으로 호출할 수 있다는 뜻이에요. 결과적으로 다음 예시는 두 변수가 같은 이름이지만 분리된 스코프를 가지므로 경고 없이 컴파일돼요.
open in Remix
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.5.0 <0.9.0;
contract C {
function minimalScoping() pure public {
{
uint same;
same = 1;
}
{
uint same;
same = 3;
}
}
}
C99 스코프 규칙의 특별한 예로, 다음에서 x에 대한 첫 번째 할당이 실제로 내부가 아니라 외부 변수에 할당된다는 점에 주의해요. 어떤 경우든 외부 변수가 섀도잉된다는 경고를 받게 될 거예요.
open in Remix
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.5.0 <0.9.0;
// This will report a warning
contract C {
function f() pure public returns (uint) {
uint x = 1;
{
x = 2; // this will assign to the outer variable
uint x;
}
return x; // x has value 2
}
}
경고 (Warning)
버전 0.5.0 이전에는 Solidity가 JavaScript와 같은 스코프 규칙을 따랐어요. 즉 함수 안 어디에서든 선언된 변수가 어디에서 선언됐든 함수 전체에 스코프가 있었어요. 다음 예시는 예전에는 컴파일됐지만 버전 0.5.0부터는 에러로 이어져요.
open in Remix
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.5.0 <0.9.0;
// This will not compile
contract C {
function f() pure public returns (uint) {
x = 2;
uint x;
return x;
}
}
Checked 또는 Unchecked 산술 (Checked or Unchecked Arithmetic)
오버플로우 또는 언더플로우는 산술 연산의 결과 값이, 제한되지 않은 정수에서 실행될 때 결과 타입의 범위 밖에 떨어지는 상황이에요. Solidity 0.8.0 이전에는 언더/오버플로우의 경우 산술 연산이 항상 래핑해서, 추가 검사를 도입하는 라이브러리가 널리 사용됐어요. Solidity 0.8.0부터 모든 산술 연산은 기본으로 오버- 및 언더플로우에서 revert해, 그런 라이브러리의 사용이 불필요해졌어요. 이전 동작을 얻으려면 unchecked 블록을 사용할 수 있어요:
open in Remix
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;
contract C {
function f(uint a, uint b) pure public returns (uint) {
// This subtraction will wrap on underflow.
unchecked { return a - b; }
}
function g(uint a, uint b) pure public returns (uint) {
// This subtraction will revert on underflow.
return a - b;
}
}
f(2, 3) 호출은 2**256-1을 반환하고, g(2, 3)은 실패 어서션을 일으켜요.
unchecked 블록은 블록 안 어디서든 사용할 수 있지만, 블록을 대체할 수는 없어요. 또한 중첩될 수 없어요. 이 설정은 구문적으로 블록 안에 있는 문에만 영향을 줘요. unchecked 블록 안에서 호출된 함수는 그 속성을 상속받지 않아요.
참고 (Note)
모호함을 피하기 위해
unchecked블록 안에서_;을 사용할 수 없어요.
다음 연산자는 오버플로우나 언더플로우에서 실패 어서션을 일으키고, unchecked 블록 안에서 사용되면 오류 없이 래핑해요: ++, --, +, 이진 -, 단항 -, *, /, %, **, +=, -=, *=, /=, %=
경고 (Warning)
unchecked블록으로 0으로 나누기 또는 0으로 모듈로의 검사를 비활성화하는 것은 불가능해요.
참고 (Note)
비트 연산자는 오버플로우나 언더플로우 검사를 수행하지 않아요. 이는 2의 거듭제곱에 의한 정수 나눗셈과 곱셈 대신 비트 시프트(
<<,>>,<<=,>>=)를 사용할 때 특히 눈에 띄어요. 예를 들어type(uint256).max << 3은type(uint256).max * 8은 revert하더라도 revert하지 않아요.
참고 (Note)
int x = type(int).min; -x;의 두 번째 문은 오버플로우를 일으켜요. 음수 범위가 양수 범위보다 하나 더 많은 값을 담을 수 있기 때문이에요. 명시적 타입 변환은 항상 자르고, 정수에서 enum 타입으로의 변환을 제외하고는 결코 실패 어서션을 일으키지 않아요.
에러 처리: Assert, Require, Revert와 예외 (Error handling: Assert, Require, Revert and Exceptions)
Solidity는 에러를 처리하기 위해 상태-되돌림(state-reverting) 예외를 사용해요. 그런 예외는 현재 호출(과 모든 하위 호출)에서 상태에 가해진 모든 변경을 되돌리고 호출자에게 에러를 표시해요.
하위 호출에서 예외가 발생하면, try/catch 문에서 잡히지 않는 한 자동으로 "bubbles up"(즉 예외가 다시 던져짐)돼요. 이 규칙의 예외는 send와 저수준 함수 call, delegatecall, staticcall이에요. 그것들은 "bubbling up" 대신 예외의 경우 첫 번째 반환 값으로 false를 반환해요.
경고 (Warning)
저수준 함수
call,delegatecall,staticcall은 EVM의 설계의 일부로, 호출된 계정이 존재하지 않으면 첫 번째 반환 값으로true를 반환해요. 필요하면 호출 전에 계정 존재를 확인해야 해요.
예외는 에러 인스턴스의 형태로 호출자에게 다시 전달되는 에러 데이터를 포함할 수 있어요. 내장 에러 Error(string)과 Panic(uint256)은 아래에서 설명하듯 특별한 함수가 사용해요. Error는 "일반적인" 에러 조건에 사용되고, Panic은 버그 없는 코드에 존재해서는 안 되는 에러에 사용돼요.
assert를 통한 Panic과 require를 통한 Error (Panic via assert and Error via require)
편의 함수 assert와 require는 조건을 확인하고 조건이 충족되지 않으면 예외를 던지는 데 사용할 수 있어요.
assert 함수는 Panic(uint256) 타입의 에러를 만들어요. 같은 에러는 아래에 나열한 특정 상황에서 컴파일러가 만들기도 해요. assert는 내부 에러를 테스트하고 불변식(invariant)을 확인하는 데만 사용해야 해요. 제대로 기능하는 코드는 잘못된 외부 입력에서조차 결코 Panic을 만들지 않아야 해요. 이것이 발생하면 컨트랙트에 고쳐야 할 버그가 있는 것이에요. 언어 분석 도구는 Panic을 일으킬 조건과 함수 호출을 식별하기 위해 컨트랙트를 평가할 수 있어요.
Panic 예외는 다음 상황에서 생성돼요. 에러 데이터와 함께 제공되는 에러 코드가 panic의 종류를 나타내요.
0x00: 컴파일러가 삽입한 일반적인 panic에 사용.0x01: 평가가 false로 되는 인자로assert를 호출할 때.0x11:unchecked { ... }블록 밖에서 산술 연산이 언더플로우나 오버플로우를 일으킬 때.0x12; 0으로 나누거나 모듈로할 때(예:5 / 0또는23 % 0).0x21: 너무 크거나 음수인 값을 enum 타입으로 변환할 때.0x22: 잘못 인코딩된 스토리지 바이트 배열에 접근할 때.0x31: 빈 배열에서.pop()을 호출할 때.0x32: 배열,bytesN또는 배열 슬라이스에 범위를 벗어나거나 음수 인덱스로 접근할 때(즉i >= x.length또는i < 0인x[i]).0x41: 너무 많은 메모리를 할당하거나 너무 큰 배열을 만들 때.0x51: 내부 함수 타입의 0-초기화된 변수를 호출할 때.
require 함수는 세 가지 오버로드를 제공해요:
require(bool): 데이터 없이(에러 셀렉터조차 없이) revert.require(bool, string):Error(string)으로 revert.require(bool, error): 두 번째 인자로 제공된 커스텀 사용자 에러로 revert.
참고 (Note)
require인자는 무조건 평가되므로, 그것들이 예상치 못한 부작용이 있는 표현식이 아닌지 특별히 주의하세요. 예를 들어require(condition, CustomError(f()));과require(condition, f());에서 함수f()는 제공된 조건이true인지false인지와 무관하게 호출돼요.
Error(string) 예외(또는 데이터가 없는 예외)는 컴파일러가 다음 상황에서 생성해요:
- false로 평가되는
x로require(x)를 호출할 때. revert()또는revert("description")을 사용할 때.- 코드를 포함하지 않는 컨트랙트를 대상으로 외부 함수 호출을 수행할 때.
- 컨트랙트가
payable수정자 없이 public 함수(생성자와 fallback 함수 포함)를 통해 Ether를 받을 때. - 컨트랙트가 public getter 함수를 통해 Ether를 받을 때.
다음 경우에는 외부 호출의 에러 데이터(제공되면)가 전달돼요. 이것은 Error 또는 Panic(또는 주어진 다른 무엇이든)을 일으킬 수 있다는 뜻이에요:
.transfer()가 실패할 때.- 메시지 호출로 함수를 호출하지만 그것이 제대로 끝나지 않을 때(즉 가스가 부족해지거나, 일치하는 함수가 없거나, 자체적으로 예외를 던질 때). 저수준 연산
call,send,delegatecall,callcode,staticcall이 사용되는 경우는 제외. 저수준 연산은 예외를 던지지 않고false를 반환해 실패를 나타내요. new키워드로 컨트랙트를 만들지만 컨트랙트 생성이 제대로 끝나지 않을 때.
require에 메시지 문자열이나 커스텀 에러를 선택적으로 제공할 수 있지만 assert에는 제공할 수 없어요.
참고 (Note)
require에 문자열이나 커스텀 에러 인자를 제공하지 않으면, 에러 셀렉터조차 포함하지 않는 빈 에러 데이터로 revert해요.
다음 예시는 require로 입력에 대한 조건을 확인하고 assert로 내부 에러 검사를 하는 것을 보여줘요.
open in Remix
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.2 <0.9.0;
contract Sharer {
function sendHalf(address payable addr) public payable returns (uint balance) {
require(msg.value % 2 == 0, "Even value required.");
uint balanceBeforeTransfer = address(this).balance;
(bool success, ) = addr.call{value: msg.value / 2}("");
require(success);
// Since require will stop execution and revert if success is false,
// there should be no way for us to still have half of the Ether.
assert(address(this).balance == balanceBeforeTransfer - msg.value / 2);
return address(this).balance;
}
}
내부적으로 Solidity는 revert 연산(명령 0xfd)을 수행해요. 이는 EVM이 상태에 가해진 모든 변경을 되돌리게 해요. revert하는 이유는 기대한 효과가 일어나지 않았기 때문에 실행을 계속하는 안전한 방법이 없기 때문이에요. 트랜잭션의 원자성을 유지하고 싶기 때문에, 가장 안전한 행동은 모든 변경을 되돌리고 전체 트랜잭션(또는 최소한 호출)을 효과 없게 만드는 것이에요.
두 경우 모두 호출자는 try/catch로 그런 실패에 반응할 수 있지만, 호출된 쪽의 변경은 항상 되돌려져요.
참고 (Note)
Panic 예외는 Solidity 0.8.0 이전에는 invalid opcode를 사용했는데, 이는 호출에 사용 가능한 모든 가스를 소비했어요.
require를 사용하는 예외는 Metropolis 릴리스 전까지 모든 가스를 소비했어요.
revert
직접 revert는 revert 문과 revert 함수로 트리거될 수 있어요.
revert 문은 괄호 없이 커스텀 에러를 직접 인자로 받아요:
> revert CustomError(arg1, arg2);
하위 호환성 이유로, 괄호를 사용하고 문자열을 받는 revert() 함수도 있어요:
> revert();
> revert("description");
에러 데이터는 호출자에게 다시 전달되고 거기서 잡힐 수 있어요. revert()를 사용하면 에러 데이터 없이 revert하고, revert("description")은 Error(string) 에러를 만들어요. 커스텀 에러 인스턴스를 사용하는 것은 보통 문자열 설명보다 훨씬 저렴해요. 에러의 이름으로 그것을 설명할 수 있고, 그것은 단지 4바이트로 인코딩되기 때문이에요. 더 긴 설명은 비용이 들지 않는 NatSpec으로 제공할 수 있어요.
다음 예시는 revert와 동등한 require와 함께 에러 문자열과 커스텀 에러 인스턴스를 사용하는 방법을 보여줘요:
open in Remix
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.4;
contract VendingMachine {
address owner;
error Unauthorized();
function buy(uint amount) public payable {
if (amount > msg.value / 2 ether)
revert("Not enough Ether provided.");
// Alternative way to do it:
require(
amount <= msg.value / 2 ether,
"Not enough Ether provided."
);
// Perform the purchase.
}
function withdraw() public {
if (msg.sender != owner)
revert Unauthorized();
(bool success, ) = payable(msg.sender).call{value: address(this).balance}("");
require(success);
}
}
if (!condition) revert(...);과 require(condition, ...); 두 방식은 revert와 require의 인자가 부작용이 없는 한(예: 그냥 문자열이라면) 동등해요.
참고 (Note)
require함수는 다른 어떤 함수처럼 평가돼요. 이는 모든 인자가 함수 자체가 실행되기 전에 평가된다는 뜻이에요. 특히require(condition, f())에서 함수f는condition이 true여도 실행돼요. 제공된 문자열은Error(string)함수 호출인 것처럼 ABI-인코딩돼요.위 예시에서
revert("Not enough Ether provided.");는 에러 반환 데이터로 다음 16진수를 반환해요:
open in Remix
0x08c379a0 // Function selector for Error(string)
0x0000000000000000000000000000000000000000000000000000000000000020 // Data offset
0x000000000000000000000000000000000000000000000000000000000000001a // String length
0x4e6f7420656e6f7567682045746865722070726f76696465642e000000000000 // String data
제공된 메시지는 호출자가 아래에서 보여 주듯 try/catch로 검색할 수 있어요.
참고 (Note)
revert()와 같은 의미를 가진throw라는 키워드가 있었고, 버전 0.4.13에서 비권장되고 0.5.0에서 제거됐어요.
try / catch
외부 호출의 실패는 다음과 같이 try/catch 문으로 잡힐 수 있어요:
open in Remix
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.8.1;
interface DataFeed { function getData(address token) external returns (uint value); }
contract FeedConsumer {
DataFeed feed;
uint errorCount;
function rate(address token) public returns (uint value, bool success) {
// Permanently disable the mechanism if there are
// more than 10 errors.
require(errorCount < 10);
try feed.getData(token) returns (uint v) {
return (v, true);
} catch Error(string memory /*reason*/) {
// This is executed in case
// revert was called inside getData
// and a reason string was provided.
errorCount++;
return (0, false);
} catch Panic(uint /*errorCode*/) {
// This is executed in case of a panic,
// i.e. a serious error like division by zero
// or overflow. The error code can be used
// to determine the kind of error.
errorCount++;
return (0, false);
} catch (bytes memory /*lowLevelData*/) {
// This is executed in case revert() was used.
errorCount++;
return (0, false);
}
}
}
try 키워드 뒤에는 외부 함수 호출이나 컨트랙트 생성(new ContractName())을 나타내는 표현식이 와야 해요. 표현식 안의 에러는 잡히지 않아요(예: 내부 함수 호출도 포함하는 복잡한 표현식이라면). 오직 외부 호출 자체 안에서 일어나는 revert만 잡혀요.
뒤따르는 returns 부분(선택적)은 외부 호출이 반환하는 타입과 일치하는 반환 변수를 선언해요. 에러가 없으면 이 변수들이 할당되고 컨트랙트의 실행은 첫 번째 성공 블록 안에서 계속돼요. 성공 블록의 끝에 도달하면 실행은 catch 블록 뒤에서 계속돼요.
Solidity는 에러의 타입에 따라 여러 종류의 catch 블록을 지원해요:
catch Error(string memory reason) { ... }: 이 catch 절은 에러가revert("reasonString")또는require(false, "reasonString")(또는 그런 예외를 일으키는 내부 에러)에 의해 발생했을 때 실행돼요.catch Panic(uint errorCode) { ... }: 에러가 panic, 즉 실패한assert, 0으로 나누기, 잘못된 배열 접근, 산술 오버플로우 등에 의해 발생했다면 이 catch 절이 실행돼요.catch (bytes memory lowLevelData) { ... }: 이 절은 에러 시그니처가 다른 어떤 절과도 일치하지 않거나, 에러 메시지를 디코딩하는 동안 에러가 있거나, 예외와 함께 에러 데이터가 제공되지 않으면 실행돼요. 선언된 변수가 그 경우 저수준 에러 데이터에 대한 접근을 제공해요.catch { ... }: 에러 데이터에 관심이 없다면 이전 절 대신catch { ... }을(심지어 유일한 catch 절로도) 사용할 수 있어요.
미래에 다른 타입의 에러 데이터를 지원할 계획이에요. Error와 Panic 문자열은 현재 있는 그대로 파싱되고 식별자로 취급되지 않아요.
모든 에러 경우를 잡으려면, catch { ...} 절 또는 catch (bytes memory lowLevelData) { ... } 절을 최소한 하나는 가져야 해요.
returns와 catch 절에서 선언된 변수는 뒤따르는 블록에서만 스코프에 있어요.
참고 (Note)
try/catch-문 안에서 반환 데이터를 디코딩하는 동안 에러가 발생하면, 이것은 현재 실행 중인 컨트랙트에서 예외를 일으키고 그 때문에 catch 절에서 잡히지 않아요.catch Error(string memory reason)을 디코딩하는 동안 에러가 있고 저수준 catch 절이 있으면, 그 에러는 거기서 잡혀요.
참고 (Note)
실행이 catch-블록에 도달하면, 외부 호출의 상태 변경 효과는 되돌려져 있어요. 실행이 성공 블록에 도달하면 효과는 되돌려지지 않았어요. 효과가 되돌려졌다면, 실행은 catch 블록에서 계속되거나
try/catch문 자체의 실행이(위에서 언급한 디코딩 실패나 저수준 catch 절을 제공하지 않아서) revert해요.
참고 (Note)
실패한 호출 뒤의 이유는 다양할 수 있어요. 에러 메시지가 호출된 컨트랙트에서 직접 온 것이라고 가정하지 마세요. 에러는 호출 체인 깊은 곳에서 일어났고 호출된 컨트랙트가 그것을 그냥 전달했을 수 있어요. 또한 out-of-gas 상황 때문일 수 있고 고의적인 에러 조건이 아닐 수 있어요. 호출자는 항상 호출에서 가스의 최소 1/64를 유지하므로, 호출된 컨트랙트가 가스가 부족해져도 호출자는 여전히 가스가 조금 남아 있어요.