함수2
20261006 함수2
개요
함수의 기본 형태로 풀리지 않는 상황이 있음.
| 상황 | 해법 |
|---|---|
| 하는 일은 같은데 받을 값의 타입이 여러 가지임 | 오버로딩 |
| 넘긴 변수 자체를 함수가 바꿔 주길 원함 | ref |
| 돌려줄 값이 둘 이상임 | out, 튜플 |
| 넘길 값의 개수가 그때그때 다름 | params |
| 같은 일을 점점 작은 문제로 줄여 가며 풀어야 함 | 재귀 |
같은 이름으로 여러 함수 만들기
인사말을 출력한는 함수가 있다고 할때 이름만 받는 경우, 이름과 횟수를 받는 경우, 아무것도 안 받는 경우가 있다고 하면 함수 이름을 세 경우 다 다르게 지어야 함. 오버로딩(overloading)은 이름은 같고 매개변수가 다른 함수를 여러개 두는것임. 컴파일러가 넘긴 인수를 보고 어느 함수를 호출할지 정해줌.
// 함수 오버로딩 예시
Greeter.Greet();
Greeter.Greet("홍길동");
Greeter.Greet("김철수", 2);
static class Greeter
{
public static void Greet()
{
Console.WriteLine("안녕하세요");
}
public static void Greet(string name)
{
Console.WriteLine($"{name}님, 안녕하세요");
}
public static void Greet(string name, int times)
{
for (int i = 0; i < times; i++)
{
Console.WriteLine($"{name}님, 안녕하세요");
}
}
}
단 함수 오버로딩은 클래스 안의 함수에서만 가능하다. 실행문 사이에 정의하는 함수로는 되지 않는다.
- 주의사항: 클래스 선언은 모든 실행문 뒤에 와야 함. 실행문 보다 먼저 쓰면
CS8803오류가 발생함.
매개변수 타입이 다른 오버로딩
인수의 개수 뿐만이 아니라 타입이 달라도 오버로딩이 가능하다.
// 타입이 다른 오버로딩 예시
static class Calculator
{
public static int Add(int a, int b)
{
return a + b;
}
public static double Add(double a, double b)
{
return a + b;
}
public static string Add(string a, string b)
{
return a + b;
}
}
어느 것이 불릴지 정해지는 방식
인수의 타입과 딱 맞는 함수가 있으면 그것이 불림. 없으면 자동 변환으로 닿을 수 있는 것 중 가장 가까운 것이 불림.
// 자동변환 예시
Console.WriteLine(Calculator.Add(10, 20)); // Add(int, int)
Console.WriteLine(Calculator.Add(10, 2.5)); // Add(double, double)
Console.WriteLine(Calculator.Add(1.5f, 2.5f)); // Add(double, double)
static class Calculator
{
public static int Add(int a, int b)
{
Console.Write("[int] ");
return a + b;
}
public static double Add(double a, double b)
{
Console.Write("[double] ");
return a + b;
}
}
두번째 호출은 int와 double이 섞여있고 세번째 호출은 float으로만 되어있어 자동 변환(혹은 암시적 변환)이 가능한 double 함수가 불러와진다. 단 딱 맞는 것도 없고 자동 변환으로 닿을 수 있는 것이 둘 이상이라 우열을 가릴 수 없으면 오류가 발생함.
오버로딩 규칙
오버로딩이 성립하려면 매개변수 목록이 달라야 함.
| 다른 점 | 예 |
|---|---|
| 개수 | Add(int), Add(int, int) |
| 타입 | Add(int, int), Add(double, double) |
| 순서 | Draw(string, int), Draw(int, string) |
| 반환 타입만 다른경우는 오버로딩이 아님. 오류 발생함. |
기본값으로 대신할 수 있는 경우
매개변수 개수만 다른 오버로딩은 기본값으로 대신할 수 있는 경우가 많음.
| 방식 | 알맞은 상황 |
|---|---|
| 기본값 | 하는 일이 완전히 같고 값 하나만 생략하는 경우 |
| 오버로딩 | 받는 타입이 다르거나, 경우마다 하는 일이 다른 경우 |
참조로 전달하기
함수에 값을 넘기면 복사본이 전달되므로, 함수 안에서 매개변수를 바꿔도 넘긴 쪽 변수는 그대로임. 이때 원본을 직접 다루고 싶을때 사용하는 방법이 있음.
ref - 원본을 직접 다루기
매개변수와 인수 양쪽에 ref를 붙이면 복사본이 아니라 원본 변수 자체가 전달됨.
ref를 쓸 때 지킬 것이 두가지 있음.
| 규칙 | 이유 |
|---|---|
호출할 때도 ref를 적음 | 원본이 바뀔 수 있다는 사실이 호출부에 드러남 |
| 넘기는 변수를 미리 초기화해둠 | 함수가 그 값을 읽을 수 있어야 함 |
| 위 두가지를 지키지 않으면 오류가 발생함. |
배열의 요소도 ref로 넘길 수 있음.
out - 값을 내보내는 통로
out은 ref와 비슷하지만 쓰임이 반대임. 함수가 값을 채워 내보내기 위한 매개변수임.
// out 사용 예시
Divide(17, 5, out int quotient, out int remainder);
Console.WriteLine($"17 ÷ 5 = {quotient} 나머지 {remainder}");
void Divide(int dividend, int divisor, out int q, out int r)
{
q = dividend / divisor;
r = dividend % divisor;
}
몫과 나머지 둘을 한번에 받음. return으로는 하나만 돌려줄 수 있으므로 이런 상황에 쓰임.
ref와의 차이점
| 구분 | ref | out |
|---|---|---|
| 넘기기 전 초기화 | 필요함 | 필요 없음 |
| 함수 안에서 값 할당 | 선택 | 반드시 해야 함 |
| 쓰임 | 값을 주고 받음 | 값을 내보냄 |
함수 안에서 out에 값을 넣지 않으면 오류가 발생함
out변수를 호출 자리에서 선언할 수 있음.
예시:Divide(17, 5, out int q2, out int r2);
필요 없는out 값은 _로 버릴 수 있음.
예시:Divide(17, 5, out int q, out _);
매개변수 모디파이어 정리(in도 있지만 여기선 넘어감)
| 모디파이어 | 넘기기 전 초기화 | 함수 안에서 | 원본 |
|---|---|---|---|
| (없음) | 필요함 | 복사된 매개변수를 바꿀 수 있음 | 매개변수 재대입은 원본 변수에 반영되지 않음 |
ref | 필요함 | 바꿔도 됨 | 바뀜 |
out | 필요 없음 | 반드시 값을 넣음 | 바뀜 |
in - 변경이나 복사 없이 값이 필요할 때
너무 큰 값의 경우 복사하면서도 생성/삭제로 인하여 메모리를 많이 사용한다. 그래서 큰 값을 복사 없이 읽기만 할때 사용한다. 값만 필요하고 내용 수정이 없는 경우에 사용할 수 있다.
개수가 정해지지 않은 인수
params
받을 인수의 개수를 미리 정할 수 없을 때 params를 붙인 배열 매개변수를 씀.
// param 사용 예시
Console.WriteLine(SumAll(3, 5));
Console.WriteLine(SumAll(3, 5, 7));
Console.WriteLine(SumAll(3, 5, 7, 9, 11));
Console.WriteLine(SumAll());
int SumAll(params int[] values)
{
int sum = 0;
foreach (int value in values)
{
sum += value;
}
return sum;
}
인수를 하나도 넘기지 않으면 길이가 0인 배열이 전달됨. null이 아니므로 foreach가 그냥 0바퀴 돌고 끝남.
배열을 통째로 넘기는 것도 가능함.
// 배열 넘기는 예시
int[] numbers = { 1, 2, 3, 4, 5 };
Console.WriteLine(SumAll(numbers));
일반 매개변수와 함께 쓰기
params는 맨 뒤에 하나만 올 수 있음. 앞쪽에는 일반 매개변수를 둘 수 있음.
예시:void PrintReport(string label, params int[] values)params를 앞에 두면 어디까지가 params의 몫인지 가릴 수 없어 오류가 발생함.
여러 값을 한 번에 반환하기
튜플로 묶어서 돌려주기
out으로도 여러 값을 내보낼 수 있지만, 값 여러 개를 하나로 묶어 반환하는 방법이 따로 있음. 반환 타입 자리에 괄호로 타입을 나열하면 됨.
// 튜플 사용 예시
var result = GetMinMax(new int[] { 5, 2, 8, 1, 9, 3 });
Console.WriteLine($"최솟값: {result.Item1}, 최댓값: {result.Item2}");
(int, int) GetMinMax(int[] numbers)
{
int min = numbers[0];
int max = numbers[0];
foreach (int number in numbers)
{
if (number < min) min = number;
if (number > max) max = number;
}
return (min, max);
}
이렇게 값 여러 개를 괄호로 묶은 것을 튜플(tuple)이라고 함.return(min, max); 처럼 괄호에 값을 나열해 돌려주고 받는쪽은 item1, item1로 꺼냄.
요소에 이름 붙이기
item1, item2는 무엇이 무엇인지 드러나지 않음. 반환 타입에 이름을 붙이면 그 이름으로 꺼낼 수 있음.
// 튜플 반환타입에 이름 붙이는 예시
var result = GetMinMax(new int[] { 5, 2, 8, 1, 9, 3 });
Console.WriteLine($"최솟값: {result.min}, 최댓값: {result.max}");
(int min, int max) GetMinMax(int[] numbers)
{
int lo = numbers[0];
int hi = numbers[0];
foreach (int number in numbers)
{
if (number < lo) lo = number;
if (number > hi) hi = number;
}
return (lo, hi);
}
튜플 분해하기
받은 튜플을 통째로 두지 않고 개별 변수로 쪼개 받을 수 있음. 이것을 분해(deconstruction)라고 함.
// 분해 예시
(int min, int max) = GetMinMax(new int[] { 5, 2, 8, 1, 9, 3 });
Console.WriteLine($"최솟값: {min}, 최댓값: {max}");
(int min, int max) GetMinMax(int[] numbers)
{
int lo = numbers[0];
int hi = numbers[0];
foreach (int number in numbers)
{
if (number < lo) lo = number;
if (number > hi) hi = number;
}
return (lo, hi);
}
타입을 일일이 적지 않고var로 한번에 받을 수도 있음
// var로 한번에 받는 예시
var (count, sum, average) = Analyze(new int[] { 88, 92, 79, 95, 61 });
Console.WriteLine($"{count}개 / 합계 {sum} / 평균 {average:F2}");
(int count, int sum, double average) Analyze(int[] values)
{
int total = 0;
foreach (int value in values)
{
total += value;
}
return (values.Length, total, (double)total / values.Length);
}
필요 없는 값은 _로 버림
// 필요 없는값 버리는 예시
var (_, _, average) = Analyze(new int[] { 88, 92, 79, 95, 61 });
Console.WriteLine($"평균만 필요함: {average:F2}");
(int count, int sum, double average) Analyze(int[] values)
{
int total = 0;
foreach (int value in values)
{
total += value;
}
return (values.Length, total, (double)total / values.Length);
}
out과 튜플 중 어느 것을 쓸까
| 방식 | 알맞은 상황 |
|---|---|
| 튜플 | 돌려줄 값들이 대등할 때. 반환값이 하나로 묶여 읽기 좋음 |
out | 성공 여부와 결과처럼 성격이 다를때. TryParse형태 |
돌려줄 값이 대등하면 튜플 쪽이 읽기 좋음. 성공하면 값이 있고 실패하면 없는 형태라면 bool을 반환하고 값은 out으로 내보내는 편이 뜻이 분명함. |
재귀
자기 자신을 부르는 함수
함수 안에서 자기 자신을 호출할 수 있음. 이것을 재귀(recursion)라고 함.
// 재귀 사용 예시
CountDown(5);
void CountDown(int n)
{
if (n <= 0)
{
Console.WriteLine("발사!");
return;
}
Console.WriteLine(n);
CountDown(n - 1);
}
종료 조건이 없으면 멈추지 않음
재귀 함수에는 더 이상 자기를 부르지 않고 끝나는 조건이 반드시 있어야 함. 이것을 종료 조건이라고 함.
종료조건이 없으면 호출이 계속 쌓여서 한계를 넘으면 프로그램이 종료됨.
이 오류는 try로 잡을 수 없고 프로그램이 그 자리에서 종료되기 때문에 종료 조건이 반드시 걸리는지 먼저 확인 해야함.
// 종료 조건이 없는 상황 예시
void CountDownBroken(int n)
{
Console.WriteLine(n);
CountDownBroken(n - 1); // 종료 조건이 없음
}
| 확인할 것 | 앞의CountDown 참고 |
|---|---|
| 끝나는 조건이 있는가 | n <= 0 |
| 호출마다 그 조건에 가까워지는가 | n - 1이므로 매번 1씩 줄어듬 |
둘중 하나만 빠져도 멈추지 않음.
팩토리얼
재귀가 잘 들어맞는 문제는 큰 문제가 같은 모양의 작은 문제로 줄어드는 경우임. 팩토리얼이 그 예임.
반복문으로도 되는 경우가 많음
같은 계산을 반복문으로도 쓸 수 있음.
using System;
Console.WriteLine($"재귀: {FactorialRecursive(5)}");
Console.WriteLine($"반복: {FactorialLoop(5)}");
int FactorialRecursive(int n)
{
if (n <= 1) return 1;
return n * FactorialRecursive(n - 1);
}
int FactorialLoop(int n)
{
int result = 1;
for (int i = 2; i <= n; i++)
{
result *= i;
}
return result;
}
둘다 같은 답을 내지만 반복문 쪽이 빠르고 안전함.(함수 호출이 쌓이지 않기 때문) 재귀가 값을 하는 곳은 반복문으로 쓰기 까다로운 구조임. 폴더 안의 폴더를 훑거나, 갈래가 계속 나뉘는 구조를 따라가는 경우가 그런 예임.
재귀가 비싸질 수 있음
피보나치 수열을 재귀로 쓰면 정의 그대로여서 읽기 좋음.
using System;
int callCount = 0;
for (int n = 5; n <= 30; n += 5)
{
callCount = 0;
int value = Fibonacci(n);
Console.WriteLine($"Fibonacci({n}) = {value,-8} 호출 {callCount}회");
}
int Fibonacci(int n)
{
callCount++;
if (n <= 1)
{
return n;
}
return Fibonacci(n - 1) + Fibonacci(n - 2);
}
그런데 호출 횟수를 세어보면 n이 5씩 늘 때마다 호출 횟수가 약 11배로 뜀. 만약 Fibonacci(30)을 구하려고 하면 270만 번을 부르고 있음.
반면에 반복문으로 쓰면 n에 비례하는 횟수로 끝남
using System;
for (int n = 5; n <= 30; n += 5)
{
Console.WriteLine($"Fibonacci({n}) = {FibonacciLoop(n)}");
}
int FibonacciLoop(int n)
{
if (n <= 1) return n;
int prev = 0;
int current = 1;
for (int i = 2; i <= n; i++)
{
int next = prev + current;
prev = current;
current = next;
}
return current;
}
재귀로 쓸 수 있다는 것과 재귀로 쓰는 것이 좋다는 것은 다름. 겹치는 계산이 많은 문제는 반복문 쪽을 먼저 검토할 것.
재귀로 쓰기 좋은 문제
자릿수를 하나씩 떼어 내는 것처럼 한 번에 문제가 작아지고 겹치지 않는 경우는 재귀가 깔끔함. 문자열을 뒤집는 경우도 마찬가지.
정렬과 검색
선택 정렬
정렬(sort)은 데이터를 순서대로 늘어놓는 것임. 여러 방법이 있는데, 그중 이해하기 쉬운 것이 선택 정렬(selection sort)임. 방법은 아직 정렬되지 않은 부분에서 가장 작은 값을 찾아 맨 앞과 바꾸는 것을 반복.
using System;
int[] data = { 7, 5, 6, 1, 10 };
SelectionSort(data);
PrintArray(data);
void SelectionSort(int[] values)
{
for (int i = 0; i < values.Length - 1; i++)
{
// i 번째부터 끝까지에서 가장 작은 값의 위치를 찾음
int minIndex = i;
for (int j = i + 1; j < values.Length; j++)
{
if (values[j] < values[minIndex])
{
minIndex = j;
}
}
// 이미 제자리면 바꿀 필요가 없음
if (minIndex != i)
{
Swap(ref values[i], ref values[minIndex]);
}
}
}
void Swap(ref int x, ref int y)
{
int temp = x;
x = y;
y = temp;
}
void PrintArray(int[] values)
{
foreach (int value in values)
{
Console.Write($"{value} ");
}
Console.WriteLine();
}
정렬이 정해진 횟수보다 일찍 끝나도 선택 정렬은 그것을 모르므로 남은 횟수 동안 계속 돎. 입력이 어떻든 비교 횟수가 같다는 것이 이 방법의 성격임.
내림차순으로 정렬하려면 비교 방향만 뒤집으면 됨.
예시: if (values[j] < values[minIndex])이 부분의 부호만 뒤집으면 됨.
순차 검색
검색(search)은 찾는 값이 어디 있는지 알아내는 것임. 가장 단순한 방법은 앞에서부터 하나씩 보는 것임.
찾지 못했을 때 -1을 돌려주는 것은 널리 쓰이는 약속임. 배열의 인덱스는 0이상이므로 음수는 "없음"을 나타내기에 안전한 값임.
찾았는지를 bool로 돌려주고 위치는 out으로 내보내는 형태도 있음. 어느 쪽이 나은지는 쓴느 쪽 코드가 결정함.
using System;
int[] data = { 3, 7, 1, 9, 5 };
if (TryFind(data, 9, out int index))
{
Console.WriteLine($"9는 {index}번 칸에 있음");
}
if (!TryFind(data, 100, out _))
{
Console.WriteLine("100은 없음");
}
bool TryFind(int[] values, int target, out int foundIndex)
{
for (int i = 0; i < values.Length; i++)
{
if (values[i] == target)
{
foundIndex = i;
return true;
}
}
foundIndex = -1;
return false;
}
이진 검색
정렬되어 있다면 훨씬 빠른 방법을 쓸 수 있음. 가운데 값을 보고 찾는 값이 그보다 큰지 작은지에 따라 한쪽 절반을 통째로 버리는 것임.
using System;
int[] data = { 1, 3, 5, 7, 9 };
Console.WriteLine(BinarySearch(data, 7));
Console.WriteLine(BinarySearch(data, 1));
Console.WriteLine(BinarySearch(data, 4));
int BinarySearch(int[] values, int target)
{
int low = 0;
int high = values.Length - 1;
while (low <= high)
{
int mid = (low + high) / 2;
if (values[mid] == target)
{
return mid;
}
if (values[mid] < target)
{
low = mid + 1; // 오른쪽 절반만 남김
}
else
{
high = mid - 1; // 왼쪽 절반만 남김
}
}
return -1;
}
- 전제 조건: 이진 검색은 정렬된 배열에서만 맞는 답을 냄. 정렬되지 않은 배열에 쓰면 있는 값을 못찾고
-1을 돌려줄 수 있음.
두 검색의 비교
순차 검색과 이진 검색의 차이점은 몇번만에 찾는지 세어보면 드러남
using System;
int[] data = new int[1000];
for (int i = 0; i < data.Length; i++)
{
data[i] = i * 2; // 0, 2, 4, ... 1998 (정렬된 상태)
}
Console.WriteLine($"순차 검색 비교 횟수: {CountSequential(data, 1998)}");
Console.WriteLine($"이진 검색 비교 횟수: {CountBinary(data, 1998)}");
int CountSequential(int[] values, int target)
{
int count = 0;
for (int i = 0; i < values.Length; i++)
{
count++;
if (values[i] == target) break;
}
return count;
}
int CountBinary(int[] values, int target)
{
int count = 0;
int low = 0;
int high = values.Length - 1;
while (low <= high)
{
count++;
int mid = (low + high) / 2;
if (values[mid] == target) break;
if (values[mid] < target) low = mid + 1;
else high = mid - 1;
}
return count;
}
실행결과
순차 검색 비교 횟수: 1000
이진 검색 비교 횟수: 10
칸이 1000개 일때 순차 검색은 최악의 경우 1000번을 보지만 이진 검색은 10번이면 끝남. 볼 범위가 매번 절반으로 줄기 때문임.
| 구분 | 순차 검색 | 이진 검색 |
|---|---|---|
| 정렬 필요 | 필요 없음 | 반드시 정렬돼 있어야 함 |
| 칸이 1000개일 때 | 최악 1000번 | 약 10번 |
| 칸이 100만 개일 때 | 최악 100만 번 | 약 20번 |
정렬하는 데 드는 비용이 있으므로, 한 번만 찾을 것이라면 순차 검색이 나을 수도 있음. 여러 번 찾을 데이터라면 한 번 정렬해 두고 이진 검색을 쓰는 편이 유리함.




오버로딩 튜플 이제 거의 다 배우셨네요~!!