35. TypeScript 학습 전 배경지식 (3)

JSLEE·2024년 6월 16일

함수 포인터

함수 포인터란, 함수를 포인터처럼 사용하는 것이다.
함수명 앞에 *를 붙여 함수 포인터를 선언한다.

ex)

int (*myFunc)(int a) ;

위 코드에서, myFunc는 함수의 주소값을 저장한다.
그럼 이제 함수 포인터를 사용해서 덧셈과 뺄셈을 사용해보자.

int Plus(int a , int b) {
    return a + b ; 
}

int Minus(int a , int b ) {
    return a - b ;
}

int main()
{
    int a = 20 ;
    int b = 10 ;
    int (*fPtr)(int a, int b) ; 
    fPtr = Plus;  
    printf("%d\n" , fPtr(a,b));
    
    fPtr = Minus ; 
    printf("%d" , fPtr(a,b));
    
    return 0;
}

주의사항으로는 *fPtr 함수 포인터는 Plus와 Minus와 같은 형태여야 한다는 것이다. (자료형, 매개변수)

함수 포인터를 사용하면 포인터를 저장하기 위한 메모리 공간의 크기를 약간 더 소모하겠지만,
코드에 유연성과 확장성에서 매우 유리하다.

위의 코드를 사용자가 - 를 입력하거나 +를 입력했을 때, 각각 뺄셈연산, 덧셈연산을 수행하도록 해보자.


int Plus(int a , int b) {
    return a + b ; 
}

int Minus(int a , int b ) {
    return a - b ;
}

void notice(int *arr){
    int a,b ;
    printf("어떤 숫자를 입력하시겠습니까? ( a = ? , b = ? ) : \n");
    printf("a : ");
    scanf("%d", &a);
    arr[0] = a ;
    printf("b : ");
    scanf(" %d", &b);
    arr[1] = b ; 
    
}

int main()
{

    int (*fPtr)(int a, int b) ; 
    int arr[2] ;
    
    while(1){
        char input ;
        int arr[2] ;
        
        printf("어떤 연산을 수행하시겠습니까? ( - , + , 0 입력 ) : ");
        scanf(" %c" , &input) ; 
        if (input == '-'){
            notice(arr) ;
            fPtr = Minus ; 
            printf("%d %c %d = %d 입니다.\n" , arr[0], input , arr[1], fPtr(arr[0], arr[1])) ; 
        }else if (input == '+'){
            notice(arr) ;
            fPtr = Plus ; 
            printf("%d %c %d = %d 입니다.\n" , arr[0], input , arr[1], fPtr(arr[0], arr[1])) ; 
        }else if (input == '0'){
            printf("프로그램을 종료합니다.");
            return 0;
        }else {
            printf("제대로 된 값을 입력해주세요.\n") ;
        }
    }
    
    return 0;
}

위 코드에서, fPtr이 가리키는 함수의 주소를 지정해줌으로써 fPtr의 동작이 바뀌는 것을 확인할 수 있다.

특이사항으로는, fPtr = Minus ; 에서 주소연산자임에도 & 기호를 안썼다는 것인데 함수를 호출할 때에는 자동으로 주소를 호출하기 때문에 &기호를 안써도 되는것이다.
즉, fPtr = &Minus ; 라고 작성해도 문제없이 동작한다.

함수 vs 함수 포인터

여기서 의문점이 생긴다.
"그냥 함수 호출해서 쓰면 되지, 왜 복잡하게 함수 포인터를 사용해서 함수를 사용하냐?"

여기에는 함수와 함수포인터의 몇가지 차이점 이있다.

메모리의 크기, 위치가 결정되는 시점은 컴파일 타임(정적 바인딩) , 런타임 시점(동적 바인딩) 이다.
정적 바인딩은 컴파일 단계에서 메모리 공간을 결정하고, 동적 바인딩은 런타임 단계에서 메모리 공간을 결정하는 것이다.

예를 들어, JavaScript의 변수 초기화를 살펴보자.

var a = 10 ;

JavaScript는 할당된 값을 확인한 다음에, 그에 맞는 자료형을 a에게 부여한다.
즉, 런타임시점에서 메모리의 공간을 결정하는 동적 바인딩이다.

그럼 이제, C의 변수 초기화를 살펴보자.

int a ;
a = 10 ;

c언어에서는 먼저 a에게 자료형을 부여한다.
이는 컴파일 시점에서 메모리 공간에 a의 공간(4Byte)이 결정된다. 그 다음에야 a에 10을 할당한다. 즉, C에서는 컴파일 단계에서 메모리의 공간을 결정하는 정적 바인딩이다.

이걸 함수와 함수포인터에 대해서 생각해보자.
함수는 정적바인딩을 사용하고,
함수 포인터는 동적바인딩을 사용한다.
즉, 함수는 컴파일 단계에서 전부 메모리에 공간을 할당하고, 런타임 상태가 된다.
반대로, 함수 포인터는 컴파일 단계에서는 동작하지 않다가 런타임 환경에서 메모리에 공간을 할당한다.

따라서, 함수 포인터는 규격(자료형, 매개변수)을 맞춰놓고 동적으로 기능을 사용하기 때문에, 함수 포인터의 사용은 프로그램의 유연한 확장성을 제공한다.

유연한 확장성이란 것은 다음과 같다. 각종 프로그램의 플러그인과 extension들을 생각해보자. 대표적인 웹브라우저 크롬은 다른 개인 개발자들이 추가적으로 확장프로그램을 개발해서 크롬 브라우저에 유연하게 연결하여 사용할 수 있다. 함수 포인터도 마찬가지로 유연한 확장성을 가지는 것이다.

구조체

다량의 데이터를 한번에 선언하기 위해서 배열을 사용했었다. 배열은 모두 같은 데이터타입을 가져야 했다.

그렇다면, 서로 다른 종류의 변수들을 묶으려면 어떻게 해야할까?

└> 구조체를 이용해서 새로운 데이터 타입을 정의하면 된다.

구조체를 사용하는 이유는 다음과 같다.

  • 연관된 변수들을 하나로 묶어서 관리 - 데이터 관리에 유용하다.
  • 변수의 개수가 많아지면 구조체가 유리해진다.

예를 들어 학생 정보 관리 시스템을 생각해보자.
학생의 이름, 나이, 성별 등의 데이터들이 필요할 것이다.
각각의 변수를 이름1, 나이1, 성별1 등으로 관리하면 연관성을 알기 어렵다.
따라서 이 변수들을 하나의 그룹으로 그룹화 해주는 것이 구조체이다.

구조체 (struct)

이제 C코드를 통해 살펴보자.

struct student {
  char name[10] ; 
  int age ;
  int height ;
}

각각의 용어들은 다음과 같다.

  • struct : 구조체라는 데이터 타입
  • student : 구조체의 이름
  • name,age, height : 구조체 멤버

이렇게 구조체를 생성했다면, 이것은 이제 사용자가 정의한 새로운 자료형이 된다.
(int, char, double처럼 하나의 자료형이 되는 것이다.)

student std1 ; 

위 코드를 통해 변수를 생성하면, 메모리에는 char, int, int 에 대한 메모리 공간이 할당된다.

구조체 멤버에 접근하기

구조체 변수를 통해 구조체 멤버의 값을 참조할 수 있다.

std1.name = "lee"
std1.age = 20
std1.height = 180
struct student{
    char name[10] ; 
    int age ;
    int height ;
}st1; // = ( student st1 ;)

int main()
{
    printf("%c, %d, %d\n" , st1.name, st1.age, st1.height) ;
    
    strcpy(st1.name , "Lee") ;
    st1.age = 20 ;
    st1.height = 180 ;
    printf("%s, %d, %d" , st1.name, st1.age, st1.height) ; // Lee, 20, 180

    return 0;
}

공용체 (union)

공용체도 구조체와 동일하게 연관된 다른 타입의 데이터를 묶어서 새로 정의한 데이터 타입이다.
다만, 공용체는 union이라는 키워드를 사용한다.

union myUnion{
  char a ;
  int b ;
  double c;
}

사용방법은 똑같은데, 차이점은 뭘까?
먼저, 구조체로 char,int,double을 선언하면 구조체는 총 1 + 4 + 8 = 13 Byte의 메모리가 할당된다.
하지만, 공용체로 char, int, double을 선언하면 가장 큰 크기의 데이터타입을 기준으로 메모리가 할당된다. 즉 8Byte가 할당된다.

Union(공용체)은 하나의 메모리 공간을 공유해서 같이 쓰는만큼, 한번에 하나의 구조체 내부의 변수를 사용해야한다.
그렇지 않으면 다음과 같은 결과를 얻게 된다.

union myUnion {
    char a;
    int b;
    double c;
} u;

int main() {
    
    u.b = 2;    
    printf("%d\n", u.b); // 2
    u.c = 3.141592;   
    printf("%d", u.b); // -57999238 (정상적이지 않은 값)

    return 0;
}

열거형 (enum)

enum 은, 정수형 상수에 이름을 붙이기 위해 사용한다. 다음은 enum의 사용방법이다.

enum 열거형이름 {
    값1 = 초깃값,
    값2,
    값3
};
enum OneWeek {
    Sunday = 0 ,
    Monday ,
    Tuesday , 
    Wednesday , 
    Thursday , 
    Friday ,
    Saturday 
};

int main(){
    enum OneWeek week ; // 열거형 변수
    week = Thursday ;  // 4
    printf("%d" , week) ; 
}

enum의 특징은 열거형의 값들은 내부적으로 값이 1씩 증가해서 가진다는 것이다.
위에서 Sunday = 0만 처리해줬는데도, Thursday에서는 4라는 값을 가진다.
따라서 열거형 변수를 이용하면, 목록의 아이템을 통해 거기에 맞는 숫자값을 얻어낼 수 있다.


동적 메모리 할당

메모리는 크게

  • 코드영역 : 실행 명령어가 순서대로 쌓인다. CPU가 이 영역에서 명령어들을 하나씩 사용한다.

  • 스택영역 : LIFO 구조를 가지며, 지역변수와 매개변수를 저장한다.

  • 힙영역 : 힙은 컴파일 시가 아닌, 실행 시에 사용자로부터 할당 메모리를 직접 입력 받는다.

  • 데이터영역 : 전역변수 static변수를 저장한다. 이 메모리는 프로그램이 종료할 때 소멸한다.

으로 나뉜다.

그럼 동적으로 메모리를 할당하는 이유를 알아보자.

보통 int a ; ; double b ;를 선언하면 메모리 할당은 컴파일에서 이루어진다.

만약, 10명의 학생의 점수를 나타낸다고 하자.
10개의 int 변수를 선언하려면, int student[10] ; 과 같이 작성할 것이다.

그리고, 100개의 int 변수를 선언하려면, student[100]; 과 같이 작성할 것이다.

근데, 학생 수를 정확하게 알지 못할 때는 어떻게 할 것인가?
└─ 이때 필요한 것이, 동적 메모리 할당이다.

다음의 코드를 살펴보자.

int main(){
    int num ; 
    printf("학생수를 입력하세요 :") ; 
    scanf(" %d" , &num);
    int student[num];
    
    printf("%d", sizeof(student) / sizeof(int));
    return 0 ;
}

C는 컴파일과 런타임의 두가지 상태로 나뉜다고 했다.
컴파일 단계에서는, 코드를 전체적으로 해석하고 기계어로 변환하는 과정이라고도 했다.
그럼 위 코드는, scanf로 num을 입력받지 않은 상태인데도 student에 대해 컴파일이 될까?

일단, 답은 YES이긴 하다.
가변 길이 배열(VLA, Variable Length Array)은 C99 표준에서 도입되어 위처럼 사용이 가능하긴 하다.
하지만, C99이 없던 초기에는 당연히 컴파일하려는 배열의 크기가 결정되지 않았기 때문에 오류가 발생했을 것이다. 따라서, 이 때 동적 메모리 할당을 위한 함수가 malloc()이다.

malloc()

사용방법은 다음과 같다.

데이터타입* malloc(할당받을 byte) ;

return 타입은, 포인터 (주소값)이고, 메모리가 부족하게 되면 NULL 포인터를 리턴한다.

이제 예제를 이용해 실습해보자.

int main(){
    int num ; 
    int *student;
    
    printf("학생수를 입력하세요 :") ;  
    scanf(" %d" , &num); // 10명 입력
    
    student = (int*)malloc(sizeof(int) * num) ; // 4Byte(int) * 10(명) = 40 Byte 할당
    
    if(student == NULL){
        printf("메모리 할당 실패");
        return 0;
    } 
    
    printf("할당된 메모리 크기는 %d ", sizeof(int)*num);
    
    free(student); // 메모리 해제
    return 0 ;
}

이 때, free를 수행하지 않는다면, 스택 영역에서 student 변수(주소값이 들어있는)가 소멸되더라도, 힙메모리 영역에는 계속해서 student가 참조하고 있는 메모리가 여전히 남아있기 때문에, 메모리 누수가 발생한다.

