함수가 실수로 참조 매개 변수를 무효화합니다. 무엇이 잘못 되었습니까?


54

오늘 우리는 특정 플랫폼에서만 간헐적으로 발생하는 불쾌한 버그의 원인을 발견했습니다. 정리하면 코드는 다음과 같습니다.

class Foo {
  map<string,string> m;

  void A(const string& key) {
    m.erase(key);
    cout << "Erased: " << key; // oops
  }

  void B() {
    while (!m.empty()) {
      auto toDelete = m.begin();
      A(toDelete->first);
    }
  }
}

이 단순화 된 경우 문제가 명백해 보일 수 있습니다 B. 키에 대한 참조를에 전달 A하면지도 항목을 인쇄하기 전에 제거합니다. (이 경우에는 인쇄되지 않았지만 더 복잡한 방식으로 사용되었습니다.) 이것은 key호출 후 댕글 링 참조 이므로 당연히 정의되지 않은 동작 erase입니다.

이 문제를 해결하는 것은 사소한 일 입니다. 매개 변수 유형을에서 const string&로 변경 했습니다 string. 문제는 :이 버그를 어떻게 처음부터 피할 수 있었습니까? 두 기능이 모두 올바른 것으로 보입니다.

  • Akey그것이 파괴하려고하는 것을 가리키는 방법은 없습니다 .
  • B에 전달하기 전에 사본을 만들 수 A있었지만 값으로 또는 참조로 매개 변수를 취할지 결정하는 것은 수신자의 작업이 아닙니까?

우리가 따르지 않은 규칙이 있습니까?

답변:


35

Akey그것이 파괴하려고하는 것을 가리키는 방법은 없습니다 .

이것이 사실이지만 A다음 사항을 알고 있습니다.

  1. 그것의 목적은 무언가파괴하는 것 입니다.

  2. 그것은 파괴 할 것과 똑같은 유형 의 매개 변수를 취합니다 .

이러한 사실을 감안할 때,이다 를 위해 A이 포인터 / 참조로 매개 변수를 사용하면 자신의 매개 변수를 파괴 할 수 있습니다. 이러한 고려 사항을 해결해야하는 C ++의 유일한 곳은 아닙니다.

이 상황은 operator=배정 연산자 의 특성으로 인해 자기 배정에 대해 염려해야하는 방법과 유사 합니다. this참조 매개 변수의 유형과 유형이 동일 하기 때문에 가능합니다 .

A나중에 key항목을 제거한 후 매개 변수 를 사용하려고 하므로 이는 문제가 될 수 있습니다 . 그렇지 않다면 괜찮을 것입니다. 물론 모든 것이 완벽하게 작동하는 것이 쉬워지고 잠재적으로 파괴 된 후에 누군가 A가 사용하도록 변경 됩니다 key.

그것은 의견을 제시하기에 좋은 장소입니다.

우리가 따르지 않은 규칙이 있습니까?

C ++에서는 규칙 집합을 맹목적으로 따르면 코드가 100 % 안전하다는 가정하에 작업 할 수 없습니다. 우리는 모든 것에 대한 규칙을 가질 수 없습니다 .

위의 포인트 2를 고려하십시오. A키와 다른 유형의 매개 변수를 사용할 수 있지만 오브젝트 자체는 맵에서 키의 하위 오브젝트 일 수 있습니다. C ++ 14에서는 find키 유형과 다른 유형을 사용할 수 있으며 키 유형과 다른 유형을 사용할 수 있습니다. 따라서 그렇게하면 m.erase(m.find(key))매개 변수 유형이 키 유형이 아니더라도 매개 변수를 삭제할 수 있습니다.

따라서 "매개 변수 유형과 키 유형이 같은 경우 값을 기준으로 사용"과 같은 규칙은 저장되지 않습니다. 그보다 더 많은 정보가 필요합니다.

궁극적으로, 경험에 따라 특정 사용 사례와 운동 판단에주의를 기울여야합니다.


10
글쎄, 당신은 "변경 불가능한 상태를 공유하지 않는다"라는 규칙을 가질 수도 있고, 이중 "공유 상태를 절대로 변경하지 않을 것이다"라고 할 수도 있지만, 식별 가능한 c ++를 작성하는 데 어려움을 겪을 것이다.
Caleth

