null 대신 Maybe 유형의 언어는 가장자리 조건을 어떻게 처리합니까?


53

Eric Lippert는 C # nullMaybe<T>유형 이 아닌 유형을 사용하는 이유에 대해 매우 흥미로운 점을 지적했습니다 .

형식 시스템의 일관성이 중요합니다. nullable이 아닌 참조가 어떤 상황에서도 유효하지 않다는 것을 항상 알 수 있습니까? null이 아닌 참조 유형의 필드를 가진 객체의 생성자에서는 어떻습니까? 참조를 작성 해야하는 코드에서 예외가 발생하여 객체가 완성되는 해당 객체의 마무리 장치는 어떻습니까? 보증에 관한 거짓말 시스템은 위험합니다.

그것은 조금 눈을 뜨는 사람이었습니다. 개념에 관심이 있었고 컴파일러와 형식 시스템을 가지고 놀았지만 그 시나리오에 대해서는 생각하지 않았습니다. 널 (null)이 아닌 참조가 실제로 유효한 상태가 아닌 초기화 및 오류 복구와 같이 널 핸들 에지 케이스 대신 Maybe 유형이있는 언어는 어떻게 사용합니까?


어쩌면 어쩌면 언어의 일부라면 null 포인터를 통해 내부적으로 구현되고 구문 설탕 일 것입니다. 그러나 나는 실제로 어떤 언어도 이런 식으로 생각하지 않습니다.
panzi

1
@panzi : Ceylon은 흐름에 민감한 타이핑을 사용하여 Type?(아마도)와 Type(널이 아님 )
Lukas Eder

1
@RobertHarvey Stack Exchange에 "좋은 질문"버튼이 없습니까?
user253751

2
@panzi 훌륭하고 유효한 최적화이지만,이 문제를 해결하는 데 도움이되지 않습니다. 무언가가 아닌 경우 Maybe T에는 그렇지 않아야 None하므로 스토리지를 널 포인터로 초기화 할 수 없습니다.

@immibis : 나는 이미 그것을 밀었다. 우리는 여기서 몇 가지 좋은 질문을받습니다. 나는 이것에 대한 언급이 필요하다고 생각했다.
Robert Harvey

답변:


45

이 인용문은 식별자 (여기 : 인스턴스 멤버)의 선언과 할당이 서로 분리 된 경우 발생하는 문제를 나타냅니다 . 빠른 의사 코드 스케치로 :

class Broken {
    val foo: Foo  // where Foo and Bar are non-nullable reference types
    val bar: Bar

    Broken() {
        foo = new Foo()
        throw new Exception()
        // this code is never reached, so "bar" is not assigned
        bar = new Bar()
    }

    ~Broken() {
        foo.cleanup()
        bar.cleanup()
    }
}

시나리오는 이제 인스턴스 생성 중에 오류가 발생하여 인스턴스가 완전히 구성되기 전에 구성이 중단됩니다. 이 언어는 메모리 할당이 해제되기 전에 실행되는 소멸자 방법을 제공합니다 (예 : 비 메모리 리소스를 수동으로 해제). 또한 구성이 중단되기 전에 수동으로 관리되는 리소스가 이미 할당되었을 수 있기 때문에 부분적으로 구성된 개체에서 실행해야합니다.

null을 사용하면 소멸자는 변수가 다음과 같이 할당되었는지 테스트 할 수 if (foo != null) foo.cleanup()있습니다. null이 없으면 객체는 정의되지 않은 상태에있게됩니다. 값은 bar무엇입니까?

그러나이 문제 는 세 가지 측면 의 조합 으로 인해 존재합니다 .

  • null멤버 변수에 대한 초기화 와 같은 기본값이 없거나 초기화가 보장되지 않습니다.
  • 선언과 과제의 차이점. 변수를 즉시 할당하는 것은 (예를 들어 let기능 언어에서 볼 수 있는 문장으로) 강제로 초기화를 강제하는 것이 쉽지만 다른 방식으로 언어를 제한합니다.
  • 언어 런타임에 의해 호출되는 메소드로서 소멸자의 특정 특징.