JS 등 객체 기반에서 new 연산자를 이용해 객체를 생성하는데, 객체 생성의 메모리 구조는 모두 스택메모리와 힙메모리의 구조를 갖는다.
(단, JS, JAVA, C# 등 프레임워크 기반 언어들은 Garbage Collector를 이용해 자동으로 관리해준다.)


객체 지향

우리는 프로그래밍을 할 때 객체지향 이라는 단어를 많이 들어보게 된다.

공부하고 있는 JS는 객체를 사용하는 객체 기반 언어 이고, 타입스크립트와 리액트에서도 객체지향 철학이 필요하다.

객체 지향의 이전의 프로그래밍 방법은 구조적 프로그래밍 방법이다.
이것은 순차적,하향식,폭포수 방식이라고도 부른다. 기본 단위는 함수이다.

예를 들면, 다음과 같은 경우이다.

//구조적 프로그래밍
int main(void){
    func1(1,2);
    func2(3,4);
    func3(5,6);
    func4(7,8);
    func5(9,0);
    
    return 0;
}

구조적 프로그래밍은, 수정이 힘들고 완벽한 설계를 요구했다. 따라서 등장한 것이, 객체 지향 프로그래밍이다.

객체 지향 프로그래밍 방식은, 계속해서 설계를 바꾸거나, 수정하는 유연한 방식이다.

추상화

추상화란, 대상에서 공통적인 특징만을 뽑아내는 것이다.
예를 들어, "개"는 추상적인 표현이다. 개에는 말티즈, 푸들, 진돗개 등 다양한 종의 개들이 있기 때문이다.

캡슐화

캡슐화란, 은닉 의 의미를 가진다. 즉, 캡슐화란 외부에서 내부를 볼 수 없게 한다는 의미이고, 외부로부터 데이터를 조작할 인터페이스가 필요하다.

캡슐화의 대표적인 예시로는, 프로퍼티(=멤버변수, 속성), 메소드가 들어있는 클래스가 있다.
예를 들어, Java에서는 class에서 private로 선언된 멤버변수에는 외부에서 직접적으로 접근할 수 없지만, get과 set등으로 생성된 public 메서드를 통해 간접적인 접근이 가능하다.

클래스

먼저, 클래스는 사용자 정의 데이터타입이다.
클래스는 구조체와 유사하고, 멤버 변수와 멤버 함수(=메소드)로 이루어진다.

사용 예시를 살펴보자.

class 클래스명 {
    접근지정자 자료형 멤버변수 ;
    접근지정자 자료형 메소드(){};
}

여기서, 접근지정자란, public과 private 등이 있다. C언어에서 객체 지향 프로그래밍을 직접적으로 지원하지는 않기 때문에,
객체기반 언어인 C#으로 개에 대한 class를 만들어보자.

public class Cat
{
    private string name;
    private int age;
    private string breed;

     public void Meow()
    {
        Console.WriteLine($"{name} says Meow!");
    }
}

위 코드를 살펴보면,
private로 지정된 Cat 클래스의 name,age,breed에는 cat1.name과 같이 직접적으로 접근할 수는 없다.
반면에, public으로 지정된 Meow() 메소드의 경우에는 cat1.Meow()로 접근이 가능하다.
즉, setName, setAge 등의 메소드를 public으로 지정하면서, 간접적으로 속성값들에 접근할 수 있는 것이다.


오버로딩&오버라이딩

오버로딩 : 클래스 내에서, 같은 이름의 메소드가 여러개 정의되었을 때, 자료형이나, 매개변수가 다르다면 각각의 함수로 작동하는 것이다.
예를 들어 다음의 java 코드를 보자.

public class Main {
    
    static int Plus(int a, int b) {
        return a + b;
    }

    static int Plus(int a, int b, int c) {
        return a + b + c;
    }

    public static void main(String[] args) {
        System.out.println(Plus(1, 2)); // 3
        System.out.println(Plus(1, 2, 3)); // 6
    }
}

Plus 라는 이름의 함수가 2개 존재함에도, 매개변수의 개수에 따라 각각의 경우로 정상적으로 작동하는 것을 확인할 수 있다.
이것이 오버로딩이다.

오버라이딩 : 오버라이딩은 상속과 함께 사용되는 개념으로, 부모 클래스가 가진 메소드와 똑같은 이름으로 자식 클래스에서 재정의 할 수 있는 것이다.
다음의 java코드를 확인해보자.

class Animal {
   public void eat() {
       System.out.println("Animal is eating");
   }
}

class Dog extends Animal {
   @Override
   public void eat() {
       System.out.println("Dog is eating");
   }
}

public class Main {
   public static void main(String[] args) {
       // Animal 객체 생성
       Animal animal = new Animal();
       animal.eat(); // Animal is eating

       // Dog 객체 생성
       Dog dog = new Dog();
       dog.eat(); // Dog is eating
   }
}

부모클래스인 Animal클래스에서 정의된 eat메소드에 대하여, Dog클래스에서 재정의한 모습이다.


profile
공부한 내용들을 정리하기 위해 사용하는 블로그입니다.

0개의 댓글