C ve C++'da İşaretçilerin Temellerine Hakim Olmak

Son Güncelleme: 12/02/2025
  • İşaretçiler bellek adreslerini temsil eder ve verilerin nerede ve nasıl depolandığı ve erişildiği üzerinde doğrudan kontrol sağlar.
  • Diziler, yapılar ve dinamik bellekle işaretçileri güvenli bir şekilde kullanmak için başvuruların kaldırılması, sabit doğruluk ve işaretçi aritmetiği esastır.
  • İşaretçiler, argümanları referans yoluyla geçirmeye, dinamik yapılar oluşturmaya ve işaretçiden işaretçiye gibi çok seviyeli dolaylı işlemleri uygulamaya olanak tanır.
  • Tanımsız davranışları ve çökmeleri önlemek için boş kontroller, doğru tahsis/tahsis kaldırma ve disiplinli başlatma çok önemlidir.

işaretçiler temelleri

C ve C++'daki işaretçilerin efsanevi bir ünü vardır: güçlüdürler, aldatıcıdırlar ve dikkatsiz davranırsanız programınızı göz açıp kapayıncaya kadar çökertebilirler. Ancak, bir işaretçinin ne olduğunu (sadece bir bellek adresi) gerçekten anladığınızda, bu gizemin büyük bir kısmı ortadan kalkmaya başlar ve düşük seviyeli ve sistem programlamadaki en çok yönlü araçlardan birine erişim kazanırsınız.

Bu makale, bellek adresleri ve basit işaretçilerin temel fikrinden, referanslar, diziler, sınıflar ve dinamik bellek konularına, işaretçiden işaretçiye kullanım durumlarına ve yaygın tuzaklara kadar adım adım size yol gösterecektir. Amaç, işaretçilerle çalışmanın karanlık büyü gibi değil, doğal hissettirmesini sağlamak, böylece kodunuz çalıştığında bellekte gerçekten ne olduğunu anlamanıza yardımcı olmaktır.

Değişkenleri ve bellek adreslerini anlama

bellek ve değişkenler

İşaretçilere değinmeden önce, değişkenlerin hafızada nasıl yaşadığına dair net bir zihinsel resme ihtiyacınız var. Bir bilgisayarın RAM'i, kavramsal olarak her biri benzersiz bir sayısal adresle etiketlenmiş uzun bir bayt dizisidir. Bir değişken bildirdiğinizde, derleyici bu baytlardan bir veya daha fazlasını ayırır ve bu adresi değişkenin adıyla ilişkilendirir.

Bir değişkeni, hafızada bir yere yerleştirilmiş etiketli bir kutu olarak düşünün: adı etikettir, adresi raftaki fiziksel konumdur ve içeriği de kutunun içinde saklanan değerdir. Örneğin, eğer bir int Tipik bir Arduino UNO'da, RAM'de 2 ardışık bayt yer kaplar ve derleyici bunun için hangi adreslerin ayrıldığını kaydeder.

Bir değişkenin bildirimi, derleyiciye hangi tür ve boyutta rezerve etmesi gerektiğini söylerken, tanımlama veya atama aslında ayrılmış konumda bir değer depolar. Örneğin, yazma int j; sadece değişkeni duyurur ve derleyicinin belleği ayırmasına izin verir, oysa j = 10; 10 sayısal değerini ait olduğu bellek hücrelerine yazar j.

Derleyici, dahili olarak her değişken adını bellek adresine ve türüne eşleyen bir sembol tablosu tutar. Derleyici buna karar verirse j 2020 adresinde yaşıyorsa, durumu kavramsal olarak şu şekilde düşünebilirsiniz: tanımlayıcı j 2020 adresine işaret ediyor ve 2020 adresindeki baytlar 10'un ikili gösterimini içeriyor.

“Bir şeyin nerede saklandığı” (adresi) fikrini “orada neyin saklandığı” (değeri) fikrinden ayırmak kritik öneme sahiptir. Derleyici teorisinde ve birçok kitapta, konum genellikle şu şekilde adlandırılır: değer (“konum değeri”nden) içerik, değer. İşaretçiler, bu konumları doğrudan manipüle etmekle ilgilidir.

Peki işaretçi tam olarak nedir?

işaretçi temelleri

Bir işaretçi, değeri başka bir nesneye işaret eden bir bellek adresi olan bir değişkendir. Verinin kendisini değil, verinin bulunduğu adresi depolar. Bir işaretçinin boyutu, makinenin mimarisine bağlıdır: 32 bit x86 sistemlerde genellikle 4 bayt, 64 bit x86-64 sistemlerde genellikle 8 bayt ve Arduino gibi küçük mikrodenetleyicilerde bir adres 2 bayta sığabilir.

Bir işaretçi bildirdiğinizde, yalnızca bir adresi sakladığını değil, aynı zamanda işaret edeceği nesnenin türünü de belirtirsiniz. Örneğin, int* p bir işaretçiyi bildirir intBuradaki yıldız, çarpma işareti değil, türün bir parçasıdır ve derleyiciye daha sonra eriştiğinizde kaç bayt okuyacağını veya yazacağını söyler. *p.

Adres operatörü & size, bir işaretçi değişkeninde saklayabileceğiniz mevcut bir nesnenin adresini verir. Diyelim ki sende var int n = 0;; daha sonra bu kod adresini depolar n bir işaretçiye:

Örnek: int n = 0;
int* p = &n; // p now holds the address of n

Bir işaretçi geçerli bir adrese sahip olduğunda, başvuru kaldırma operatörü * o adreste yaşayan nesneye erişmenizi sağlar. If p bir işaretçidir int, Daha sonra *p Bellekte saklanan gerçek tam sayıya bir takma ad gibi davranır. Örneğin:

Parçacık: *p = 1; // writes 1 into n through the pointer
std::cout << *p; // reads the current value of n

Temel fikir, yıldızın farklı bağlamlarda farklı şeyler ifade etmesidir: bir bildirimde kullanıldığında işaretçi türünü oluşturur ve bir ifadede kullanıldığında işaretçiyi referanssızlaştırır. Bu iki rolü karıştırmak yeni başlayanların yaptığı klasik hatalardan biridir, bu yüzden bir işaretçi mi bildirdiğinize yoksa onu belleğe erişmek için mi kullandığınıza her zaman dikkat edin.

Arduino gibi küçük aygıtlarda, açıkça başlatılmamış bir işaretçi ya geçerli bir 16 bit adres tutar ya da çöp içerir. Kasıtlı olarak boş bir işaretçi sabitine ayarlamadığınız sürece sihirli bir "boş" değer yoktur. nullptr C++'da. Bu tür çöp bir adresin başvurusunu kaldırmak, mikrodenetleyicinizi kilitlemenin neredeyse kesin bir yoludur.

Sabit doğruluk ve farklı işaretçi türleri

İşaretçiler etkileşime girer const İlk başta kafa karıştırıcı olabilecek şekillerde, ancak doğru C++ yazmak için bu konuda uzmanlaşmak çok önemlidir. Pozisyonu const Yıldıza göre işaret edilen nesnenin, işaret edenin kendisinin veya her ikisinin de değişmez olup olmadığına karar verir.

Sabit bir tam sayıya sahipseniz, işaretçi türü yalnızca okuyabileceğinizi, değiştiremeyeceğinizi yansıtmalıdır. Şu kodu hayal edin:

Demo: auto const cn = int{0}; // cn is a constant int
int const* p = &cn; // pointer to const int

type p İşte "sabit int'e işaretçi": okuyabilirsiniz *p ama ona atfedilemez. Deneniyor int* p = &cn; Bu bir tür hatası olurdu, çünkü bu, dil tarafından yasaklanmış olan sabit bir nesneyi değiştirebileceğinize dair söz verirdi.

Bazen nesnenin kendisi sabit değildir, ancak bilerek yalnızca okuma erişimine izin veren bir işaretçi istersiniz. Bu durumda tekrar kullanırsınız int const*:

Kullanımı: auto n = int{0}; // non-const int
int const* p = &n; // can read n via p, but not write through p

Dikkat edin int const* hem de const int* tam olarak aynı anlama gelir: tam sayı işaretçi aracılığıyla salt okunurdur, ancak işaretçi yine de başka bir yeri gösterecek şekilde değiştirilebilir. Öte yandan, eğer yazarsanız int* const p = &n;, senin bir sabit işaretçi sabit olmayan bir int'e: saklanan adres p başlatmadan sonra değiştirilemez, ancak değer *p serbestçe değişebilir.

Hatta her iki formu birleştirerek sabit bir tam sayıya sabit bir işaretçi bile oluşturabilirsiniz: int const* const p. Bu, derleyiciye adresin ne olduğunu söyler p Bu adreste saklanan değerin değişmesine izin verilmez. Bu varyasyonları anlamak, amacınızı çok net bir şekilde ifade etmenize yardımcı olur ve derleyici sizi dürüst tutar.

Yapılara ve sınıflara işaretçiler

Bir işaretçi bir yapıya veya sınıfa atıfta bulunduğunda, genellikle onun genel arayüzüne erişmek istersiniz: veri üyeleri ve üye fonksiyonları. Dereferans ile * hala çalışıyor, ancak sözdizimi biraz ayrıntılı hale gelebilir, bu nedenle C++ ok operatörünü sağlar -> kısaltma olarak.

Basit bir örnek düşünün Student notlandırma ve ortalamayı hesaplayan bir yöntem içeren bir yapı. If Student* p bir adresini tutar Student nesne yazabilirsiniz (*p).grade_2 ikinci sınıfa ulaşmak veya (*p).average() üye fonksiyonunu çağırmak için.

Ok operatörü, başvuruyu kaldırma ve üye erişimini tek adımda birleştirir: p->grade_2 hem de p->average() tam olarak aynı anlama geliyor (*p).grade_2 hem de (*p).average(). Kaputun altında p->member sadece sözdizimsel şekerdir (*p).memberBu yüzden neredeyse her zaman şunu göreceksiniz: -> Nesnelere yönelik işaretçilerle uğraşırken gerçek dünya kodunda kullanılır.

Sınıf aşırı yüklenmediği sürece operator* or operator-> bazı egzotik davranışlarla tedavi edebilirsiniz p->member Bir işaretçinin arkasındaki nesneye erişmenin standart yolu olarak. Pek çok çerçeve akıllı işaretçiler için bu operatörleri aşırı yüklemeye dayanır, ancak kavramsal olarak anlamları aynıdır: işaretçiyi takip et ve sonra üyeye eriş.

Boş işaretçiler ve güvenlik

Şu anda geçerli bir nesneye atıfta bulunmayan bir işaretçinin null olduğu söylenir ve modern C++'da bunu ifade etmenin kanonik yolu şudur: nullptr. Yazı Yazmak int* p = nullptr; açıkça belirtiyor ki p henüz anlamlı bir yere işaret etmiyor.

Boş bir işaretçinin başvurusunun kaldırılması, genellikle çökmelere, erişim ihlallerine veya küçük panolarda sistemin donmasına yol açan tanımsız bir davranıştır. Bu nedenle, bir işaretçiyi parametre olarak alan kod, kullanmadan önce genellikle boş olup olmadığını kontrol eder. Mantığınız anlamlı bir durum olarak "nesne yok"a izin veriyorsa, bir işaretçi parametresi uygundur, çünkü bu "yok" bilgisini şu şekilde taşıyabilir: nullptr.

Bir deyimsel örnek, C tarzı bir dizeyi (char const*) Ile std::string ancak giriş işaretçisinin boş olduğu durumu zarif bir şekilde ele alması gerekir. Fonksiyon, oluşturmadan önce işaretçinin boş olup olmadığını kontrol eder. std::stringEğer null ise geçersiz bir adresin başvurusunu kaldırmak yerine boş bir dize döndürür.

Parametre zorunluysa ve yok sayılamıyorsa, C++ referansları genellikle ham işaretçilerden daha iyi bir seçimdir. Bir referans yeniden yerleştirilemez ve boş olması amaçlanmamıştır, bu nedenle tür sistemi, çağıranın geçerli bir nesne sağlaması gerektiği beklentisini açıkça ifade eder. Bu, API'yi daha güvenli ve kodun üzerinde akıl yürütmeyi daha kolay hale getirir.

Fonksiyon parametreleri olarak işaretçiler: değere göre mi yoksa referansa göre mi?