예를 들어 항상 선언과 할당을 결합하고 언어가 단일 종료 방법 대신 여러 개의 종료 자 블록을 제공하도록함으로써 이러한 문제를 나타내지 않는 다른 디자인을 쉽게 선택할 수 있습니다.

// the body of the class *is* the constructor
class Working() {
    val foo: Foo = new Foo()
    FINALIZE { foo.cleanup() }  // block is registered to run when object is destroyed

    throw new Exception()

    // the below code is never reached, so
    //  1. the "bar" variable never enters the scope
    //  2. the second finalizer block is never registered.
    val bar: Bar = new Bar()
    FINALIZE { bar.cleanup() }  // block is registered to run when object is destroyed
}

따라서 null이없는 문제는 없지만 null이없는 다른 기능 세트의 조합에는 문제가 없습니다.

흥미로운 질문은 C #이 하나의 디자인을 선택했지만 다른 디자인은 아닌 이유입니다. 여기 인용문의 문맥에는 C # 언어의 널 (null)에 대한 다른 많은 인수가 나열되어 있는데, 이는 대부분 "친숙성과 호환성"으로 요약 할 수 있으며 그 이유가 있습니다.


null종료자가 s 를 처리해야하는 또 다른 이유가 있습니다 . 참조주기의 가능성으로 인해 종료 순서가 보장되지 않습니다. 그러나 귀하의 FINALIZE디자인도 다음과 같이 해결한다고 생각합니다 . foo이미 마무리되면 FINALIZE섹션이 실행되지 않습니다.
svick

14

다른 데이터가 유효한 상태임을 보장하는 것과 동일한 방법입니다.

의미와 구조를 구조화 하여 값을 완전히 만들지 않고 어떤 유형의 변수 / 필드를 가질 수 없습니다 . 객체를 생성하고 생성자가 필드에 "초기"값을 할당하는 대신 모든 필드의 값을 한 번에 지정하여 객체를 생성 할 수 있습니다. 변수를 선언 한 다음 초기 값을 할당하는 대신 초기화가 가능한 변수 만 도입 할 수 있습니다.

예를 들어 Rust Point { x: 1, y: 2 }에서는을 수행하는 생성자를 작성하는 대신 struct 유형의 객체를 생성합니다 self.x = 1; self.y = 2;. 물론 이것은 당신이 생각하는 언어 스타일과 충돌 할 수 있습니다.

또 다른 보완적인 접근 방식은 초기화 전에 저장소에 액세스하지 못하도록 라이브 니스 분석을 사용하는 것입니다. 이를 통해 첫 번째 읽기 전에 할당 된 변수를 즉시 초기화하지 않고 선언 할 수 있습니다. 또한 다음과 같은 실패 관련 사례를 포착 할 수 있습니다.

Object o;
try {
    call_can_throw();
    o = new Object();
} catch {}
use(o);

기술적으로 객체에 대한 임의의 기본 초기화를 정의 할 수도 있습니다 (예 : 모든 숫자 필드를 0으로 설정, 배열 필드에 대해 빈 배열 만들기 등). 그러나 이것은 다른 옵션보다 다소 임의적이며 효율성이 낮으며 버그를 숨길 수 있습니다.


7

하스켈이하는 방법은 다음과 같습니다.

경고 : 심각한 Haskell 팬보이의 긴 답변.

TL; DR

이 예제는 Haskell과 C #의 차이점을 정확하게 보여줍니다. 구조 생성의 물류를 생성자에게 위임하는 대신 주변 코드에서 처리해야합니다. 널 (Null Nothing) 값 이 널 (Null )이 아닌 값을 기대하는 위치에서 널 ( Oskell의 값) 값을 자를 수있는 방법은 없습니다. 널 ( Null) 값은 일반 랩퍼 유형 Maybe과 교환 가능하거나 직접 변환 할 수없는 특수 랩퍼 유형 내에서만 발생할 수 있기 때문입니다. 널 입력 가능 유형. 로 래핑하여 nullable 값을 사용 Maybe하려면 먼저 패턴 일치를 사용하여 값을 추출해야합니다.이를 통해 제어 흐름을 null이 아닌 값이 있음을 확실히 알 수있는 분기로 제어 흐름을 전환해야합니다.

