CodeGym /행동 /C++ SELF /find_if, count_if, any_of/all_of: 루프 없이 검사하기

find_if, count_if, any_of/all_of: 루프 없이 검사하기

C++ SELF
레벨 21, 레슨 1
사용 가능

1. 도입

프로그래밍을 시작하면 for 루프가 만능 기술처럼 보입니다: 요소를 찾고 싶으면 루프를 쓰고, 세고 싶으면 루프를 쓰고, 조건을 확인하고 싶으면 또 루프를 씁니다. 그건 정상입니다. 문제는 코드의 70%가 문제의 핵심이 아니라 반복적인 의례(카운터 생성, 플래그 생성, break 잊지 않기, 빈 컨테이너 처리 등)에 할애될 때 시작됩니다.

STL 알고리즘은 코드를 좀 더 "사람 친화적" 언어로 말하는 방식입니다:

  • "첫 번째로 조건에 맞는 요소를 찾아라" → std::find_if
  • "조건에 맞는 요소를 몇 개인가 세라" → std::count_if
  • "하나라도 있는가?" → std::any_of
  • "모두 조건을 만족하는가?" → std::all_of

이것은 '마법'이 아닙니다. 내부에서는 여전히 루프가 돌고 있습니다. 단 한 번 구현되어 많은 사람들이 검증했으며, 이름이 의도를 잘 드러내기 때문에 코드를 문장처럼 읽을 수 있습니다. 이는 프로그래밍에서 드문 장점입니다.

이 알고리즘들을 사용하려면 한 가지 용어가 더 필요합니다.

2. 술어(predicate): "예/아니오"로 답하는 함수

이제 고대 신의 이름처럼 들리는 단어가 나옵니다: 술어. 하지만 실제로는 매우 단순한 것입니다: 하나의 요소를 받아 bool을 반환하는 함수.

즉, 술어는 "검사"입니다. 예: "숫자가 짝수인가?", "작업이 완료되었는가?", "문자열이 비어있지 않은가?". 코드에서는 보통 이렇게 보입니다: bool pred(const T& x).

미니 예제: int용 술어

bool is_even(int x) {
    return x % 2 == 0;
}

미니 예제: struct Task용 술어

예를 들어, 작업이 있다고 합시다:

#include <string>

struct Task {
    int id{};
    std::string title;
    bool done{};
};

그렇다면 "작업이 완료되었는가?" 술어는 이렇게 됩니다:

bool is_done(const Task& t) {
    return t.done;
}

주의할 점: 함수 이름 is_done 자체만으로도 무슨 일이 일어나는지 설명합니다. 이는 주석과 비슷하지만 더 낫습니다(주석은 가끔 거짓말하지만 함수는 거의 그렇지 않습니다).

3. std::find_if: "첫 번째 적합한 요소는 어디?"

이 섹션에서는 첫 번째 진정한 슈퍼파워에 접근합니다. 많은 경우에 우리는 개수를 세거나 단순히 확인하는 것이 아니라 바로 찾기를 원합니다. 예: id로 작업 찾기, 첫 번째 완료된 작업 찾기, 첫 번째 빈 문자열 찾기, 100보다 큰 첫 번째 숫자 찾기 등.

std::find_if는 "범위에서 술어가 true를 반환하는 첫 번째 요소를 찾아라"라는 문제를 해결합니다.

필요한 헤더:

#include <algorithm>

기본 형태

컨테이너 v가 있다면 "컨테이너 전체"는 범위 [v.begin(), v.end())입니다.

auto it = std::find_if(v.begin(), v.end(), pred);

결과: it는 이터레이터입니다. 찾은 요소를 가리키거나, 찾지 못하면 v.end()와 같습니다(즉 "찾지 못함").

예제 1: 첫 번째 완료된 작업 찾기

#include <algorithm>
#include <iostream>
#include <string>
#include <vector>

struct Task { int id{}; std::string title; bool done{}; };

bool is_done(const Task& t) { return t.done; }

int main() {
    std::vector<Task> tasks{{1, "Read", false}, {2, "Code", true}};
    auto it = std::find_if(tasks.begin(), tasks.end(), is_done);

    if (it != tasks.end()) std::cout << it->title << '\n'; // Code
}

여기서 중요한 두 가지가 있습니다. 첫째, it를 사용하기 전에 it != tasks.end()인지 확인합니다. 둘째, it->title는 매우 자연스럽게 읽힙니다: "찾은 작업에서 title을 가져온다".

예제 2: 술어로 id로 작업 찾기

술어는 무엇이든 검사할 수 있지만, 아직 람다를 배우지 않았으므로 고정된 id(예: 2)를 찾는 술어를 만들겠습니다(유연성은 낮지만 현재 지식 범위에서는 정직한 방법입니다).

bool has_id_2(const Task& t) {
    return t.id == 2;
}

사용 예:

auto it = std::find_if(tasks.begin(), tasks.end(), has_id_2);
if (it != tasks.end()) std::cout << it->title << '\n';

나중에(오늘은 아니지만) 매개변수를 갖는 조건을 편리하고 깔끔하게 만드는 법을 배우게 됩니다. 지금은 메커니즘을 이해하는 것이 중요합니다: find_if는 당신이 준 규칙대로 검색합니다.

4. std::count_if: "몇 개가 조건에 맞는가?"

"찾기" 다음에는 보통 "세기"가 옵니다. 초보자들은 거의 항상 같은 루프를 작성합니다: int cnt = 0; 그런 다음 for (...) if (...) ++cnt; 이는 정상적인 기법이지만 프로젝트마다 반복되므로 코드가 '데자뷰'처럼 보입니다.

std::count_if는 같은 일을 더 짧게 해줍니다: 술어가 참인 요소의 개수를 반환합니다.

find_if와의 의미적 차이: find_if는 첫 번째 적합한 요소에서 멈출 수 있지만, count_if는 정확한 개수를 알기 위해 전체 범위를 반드시 통과해야 합니다.

예제 1: 완료된 작업 수 세기

#include <algorithm>
#include <iostream>
#include <string>
#include <vector>

struct Task { int id{}; std::string title; bool done{}; };
bool is_done(const Task& t) { return t.done; }

int main() {
    std::vector<Task> tasks{{1, "Read", false}, {2, "Code", true}, {3, "Sleep", true}};
    int done_count = std::count_if(tasks.begin(), tasks.end(), is_done);

    std::cout << done_count << '\n'; // 2
}

의도가 즉시 드러납니다: count_if(..., is_done) — "완료된 것들을 세라".

예제 2: 비어있지 않은 제목 수 세기

bool has_non_empty_title(const Task& t) {
    return !t.title.empty();
}

사용 예:

int ok_titles = std::count_if(tasks.begin(), tasks.end(), has_non_empty_title);
std::cout << ok_titles << '\n';

이것은 데이터 품질 검사처럼 보입니다: "우리에겐 얼마나 많은 작업이 정상적인 제목을 가지고 있는가".

5. std::any_of와 std::all_of: "하나라도?"와 "모두?" 검사

데이터에 대한 매우 흔한 질문은 "하나라도 그런 속성이 있는가?" 또는 "모두 정상인가?"입니다. 플래그로 루프를 돌려 해결할 수도 있고 find_if로 해결할 수도 있지만, STL에는 이를 위한 전용 알고리즘이 있습니다. 이들은 의도를 직접 표현하므로 종종 더 잘 읽힙니다.

std::any_of(first, last, pred)는 범위에 대해 술어가 참인 요소가 하나라도 있으면 true를 반환합니다.

std::all_of(first, last, pred)는 모든 요소에 대해 술어가 참일 때 true를 반환합니다.

추가 보너스: 두 알고리즘 모두 범위 끝까지 갈 필요 없이 조기에 종료할 수 있습니다. any_of는 첫 번째 true에서, all_of는 첫 번째 false에서 멈춥니다. 이는 더 빠를 뿐만 아니라 논리적으로도 정확합니다: 답이 이미 분명하면 전체를 볼 필요가 없습니다.

간단한 의미 표

알고리즘 데이터에 대한 질문 조기에 멈출 수 있는 경우
find_if
"첫 번째 적합한 요소는 어디?" 예 (찾으면 멈춤)
count_if
"조건에 맞는 것은 몇 개?" 아니오 (전체를 돌아야 함)
any_of
"하나라도 있는가?" 예 (찾으면 멈춤)
all_of
"모두 조건을 만족하는가?" 예 (위반자가 나오면 멈춤)

예제 1: 완료된 작업이 하나라도 있는가?

#include <algorithm>
#include <iostream>
#include <string>
#include <vector>

struct Task { int id{}; std::string title; bool done{}; };
bool is_done(const Task& t) { return t.done; }

int main() {
    std::vector<Task> tasks{{1, "Read", false}, {2, "Code", false}};
    bool has_done = std::any_of(tasks.begin(), tasks.end(), is_done);

    std::cout << has_done << '\n'; // 0
}

예제 2: 모든 id가 양수인가?

bool id_is_positive(const Task& t) {
    return t.id > 0;
}

사용 예:

bool ok = std::all_of(tasks.begin(), tasks.end(), id_is_positive);
std::cout << ok << '\n';

이런 검사는 작은 "데이터 계약"입니다: 만약 id가 갑자기 0이거나 음수가 되면, 나중에 이상한 버그로 알게 되는 대신 명시적 검사로 바로 알 수 있습니다.

6. 실전 미니 예제: TaskBox

이제 모든 것을 하나의 작은 "앱 골격"으로 모아보겠습니다. 완전한 작업 관리자까지는 아니지만(지금은 복잡한 입력/메뉴를 만들지 않습니다), 데이터는 모델(struct)로 저장되고 연산은 함수로 분리됩니다. 가장 중요한 점: 검색/계수/검사를 알고리즘으로 수행합니다.

1단계: 모델과 테스트 데이터

#include <string>
#include <vector>

struct Task {
    int id{};
    std::string title;
    bool done{};
};

std::vector<Task> make_demo_tasks() {
    return {{1, "Read docs", false}, {2, "Write code", true}, {3, "", false}};
}

주의: 한 작업은 의도적으로 빈 title을 가집니다. 이것은 검사들이 장식이 아니도록 하는 내장된 테스트입니다.

2단계: 술어들

bool is_done(const Task& t) {
    return t.done;
}

bool title_not_empty(const Task& t) {
    return !t.title.empty();
}

bool id_is_positive(const Task& t) {
    return t.id > 0;
}

농담 삼아 말하자면, 술어는 "클럽 입구의 문지기" 같습니다: 통과하면 들여보냅니다. 진지하게 말하면, 이런 함수들의 좋은 이름은 코드를 거의 자체 문서화하게 만듭니다.

3단계: 첫 번째 완료된 작업 찾기

#include <algorithm>
#include <vector>

std::vector<Task>::const_iterator find_first_done(const std::vector<Task>& tasks) {
    return std::find_if(tasks.begin(), tasks.end(), is_done);
}

여기서 이터레이터를 반환합니다. 왜 const_iterator인가요? 이 결과를 통해 작업을 수정할 생각이 없기 때문입니다. 이는 깔끔한 계약입니다: "찾기만 하고 수정하지 않는다".

4단계: 완료된 작업 수 세기

#include <algorithm>
#include <vector>

int count_done(const std::vector<Task>& tasks) {
    return static_cast<int>(std::count_if(tasks.begin(), tasks.end(), is_done));
}

왜 static_cast<int>를 쓰나요? count_if는 일반적으로 std::ptrdiff_t 같은 타입을 반환하고, 간단한 출력과 교육용 예시에서는 int가 더 편리합니다. 작업 수가 수십억이 아닌 한 괜찮은 관행입니다.

5단계: "하나라도?"와 "모두?" 검사

#include <algorithm>
#include <vector>

bool has_any_done(const std::vector<Task>& tasks) {
    return std::any_of(tasks.begin(), tasks.end(), is_done);
}

bool all_titles_ok(const std::vector<Task>& tasks) {
    return std::all_of(tasks.begin(), tasks.end(), title_not_empty);
}

이 함수들을 보고 "너무 단순하다"고 생각한다면 훌륭합니다. 검사와 통계의 단순함은 보통 버그를 줄여줍니다.

6단계: main()에서 연결하고 결과 출력

#include <iostream>

int main() {
    auto tasks = make_demo_tasks();

    std::cout << "done_count=" << count_done(tasks) << '\n';        // done_count=1
    std::cout << "has_any_done=" << has_any_done(tasks) << '\n';    // has_any_done=1
    std::cout << "all_titles_ok=" << all_titles_ok(tasks) << '\n';  // all_titles_ok=0
}

이미 이것은 유용한 프로그램입니다: 데이터의 "건강 상태"를 보여줍니다. 그리고 검사 로직에서 수동 루프를 하나도 쓰지 않고 작성했습니다. 물론 루프가 완전히 사라진 것은 아니고 STL 안에 깔끔하게 포장되어 있을 뿐입니다.

질문에 맞는 알고리즘 선택법

초보자가 자주 하는 실수는 도구를 "비슷하다"는 이유로 선택하는 것입니다. 예를 들어 any_of 대신 count_if(...) > 0을 쓰는 경우입니다. 작동은 하지만 의도를 덜 잘 전달하고 효율도 떨어질 수 있습니다. count_if는 전체 범위를 돌아야 하지만 any_of는 조기에 종료할 수 있습니다.

유용한 습관: 코드를 쓰기 전 데이터에 대한 질문을 자연어(자국어)로 표현해 보세요. 질문이 "찾아라"로 들리면 대개 첫 후보는 find_if입니다. "몇 개?"이면 count_if. "있는가?"이면 any_of. "모두인가?"이면 all_of를 선택하세요.

그리고 때로는 정답이 "일반적인 루프"일 수도 있습니다. 예를 들어, 동시에 카운트도 하고 찾은 것들을 수집하고 중간에 출력까지 해야 한다면 루프가 적절할 수 있습니다. 오늘은 알고리즘이 의도를 더 간단히 표현하는 상황을 알아보는 연습을 합니다.

7. 전형적인 실수

실수 #1: find_if의 결과를 end()인지 확인하지 않고 역참조하는 것.
std::find_if는 "찾지 못함"의 경우 end()와 같은 이터레이터를 반환합니다. 그 이후에 *it 또는 it->field를 쓰면 최선의 경우 프로그램이 충돌하고 최악의 경우 이상한 동작을 하게 됩니다. 올바른 패턴은 항상 동일합니다: 먼저 if (it != v.end())를 확인하고 그 내부에서 요소에 접근하세요.

실수 #2: "있는가?"에 대해 count_if를 사용하는 것.
std::count_if(...) > 0처럼 쓰면 논리적으로는 맞지만 의도를 숨깁니다. 다음 코드를 읽는 사람은 "정확한 개수가 중요하구나"라고 오해할 수 있습니다. 게다가 count_if는 적합한 요소가 첫 번째였더라도 전체 컨테이너를 순회합니다. "있는가?"에는 std::any_of나 find_if가 더 낫습니다.

실수 #3: 부작용이 있는 술어(출력, 데이터 수정).
술어는 "예/아니오"를 위한 순수한 검사로 설계되었습니다. 술어 내부에서 콘솔에 출력하거나 더 나쁘게는 요소를 변경하면 알고리즘의 동작이 예측 불가능해지고 디버깅이 어려워집니다. 특히 조기 종료가 가능한 검사(any_of/all_of)에서는 일부 요소만 검사되어 출력이 부분적으로 나오거나 변경이 불완전하게 적용될 수 있습니다.

실수 #4: "이터레이터는 인덱스다"라는 혼동.
이터레이터는 숫자가 아닙니다. 정수형 인덱스에 1을 더하듯 다룰 수 없습니다. 이터레이터에는 ++it가 있지만 그것은 다른 개념입니다: 요소를 가리키는 "포인터" 같은 존재입니다. 요소의 번호가 필요하면 별도로 계산해야 합니다(항상 필요한 것도 아닙니다). 간단한 규칙을 지키세요: 알고리즘은 이터레이터를 반환하고 이를 비교할 때는 end()와 비교하세요, 숫자와 비교하지 마세요.

실수 #5: 서로 다른 컨테이너의 이터레이터를 비교하는 것.
때때로 코드는 이렇게 됩니다: "한 벡터에서 찾았는데 다른 벡터의 end()와 비교했다". 컴파일러가 이를 허용할 수도 있지만(타입에 따라), 논리적으로 말이 안 됩니다. 이터레이터는 자신이 속한 컨테이너에 속해 있습니다. 예를 들어 auto it = find_if(a.begin(), a.end(), ...); 라면 검사해야 할 것은 it != a.end()이지 b.end()가 아닙니다.

1
과제
C++ SELF, 레벨 21, 레슨 1
잠금
따뜻한 날
따뜻한 날
1
과제
C++ SELF, 레벨 21, 레슨 1
잠금
비밀번호의 숫자
비밀번호의 숫자
1
과제
C++ SELF, 레벨 21, 레슨 1
잠금
첫 번째 배수
첫 번째 배수
1
과제
C++ SELF, 레벨 21, 레슨 1
잠금
카탈로그 점검
카탈로그 점검
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION