1. 소개
학교에서 한 번쯤 들어봤을 거야: "이건 함수의 함수다". 고차 함수(Higher-Order Functions, HOF)는 다른 함수를 인수로 받거나, 함수를 결과로 반환하거나, 둘 다 하는 함수야.
쉽게 말하면: 네 메서드가 다른 함수를 파라미터로 받을 수 있다면(델리게이트나 람다 같은), 또는 함수를 반환할 수 있다면 — 축하해, 그건 고차 함수야!
고차 함수는 마스터 키와 비슷해. 스스로 문을 열 수 있을 뿐만 아니라, 나중에 네가 열 수 있게 다른 키를 전달할 수도 있어.
실무에서의 활용
괜히 복잡해 보일 수 있지만, 실무에서 왜 이런 꼼수들이 필요한지 한 문장으로 정리하면:
고차 함수는 코드를 유연하고 재사용 가능하며 간결하게 만든다.
이건 LINQ, 컬렉션 필터링/정렬, 데이터 처리 파이프라인 구축, 콜백/이벤트 설정, 심지어 DI(의존성 주입) 같은 것들의 기반이야.
몇 가지 실제 시나리오:
- 메서드 동작을 제한하거나 바꾸고 싶을 때 로직(예: 필터, 정렬, 변환)을 전달한다.
- 공통 처리기를 만들어 필요한 동작을 델리게이트로 "끼워넣기" 한다.
- 각 함수가 자기 방식대로 데이터를 수정하는 체인(파이프라인)을 만든다.
- 플랫폼별 추상화: Windows에서 할 일, Linux에서 할 일을 함수로 전달해서 분리한다.
2. 간단한 고차 함수 예제
다른 함수를 받는 함수
가장 고전적인 예 — 델리게이트나 람다를 받는 메서드.
// 고차 함수: process 함수를 매개변수로 받음
void ForEach<T>(IEnumerable<T> collection, Action<T> process)
{
foreach (var item in collection)
{
process(item); // 인수로 받은 함수를 호출
}
}
// 사용 예:
var numbers = new List<int> { 1, 2, 3 };
ForEach(numbers, n => Console.WriteLine($"요소: {n}"));
무슨 일이 일어나는지? 메서드 ForEach는 각 요소에 대해 뭘 해야 할지 모른다. 할 일은 전달된 프로세서(process)를 호출하는 것뿐이다. 이 프로세서는 화면에 출력하거나, DB에 저장하거나, UI에 그리는 등 어떤 것도 될 수 있다.
참고: 컬렉션의 ForEach나 거의 모든 LINQ 메서드는 사실상 고차 함수야!
함수를 반환하는 함수
이번에는 반환도 하는 예제를 보자.
// 고차 함수: 다른 함수를 반환
Func<int, int> CreateMultiplier(int factor)
{
// factor 변수를 사용하는 람다를 반환
return x => x * factor;
}
// 사용 예:
var multiplyBy10 = CreateMultiplier(10);
Console.WriteLine(multiplyBy10(7)); // 70
여기서 CreateMultiplier는 인자를 미리 곱해주는 함수를 반환한다. 이건 함수 팩토리 예시지.
함수를 받고 함수를 반환하는 함수
Func<int, int> Compose(Func<int, int> f, Func<int, int> g)
{
// g를 적용한 뒤 f를 적용: f(g(x))를 반환
return x => f(g(x));
}
// 사용 예:
Func<int, int> increment = x => x + 1;
Func<int, int> doubleIt = x => x * 2;
var incrementThenDouble = Compose(doubleIt, increment);
Console.WriteLine(incrementThenDouble(5)); // (5 + 1) * 2 = 12
이런 식의 조합(composition)은 Select, Where, OrderBy 같은 메서드로 데이터 스트림을 처리할 때 핵심이야.
3. C#에서 고차 함수를 어떻게 지원하는가
함수형 언어(Haskell, F# 등)는 기본적으로 모든 함수가 고차 함수야. 하지만 C#도(2.0부터) 델리게이트와 람다 덕에 이 패러다임을 잘 지원해.
- 델리게이트(Func, Action, Predicate) — 함수 타입.
- 람다 표현식 — 즉석에서 함수를 만드는 문법.
- 메서드는 델리게이트를 인자로 받거나 반환할 수 있으므로 고차 함수가 기본 제공된다.
시각적 다이어그램
flowchart LR
A[데이터] --> B[함수 1]
B --> C[함수 2]
C --> D[결과]
subgraph "처리 파이프라인 (고차 함수)"
B
C
end
4. 애플리케이션 확장하기
단계별 데모 앱을 계속 발전시켜보자 — 이번엔 "미니 문자열 처리기"를 만들어 보자.
간단한 고차 메서드 추가
사용자 이름 목록을 임의로 변형하고 싶다고 해보자. 함수로 변형 로직을 전달한다.
// 문자열 리스트와 변환 함수(transformer)를 받는 메서드
List<string> TransformNames(List<string> names, Func<string, string> transformer)
{
var result = new List<string>();
foreach (var name in names)
{
result.Add(transformer(name));
}
return result;
}
이 메서드를 어떻게 쓰지?
var names = new List<string> { "안나", "보리스", "세르게이" };
// 대문자로 변환
var upperNames = TransformNames(names, n => n.ToUpper());
// 각 이름에 "존경하는(님) " 붙이기
var politeNames = TransformNames(names, n => "존경하는(님) " + n);
foreach (var n in upperNames)
Console.WriteLine(n); // 안나, 보리스, 세르게이
foreach (var n in politeNames)
Console.WriteLine(n); // 존경하는(님) 안나, ...
TransformNames는 범용적이다: 변환 로직은 인자로 받은 transformer에게 위임한다. 예를 들어 ToUpper를 호출하거나 다른 레시피를 넣을 수 있다.
타입에 대한 적응
지금 예제는 어떤 타입에도 쉽게 확장할 수 있다.
// 제네릭한 범용 메서드 - 아무 T에 대해 동작하는 고차 함수
List<TResult> Map<T, TResult>(List<T> items, Func<T, TResult> transformer)
{
var result = new List<TResult>();
foreach (var item in items)
{
result.Add(transformer(item));
}
return result;
}
적용 예:
var numbers = new List<int> { 1, 2, 3 };
var doubled = Map(numbers, x => x * 2); // [2, 4, 6]
var strings = Map(numbers, x => $"숫자: {x}"); // ["숫자: 1", ...]
5. 고차 함수를 통한 필터링과 집계
필터링과 검색 로직은 오래전부터 고차 함수로 구현되어 왔다.
// 필터링: 고차 함수
List<T> Filter<T>(List<T> items, Predicate<T> criteria)
{
var result = new List<T>();
foreach (var item in items)
{
if (criteria(item)) // 기준 함수 호출
{
result.Add(item);
}
}
return result;
}
사용 예:
var names = new List<string> { "안나", "보리스", "안드레이" };
var aNames = Filter(names, n => n.StartsWith("안"));
// 결과: "안나", "안드레이"
6. 함수 합성(function composition) 개념
고차 함수는 개별 함수를 조합해서 체인을 만들 수 있게 해준다. C#에서는 두 함수를 받아서 그 조합을 반환하는 식으로 구현할 수 있다.
// 함수 합성기: 먼저 g를 적용하고 그 결과에 f를 적용하는 함수 반환
Func<T, TResult> Compose<T, TIntermediate, TResult>(
Func<TIntermediate, TResult> f,
Func<T, TIntermediate> g)
{
return x => f(g(x));
}
// 예제:
Func<int, int> plusOne = n => n + 1;
Func<int, int> timesTwo = n => n * 2;
var plusOneThenDouble = Compose(timesTwo, plusOne);
Console.WriteLine(plusOneThenDouble(3)); // (3 + 1) * 2 = 8
7. 유용한 팁들
문제의 배경: 왜 예전엔 더 복잡했나?
델리게이트와 람다가 없던 시절엔 개발자들이 비슷한 루프를 잔뜩 복사해서 "필터", "변환", "그룹화" 같은 걸 구현했어. 고차 함수 덕분에 가변적인 동작 부분을 함수 파라미터로 빼낼 수 있게 되었고, 코드 중복을 크게 줄이고 표현력을 끌어올렸지.
조금의 문법적 설탕: 식-본문 메서드(expression-bodied methods)
고차 함수는 종종 한 줄짜리 식-본문 메서드로 쓰면 간결하다:
List<string> FilterNames(Predicate<string> pred) =>
Names.Where(name => pred(name)).ToList();
List<TResult> MapNames<TResult>(Func<string, TResult> transformer) =>
Names.Select(transformer).ToList();
LINQ와의 비교
LINQ가 고차 함수를 어떻게 활용하는지 보자:
| LINQ 메서드 | 어떤 델리게이트를 받는가 | 목적 |
|---|---|---|
|
|
요소를 필터링 |
|
|
요소를 변환 |
|
|
키로 정렬 |
|
|
컬렉션을 집계(축약) |
|
|
조건을 만족하는 요소가 있는지 확인 |
이 모든 메서드는 고차 함수라는 아이디어 위에서 만들어졌어: 네가 동작 규칙을 쓰면 표준 라이브러리가 인프라를 제공하는 방식이지.
8. 가능한 실수와 함정
델리게이트 타입 혼동.
처음엔 Action, Func, Predicate가 헷갈릴 수 있다.
팁: 반환 타입이 bool이면 보통 Predicate다. 값을 반환하면 Func, 아무것도 반환하지 않으면 Action.
변수 캡처(클로저).
반환된 함수가 외부 스코프의 변수를 사용할 때는, 호출 시점에 값이 실제로 무엇인지 신경 써야 해. 값이 복사되는 게 아니라 캡처되는 거야.
복잡한 체인의 디버깅.
함수들이 긴 파이프라인으로 조합될 때 어느 단계에서 데이터가 꼬였는지 파악하기 어려울 수 있다. 중간에 로그를 찍거나 주석을 달자:
n => {
Console.WriteLine("연산 전:" + n);
var res = n * 2;
Console.WriteLine("연산 후:" + res);
return res;
}
GO TO FULL VERSION