따라서:

nullable이 아닌 참조가 어떤 상황에서도 유효하지 않다는 것을 항상 알 수 있습니까?

예. Int그리고 Maybe Int두 개의 완전히 분리 된 종류가 있습니다. Nothing평야에서 찾는 것은 에서 Int"fish"문자열을 찾는 것과 비교할 수 있습니다 Int32.

null이 아닌 참조 유형의 필드를 가진 객체의 생성자에서는 어떻습니까?

문제 없음 : Haskell의 값 생성자는 아무 것도 할 수 없으며 주어진 값을 가져와 합칩니다. 모든 초기화 로직은 생성자가 호출되기 전에 발생합니다.

참조를 작성 해야하는 코드에서 예외가 발생하여 객체가 완성되는 해당 객체의 마무리 장치는 어떻습니까?

Haskell에는 종료자가 없으므로 실제로이 문제를 해결할 수 없습니다. 그러나 나의 첫 반응은 여전히 ​​유효합니다.

전체 답변 :

Haskell에는 null이 없으며 Maybe데이터 형식을 사용하여 nullable을 나타냅니다. 어쩌면 다음과 같이 정의 된 조류 데이터 유형 일 수 있습니다 .

data Maybe a = Just a | Nothing

Haskell에 익숙하지 않은 사용자는 "A Maybe는 a Nothing또는 Just a"입니다. 구체적으로 :

  • Maybe는 IS 타입 생성자 :이 (제네릭 클래스로 (잘못) 생각 될 수있는 a유형 변수입니다). C # 비유는 class Maybe<a>{}입니다.
  • JustA는 값 생성자 :이 유형의 하나 개의 인수를 취하는 함수 a와 타입의 값 반환 Maybe a값이 포함되어 있습니다. 따라서 코드 x = Just 17는와 유사합니다 int? x = 17;.
  • Nothing또 다른 값 생성자이지만 인수 Maybe를 사용하지 않으며 반환 된 "Nothing"이외의 값이 없습니다. (우리 가 Haskell에서 우리를 제한한다고 가정하면 을 쓸 수 있다고 가정 x = Nothing)과 유사합니다 .int? x = null;aIntx = Nothing :: Maybe Int

이제 Maybe유형 의 기본 사항 이 제대로 작성되지 않았으므로 Haskell은 OP의 질문에서 논의 된 문제를 어떻게 피합니까?

음, 하스켈은 정말 지금까지 논의 된 언어의 가장 다른, 그래서 나는 몇 가지 기본적인 언어 원리를 설명하는 것으로 시작하겠습니다.

우선 Haskell에서는 모든 것이 불변 입니다. 모두. 이름은 값을 저장할 수있는 메모리 위치가 아니라 값을 나타냅니다 (이것만으로도 엄청난 버그 제거 원인이됩니다). 변수 선언 대입 그들의 값을 정의하여 만든 하스켈 값의 두 개의 별도의 연산이다 C #을 달리 (예를 들어 x = 15, y = "quux", z = Nothing), 변경할 수 없다한다. 따라서 코드는 다음과 같습니다.

ReferenceType x;

하스켈에서는 불가능합니다. 존재 null하기 위해서는 모든 것을 명시 적으로 값으로 초기화해야 하기 때문에 값을 초기화하는 데 아무런 문제가 없습니다 .

둘째, Haskell은 객체 지향 언어가 아닙니다 . 순전히 기능적인 언어이므로 엄격한 의미의 객체는 없습니다. 대신, 인수를 취하고 합쳐진 구조를 반환하는 함수 (값 생성자)가 있습니다.

다음 으로 명령형 스타일 코드는 전혀 없습니다. 이것은 대부분의 언어가 다음과 같은 패턴을 따른다는 것을 의미합니다.

do thing 1
add thing 2 to thing 3
do thing 4
if thing 5:
    do thing 6
return thing 7

프로그램 동작은 일련의 명령어로 표현됩니다. 객체 지향 언어에서 클래스 및 함수 선언도 프로그램 흐름에서 큰 역할을하지만 본질적으로 프로그램 실행의 "고기"는 일련의 명령 형태로 실행됩니다.

하스켈에서는 불가능합니다. 대신, 프로그램 흐름은 전적으로 체인 기능에 의해 결정됩니다. 명령형으로 보이는 do표기법 조차도 익명의 기능을 >>=연산자 에게 전달하기위한 구문 설탕 일뿐입니다 . 모든 기능은 다음과 같은 형태를 취합니다.

<optional explicit type signature>
functionName arg1 arg2 ... argn = body-expression

body-expression값으로 평가되는 것은 어디에나 있을 수 있습니다. 분명히 더 많은 구문 기능을 사용할 수 있지만 요점은 문장 시퀀스가 ​​완전히 없다는 것입니다.

마지막으로, 아마도 가장 중요한 것은 Haskell의 타입 시스템은 엄청나게 엄격 하다는 것입니다 . Haskell 타입 시스템의 중심 디자인 철학을 요약해야한다면, "컴파일 타임에 가능한 많은 것들이 런타임에 가능한 한 잘못되도록합니다."라고 말할 것입니다. 어떠한 암시 적 변환 (AN 홍보하려는이 없습니다 IntA와를 Double? 사용 fromIntegral기능). 런타임에 유효하지 않은 값을 가질 수있는 유일한 방법은 사용 Prelude.undefined하는 것입니다 (겉보기 만 있으면 제거 할 수 없음 ).

이 모든 것을 염두에두고 amon의 "손상된"예 를보고 Haskell에서이 코드를 다시 표현해 봅시다 . 먼저 데이터 선언 (명명 된 필드에 레코드 구문 사용) :

data NotSoBroken = NotSoBroken {foo :: Foo, bar :: Bar } 

( foo그리고 bar정말 익명 필드 여기 대신 실제 필드에 액세서 기능을하고 있지만 우리는이 세부 사항을 무시할 수 있습니다).

NotSoBroken값 생성자는 복용 이외의 조치를 취할 수없는 것입니다 FooBar(null 허용되지 않는)를하고 만드는 NotSoBroken그들 중입니다. 명령형 코드를 넣거나 필드를 수동으로 할당 할 장소가 없습니다. 모든 초기화 로직은 다른 곳에서 이루어져야하며, 대부분 전용 팩토리 기능이어야합니다.

이 예에서 구성은 Broken항상 실패합니다. NotSoBroken값 생성자를 비슷한 방식으로 깰 수있는 방법은 없지만 (코드를 작성할 곳은 없습니다) 유사하게 결함이있는 팩토리 함수를 만들 수 있습니다.

makeNotSoBroken :: Foo -> Bar -> Maybe NotSoBroken
makeNotSoBroken foo bar = Nothing

(첫 번째 줄은 형식 서명 선언입니다. makeNotSoBrokena Foo와 a Bar를 인수로 사용하고을 생성합니다 Maybe NotSoBroken).

반환 유형은 Maybe NotSoBroken단순히 에 대한 값 생성자 인 NotSoBroken평가하도록 지시했기 때문에가 아니라 반드시 있어야합니다 . 우리가 다른 것을 썼다면 타입은 단순히 정렬되지 않을 것입니다.NothingMaybe

절대적으로 무의미한 것 외에도이 기능은 사용하려고 할 때 볼 수 있듯이 실제 목적을 달성하지 못합니다. useNotSoBrokena NotSoBroken를 인수로 기대 하는 함수를 작성해 봅시다 :