7
@Caleth 만약 당신이 그 규칙들을 사용하고 싶다면 C ++는 아마도 당신을위한 언어가 아닐 것이다.
user253751

3
@Caleth Rust를 묘사하고 있습니까?
Malcolm

1
"우리는 모든 것에 대한 규칙을 가질 수 없습니다." 그래 우리는 할 수있어. cstheory.stackexchange.com/q/4052
Ouroborus

23

예, 당신은 당신을 구해낸 아주 간단한 규칙이 있습니다 : 단일 책임 원칙.

지금, A지도에서 항목을 제거, 모두에 그것을 사용하는 매개 변수를 전달 하고 (실제 코드에서 다른 분명히 뭔가를 위와 같이 인쇄) 다른 처리를 수행하십시오. 이러한 책임을 결합하면 문제의 원인이 된 것 같습니다.

우리는 하나 개 그 기능이 있다면 바로 지도에서 값을 삭제하고 다른 단지 지도에서 값의 처리를 수행을, 우리는 높은 수준의 코드에서 각 전화를해야 할 것, 그래서 우리는이 같은 끝낼 것 :

std::string &key = get_value_from_map();
destroy(key);
continue_to_use(key);

물론, 내가 사용한 이름은 의심 할 여지없이 실제 이름보다 문제를 더 명백하게하지만, 이름이 전혀 의미가 없다면 참조를 사용한 후에도 계속해서 참조를 사용하려고한다는 것을 분명히해야합니다. 무효화되었습니다. 문맥의 간단한 변화는 문제를 훨씬 더 분명하게 만듭니다.


3
이것은 유효한 관찰입니다.이 경우에만 매우 좁게 적용됩니다. SRP가 존중되는 예가 많이 있으며 여전히 자체 매개 변수를 무효화하는 기능 문제가 있습니다.
Ben Voigt

5
@ BenVoigt : 매개 변수를 무효화해도 문제가 발생하지 않습니다. 무효화 된 후에도 매개 변수를 계속 사용하므로 문제가 발생합니다. 그러나 궁극적으로 그렇습니다. 맞습니다.이 경우에는 그를 구할 수 있었지만 의심 할 여지없이 충분하지 않은 경우가 있습니다.
Jerry Coffin

3
간단한 예제를 작성할 때 일부 세부 사항을 생략해야하며 때로는 이러한 세부 사항 중 하나가 중요하다는 것이 밝혀졌습니다. 우리의 경우 A실제로 key두 개의 다른 맵에서 찾아서 발견하면 항목과 추가 정리를 제거했습니다. 따라서 ASRP를 위반 한 것이 확실하지 않습니다 . 이 시점에서 질문을 업데이트해야하는지 궁금합니다.
Nikolai

2
@BenVoigt의 요점을 확장하려면 : Nicolai의 예 m.erase(key)에서 첫 번째 책임 cout << "Erased: " << key이 있고 두 번째 책임이 있으므로이 답변에 표시된 코드의 구조는 실제로 예제의 코드 구조와 다르지 않습니다. 실제 문제는 간과되었습니다. 단일 책임 원칙은 모순 된 단일 동작 시퀀스가 ​​실제 코드에서 근접하게 나타날 것이라는 것을 보장하거나 심지어는 가능성을 높이 지 않습니다.
sdenham

10

우리가 따르지 않은 규칙이 있습니까?

예, 함수를 문서화하지 못했습니다 .

매개 변수 전달 계약에 대한 설명이 없으면 (특히 매개 변수의 유효성과 관련된 부분-함수 호출의 시작 부분 또는 전체에 해당) 오류가 구현에 있는지 여부를 알 수 없습니다 (호출 계약의 경우) 호출이 시작될 때 매개 변수가 유효하고, 함수는 매개 변수를 무효화 할 수있는 조치를 수행하기 전에 사본을 작성해야합니다. 수정중인 컬렉션 내부의 데이터에 대한 참조를 전달하십시오).

예를 들어 C ++ 표준 자체는 다음을 지정합니다.

함수에 대한 인수가 유효하지 않은 값 (예 : 함수 도메인 외부의 값 또는 의도 된 용도에 유효하지 않은 포인터)을 갖는 경우 동작이 정의되지 않습니다.

그러나 이것이 호출 즉시 또는 함수 실행 전체에 적용되는지 여부는 지정하지 않습니다. 그러나 대부분의 경우 후자 만 가능하다는 것이 명백합니다. 즉, 사본을 작성하여 인수를 유효하게 유지할 수없는 경우입니다.

이러한 차이가 발생하는 실제 사례는 꽤 있습니다. 예를 들어, 자체에 a std::vector<T>를 추가하면


"이것이 전화가 온 순간에만 적용되는지 또는 기능이 실행되는 동안에 만 적용되는지 지정하지 않습니다." 실제로 컴파일러는 일단 UB가 호출되면 함수 전체에서 원하는 거의 모든 작업을 수행합니다. 프로그래머가 UB를 잡지 않으면 이상한 동작이 발생할 수 있습니다.

@snowman은 흥미롭지 만 UB 재정렬은이 답변에서 논의 한 내용과 완전히 관련이 없으므로 유효성을 보장해야합니다 (UB가 발생하지 않도록).
Ben Voigt

정확히 내 요점은 : 코드를 작성하는 사람은 문제로 가득 찬 전체 토끼 구멍을 피하기 위해 UB를 피해야 할 책임이 있습니다.

@ Snowman : 프로젝트에 모든 코드를 작성하는 "한 사람"은 없습니다. 이것이 인터페이스 문서가 매우 중요한 이유 중 하나입니다. 또 다른 하나는 잘 정의 된 인터페이스가 한 번에 추론해야 할 코드의 양을 줄여 준다는 것입니다. 사소한 프로젝트가 아닌 경우, 모든 진술의 정확성에 대해 누군가가 "책임"할 수는 없습니다.
Ben Voigt

한 사람이 모든 코드를 작성한다고 말한 적이 없습니다. 어느 시점에서 프로그래머는 함수를 보거나 코드를 작성하고있을 수 있습니다. 실제로 말하고 싶은 것은 코드를보고있는 사람이 실제로 조심해야한다는 것입니다. 실제로 UB는 전염성이 있으며 컴파일러가 관여하면 한 줄의 코드에서 더 넓은 범위로 확산되기 때문입니다. 이것은 기능 계약을 위반하는 것에 대한 당신의 요점으로 돌아갑니다. 나는 당신에게 동의하고 있지만, 그것이 더 큰 문제가 될 수 있다고 말합니다.

2

우리가 따르지 않은 규칙이 있습니까?

예, 올바르게 테스트하지 못했습니다. 당신은 혼자가 아니며 배우는 올바른 장소에 있습니다 :)


C ++에는 미정의와 성가신 방식으로 정의되지 않은 동작, 정의되지 않은 동작이 많이 있습니다.

아마도 100 % 안전한 C ++ 코드를 작성할 수는 없지만 많은 도구를 사용하여 실수로 코드 기반에 정의되지 않은 동작이 발생할 가능성을 줄일 수 있습니다.

  1. 컴파일러 경고
  2. 정적 분석 (확장 된 버전의 경고)
  3. 인스트루먼트 된 테스트 바이너리
  4. 강화 된 생산 바이너리

귀하의 경우에는 (1)과 (2)가 많은 도움이 되었음이 의심 스럽지만 일반적으로 사용하는 것이 좋습니다. 지금은 다른 두 가지에 집중하자.

gcc와 Clang에는 모두 -fsanitize다양한 문제를 확인하기 위해 컴파일하는 프로그램을 계측 하는 플래그가 있습니다. -fsanitize=undefined예를 들어 특정 경우 ... 등 너무 높은 양에 의해 이동, 정수 언더 / 오버 플로우에 서명 잡을 것, -fsanitize=address그리고 -fsanitize=memory당신이 함수를 호출 테스트를 제공하는 ... 문제에 데리러 가능성이 있었을 것이다. 완전성 -fsanitize=thread을 위해 멀티 스레드 코드베이스가있는 경우 사용하는 것이 좋습니다. 바이너리를 구현할 수없는 경우 (예 : 소스가없는 타사 라이브러리 valgrind가있는 경우) 일반적으로 속도는 느리지 만 사용할 수 있습니다 .

최근의 컴파일러는 또한 풍부한 강화 기능을 갖추고 있습니다. 인스트루먼트 된 바이너리와의 주요 차이점은 강화 검사가 성능에 미치는 영향 (1 % 미만)이 낮게 설계되어 일반적으로 프로덕션 코드에 적합하다는 것입니다. 가장 잘 알려진 것은 CFI 검사 (Control Flow Integrity)로, 제어 흐름을 파괴하는 다른 방법 중에서 스택 스매싱 공격과 가상 포인터 하이재킹을 방지하기 위해 설계되었습니다.

(3)과 (4)의 요점은 간헐적 실패특정 실패 로 변환하는 것입니다. 둘 다 실패 빠른 원칙을 따릅니다 . 이것은 다음을 의미합니다.

  • 지뢰를 밟을 때 항상 실패
  • 메모리가 임의로 손상되지 않고 오류가 발생하면 즉시 실패 합니다 .

테스트 커버리지가 좋은 (3)을 결합하면 프로덕션에 오기 전에 대부분의 문제를 파악해야합니다. 프로덕션에서 (4)를 사용하는 것은 성가신 버그와 악용의 차이가 될 수 있습니다.


0

@note :이 게시물은 Ben Voigt의 답변 위에 더 많은 주장을 추가합니다 .

문제는 :이 버그를 어떻게 처음부터 피할 수 있었습니까? 두 기능이 모두 올바른 것으로 보입니다.

  • A는 열쇠가 파괴하려고하는 것을 가리킨다는 것을 알 수있는 방법이 없습니다.
  • B는 A로 전달하기 전에 사본을 만들 수 있었지만 매개 변수를 값으로 또는 참조로 취할지 여부를 결정하는 것은 수신자의 작업이 아닙니까?

두 기능 모두 올바른 일을했습니다.

문제는 클라이언트 코드 내에 있으며 A 호출 의 부작용 을 고려하지 않았습니다 .

C ++에는 언어에서 부작용을 지정하는 직접적인 방법이 없습니다.

즉, 부작용과 같은 사항이 코드에서 (문서로) 표시되고 코드와 함께 유지되는지 확인하는 것은 전제 조건, 사후 조건 및 변형 문서화를 고려해야합니다. 또한 가시성 이유로 인해).

코드 변경 :

class Foo {
  map<string,string> m;

  /// \sideeffect invalidates iterators
  void A(const string& key) {
    m.erase(key);
    cout << "Erased: " << key; // oops
  }
  ...

이제부터 API 위에 단위 테스트가 필요하다는 것을 알려주는 무언가가 있습니다. 또한 API를 사용하는 방법과 사용하지 않는 방법도 알려줍니다.


-4

처음에는 어떻게이 버그를 피할 수 있었습니까?

버그를 피하는 유일한 방법은 코드 작성을 중지하는 것입니다. 다른 모든 방법으로 실패했습니다.

그러나 다양한 수준 (단위 테스트, 기능 테스트, 통합 테스트, 승인 테스트 등)에서 코드를 테스트하면 코드 품질이 향상 될뿐만 아니라 버그 수가 줄어 듭니다.


1
이건 말도 안돼 버그를 피하는 유일한 방법 은 없습니다 . 버그의 존재 를 완전히 피할 수있는 유일한 방법 은 코드를 작성하지 않는 것이 분명하지만, 처음 코드를 작성할 때 수행 할 수있는 다양한 소프트웨어 엔지니어링 절차가 있다는 것 또한 훨씬 더 유용합니다. 테스트 할 때 버그의 존재를 크게 줄일 수 있습니다 . 누구나 테스트 단계에 대해 알고 있지만, 처음에는 코드를 작성하면서 책임있는 디자인 방식과 관용구를 따르면 가장 큰 비용이 가장 저렴한 비용으로 발생할 수 있습니다.
Cody Grey
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.