عنوان:

‫تحلیل و طراحی گام‌به‌گام عبارت منظم (Regex) برای اعتبارسنجی کدپستی ایران


نویسنده: وحید نصیری
تاریخ: ۱۴۰۴/۰۴/۳۱ ۰۸:۱۰
آدرس: www.dntips.ir
در این مطلب، فرآیند طراحی و توسعه‌ی یک عبارت منظم (Regular Expression) برای اعتبارسنجی کدهای پستی ایران را بر اساس مجموعه‌ای از قوانین مشخص، مورد بررسی قرار خواهیم داد. هدف، رسیدن به یک الگوی دقیق و کارآمد است که تمامی نیازمندی‌های مسئله را پوشش دهد. عبارات منظم ابزاری قدرتمند برای تطبیق الگو در رشته‌ها هستند و تسلط بر آن‌ها برای اعتبارسنجی داده‌ها ضروری است.

مرحله اول: تدوین الگوی پایه بر اساس مشخصات اولیه

کدپستی در ایران با اعداد ۱،۳،۴،۵،۶،۷،۸،۹ شروع می شود. در پنج رقم اول این کد از ۰ و ۲ به دلیل شباهت با اعداد ۵ و ۳ استفاده نمی شود؛ ولی در پنج رقم دوم استفاده از این دو عدد بلامانع است. به پنج رقم اول کدپستی “گشت نامه رسانی” می گویند که به مشخصات شهر مقصد و محدوده جغرافیایی آن می پردازد. رقم اول به حوزه یعنی بزرگترین واحد تقسیمات پستی و رقم دوم به منطقه پستی اشاره می کند. سه رقم بعدی حوزه مکان مورد نظر را مشخص می کند. پنج رقم بعدی کدپستی به مشخصات مکانی مقصد می پردازد و دسترسی مستقیم به مقصد را ممکن می سازد.

ابتدا، نیازمندی‌های اولیه برای ساختار کد پستی ایران را مدون می‌کنیم:
  • طول رشته: کد پستی باید دقیقاً شامل ۱۰ کاراکتر عددی باشد.
  • رقم اول: رقم ابتدایی نمی‌تواند 0 یا 2 باشد. بنابراین، باید از مجموعه‌ی [13456789] انتخاب شود.
  • پنج رقم اول: در بلاک اول (پنج رقم ابتدایی)، استفاده از ارقام 0 و 2 مجاز نیست.
  • پنج رقم دوم: در بلاک دوم (پنج رقم انتهایی)، استفاده از تمامی ارقام (0 تا 9) مجاز است.

برای پیاده‌سازی این قوانین، الگوی Regex را به صورت زیر تجزیه و تدوین می‌کنیم:
  • مهارها (Anchors): برای اطمینان از اینکه الگو کل رشته را پوشش می‌دهد و نه بخشی از آن، از مهار ^ برای شروع رشته و $ برای انتهای رشته استفاده می‌کنیم.
  • بلاک اول: بر اساس قوانین ۲ و ۳، هر پنج رقم اول باید از کلاس کاراکترهای [13-9] باشند. با استفاده از سور (Quantifier)، این بخش را به صورت [13-9]{5} می‌نویسیم.
  • بلاک دوم: طبق قانون ۴، هر پنج رقم دوم می‌توانند هر عددی باشند که با کلاس کاراکتر از پیش‌تعریف‌شده‌ی \d مشخص می‌شود. این بخش نیز به صورت \d{5} نوشته می‌شود.

با ترکیب این اجزا، الگوی پایه‌ی ما به شرح زیر حاصل می‌شود:
^[13-9]{5}\d{5}$
این الگو به درستی ساختار اصلی کد پستی را اعتبارسنجی می‌کند و رشته‌هایی را که از نظر طول یا کلاس کاراکترهای مجاز در هر بلاک مغایرت داشته باشند، رد می‌کند.

مرحله دوم: اعمال محدودیت عدم تکرار سراسری

در این مرحله، یک نیازمندی پیچیده‌تر به مسئله اضافه می‌شود: هیچ رقمی نباید در کل رشته‌ی ۱۰ رقمی تکرار شود.
برای پیاده‌سازی چنین قانونی، استفاده از ساختارهای استاندارد کفایت نمی‌کند و باید از تطابق پیش‌نگر منفی (Negative Lookahead) استفاده کرد. این ساختار با (?!...) نمایش داده می‌شود و یک گزاره‌ی صفر-پهنا (Zero-width assertion) است؛ یعنی بدون مصرف کردن کاراکتر، شرطی را در ادامه‌ی رشته بررسی می‌کند و تنها در صورت عدم برقراری آن شرط، تطابق کلی موفقیت‌آمیز خواهد بود.
الگوی مورد نیاز برای بررسی عدم تکرار هر کاراکتر در کل رشته به صورت (?!.*(.).*\1) است. تحلیل این عبارت:
  • .*: این بخش با هر توالی از کاراکترها (از نقطه‌ی فعلی تا انتهای رشته) تطبیق می‌یابد.
  • (.): هر کاراکتری را تطبیق داده و آن را در گروه اول به حافظه می‌سپارد (Capturing Group 1).
  • \1: این یک بازآوری (Backreference) به گروه اول است و با همان کاراکتری که در گروه اول ذخیره شده بود، تطبیق می‌یابد.

بنابراین، عبارت (.) و \1 با یک فاصله‌ی .*، وجود یک کاراکتر تکراری را در رشته مشخص می‌کند. قرار دادن آن در یک Negative Lookahead، این شرط را معکوس کرده و تنها رشته‌هایی را می‌پذیرد که فاقد هرگونه کاراکتر تکراری باشند.
با ادغام این ساختار در الگوی پایه، به الگوی پیشرفته‌تر زیر می‌رسیم:
^(?!.*(.).*\1)[13-9]{5}\d{5}$
این الگو اکنون علاوه بر ساختار، یکتایی تمامی ارقام را نیز تضمین می‌کند.

مرحله سوم: بهینه‌سازی الگو برای بررسی تکرار متوالی

در تحلیل نهایی، نیازمندی به شکل دقیق‌تری تعریف می‌شود: محدودیت تکرار، تنها برای ارقامی اعمال می‌شود که به صورت متوالی (پشت سر هم) قرار گرفته باشند.
این تعدیل، نیازمند بهینه‌سازی عبارت Lookahead است. الگوی یافتن یک کاراکتر و تکرار فوری آن (.)\1 است. در این حالت، دیگر به .* میانی برای ایجاد فاصله نیازی نیست.
برای ممنوع کردن این الگو، آن را در ساختار Lookahead قرار می‌دهیم: (?!.*(.)\1). این عبارت تضمین می‌کند که در هیچ کجای رشته، یک کاراکتر و تکرار بلافصل آن وجود نداشته باشد.
در نتیجه، الگوی نهایی و بهینه به صورت زیر تدوین می‌گردد:
^(?!.*(.)\1)[13-9]{5}\d{5}$
این الگو به درستی رشته‌ای مانند 1345678901 را (که دارای تکرار غیرمتوالی است) معتبر تشخیص داده، اما رشته‌ی 1344567890 را (به دلیل تکرار متوالی 44) رد می‌کند:
using System.Text.RegularExpressions;

namespace PostalCodeValidation
{
    /// <summary>
    /// شامل متدهای الحاقی برای اعتبارسنجی رشته‌ها
    /// </summary>
    public static class ValidationExtensions
    {
        // الگوی Regex به صورت استاتیک و فقط-خواندنی تعریف می‌شود تا تنها یک بار کامپایل و در حافظه نگه‌داری شود.
        // این کار بهینگی را در فراخوانی‌های متعدد افزایش می‌دهد.
        private static readonly Regex IranianPostalCodeRegex = new Regex(@"^(?!.*(.)\1)[13-9]{5}\d{5}$");

        /// <summary>
        /// بررسی می‌کند که آیا رشته‌ی ورودی یک کد پستی معتبر ایرانی است یا خیر.
        /// (بدون ارقام تکراری پشت سر هم)
        /// </summary>
        /// <param name="postalCode">رشته‌ی مورد بررسی به عنوان کد پستی.</param>
        /// <returns>اگر کد پستی معتبر باشد true و در غیر این صورت false را برمی‌گرداند.</returns>
        public static bool IsValidIranianPostalCode(this string postalCode)
        {
            // یک کد پستی معتبر نمی‌تواند null، خالی یا فقط شامل فضای خالی باشد.
            if (string.IsNullOrWhiteSpace(postalCode))
            {
                return false;
            }

            return IranianPostalCodeRegex.IsMatch(postalCode);
        }
    }
}


using Microsoft.VisualStudio.TestTools.UnitTesting;
using PostalCodeValidation; // اضافه کردن فضای نام کلاس الحاقی

namespace PostalCodeValidation.Tests
{
    [TestClass]
    public class IranianPostalCodeValidationTests
    {
        // این متد کدهای معتبر را بر اساس قوانین نهایی آزمون می‌کند.
        [TestMethod]
        public void IsValidIranianPostalCode_ValidCodes_ReturnsTrue()
        {
            // Arrange: آماده‌سازی داده‌های ورودی
            string validCodeWithNonConsecutiveRepeat = "1345678901"; // تکرار غیرمتوالی مجاز است
            string validCodeWithoutRepeat = "9876543210";

            // Act: فراخوانی متد مورد آزمون
            bool result1 = validCodeWithNonConsecutiveRepeat.IsValidIranianPostalCode();
            bool result2 = validCodeWithoutRepeat.IsValidIranianPostalCode();

            // Assert: بررسی نتیجه‌ی مورد انتظار
            Assert.IsTrue(result1, "کد با تکرار غیرمتوالی باید معتبر شناخته شود.");
            Assert.IsTrue(result2, "کد بدون تکرار باید معتبر باشد.");
        }

        // این متد کدهایی را که دارای ارقام تکراری پشت سر هم هستند، آزمون می‌کند.
        [TestMethod]
        public void IsValidIranianPostalCode_WithConsecutiveRepeats_ReturnsFalse()
        {
            // Arrange
            string codeWithConsecutiveRepeat1 = "1344567890"; // تکرار '44'
            string codeWithConsecutiveRepeat2 = "3456788912"; // تکرار '88'

            // Act
            bool result1 = codeWithConsecutiveRepeat1.IsValidIranianPostalCode();
            bool result2 = codeWithConsecutiveRepeat2.IsValidIranianPostalCode();

            // Assert
            Assert.IsFalse(result1, "کد با تکرار '44' باید نامعتبر باشد.");
            Assert.IsFalse(result2, "کد با تکرار '88' باید نامعتبر باشد.");
        }
        
        // این متد کدهایی را که با ارقام غیرمجاز شروع شده‌اند، آزمون می‌کند.
        [TestMethod]
        public void IsValidIranianPostalCode_InvalidStartingDigit_ReturnsFalse()
        {
            // Arrange
            string codeStartingWithTwo = "2987654321";

            // Act
            bool result = codeStartingWithTwo.IsValidIranianPostalCode();
            
            // Assert
            Assert.IsFalse(result, "کد شروع شده با '2' باید نامعتبر باشد.");
        }

        // این متد کدهایی را که در پنج رقم اول ارقام غیرمجاز دارند، آزمون می‌کند.
        [TestMethod]
        public void IsValidIranianPostalCode_InvalidDigitInFirstBlock_ReturnsFalse()
        {
            // Arrange
            string codeWithZeroInFirstBlock = "1045678932";
            
            // Act
            bool result = codeWithZeroInFirstBlock.IsValidIranianPostalCode();

            // Assert
            Assert.IsFalse(result, "کد دارای '0' در پنج رقم اول باید نامعتبر باشد.");
        }

        // این متد موارد مرزی مانند رشته‌ی null یا با طول اشتباه را آزمون می‌کند.
        [TestMethod]
        public void IsValidIranianPostalCode_EdgeCases_ReturnsFalse()
        {
            // Arrange
            string nullString = null;
            string emptyString = "";
            string shortString = "12345";

            // Act & Assert
            Assert.IsFalse(nullString.IsValidIranianPostalCode(), "رشته null باید نامعتبر باشد.");
            Assert.IsFalse(emptyString.IsValidIranianPostalCode(), "رشته خالی باید نامعتبر باشد.");
            Assert.IsFalse(shortString.IsValidIranianPostalCode(), "رشته با طول کمتر از 10 باید نامعتبر باشد.");
        }
    }
}

نظرات

  • وحید نصیری در ۱۴۰۴/۰۴/۳۱ ۱۲:۳۳
    یک نکته‌ی تکمیلی

    الگوی نهایی که از آن در کتابخانه‌ی DNTPersianUtils.Core استفاده شده، این مورد است که در اعتبارسنج کدپستی از آن استفاده می‌شود:
    private static readonly Regex _matchIranianPostalCode = new(pattern: @"^(?!(\d)\1{3})[13-9]{5}\d{5}$",
            RegexOptions.Compiled | RegexOptions.IgnoreCase, RegexUtils.MatchTimeout);
    و در این الگوی نهایی، شرط عدم تکرار، فقط برای چهار رقم اول کد بررسی می‌شود و کاری به بقیه ارقام نداریم (یعنی نسبت به حالت بررسی کردن اینکه هیچ دو رقم یکسانی (مثلاً 11 یا 55) به صورت پشت سر هم وجود نداشته باشند، ساده‌گیرتر است).

    مقایسه دو الگوی Regex

    ویژگیالگوی نهایی^(?!(\d)\1{3})[13-9]{5}\d{5}$الگوی پیشنهادی^(?!.*(.)\1)[13-9]{5}\d{5}$
    شرط بررسی تکرار (?!(\d)\1{3}) این الگو بررسی می‌کند که کد با چهار رقم یکسان (مثلاً 1111) شروع نشود.(?!.*(.)\1) این الگو بررسی می‌کند که هیچ دو رقم یکسانی (مثلاً 11 یا 55) به صورت پشت سر هم وجود نداشته باشد.
    محدوده‌ی بررسی تکرار فقط ابتدای کد: شرط عدم تکرار را فقط برای چهار رقم اول کد بررسی می‌کند و کاری به بقیه ارقام ندارد.تمام طول کد: شرط عدم تکرار را به لطف .* در تمام طول رشته‌ی ۱۰ رقمی بررسی می‌کند.

    • وحید نصیری در ۱۴۰۴/۰۵/۱۱ ۱۲:۳۴
      یک نکته‌ی تکمیلی: یکی از بازدیدکنندگان سایت، با ایمیلی از طرف اداره پست ایران، چنین مطلبی را ارسال کرده:
      برای ساختار کد پستی، باید در 5 رقم اول، حتما عدد 1 وجود داشته باشد و تا رقمی که شامل یک میشود، به عنوان کد شهر در نظر گرفته می‌شود؛ مثل 79146 که در آن، عدد 791، کد شهر و 46، کد گشت مربوط به آن شهر می‌باشد. ضمنا اگر در 5 رقم اول، به 5 ختم شود، این گشت مربوط به صندوق پستی می‌باشد و معمولا در این حالت بعلاوه 5 رقم، 3 یا 4 رقم می‌باشد؛ مثلا 79145-1315 که این یک آدرس صندوق شخصی است.

      بر این اساس، تغییرات به صورت زیر خواهند بود:
      • قانون اجباری بودن عدد '1': در ۵ رقم اول، وجود حداقل یک عدد '1'الزامی است. این یک شرط منطقی جدید به اعتبارسنجی اضافه می‌کند.
      • قانون صندوق پستی (PO Box): اگر رقم پنجم عدد '5' باشد، آن کد مربوط به صندوق پستی است.
      • قانون فرمت متغیر: کدها اکنون دو فرمت کلی دارند:
      • کد استاندارد: یک رشته ۱۰ رقمی پیوسته (مانند قبل، با قانون جدید '1').
      • کد صندوق پستی: یک ساختار ۹ یا ۱۰ کاراکتری شامل یک خط تیره، مانند XXXX5-XXX یا XXXX5-XXXX.
      • قانون استخراج کد شهر: این یک وظیفه استخراج اطلاعات است، نه صرفاً اعتبارسنجی. یک الگوی Regex می‌تواند همزمان با اعتبارسنجی، بخش مورد نظر (کد شهر) را نیز برای استفاده‌های بعدی به ما تحویل دهد.

      بر این اساس، الگوی نهایی به صورت زیر تغییر می‌کند:
      // الگوی جدید و یکپارچه
      private static readonly Regex IranianPostalCodeRegex = new Regex(
                  @"^(?!(\d)\1{3})(?=.{0,4}1)((?:[13-9]{0,4}1[13-9]{0,4})\d{5}|(?:[13-9]{4}5)-\d{3,4})$");
      
      // گروه ضبط برای کد شهر. گروه 2 یا 3 بسته به فرمت کد.
      // ((...)\d{5} | (...)-\d{3,4})
      //  ^گروه 2     ^گروه 3
      private static readonly Regex CityCodeExtractorRegex = new Regex(
                  @"^(?!(\d)\1{3})(?=.{0,4}1)(([13-9]{0,4}1[13-9]{0,4})\d{5}|([13-9]{4}5)-\d{3,4})$");

      تجزیه و تحلیل الگوی نهایی

      این الگو از چندین بخش کلیدی تشکیل شده‌است:
      • ^...$ : تضمین می‌کند که کل رشته با الگو مطابقت دارد.
      • (?!.*(.)\1) : (شرط عدم تکرار متوالی) این تطابق پیش‌نگر منفی (Negative Lookahead) مانند قبل، تضمین می‌کند که هیچ دو رقم یکسانی پشت سر هم قرار نگرفته باشند (که البته در این مورد، در نظر قبلی بیشتر بحث شد و حالت دیگری را هم می‌توان درنظر گرفت: ?!(\d)\1{3}).
      • (?=.{0,4}1) : (شرط اجباری بودن '1') این یک تطابق پیش‌نگر مثبت (Positive Lookahead) است. این بخش بدون اینکه کاراکتری را مصرف کند، بررسی می‌کند که آیا در ۴ کاراکتر بعدی (یعنی مجموعا در ۵ رقم اول)، کاراکتر '1' وجود دارد یا خیر. اگر وجود نداشته باشد، کل الگو رد می‌شود.
      • ( ... | ... ) : این ساختار OR منطقی است که به الگو اجازه می‌دهد یکی از دو فرمت ممکن را بپذیرد.
      • بخش اول (کد استاندارد):([13-9]{0,4}1[13-9]{0,4})\d{5}
      • ([13-9]{0,4}1[13-9]{0,4}): این بخش ۵ رقم اول را با این تضمین که حتما شامل یک عدد '1' است، تطبیق می‌دهد و آن را در یک گروه ضبط (Capturing Group) برای استخراج بعدی قرار می‌دهد.
      • \d{5}: پنج رقم دوم کد.
      • بخش دوم (کد صندوق پستی):([13-9]{4}5)-\d{3,4}
      • ([13-9]{4}5): چهار رقم از مجموعه مجاز و سپس عدد '5' که آن را نیز در یک گروه ضبط می‌کند.
      • -: کاراکتر خط تیره.
      • \d{3,4}: ۳ یا ۴ رقم برای شماره صندوق.


      کد اصلاح‌شده #C

      کدها، بر اساس الگوی جدید و نیازمندی استخراج اطلاعات، بازنویسی شده‌اند. این کلاس اکنون شامل دو متد است: یکی برای اعتبارسنجی ساده (True/False) و دیگری برای اعتبارسنجی و استخراج همزمان کد شهر:
      using System.Text.RegularExpressions;
      
      namespace PostalCodeValidation
      {
          public static class ValidationExtensions
          {
              // الگوی جدید و یکپارچه
              private static readonly Regex IranianPostalCodeRegex = new Regex(
                  @"^(?!(\d)\1{3})(?=.{0,4}1)((?:[13-9]{0,4}1[13-9]{0,4})\d{5}|(?:[13-9]{4}5)-\d{3,4})$");
      
              // گروه ضبط برای کد شهر. گروه 2 یا 3 بسته به فرمت کد.
              // ((...)\d{5} | (...)-\d{3,4})
              //  ^گروه 2     ^گروه 3
              private static readonly Regex CityCodeExtractorRegex = new Regex(
                  @"^(?!(\d)\1{3})(?=.{0,4}1)(([13-9]{0,4}1[13-9]{0,4})\d{5}|([13-9]{4}5)-\d{3,4})$");
      
              /// <summary>
              /// بررسی می‌کند که آیا رشته ورودی یک کد پستی معتبر ایرانی (فرمت استاندارد یا صندوق پستی) است.
              /// </summary>
              public static bool IsValidIranianPostalCode(this string postalCode)
              {
                  if (string.IsNullOrWhiteSpace(postalCode))
                  {
                      return false;
                  }
                  return IranianPostalCodeRegex.IsMatch(postalCode);
              }
      
              /// <summary>
              /// کد پستی را اعتبارسنجی کرده و در صورت اعتبار، کد شهر را استخراج می‌کند.
              /// </summary>
              /// <param name="postalCode">کد پستی ورودی.</param>
              /// <param name="cityCode">خروجی کد شهر در صورت معتبر بودن کد پستی.</param>
              /// <returns>اگر کد پستی معتبر باشد true و در غیر این صورت false.</returns>
              public static bool TryParseIranianPostalCode(this string postalCode, out string cityCode)
              {
                  cityCode = null;
                  if (string.IsNullOrWhiteSpace(postalCode))
                  {
                      return false;
                  }
      
                  Match match = CityCodeExtractorRegex.Match(postalCode);
                  if (match.Success)
                  {
                      // بر اساس الگو، کد شهر یا در گروه 3 (برای کد استاندارد) یا گروه 4 (برای کد صندوق پستی) قرار دارد.
                      // ما به دنبال اولین گروهی هستیم که با موفقیت ضبط شده باشد.
                      if (match.Groups[3].Success)
                      {
                          // کد شهر در فرمت استاندارد تا اولین '1' است
                          var firstBlock = match.Groups[3].Value;
                          cityCode = firstBlock.Substring(0, firstBlock.IndexOf('1') + 1);
                      }
                      else if (match.Groups[4].Success)
                      {
                           // کد شهر در فرمت صندوق پستی تا اولین '1' است
                          var firstBlock = match.Groups[4].Value;
                          cityCode = firstBlock.Substring(0, firstBlock.IndexOf('1') + 1);
                      }
                      return true;
                  }
                  return false;
              }
          }
      }