타입 세이프한 범용 데이터 구조 C?
나는 "일반적인 오래된 C" 프로그래밍보다 훨씬 더 많은 C++ 프로그래밍을 했다.플레인 C에서 프로그래밍할 때 가장 놓치는 것은 템플릿을 통해 C++에서 제공되는 타입 세이프 범용 데이터 구조입니다.
구체성을 위해 일반적인 단일 링크 리스트를 검토합니다.C++ 에서는, 독자적인 템플릿클래스를 정의하고 나서, 필요한 타입에 맞추어 인스턴스화하는 것이 간단합니다.
C에서는 일반적인 단일 링크 리스트를 구현하는 몇 가지 방법을 생각할 수 있습니다.
- void 포인터를 사용하여 링크된 목록 유형 및 지원 절차를 한 번 작성합니다.
- 데이터 구조 및 지원 프로시저의 유형별 버전을 생성하기 위해 필요한 유형 이름 등을 사용하는 프리프로세서 매크로를 작성합니다.
- 보다 정교한 독립 실행형 도구를 사용하여 필요한 유형의 코드를 생성합니다.
옵션 1은 타입 시스템을 파괴하고, 타입 고유의 실장보다 퍼포먼스가 나빠지기 때문에 좋아하지 않습니다.모든 유형에 대해 데이터 구조의 균일한 표현을 사용하고 보이드 포인터를 캐스트/캐스팅하는 것은 요소 유형에 특화된 구현에 의해 피할 수 있는 인다이렉션이 필요합니다.
옵션 2는 별도의 툴이 필요하지 않지만 다소 투박하고 부적절하게 사용할 경우 컴파일러 오류가 발생할 수 있습니다.
옵션 3은 옵션2보다 더 나은 컴파일러 에러 메시지를 제공할 수 있습니다.특화된 데이터 구조 코드는 (프리프로세서 매크로에 의해 생성된 코드와는 대조적으로) 에디터에서 열리고 프로그래머에 의해 검사될 수 있는 확장된 형태로 존재하기 때문입니다.그러나 이 옵션은 가장 무거운 "가난한 사람의 템플릿"의 일종입니다.이 접근방식을 사용한 적이 있습니다.심플한 sed 스크립트를 사용하여 C코드의 "템플릿" 버전을 특화한 적이 있습니다.
향후의 「로우 레벨」프로젝트를 C++가 아닌 C로 프로그램 하고 싶다고 생각하고 있습니다만, 공통의 데이터 구조를 특정의 타입 마다 고쳐 쓰는 것에 겁을 먹고 있습니다.
사람들은 이 문제에 대해 어떤 경험을 가지고 있나요?C에 옵션 1(즉, 유형 안전을 희생하고 간접적인 수준을 추가하는 보이드 포인터에 대한 주조)과 일치하지 않는 일반 데이터 구조와 알고리즘의 좋은 라이브러리가 있는가?
C는 C++와는 다른 장점을 가지고 있으며 타입 세이프티와 디버거에 캐스트를 넣지 않고 코드를 통해 트레이스 할 때 모든 것을 항상 확인할 수 있는 것은 보통 그 중 하나가 아닙니다.
C의 장점은 타입의 안전성 결여, 타입 시스템 주변에서의 작업, 비트 및 바이트의 원시 레벨에서 많이 볼 수 있습니다.그 때문에, 예를 들면, 가변 길이의 구조나 실행시에 크기가 정해져 있는 어레이에서도 스택을 사용하는 등, 언어에 대항하지 않고, 보다 간단하게 작업을 실시할 수 있습니다.또, 이 낮은 레벨에서 작업할 때는, ABI를 보존하는 것이 훨씬 간단해지는 경향이 있습니다.
여기에는 다른 종류의 미적 측면과 여러 가지 과제가 관련되어 있습니다.C에서 일할 때는 사고방식의 변화를 추천합니다.그 진가를 인정하기 위해서, 메모리 할당기나 디바이스 드라이버의 실장 등, 오늘날 많은 사람들이 당연하게 생각하고 있는 것을 추천합니다.이렇게 낮은 수준에서 작업할 때는 모든 것을 동작이 연결된 '객체'가 아니라 비트와 바이트의 메모리 레이아웃으로 볼 수밖에 없습니다.에서는, 가, C 일이 있습니다.reinterpret_casts
예에 타입에 )을 합니다.T
링크 리스트의 로직과 표현을, 링크 리스트의 로직과 표현으로부터 수 T
자체)처럼요
struct ListNode
{
struct ListNode* prev;
struct ListNode* next;
MAX_ALIGN char element[1]; // Watch out for alignment here.
// see your compiler's specific info on
// aligning data members.
};
이제 다음과 같은 목록 노드를 만들 수 있습니다.
struct ListNode* list_new_node(int element_size)
{
// Watch out for alignment here.
return malloc_max_aligned(sizeof(struct ListNode) + element_size - 1);
}
// create a list node for 'struct Foo'
void foo_init(struct Foo*);
struct ListNode* foo_node = list_new_node(sizeof(struct Foo));
foo_init(foo_node->element);
목록에서 요소를 T* 로 가져오려면 다음 절차를 수행합니다.
T* element = list_node->element;
C이기 때문에 이런 식으로 포인터를 던질 때 체크하는 타입은 없고, C++ 배경이라면 불안할 수도 있습니다.
은 이 입니다.element
저장하려는 유형에 따라 적절하게 정렬되어 있습니다.이 문제를 필요에 따라서 간단하게 해결할 수 있으면, 효율적인 메모리 레이아웃과 할당기를 작성할 수 있는 강력한 솔루션을 얻을 수 있습니다.대부분의 경우 낭비처럼 보일 수 있는 모든 것에 대해 최대 정렬을 사용해야 하지만, 일반적으로는 개별 단위로 수많은 작은 요소에 대해 이러한 오버헤드를 지불하지 않는 적절한 데이터 구조 및 할당자를 사용해야만 하지 않습니다.
현재 이 솔루션은 여전히 활자 주조와 관련되어 있습니다.이 리스트 노드의 개별 버전의 코드와 지원하는 모든 타입 T에 대응하는 로직(다이나믹 다형성 제외)을 갖는 것 이외에는 할 수 있는 일이 거의 없습니다.단, 필요하다고 생각되는 수준의 간접은 필요하지 않으며 목록 노드와 요소 전체를 단일 할당으로 할당합니다.
많은 경우 C에서 범용성을 실현하기 위한 간단한 방법을 권장합니다. 바꿔 말하면 .T
가 합니다.sizeof(T)
이치노적절한 얼라인먼트를 보증하기 위해 범용적이고 안전한 방법이 있다면 캐시 적중률을 높이고 힙 할당/배분의 빈도, 필요한 간접의 양, 빌드 시간 등을 줄이는 매우 강력한 메모리 사용 방법을 사용할 수 있습니다.
예: 자동화가 필요한 경우)list_new_node
으로 초기화하다struct Foo
T가 얼마나 큰지, T의 디폴트인스턴스를 만드는 함수를 가리키는 함수 포인터, T를 복사하는 함수, T를 복제하는 클론, T를 파괴하는 비교기 등을 포함한 일반적인 타입의 테이블 구조를 작성하는 것을 추천합니다.C++에서는 템플릿과 복사 생성자 및 소멸자 등의 삽입 언어 개념을 사용하여 이 테이블을 자동으로 생성할 수 있습니다.C는 조금 더 수작업이 필요하지만 매크로를 사용하면 보일러 플레이트를 조금 줄일 수 있습니다.
보다 매크로 지향적인 코드 생성 경로를 사용할 경우 유용한 또 다른 방법은 식별자의 접두사 또는 접미사 기반 명명 규칙을 현금화하는 것입니다.들어 CLONE하면 "CLONE(Type, ptr)"을 반환할 수 있습니다.Type##Clone(ptr)
, (그래서)CLONE(Foo, foo)
를 호출할 수 FooClone(foo)
이것은 C에서 함수 오버로드와 같은 것을 얻기 위한 일종의 속임수이며, 대량으로 코드를 생성하거나(다른 매크로를 구현하기 위해 CLONE을 사용하는 경우), 보일러 플레이트의 균일성을 개선하기 위해 보일러 플레이트 타입의 코드를 복사 및 붙여넣을 때 유용합니다.
옵션 1은 일반 컨테이너의 대부분의 C 구현에서 채택된 접근법입니다.Windows 드라이버 키트와 Linux 커널은 매크로를 사용하여 컨테이너의 링크를 구조체 내 어디에나 삽입할 수 있도록 합니다.이 매크로를 사용하여 구조체 포인터를 링크 필드로 가져오는 데 사용됩니다.
옵션 2는 BSD의 tree.h 및 queue.h 컨테이너 구현에서 채택된 택입니다.
이 방법들 중 어느 것도 안전하다고 생각하지 않을 것 같습니다.유용하지만 안전하지는 않습니다.
1 (''1' 중 을 사용합니다.void *
몇 가지 (일부)union
의 C 사용하는 으로, miss가 적기 에, 타입에 의 실장을 하는 C보다 뛰어난 수 있습니다.+/어울리다
void 포인터(void*)를 사용하여 구조 및 typedef로 정의된 범용 데이터 구조를 나타냅니다.이하에, 내가 작업하고 있는 lib의 실장을 나타냅니다.
이런 종류의 구현에서는 typedef로 정의된 각각의 새로운 유형을 의사 클래스처럼 생각할 수 있습니다.여기서 이 의사 클래스는 소스 코드(some_type_implementation.c)와 그 헤더 파일(some_type_implementation.h)의 세트입니다.
소스 코드에서는 새 유형을 나타내는 구조를 정의해야 합니다."node.c" 소스 파일의 구조를 확인합니다.거기서 나는 정보 속성에 대한 무효 포인터를 만들었다.이 포인터는 어떤 타입의 포인터도 탑재할 수 있지만(내 생각에는), 각 타입의 프로퍼 핸들을 정의하기 위한 모든 스위치 내의 타입 식별자(int 타입)와 모든 스위치에서 지불해야 합니다.그래서, 노드에서.h" 헤더 파일에 "Node" 유형을 정의하고(구조 노드를 매번 입력하는 것을 피하기 위해), "EMPTY_NODE", "COMPLEX_NODE", "MATRIX_NODE" 상수를 정의해야 했습니다.
컴파일은 "gcc *.c -lm"을 사용하여 수동으로 수행할 수 있습니다.
main.c 소스 파일
#include <stdio.h>
#include <math.h>
#define PI M_PI
#include "complex.h"
#include "matrix.h"
#include "node.h"
int main()
{
//testCpx();
//testMtx();
testNode();
return 0;
}
node.c 소스 파일
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include "node.h"
#include "complex.h"
#include "matrix.h"
#define PI M_PI
struct node
{
int type;
void* info;
};
Node* newNode(int type,void* info)
{
Node* newNode = (Node*) malloc(sizeof(Node));
newNode->type = type;
if(info != NULL)
{
switch(type)
{
case COMPLEX_NODE:
newNode->info = (Complex*) info;
break;
case MATRIX_NODE:
newNode->info = (Matrix*) info;
break;
}
}
else
newNode->info = NULL;
return newNode;
}
int emptyInfoNode(Node* node)
{
return (node->info == NULL);
}
void printNode(Node* node)
{
if(emptyInfoNode(node))
{
printf("Type:%d\n",node->type);
printf("Empty info\n");
}
else
{
switch(node->type)
{
case COMPLEX_NODE:
printCpx(node->info);
break;
case MATRIX_NODE:
printMtx(node->info);
break;
}
}
}
void testNode()
{
Node *node1,*node2, *node3;
Complex *Z;
Matrix *M;
Z = mkCpx(POLAR,5,3*PI/4);
M = newMtx(3,4,PI);
node1 = newNode(COMPLEX_NODE,Z);
node2 = newNode(MATRIX_NODE,M);
node3 = newNode(EMPTY_NODE,NULL);
printNode(node1);
printNode(node2);
printNode(node3);
}
node.h 헤더 파일
#define EMPTY_NODE 0
#define COMPLEX_NODE 1
#define MATRIX_NODE 2
typedef struct node Node;
Node* newNode(int type,void* info);
int emptyInfoNode(Node* node);
void printNode(Node* node);
void testNode();
matrix.c 소스 파일
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include "matrix.h"
struct matrix
{
// Meta-information about the matrix
int rows;
int cols;
// The elements of the matrix, in the form of a vector
double** MTX;
};
Matrix* newMtx(int rows,int cols,double value)
{
register int row , col;
Matrix* M = (Matrix*)malloc(sizeof(Matrix));
M->rows = rows;
M->cols = cols;
M->MTX = (double**) malloc(rows*sizeof(double*));
for(row = 0; row < rows ; row++)
{
M->MTX[row] = (double*) malloc(cols*sizeof(double));
for(col = 0; col < cols ; col++)
M->MTX[row][col] = value;
}
return M;
}
Matrix* mkMtx(int rows,int cols,double** MTX)
{
Matrix* M;
if(MTX == NULL)
{
M = newMtx(rows,cols,0);
}
else
{
M = (Matrix*)malloc(sizeof(Matrix));
M->rows = rows;
M->cols = cols;
M->MTX = MTX;
}
return M;
}
double getElemMtx(Matrix* M , int row , int col)
{
return M->MTX[row][col];
}
void printRowMtx(double* row,int cols)
{
register int j;
for(j = 0 ; j < cols ; j++)
printf("%g ",row[j]);
}
void printMtx(Matrix* M)
{
register int row = 0, col = 0;
printf("\vSize\n");
printf("\tRows:%d\n",M->rows);
printf("\tCols:%d\n",M->cols);
printf("\n");
for(; row < M->rows ; row++)
{
printRowMtx(M->MTX[row],M->cols);
printf("\n");
}
printf("\n");
}
void testMtx()
{
Matrix* M = mkMtx(10,10,NULL);
printMtx(M);
}
matrix.h 헤더 파일
typedef struct matrix Matrix;
Matrix* newMtx(int rows,int cols,double value);
Matrix* mkMatrix(int rows,int cols,double** MTX);
void print(Matrix* M);
double getMtx(Matrix* M , int row , int col);
void printRowMtx(double* row,int cols);
void printMtx(Matrix* M);
void testMtx();
complex.c 소스 파일
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include "complex.h"
struct complex
{
int type;
double a;
double b;
};
Complex* mkCpx(int type,double a,double b)
{
/** Doc - {{{
* This function makes a new Complex number.
*
* @params:
* |-->type: Is an interger that denotes if the number is in
* | the analitic or in the polar form.
* | ANALITIC:0
* | POLAR :1
* |
* |-->a: Is the real part if type = 0 and is the radius if
* | type = 1
* |
* `-->b: Is the imaginary part if type = 0 and is the argument
* if type = 1
*
* @return:
* Returns the new Complex number initialized with the values
* passed
*}}} */
Complex* number = (Complex*)malloc(sizeof(Complex));
number->type = type;
number->a = a;
number->b = b;
return number;
}
void printCpx(Complex* number)
{
switch(number->type)
{
case ANALITIC:
printf("Re:%g | Im:%g\n",number->a,number->b);
break;
case POLAR:
printf("Radius:%g | Arg:%g\n",number->a,number->b);
break;
}
}
void testCpx()
{
Complex* Z = mkCpx(ANALITIC,3,2);
printCpx(Z);
}
complex.h 헤더 파일
#define ANALITIC 0
#define POLAR 1
typedef struct complex Complex;
Complex* mkCpx(int type,double a,double b);
void printCpx(Complex* number);
void testCpx();
제가 놓친 게 없었으면 좋겠어요.
오래된 질문인건 알지만, 그래도 관심있는 질문인거 같아요.오늘 옵션 2) (프리프로세서 매크로)를 시험하고 있었는데, 다음에 붙여넣을 예를 생각해 냈습니다.약간 투박하긴 하지만 끔찍하진 않아이 코드는 완전한 타입의 세이프는 아니지만 적정 수준의 안전성을 제공하기 위한 건전성 검사를 포함하고 있습니다.또한 C++ 템플릿이 실행되었을 때 본 것과 비교하면 컴파일러 오류 메시지에 대한 대처는 간단했습니다.아마 "main" 함수의 코드 사용 예에서 이 내용을 읽는 것이 가장 좋습니다.
#include <stdio.h>
#define LIST_ELEMENT(type) \
struct \
{ \
void *pvNext; \
type value; \
}
#define ASSERT_POINTER_TO_LIST_ELEMENT(type, pElement) \
do { \
(void)(&(pElement)->value == (type *)&(pElement)->value); \
(void)(sizeof(*(pElement)) == sizeof(LIST_ELEMENT(type))); \
} while(0)
#define SET_POINTER_TO_LIST_ELEMENT(type, pDest, pSource) \
do { \
ASSERT_POINTER_TO_LIST_ELEMENT(type, pSource); \
ASSERT_POINTER_TO_LIST_ELEMENT(type, pDest); \
void **pvDest = (void **)&(pDest); \
*pvDest = ((void *)(pSource)); \
} while(0)
#define LINK_LIST_ELEMENT(type, pDest, pSource) \
do { \
ASSERT_POINTER_TO_LIST_ELEMENT(type, pSource); \
ASSERT_POINTER_TO_LIST_ELEMENT(type, pDest); \
(pDest)->pvNext = ((void *)(pSource)); \
} while(0)
#define TERMINATE_LIST_AT_ELEMENT(type, pDest) \
do { \
ASSERT_POINTER_TO_LIST_ELEMENT(type, pDest); \
(pDest)->pvNext = NULL; \
} while(0)
#define ADVANCE_POINTER_TO_LIST_ELEMENT(type, pElement) \
do { \
ASSERT_POINTER_TO_LIST_ELEMENT(type, pElement); \
void **pvElement = (void **)&(pElement); \
*pvElement = (pElement)->pvNext; \
} while(0)
typedef struct { int a; int b; } mytype;
int main(int argc, char **argv)
{
LIST_ELEMENT(mytype) el1;
LIST_ELEMENT(mytype) el2;
LIST_ELEMENT(mytype) *pEl;
el1.value.a = 1;
el1.value.b = 2;
el2.value.a = 3;
el2.value.b = 4;
LINK_LIST_ELEMENT(mytype, &el1, &el2);
TERMINATE_LIST_AT_ELEMENT(mytype, &el2);
printf("Testing.\n");
SET_POINTER_TO_LIST_ELEMENT(mytype, pEl, &el1);
if (pEl->value.a != 1)
printf("pEl->value.a != 1: %d.\n", pEl->value.a);
ADVANCE_POINTER_TO_LIST_ELEMENT(mytype, pEl);
if (pEl->value.a != 3)
printf("pEl->value.a != 3: %d.\n", pEl->value.a);
ADVANCE_POINTER_TO_LIST_ELEMENT(mytype, pEl);
if (pEl != NULL)
printf("pEl != NULL.\n");
printf("Done.\n");
return 0;
}
GLib에는 다수의 범용 데이터 구조가 포함되어 있습니다.http://www.gtk.org/
CCAN에는 http://ccan.ozlabs.org/ 등의 유용한 스니펫이 많이 있습니다.
당신의 옵션 1은 가장 오래된 c 프로그래머들이 사용하는 것입니다.아마도 반복적인 타이핑을 줄이기 위해 2의 약간의 소금에 절여져 있을 것입니다.또한 다형성을 맛보기 위해 몇 가지 함수 포인터를 사용할 수도 있습니다.
옵션 1에는 공통적인 변화가 있는데, 이는 결합을 사용하여 목록 노드에 값을 저장하기 때문에 더 효율적입니다. 즉, 추가적인 간접 작업이 없습니다.단점은 리스트가 특정 유형의 값만 허용하고 유형이 다른 경우 일부 메모리가 낭비될 수 있다는 것입니다.
이 때 ㄴ, ㄴ, ㄴ, ㄴ, ㄴ, ㄴ, ㄴ, ㄴ, ㄴ, ㄴ, ㄴ, ㄴ, ㄴ, ㄴ, ㄴ, ㄴ, ㄴ, ㄴ, ㄴ, ㄴ,union
엄격한 에일리어싱을 해제하려면 플렉시블 어레이 멤버를 사용합니다.C99 c c :
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
struct ll_node
{
struct ll_node *next;
long long data[]; // use `long long` for alignment
};
extern struct ll_node *ll_unshift(
struct ll_node *head, size_t size, void *value);
extern void *ll_get(struct ll_node *head, size_t index);
#define ll_unshift_value(LIST, TYPE, ...) \
ll_unshift((LIST), sizeof (TYPE), &(TYPE){ __VA_ARGS__ })
#define ll_get_value(LIST, INDEX, TYPE) \
(*(TYPE *)ll_get((LIST), (INDEX)))
struct ll_node *ll_unshift(struct ll_node *head, size_t size, void *value)
{
struct ll_node *node = malloc(sizeof *node + size);
if(!node) assert(!"PANIC");
memcpy(node->data, value, size);
node->next = head;
return node;
}
void *ll_get(struct ll_node *head, size_t index)
{
struct ll_node *current = head;
while(current && index--)
current = current->next;
return current ? current->data : NULL;
}
int main(void)
{
struct ll_node *head = NULL;
head = ll_unshift_value(head, int, 1);
head = ll_unshift_value(head, int, 2);
head = ll_unshift_value(head, int, 3);
printf("%i\n", ll_get_value(head, 0, int));
printf("%i\n", ll_get_value(head, 1, int));
printf("%i\n", ll_get_value(head, 2, int));
return 0;
}
몇 가지 고성능 컬렉션에 옵션 2를 사용하고 있는데, 실제로 컴파일할 때 범용적이고 사용할 가치가 있는 작업을 수행하는 데 필요한 매크로 로직을 처리하는 데 매우 많은 시간이 걸립니다.저는 순전히 미숙한 퍼포먼스(게임)를 위해서 하는 것입니다.X-매크로스 방식을 사용합니다.
옵션 2에서 항상 발생하는 골치 아픈 문제는 "8/16/32/64 비트키와 같은 일부 한정된 수의 옵션을 가정할 때 해당 값을 상수로 하고 상수가 취할 수 있는 값 세트의 다른 요소를 사용하여 여러 함수를 정의해야 합니까, 아니면 단순히 멤버 변수로 해야 합니까?"입니다.전자는 1, 2개의 숫자만 다른 반복된 함수가 많기 때문에 명령 캐시의 성능이 떨어짐을 의미하며, 후자는 할당된 변수를 참조해야 함을 의미하며, 최악의 경우 데이터 캐시가 누락됨을 의미합니다.옵션 1은 완전히 동적이기 때문에 이러한 값은 생각할 필요도 없이 멤버 변수로 만듭니다.하지만 이것은 정말로 마이크로 최적화입니다.
또한 반환 포인터와 값 간의 트레이드오프에 유의하십시오.데이터 항목의 크기가 포인터 크기보다 작거나 같을 때 후자가 가장 성능이 우수합니다.데이터 항목이 클 경우 값을 반환하여 큰 개체의 복사본을 강제로 생성하는 것보다 포인터를 반환하는 것이 가장 좋습니다.
수집 성능이 병목현상이 될 것이라고 100% 확신할 수 없는 경우에는 옵션 1을 선택하는 것이 좋습니다.2를 "퀵 셋업 "2"의 "1"을 합니다.void *
내 목록과 맵에 값을 입력합니다.90%
https://github.com/clehner/ll.c 를 참조해 주세요.
사용하기 쉽다:
#include <stdio.h>
#include <string.h>
#include "ll.h"
int main()
{
int *numbers = NULL;
*( numbers = ll_new(numbers) ) = 100;
*( numbers = ll_new(numbers) ) = 200;
printf("num is %d\n", *numbers);
numbers = ll_next(numbers);
printf("num is %d\n", *numbers);
typedef struct _s {
char *word;
} s;
s *string = NULL;
*( string = ll_new(string) ) = (s) {"a string"};
*( string = ll_new(string) ) = (s) {"another string"};
printf("string is %s\n", string->word);
string = ll_next( string );
printf("string is %s\n", string->word);
return 0;
}
출력:
num is 200
num is 100
string is another string
string is a string
언급URL : https://stackoverflow.com/questions/3039513/type-safe-generic-data-structures-in-plain-old-c
'sourcecode' 카테고리의 다른 글
init-module 및 context-module (0) | 2022.09.03 |
---|---|
SimpleDateFormat 및 로케일 기반 형식 문자열 (0) | 2022.09.03 |
Java: 문자열을 타임스탬프로 변환 (0) | 2022.09.03 |
안드로이드:XML을 사용하여 전환 단추에 대해 두 개의 다른 이미지 지정 (0) | 2022.09.03 |
Vue/Nuxt 비동기 메타 태그 생성 (0) | 2022.09.03 |