useNotSoBroken :: NotSoBroken -> Whatever

(를 인수로 useNotSoBroken받아들이고를 NotSoBroken생성합니다 Whatever).

그리고 그렇게 사용하십시오 :

useNotSoBroken (makeNotSoBroken)

대부분의 언어에서 이러한 종류의 동작으로 인해 널 포인터 예외가 발생할 수 있습니다. Haskell에서 형식이 일치하지 않습니다 :를 makeNotSoBroken반환 Maybe NotSoBroken하지만 useNotSoBrokena를 기대합니다 NotSoBroken. 이러한 유형은 호환되지 않으며 코드 컴파일에 실패합니다.

이 문제를 해결하기 위해, 패턴 일치 라는 기능을 사용하여 값 case의 구조를 기반으로 분기 하는 명령문을 사용할 수 있습니다 .Maybe

case makeNotSoBroken of
    Nothing  -> --handle situation here
    (Just x) -> useNotSoBroken x

분명히이 스 니펫은 실제로 컴파일하기 위해 일부 컨텍스트 안에 배치해야하지만 Haskell이 nullable을 처리하는 방법의 기본 사항을 보여줍니다. 위 코드에 대한 단계별 설명은 다음과 같습니다.

  • 먼저 makeNotSoBrokentype의 값을 생성하도록 보장됩니다 Maybe NotSoBroken.
  • case문은이 값의 구조를 검사합니다.
  • 값이 Nothing인 경우 "여기 상황 처리"코드가 평가됩니다.
  • 값이 대신 값과 일치 Just하면 다른 분기가 실행됩니다. 일치하는 절이 동시에 값을 Just구성 으로 식별 하고 내부 NotSoBroken필드를 이름 (이 경우 x)에 바인딩 하는 방법에 유의하십시오 . x그런 다음 정상 NotSoBroken값 처럼 사용할 수 있습니다 .

따라서 패턴 일치는 개체의 구조가 제어 분기와 불가분의 관계가 있기 때문에 형식 안전성을 강화하는 강력한 기능을 제공합니다 .

나는 이것이 이해하기 쉬운 설명이기를 바란다. 이해가되지 않으면 Learn You A Haskell For Great Good! 으로 넘어갑니다 . , 내가 읽은 최고의 온라인 언어 자습서 중 하나입니다. 이 언어에서 내가하는 것과 같은 아름다움을 볼 수 있기를 바랍니다.


TL; DR은 상단에 있어야합니다 :)
andrew.fox

@ andrew.fox 좋은 지적. 편집하겠습니다.
ApproachingDarknessFish

0

나는 당신의 인용이 짚맨 논쟁이라고 생각합니다.

오늘날의 현대 언어 (C # 포함)는 생성자가 완전히 완료되었거나 그렇지 않은 것을 보장합니다.

이 생성자 예외이며 개체 갖는 부분적두면 초기화 null하거나 Maybe::none초기화 상태는 소멸 코드의 실질적인 차이가 없기 때문.

당신은 어느 쪽이든 그것을 처리해야합니다. 관리 할 외부 리소스가 있으면 명시 적으로 관리해야합니다. 언어와 라이브러리가 도움이 될 수 있지만 이에 대해 몇 가지 생각을해야합니다.

Btw : C #에서 nullvalue 는와 거의 같습니다 Maybe::none. null유형 레벨에서 널 입력 가능 으로 선언 된 변수 및 오브젝트 멤버에만 지정할 수 있습니다 .

String? nullableString = getOptionalString();
Nullable<String> maybe = nullableString; // This is equivalent

이것은 다음 스 니펫과 다르지 않습니다.

Maybe<String> optionalString = getOptionalString();

결론적으로, nullable이 Maybe형식 과 반대되는 방식을 보지 못했습니다 . 심지어 C #이 자체 Maybe유형으로 몰래 호출되었다고 제안 합니다 Nullable<T>.

확장 방법을 사용하면 Nullable 을 정리 하여 모나 딕 패턴을 쉽게 따라갈 수 있습니다.

Resource? resource = initializationThatMayFail();
...
resource.ifExists( Resource r -> r.cleanup() );

2
"생성자가 완전히 완료되었거나 그렇지 않다"는 것은 무엇을 의미합니까? 예를 들어 Java의 경우 생성자에서 (최종) 필드의 초기화는 데이터 경쟁으로부터 보호되지 않습니다. 이것이 완전 완료로 한정됩니까?
gnat

@gnat : "예를 들어, Java에서 생성자에서 (최종이 아닌) 필드의 초기화는 데이터 경쟁으로부터 보호되지 않습니다"는 무슨 의미입니까? 여러 스레드와 관련하여 놀랍도록 복잡한 작업을 수행하지 않으면 생성자 내부의 경쟁 조건이 거의 불가능합니다. 객체 생성자 내를 제외하고는 생성되지 않은 객체의 필드에 액세스 할 수 없습니다. 그리고 구성에 실패하면 객체에 대한 참조가 없습니다.
Roland Tepp

null모든 유형의 암시 적 멤버와 의 큰 차이점은 로 , 기본 값이없는 just 만 가질 수 Maybe<T>있다는 것입니다 . Maybe<T>T
svick

배열을 만들 때 일부 요소를 읽지 않아도 모든 요소에 유용한 값을 결정할 수 없거나 유용한 값이 없으면 계산 된 요소가 없는지 정적으로 확인할 수 없습니다. 가장 좋은 방법은 사용할 수없는 것으로 인식 될 수있는 방식으로 배열 요소를 초기화하는 것입니다.
supercat

@ svick : C # (OP가 문제의 언어)에서 null모든 유형의 암시 적 구성원은 아닙니다. 들어 nulllebal 값을로, 당신은 만드는 명시 적으로 null로 유형 정의해야합니다 T?(구문 설탕 Nullable<T>) 본질적으로 동등 Maybe<T>.
Roland Tepp

-3

C ++은 생성자 본문 이전에 발생하는 이니셜 라이저에 액세스하여이를 수행합니다. C #은 생성자 본문 이전에 기본 이니셜 라이저를 실행합니다. 대략적으로 모든 항목에 0을 할당하고 floats0.0 bools이되고 거짓이되고 참조가 null이됩니다. .

class Foo { Foo(int i) { throw new Exception("Never finishes"); }
class Bar { Bar(string s) { } }

class Broken
{
    val foo: Foo  // where Foo and Bar are non-nullable reference types
    val bar: Bar

    Broken() :
        foo = new Foo(123),// roughly causes a "goto destroy_foo;"
        bar = new Bar("never executes") { }

    // This destructory-function never runs because the constructor never completed
    ~Broken() 
    // This is made-up syntax:
    // : 
    // destroy_bar:
    // bar.~Bar();
    // destroy_foo:
    // foo.~Foo();
    {
    }
}

2
질문은 Maybe types
gnat의

3
참조는 null이된다 ”– 문제의 전제는 전제 조건이없고 null, 값이 없음을 나타내는 유일한 방법은 AFAIK C ++에없는 Maybe유형 (이라고도 함 Option) 을 사용하는 것입니다. 표준 라이브러리. 널이 없으면 필드가 항상 유형 시스템의 특성 으로 유효 보장 할 수 있습니다 . 이것은 변수가 여전히있을 수있는 위치에 코드 경로가 없는지 수동으로 확인하는 것보다 강력합니다 null.
amon

c ++에는 기본적으로 Maybe 유형이 없지만 std :: shared_ptr <T>와 같은 것은 변수의 초기화가 생성자의 "범위를 벗어남"일 수있는 경우를 c ++가 처리하는 것과 관련이 있다고 생각합니다. 참조 유형 (&)에는 널이 될 수 없으므로 실제로 필요합니다.
FryGuy
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.