Varsayılan olarak, C veya C++'da bir değişkeni bir fonksiyona geçirdiğinizde, bu değişken değere göre geçirilir: fonksiyon, orijinal değişkeni değil, argümanın değerinin bir kopyasını alır. Bu, fonksiyon içindeki parametreye yapılan herhangi bir atamanın yalnızca yerel kopyayı etkileyeceği ve çağıranın değişkenini değiştirmeyeceği anlamına gelir.

Bu davranış çoğu zaman arzu edilirdir; fonksiyonları izole eder ve şaşırtıcı yan etkilerden kaçınır; ancak bazen bir fonksiyonun çağıranın değişkenlerini değiştirmesini gerçekten istersiniz. Küresel değişkenleri kullanmayı düşünebilirsiniz, ancak programlar büyüdükçe küresel değişkenlerin izlenmesi hızla zorlaşır ve hataya açık hale gelirler.

İşaretçiler temiz bir alternatif sunar: Değişkenin adresini fonksiyona geçirirsiniz ve fonksiyon daha sonra o adresteki değeri değiştirebilir. Bu, "işaretçiler aracılığıyla referansla geçirme" olarak bilinir. C++'da, referans parametrelerini de kullanabilirsiniz (int&), ki bunlar çoğu zaman daha da açıktır, ancak işaretçi formunu anlamak yine de önemlidir.

Bir fonksiyonu hayal edin double_value bu, çağıran tarafından tanımlanan bir tam sayıyı iki katına çıkarmalıdır. İşaretçi tabanlı bir arayüz kullanarak, bunu bir işaretçi olarak beyan edersiniz. int*ve değişkeninizin adresini geçirerek çağırın: double_value(&k);Fonksiyonun içinde, *k = *k * 2; orijinal değeri işaretçi aracılığıyla günceller.

Bu teknik aynı zamanda bir fonksiyonun, adresleri argüman olarak geçirilen birkaç değişkeni değiştirerek birden fazla sonucu etkili bir şekilde "döndürmesine" olanak tanır. Karmaşık bir yapı döndürmek yerine, birden fazla işaretçi parametresini kabul edip hepsini güncelleyebilirsiniz. Modern C++'ta genellikle açıklık açısından referansları, ikilileri veya yapıları tercih edersiniz, ancak işaretçi parametreleri düşük seviyeli API'lerde ve C kütüphanelerinde yaygın olarak kullanılmaya devam etmektedir.

İşaretçi aritmetiği ve diziler

İşaretçilerin en güçlü ve en tehlikeli yönlerinden biri, özellikle diziler bağlamında, işaretçi aritmetiğidir. C ve C++'da bir dizi, bellekte bitişik elemanlardan oluşan bir blok olarak saklanır ve dizinin adı, bir fonksiyona geçirildiğinde veya belirli ifadelerde kullanıldığında ilk elemanına işaret eden bir işaretçiye dönüşebilir.

Eğer beyan ederseniz char h[] = {'P','r','o','m','e','t','e','c','\n'};, Daha sonra h bir işaretçi olarak kabul edilebilir h[0]. erişme h[i] kavramsal olarak hesaplamaya eşdeğerdir *(h + i), Burada h temel adres ve i elemanlardaki ofsettir (bayt cinsinden değil). Derleyici çarpar i her bir öğenin boyutuna göre (1 bayt) char, 4 bayt için int, vb.) işaretçiye eklemeden önce.

Bu, şu anlama gelir: Şu ifadeyi gördüğünüzde: *(h + i), klasik işaretçi aritmetiği yapıyorsunuz: işaretçiyi ilerletiyorsunuz h by i konumlandırın ve ardından sonucu dereferanslayın. Performans nedenlerinden ötürü, derleyiciler bu deseni optimize etmede çok iyidirler; bu yüzden C dizileri ve işaretçileri tarihsel olarak düşük seviyeli işler için çok popüler bir kombinasyon olmuştur.

Ayrıca bir dizinin ilk elemanına açık bir işaretçi oluşturabilir ve dizide gezinmek için bu işaretçiyi artırabilirsiniz. Örneğin, beyan etmek char* ptr = h; ve sonra tekrar tekrar yazdırma *ptr++ Bir döngüde her karakteri sırayla dolaşacaktır. Son ek ++ her erişimden sonra işaretçiyi ilerletir ve onu bir sonraki dizi öğesine taşır.

Bu kompakt stil C dilinde deyimseldir, ancak yeni başlayanlar için anlaşılması güç olabilir, bu nedenle modern C++'da birçok geliştirici aşağıdaki gibi daha açık biçimleri tercih eder: for endeksli döngüler veya aralık tabanlı for döngüleri. Yine de işaretçi aritmetiğini anlamak, eski kodu okumak ve sürdürmek ve performans açısından kritik rutinleri uygulamak için vazgeçilmezdir.

Dinamik bellek, yeni/silme ve işaretçi yinelemesi

İşaretçiler ayrıca nesneleri serbest depoya (genellikle gayriresmi olarak yığın olarak adlandırılır) dinamik olarak tahsis ettiğinizde aldığınız temel tutamaçtır. C++'da operatör new yeni tahsis edilen nesneye bir işaretçi döndürür ve delete Artık ihtiyacınız olmadığında bu belleği serbest bırakır.

Örneğin, Student* p = new Student{...}; bir kişi için yeterli hafıza ayırır Student nesneyi alır ve adresini döndürür. Daha sonra kullanırsınız p->member üyelerine erişmek veya yöntemlerini çağırmak için. Nesne artık gerekli olmadığında, delete p; onu yok eder ve belleği serbest depoya geri bırakır.

C++ ayrıca dizilerin dinamik olarak tahsis edilmesine de olanak tanır new[]Dizinin ilk elemanına bir işaretçi döndüren. Örneğin, Student* p = new Student[100]; 100 kişiye yer ayırır Student bellekte bitişik olarak yerleştirilen nesneler, p 0 indeksindeki öğeyi işaret ediyor.

İşaretçi aritmetiğini kullanarak ifade p + i işaret ediyor ibu dizinin -inci elemanı, yani (p + 4)->grade_1 eşdeğerdir p[4].grade_1. kavramsal olarak, p ilk elemandan başlayan bir yineleyici gibidir ve p + i yineleyiciyi ilerletir i dizi boyunca adımlar.

İşaretçiler arasındaki farklar da anlam taşır: eğer q = p + 4;, Daha sonra q - p 4 olarak değerlendirilir, bu da bu iki işaretçi arasındaki eleman sayısıdır. Bu anlamda, ham bir işaretçi, rastgele erişimli bir yineleyicinin en basit biçimidir. Birçok STL kapsayıcısı, benzer şekilde davranan, ancak güvenlik ve esneklik için ham işaretçi ayrıntılarını gizleyen yineleyiciler sunar.

Her ne kadar çiğ olsa da new/delete Güçlüdür, modern C++ kaynakları otomatik olarak yönetmek için akıllı işaretçilerin ve RAII'nin (Kaynak Edinimi Başlatmadır) kullanılmasını şiddetle teşvik eder. Akıllı işaretçiler gibi std::unique_ptr hem de std::shared_ptr Sahipliği kapsülleyin ve artık ihtiyaç duyulmadığında belleği otomatik olarak serbest bırakın, böylece sızıntı ve çift silme riskini azaltın.

İşaretçilere işaretler ve daha derin dolaylılıklar

Basit ipuçlarını rahatça kullanabildiğinizde, kaçınılmaz olarak ipuçlarıyla (ve bazen daha yüksek dolaylılık seviyeleriyle) karşılaşacaksınız. Kavramsal olarak, bir işaretçiye işaretçi, doğrudan bir int, double veya nesneye başvurmak yerine, bir işaretçi değişkeninin adresini tutan başka bir değişkendir.

En belirgin kullanım durumu, dinamik olarak tahsis edilmiş dizelerin dinamik olarak oluşturulmuş bir tablosu gibi, dinamik olarak tahsis edilmiş işaretçi dizilerini yönetmektir. Düz C'de klasik bir örnek şöyledir: char** argv içinde main her biri kendisi bir C tarzı dize dizisine işaret eden bir işlevdir char*.

Sık karşılaşılan bir diğer senaryo ise, bir fonksiyonun yalnızca işaret ettiği veriyi değil, çağıranın sağladığı bir işaretçiyi de değiştirmek zorunda kalmasıdır. Bir işaretçiden işaretçiye geçmek, fonksiyonun orijinal işaretçinin hangi nesneye atıfta bulunduğunu değiştirmesine veya yeni bir nesneyi tahsis ederek onu başlatmasına olanak tanır. new or mallocÇağrı yapan kod daha sonra güncellenmiş işaretçi değerini görür.

Çoklu dolaylılık düzeyleri, özellikle yığın üzerinde oluşturulan bağlantılı veri yapılarında doğal olarak ortaya çıkar. Örneğin, tahsis edilmiş düğümlerin dinamik olarak oluşturulmuş bağlantılı bir listesi malloc or new düğümlere işaretçiler ve baş işaretçiyi güncellerken öğeleri eklemek veya kaldırmak için bu işaretçilere işaretçiler alan işlevler içerebilir.

Ve tabii ki, çok boyutlu dinamik diziler genellikle C tarzı arayüzlerde işaretçilere işaretçiler olarak temsil edilir: bir "matris" genellikle şu şekilde modellenir: int**, burada ilk boyutun her bir elemanı bir satır dizisine işaret eder. Modern C++'da şunu tercih edebilirsiniz: std::vector<std::vector<T>> veya özel matris sınıfları, ancak işaretçiden işaretçiye düzenler düşük seviyeli API'lerde ve bağlamalarda temel olmaya devam ediyor.

İpuçlarıyla birlikte yaygın tuzaklar ve iyi uygulamalar

İşaretçilerle doğrudan çalışmak size ince ayarlı bir kontrol sağlar, ancak disiplinli olmazsanız, aynı zamanda incelikli ve hata ayıklaması zor hatalara da kapı açar. C ve C++ kod tabanlarındaki birçok efsanevi hata, ham adreslerin yanlış yönetilmesinden, ya size ait olmayan belleğe yazılmasından ya da nesne ömürlerinin yönetilmesinin unutulmasından kaynaklanır.

Klasik hatalardan biri, hedef türün tutabileceğinden daha fazla bayt yazmak veya bellek konumunun türünü yanlış yorumlamaktır. Örneğin, bir long bir değerde int değişken veya bir tane yaz long yalnızca bir süreliğine boyutlandırılmış bir yere int, bitişik belleğin üzerine yazarak diğer değişkenleri veya kod işaretçilerini bile bozabilirsiniz.

Bir diğer tehlike ise işaretçi değişkenlerine doğrudan keyfi sayısal değerler atamak, örneğin: ptrNum = 7;, çok düşük seviyeli bir sistem çalışması yapmıyorsanız ve o adreste tam olarak neyin bulunduğunu bilmiyorsanız. Sıradan uygulama kodu için, bir tam sayıyı adres olarak ele almak, tanımsız davranışlara ve düzensiz çökmelere yol açar.

İşaretçileri düzgün bir şekilde başlatmayı unutmak da risklidir: belirsiz bir değer tutan bir işaretçi iyi görünebilir ancak bellekte herhangi bir yeri işaret ediyor olabilir. İşaretçilerinizi her zaman geçerli bir adrese veya nullptr – ve herhangi bir şüphe varsa, bunları referanstan çıkarmadan önce kontrol edin.

Son olarak, dinamik bellekle her ham veri new tam olarak bir tane karşılık gelenle eşleştirilmelidir delete, ve hepsi new[] tam olarak bir tane ile delete[]. Sızıntılar, dinamik olarak tahsis edilmiş belleği silmeden izini kaybettiğinizde ortaya çıkar ve çift silmeler (veya sahip olmadığınız belleği silme) tahsis edicinin iç yapılarını bozar ve bu genellikle aralıklı ve yeniden üretilmesi çok zor kusurlar olarak ortaya çıkar.

Dikkatli bir şekilde kullanıldığında işaretçiler, rastgele bir kaos kaynağı olmaktan ziyade keskin ve iyi dengelenmiş bir araca benzerler: programınızın bellek düzeni hakkında akıl yürütmenize, verimli veri yapıları tasarlamanıza ve bu düşük seviyeli kontrolün üzerine güçlü soyutlamalar oluşturmanıza olanak tanırlar. Pratik yaptıkça adresler arasında hareket etmek, referansları kaldırmak ve fonksiyonların ve dizilerin işaretçilerle nasıl etkileşime girdiğini anlamak ikinci doğanız haline gelir ve başlangıçtaki korku yerini sağlıklı bir saygıya ve çok sayıda pratik faydaya bırakır.

İlgili Mesajlar: