C#과 유니티로 만드는 MMORPG 게임 개발 시리즈 강의를 수강하며 객체지향 파트에서 배우고 느낀 점을 정리해보았다.
- 절차지향과 객체 지향의 비교
- 절차지향 (Procedural)
: 모든 기능을 함수 기반으로 만들고, 함수끼리 조립하여 프로그래밍하는 형식. 심플하고 직관적이라는 장점이 있다. 동작시 순차적으로 실행되기 때문에 간단한 프로그램이나 작은 규모의 툴인 경우 간단하게 만들 수 있다.
하지만 순차 실행되는 성질 때문에 로직을 수정 / 추가하게되면 전체적인 코드 흐름에 맞추어 수정해야 하기 때문에 무척 까다롭다. 특히 기능 추가 시 계속해서 유사한 함수를 만들어야 한다. 즉, 프로그램의 규모가 커질수록 유지보수가 힘들다는 큰 단점이 있다. - 객체지향 (Object oriented)
- 객체(=코드 내의 모든 것들, ex. 플레이어나 몬스터, NPC, 화살, 스킬...)를 중심으로 프로그램을 만드는 것.
- 모든 객체는 묘사(Class화)가 가능하며, 묘사의 구성은 속성(=데이터 ex. hp, attack(공격력), pos(현재위치)) 그리고 기능(=함수, ex. Die, Attack(공격기능), Move)으로 나눌 수 있다. - Class
- 일종의 붕어빵틀, 또는 설계도라고 할 수 있다. 알맞은 재료만 넣어주면(변수 할당 등) 미리 선언해둔 대로 객체가 생성된다.
- 클래스로 선언한 객체는 실사용시 new 키워드로 객체를 생성해주어야 사용이 가능하다.
class Knight
{
public int hp;
public int attack;
public void Move()
{
Console.WriteLine("Knight Move");
}
public void Attack()
{
Console.WriteLine("Knight Attack");
}
}
class Program
{
static void Main(string[] args)
{
Knight knight = new Knight(); // new 키워드를 쓰지 않으면 해당 객체는 존재하지 않기 때문에 오류가 발생한다.
knight.hp = 100;
knight.attack = 10;
knight.Move();
knight.Attack();
}
}
- 복사와 참조(얕은 복사와 깊은 복사)
- 구조체(struct)인 Mage와 클래스(Class)인 Knight의 hp를 각각 0으로 만드는 함수를 실행해보고 둘의 차이점을 알아보았다.
- 아래의 예제를 통해 Struct의 경우 복사본(얕은 복사)으로 연산되고, Class의 경우 ref(참조, 깊은 복사)로 작동됨을 알 수 있다. 결론적으로, Struct의 연산 결과는 실제 원본 값에 영향을 끼치지 않는다.
- 같은 구조체를 사용하여 여러개의 객체를 만들더라도 두 객체는 서로 다른 것으로 취급되며 역시 원본 값에 영향을 끼치지 않고 복사본으로 연산을 수행하게 된다. 아래의 예제는 함수를 거치지 않고 객체의 변숫값에 변화를 준 것이다.
- struct로 구성된 mage2의 경우 첫번째 mage의 hp가 100인 것과 달리 hp가 0이 되었다. 이로써 둘은 별도의 객체처럼 취급됨을 알 수 있다. 반면 class로 구성된 knight2는 첫 번째 knight와 동일하게 hp가 0이 되었다. 즉 동일 클래스가 할당되어 있고, 클래스는 ref로 연산되기 때문에 둘은 서로 같은 객체를 가리키고 있다고 할 수 있다.
- 별도의 객체를 만들고 싶다면 위의 예제처럼 이미 생성된 객체를 할당하는 것이 아니라 new 키워드를 사용해 설계도가 동일한 다른 객체를 생성해서 사용해야한다.
- 깊은 복사에 대해 알기 위해 knight 클래스의 코드를 수정했다.
class Knight
{
public int hp;
public int attack;
public Knight Clone() // 클론함수를 호출하면 새로운 knight를 만들어 준다음, 자신이 가진 hp값을 넣어주고 리턴함.
// 진퉁도, 짝퉁도 아닌 별개의 새로운 객체를 생성하는 것.
{
Knight knight = new Knight(); // new 키워드로 새롭게 생성했으므로
// 기존에 생성된 knight와는 별개의 객체가 된다.
knight.hp = hp;
knight.attack = attack;
return knight;
}
public void Move()
{
Console.WriteLine("Knight Move");
}
public void Attack()
{
Console.WriteLine("Knight Attack");
}
}
- 추가된 Clone함수를 이용해서 객체를 생성하면 knight와 동일한 hp값이 할당된 이전의 knight2와 달리 완전하게 분리된 별도의 객체로 생성되었기 때문에 hp의 값이 서로 다르게 할당됨을 알 수 있다.
- 스택과 힙
- 데이터 저장 시 사용되는 스택과 힙 메모리가 있다.
- 스택은 불안정하고, 임시적으로 사용할 메모리라고 할 수 있다. ex. 일종의 함수를 실행하기 위한 메모장. 함수 안에 선언되는 변수들은 스택 메모리로 들어간다.
- 스택을 복사 영역과 참조 영역으로 나누어 보면, 복사에는 본체가 들어간다. ex. mage의 hp와 attack에 해당하는 크기만큼(int형 2개 이므로 8바이트) 메모리 사이즈가 할당된다.
- 참조 영역은 본체가 들어가는 것이 아니라, 주소가 들어간다. 주소는 64bit에서는 64, 32bit 에서는 32bit 이다.
- 주소 안에는 실제 본체가 있는 메모리의 주소를 나타낸다. 참조 타입의 본체는 힙 영역에 들어간다.
- 힙 영역은 동적으로 할당되는 것, 실시간으로 할당되는 것들을 취급한다.
- 즉, 스택 안의 main 함수 안을 들여다보면 구조체로 선언한 mage의 경우 스택 안에 메모리가 할당(각각의 진퉁)이되지만, class인 knight는 첫 knight의 경우 new 키워드를 사용하여 생성하였으므로 힙 영역의 kinght주소를 가리키게 된다. 또한 clone 함수를 이용해 생성된 경우도 함수 안에서 new 키워드를 사용한 순간에 heap 영역에 별도의 kight를 가리키는 형태가 된다.
- knight k = kight; 처럼 변수에 이미 생성된 변수를 할당한 경우 레퍼런스(참조타입)이기 때문에 같은 본체를 가리키는 형태로 저장이 된다.
- 스택 영역의 경우 함수가 실행되고 종료되는 구간까지 현재 자신이 어느 정도의 메모리를 사용하고 있는지를 계속해서 추적한다. 이때 알아서 유동적으로 늘어나고 줄어들기 때문에 해당 영역을 따로 신경 쓸 필요가 없다. 하지만 힙 영역의 경우 메모리를 할당하고 어떤 행동도 하지 않으면 메모리가 계속 남아 유지가 된다. cpp의 경우 반드시 프로그래머가 메모리를 해제해주어야만 하는데, c#의 경우는 언어 차원에서 이를 해결해주기 때문에 더 이상 본체를 가리키는 메모리가 없을 때는 알아서 할당을 해제하여 메모리를 관리해준다.
- 참조타입이라고 무조건 힙을 가리키는 것은 아니다. 어떤 함수를 호출하다가 mage를 그냥 호출하는 것이 아니라 ref타입으로 사용하면 본체가 아니라 주소를 사용하게 된다. 이런 본체는 stack에도 존재할 수 있다. - 생성자
- 앞서 살펴본 코드에서, knight를 new 로 생성한 뒤 hp와 attack에 각각 변수값을 할당해주는 형식으로 작성했다. 하지만 이렇게 작업할 경우 자칫 변수 선언을 생략하는 등의 실수가 벌어지면 원래 의도했던 대로 객체를 생성할 수 없게 된다. 이러한 실수를 방지하기 위해, 객체 생성과 동시에 변수값을 할당해주는 작업을 생성자를 이용해 할 수 있다.
- 생성자는 클래스와 동일한 이름을 가지지만 반환 형식은 없는 형태를 가지고 있다.
class Knight
{
public int hp;
public int attack;
public Knight() // 생성자를 선언할 때 반환 타입을 넣으면 안된다(void 타입 포함).
{
hp = 100;
attack = 10;
Console.WriteLine("생성자 호출!");
}
public Knight Clone() // 클론함수를 호출하면 새로운 knight를 만들어 준다음, 자신이 가진 hp값을 자신이 가진 hp값을 넣어주고 리턴함.
// 진퉁도, 짝퉁도 아닌 별개의 새로운 객체를 생성하는 것.
{
Knight knight = new Knight(); // new 키워드로 새롭게 생성했으므로
// 기존에 생성된 knight와는 별개의 객체가 된다.
knight.hp = hp;
knight.attack = attack;
return knight;
}
public void Move()
{
Console.WriteLine("Knight Move");
}
public void Attack()
{
Console.WriteLine("Knight Attack");
}
}
class Program
{
static void Main(string[] args)
{
Knight knight = new Knight(); // 실행하면 "생성자 호출!"문자열이 출력된다.
}
}
- 동일한 이름의 생성자라도 매개변수의 타입이 다르면 선언과 사용이 가능하다.
class Knight
{
public int hp;
public int attack;
public Knight() // 생성자를 선언할 때 반환 타입을 넣으면 안된다(void 타입 포함).
{
hp = 100;
attack = 10;
Console.WriteLine("생성자 호출!");
}
// 동일한 이름의 생성자라도 매개변수의 타입에 따라 다양하게 선언해줄 수 있다.
public Knight(int hp) : this() // 나 자신의 빈 생성자를 호출시킨다.
// 위에 작성한 생성자가 실행되기 때문에 변수 할당을 자동으로 할 수 있다.
// 따라서 아래 스코프에서 사용하지 않은 attack도 10으로 할당된다.
{
this.hp = hp; // 인자를 할당할 변수가 Kinght 클래스에서 public으로 선언된 hp가 아니라
// 나 자신의 hp임을 명시적으로 표현하기 위해 this 키워드를 사용했다.
Console.WriteLine($"hp는 {hp}, attack은 {attack}으로 할당되었습니다.");
Console.WriteLine("int 매개변수를 가진 Knight 생성자가 사용되었다.");
}
}
class Program
{
static void Main(string[] args)
{
Knight knight = new Knight(50); // 인자로 int 값 50을 넣어주었기 때문에 int를 매개변수로 가진
// 2번째 Knight 생성자가 실행된다.
}
}
위 예제의 출력 결과는 다음과 같다.
만약 this()가 없다면 attack은 할당되지 않았기 때문에 0으로 출력되고,
"생성자 호출!" 문자열을 출력하는 wirteLine 함수도 실행되지 않는다.
- Static
- 정적 필드이기 때문에 static으로 선언된 변수는 다른 여러 개의 변수에서 호출하더라도 하나의 변수로 공용화되어 사용된다. 즉, 오로지 1개만 존재한다는 유일성을 가지게 되며, 선언된 클래스 또는 함수에 종속된다. 아래의 예제는 게임에서 유일성을 가져야하는 대표적인 변수중 하나인 id를 static을 이용해 처리하는 과정을 보여준다.
class Knight
{
static public int counter; // ID를 증가시키기 위한 변수
public int id; // MMORPG에서 각 플레이어가 하나의 고유한 ID를 가진다고 할 때
public int hp;
public int attack;
public Knight()
{
id = counter; // ID에 현재까지 카운트된 수를 할당하고
counter++; // 새로운 유저가 생성되었으므로 Count를 1 증가시켜준다.
hp = 100;
attack = 10;
Console.WriteLine("생성자 호출!");
}
}
- 함수에 Static을 사용하는 경우 일종의 공용 함수(붕어빵x, 클래스에 종속적임)가 된다. 이 경우 모두가 사용할 수 있기 때문에 static 변수가 아닌 id, hp, attack 변수를 내부에서 할당 할 수 없다. 즉 Static 함수에서는 static 변수만을 연산할 수 있음을 유의하자.
- 그러나 static 함수라고 해서 무조건 일반 인스턴스에 접근할 수 없는 것은 아니다. 아래 코드처럼 생성자를 통해 접근할 클래스를 생성하면, 이렇게 생성된 클래스 내부에 있는 변수에 접근할 수 있다. 변수를 자유롭게 할당해주고 마지막으로 클래스 변수를 리턴해주기만 하면 된다.
static public Knight CreateKnight()
{
Knight knight = new Knight();
knight.hp = 100;
knight.attack = 1;
return knight;
}
- 또한 static으로 설정된 함수의 경우 해당 클래스에 종속적이기 때문에 사용을 위해 호출할 때에도 Console.WirteLine 함수처럼 종속 클래스를 통해야한다. 구체적인 예시 코드는 다음과 같다.
public void Move()
{
Console.WriteLine("Knight Move");
}
public void Attack()
{
Console.WriteLine("Knight Attack");
}
}
class Program
{
static void Main(string[] args)
{
Knight knight = Knight.CreateKnight();
// static함수를 호출하고자 할 때, 이미 종속 클래스 객체가 생성되어 있는 경우
// 그냥 Knight.CreateKnight(); 로 호출 가능
Knight.CreateKnight();
// 종속적이지 않은 일반 함수들의 경우에는
// 샘플 객체 없이 클래스 자체만을 통해 호출 할 순 없다.
Knight.Move(); // 불가능
knight.Move(); // 가능
}
}
- 상속성(Inheritance)
- OOP의 대표적인 특징은 은닉성, 상속성, 다형성으로 총 3가지가 있다.
- 상속을 받으면 부모 객체의 필드 뿐만 아니라 함수와 같은 기능을 사용할 수 있다.
- 이러한 특징을 이용하여 공통적인 부분에 대해서는 부모 클래스에서 작성하고 단독으로 사용되는 기능만 자식 클래
스에 구현하여 프로그래밍의 편의성을 높일 수 있다.
- 상속 받은 객체를 생성하면 부모의 속성이 먼저 실행된 후 자식의 속성이 실행된다. (물이 위에서 아래로 흐르듯)
- 생성자에서 상속 받을 객체를 명시할 때는 base 키워드를 사용한다. 아래는 각각 잘못된 예시와 옳은 예시이다.
class Knight : Player
{
public Knight() : Player(100) // 이렇게 하지 않고 base 키워드를 사용해야함
{
Console.WriteLine("나이트 생성자 호출");
}
static public Knight CreateKniht()
{
Knight knight = new Knight();
knight.hp = 100;
knight.attack = 1;
return knight;
}
}
class Knight : Player
{
int c;
public Knight() : base(100) // base : 부모의 생성자
{
this.c = 10; // this : 자신의 것
base.hp = 100; // base : 부모의 것
Console.WriteLine("나이트 생성자 호출");
}
static public Knight CreateKniht()
{
Knight knight = new Knight();
knight.hp = 100;
knight.attack = 1;
return knight;
}
}
- 이러한 기능을 활용해 GameObject라는 최상위 객체를 선언하고 그것을 상속받아 플레이어, 크리처(npc, 몬스터)를 나타내는 클래스로 파생해서 사용하는 방법이 있다.
- 은닉성 (hiding)
- 접근 제한자를 사용하여 프로그램의 기능들에 대한 접근을 제어할 수 있다.
- c#의 접근 제한자에는 public, internal(동일한 Assembly 내에 있는 다른 타입들만 액세스 및 접근 가능), protected, protected internal(동일 Assembly나 포함 클래스에서 파생된 형식에서만 액세스 및 접근 가능-https://docs.microsoft.com/ko-kr/dotnet/csharp/language-reference/keywords/protected-internal), private가 있다.
- hp, mp, attack(공격력) 처럼 자식 클래스에서 빈번하게 사용되고 접근이 필요한 변수는 protected로 선언해준다.
class Program
{
// 은닉성 예제
class Knight
{
private int hp; // 클래스 내부 선언 함수가 아닌 곳에서 호출할 수 없음
// 원본 변수에 대한 고유성을 지킴 -> 무분별한 호출을 방지하여 예상치 못한 에러를 방지함
public void SetHp(int hp) // 클래스 외부에서도 함수 호출 가능
{
this.hp = hp;
}
}
static void Main(string[] args)
{
Knight knight = new Knight();
knight.SetHp(100);
}
}
- 클래스 형식 변환
- 클래스의 상속성을 활용하여 불필요한 함수 중복을 방지할 수 있다.
class Player
{
}
class Knight : Player
{
}
class Mage : Player
{
}
class Program
{
// 클래스 형식 변환 예제
static void EnterGame(Player player)
{
}
static void Main(string[] args)
{
Knight knight = new Knight();
// 스택 영역 // 힙 영역
// = 기호를 기준으로 스택 영역과 힙 영역이 나눠짐
// knight -> 스택 영역에서 힙 메모리 주소를 참조함
Mage mage = new Mage();
// 형식 변환으로 하나의 함수에서 여러 클래스를 매개변수로 받을 수 있음
EnterGame(knight);
EnterGame(mage);
}
}
- is와 as를 이용해 쉽게 클래스 형식을 변환할 수 있다.
- is가 가능한 상황에서는 as도 사용가능하며 두 코드 모두 실행결과가 동일하게 도출된다.
- 다만 코드상으로는 as를 활용하는 쪽이 더 깔끔하므로 이쪽이 더 추천된다.
class Player
{
}
class Knight : Player
{
}
class Mage : Player
{
public int mp;
}
class Program
{
// 클래스 형식 변환 예제
static void EnterGame(Player player)
{
bool isMage = (player is Mage);
if(isMage) // 1번째 방법 : 리턴 값이 true일 때만 캐스팅을 실행하도록
// 체크해주는 함수를 구현함
{
Mage mage = (Mage)player;
mage.mp = 10;
}
// 2번째 방법 : as를 이용
Mage mage = (player as Mage); // 반환값이 bool이 아님.
// as를 사용하면 캐스팅까지 포함해서 실행됨
if (mage != null) // 캐스팅에 성공했는지 체크한 뒤 변수를 할당해줌
{
mage.mp = 10;
}
}
static void Main(string[] args)
{
Knight knight = new Knight();
// 스택 영역 // 힙 영역
// = 기호를 기준으로 스택 영역과 힙 영역이 나눠짐
// knight -> 스택 영역에서 힙 메모리 주소를 참조함
Mage mage = new Mage();
// 클래스 강제 캐스팅의 문제점
// Mage 타입 -> Player 타입 캐스팅 가능(Mage가 Plyer의 자식이므로)
// Player 타입 -> Mage 타입 캐스팅 가능?
// (꼭 그렇지는 않음->player 타입이 mage가 아니라 knight 타입이 변환된 것일 수 있기 때문에)
Player magePlayer = mage;
Mage mage2 = (Mage)magePlayer; // 위의 문제를 해결하기 위해 강제 캐스팅을 사용해봄
// 그러나 강제 캐스팅 했을 때 캐스팅이 잘못 걸릴 수도 있음
// 문법상으로는 문제가 없기 때문에 캐스팅이 잘못되었음을
// 실제로 실행을 해봐야만 알 수 있기 때문에 위험한 코드임
// ** 결론 **
// 자식 클래스에서 부모 클래스로 변환하는 것은 문제 없이 가능하지만
Player p1 = knight;
// 반대로 부모 타입에서 자식 타입으로 넘어가는 것은 케바케(될 수도 있고 안될 수도 있다)
Mage mage = (Mage)Player; // 강제 캐스팅할 클래스에 대해서 확신이 있으면 변환해도 되지만
// 상술한 문제 때문에 프로그램이 뻗을 수 있으므로 매우 유의해야한다.
// 이러한 문제를 최대한 방지하기 위해
// 사전에 체크하는 방법이 있다. (EnterGame함수의 추가 부분 참고)
// 형식 변환으로 하나의 함수에서 여러 클래스를 매개변수로 받을 수 있음
EnterGame(knight);
EnterGame(mage);
}
}
+) null 응용
// +) Null의 응용
// null : 아무것도 없다. 즉, 참조하는 타입이 아무것도 가리키고 있지 않은 상태
static Player FindPlayerById(int id)
{
// id에 해당하는 플레이어를 탐색
// 못찾았으면
return null;
}
- 다형성(polymorphism)
- 다형성이 필요한 이유?
: 똑같은 이동 기능을 가진 경우에도 직업에 따라 Knight는 일반적인 걷기 기능을 가지고, Mage는 mp를 소모해서 텔레포트를 할 수도 있다. 이 경우 공통된 move 함수를 두고 용도에 따라 내용을 수정해주면 구현이 간단해지며 유지보수에 용이하다.
- 가상(virtual)함수와 오버라이드(override)를 사용해 다형성을 가진 클래스를 구현할 수 있다.
- 오버라이드를 사용하기 위해서는 반드시 상위 객체에서 virtual 키워드를 사용해 가상 함수 선언을 해주어야만 한다. 이때의 상위 객체는 반드시 직전 객체일 필요는 없다. ex) player를 상속받은 knight가 있고, 이 knight를 상속받은 superKnight라는 것이 있다고 가정할 때, knight에 virual로 선언된 move 함수가 없어도 player에 virual 함수 move가 존재한다면 superKnight에서도 똑같이 이 move 함수를 사용할 수 있게 된다.
- 오버라이딩은 하나의 함수가 실제 타입에 따라 다양하게 동작하게 되는 것을 의미한다.
- ** 오버라이드(= 오버라이딩, 다형성을 만드는 것)와 오버로딩(함수 이름의 재사용, 이름은 같지만 인자가 다른 경우를 오버로딩이라 한다.)은 서로 다른 키워드이므로 헷갈리지 않도록 주의해야 한다.
- virtual을 사용하는 함수는 일반 함수보다 성능에 부하를 주기 때문에 모든 함수를 virtual로 사용해선 안된다. 꼭 다형성이 필요한 경우에만 virtual과 오버라이드를 활용하도록 하자.
class Player
{
protected int hp;
protected int attack;
public virtual void Move()
{
Console.WriteLine("Player 이동!");
}
}
class Knight : Player
{
public override void Move()
{
Console.WriteLine("Knight 이동!");
}
}
class Mage : Player
{
public int mp;
public override void Move()
{
Console.WriteLine("Mage 이동!");
}
}
class Program
{
static void EnterGame(Player player)
{
Mage mage = (player as Mage);
if (mage != null)
{
mage.mp = 10;
}
}
static void Main(string[] args)
{
Knight knight = new Knight();
Mage mage = new Mage();
knight.Move(); // "Knight 이동!" 이 출력된다.
mage.Move(); // "Mage 이동!" 이 출력된다.
EnterGame(mage);
}
}
class Player
{
protected int hp;
protected int attack;
public virtual void Move()
{
Console.WriteLine("Player 이동!");
}
}
class Knight : Player
{
public override void Move()
{
Console.WriteLine("Knight 이동!");
}
}
class Mage : Player
{
public int mp;
public override void Move()
{
Console.WriteLine("Mage 이동!");
}
}
class Program
{
static void EnterGame(Player player) // 인자로 받은 객체가 어떤 타입의 인스턴스인지에 따라서
// 그 타입에 맞는 버전의 함수를 자동으로 호출해준다.
{
player.Move();
Mage mage = (player as Mage);
if (mage != null)
{
mage.mp = 10;
}
}
static void Main(string[] args)
{
Knight knight = new Knight();
Mage mage = new Mage();
knight.Move();
mage.Move();
EnterGame(mage); // "Mage 이동!"이 출력됨.
}
}
- 부모 객체가 가진 가상 함수를 한번은 호출 및 재사용하고 싶을 때는 base.함수()로 부모를 호출한 다음 자식 객체에서 이어서 실행하고 싶은 내용을 호출할 수 있다.
class Player
{
protected int hp;
protected int attack;
public virtual void Move()
{
Console.WriteLine("Player 이동!");
}
}
class Knight : Player
{
public override void Move()
{
base.Move(); // 부모인 player의 move를 한번 호출 함. "Player 이동!"이 출력된 다음
Console.WriteLine("Knight 이동!"); // 자신의 내용이 호출됨
}
}
+) sealed 키워드의 활용 (봉인)
class Knight : Player
{
public sealed override void Move() // Knight(본인)까지는 오버라이드로 함수를 재정의 할 수 있지만,
// 이 knight를 상속받는 자식들은 이 함수를 재정의할 수 없도록 봉인함.
{
base.Move(); // 부모인 player의 move를 한번 호출 함
Console.WriteLine("Knight 이동!");
}
}
- 문자열 둘러보기
static void Main(string[] args)
{
string name = "Harry Potter";
// 1. 찾기
bool found = name.Contains("Harry"); // 특정 문자열을 포함하고 있는지 확인하여 true/false 리턴
int index = name.IndexOf('P'); // 특정 문자가 있는 인덱스 값(int)을 리턴
// 2. 변형
name = name + "Junior";
string lowerCaseName = name.ToLower();
// 해당 문자열을 모두 소문자로 바꾸어 반환
string UpperCaseName = name.ToUpper();
// 해당 문자열을 모두 대문자로 바꾸어 반환
string newName = name.Replace('r', 'l');
// 특정 문자열을 , 뒤에 오는 문자열로 변환
// 3. 분할
string[] names = name.Split(new char[] { ' ' });
// 입력한 문자열을 기준으로 문자열을 잘라서 배열에 저장
string subStringName = name.Substring(5);
// 입력한 인덱스부터 문자열을 잘라 새롭게 string 변수에 저장함
}
- 아래는 디버깅 모드로 확인한 출력 결과이다.